From b9a8878fb8b560085cd9f0b68bf2e56a6befbaf5 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:28:47 +0200 Subject: [PATCH 01/86] refactor(workflows): decontaminate inference imports Remove 196 of 571 imports from inference/core/workflows in place, behind ports bound through the existing init_parameters path: - AST import lint with a shrink-only baseline (571 -> 375) - module-local stdlib loggers + structlog ProcessorFormatter adapter (parity test) - ModelsProvider, WorkflowsCache, BackgroundTaskScheduler protocols; vendored LRUCache - pure helpers vendored into workflows.utils with local exception/warning classes - enterprise blocks loaded as a workflows_core-sourced plugin (prepended) - Roboflow step error handlers moved to the server and passed explicitly at all four composition roots; workflows default is mapping-free legacy - dead model_manager parameters removed from 30 leaf blocks - isolation probe + strict-xfail wrapper (diagnostic; fails until phases 5-12) Direct ExecutionEngine.init callers must now pass the server handler to keep the Roboflow error mappings; server error handlers also catch the workflows environment-value error. Co-Authored-By: Claude Fable 5.1 --- inference/core/env.py | 20 + .../core/interfaces/http/error_handlers.py | 3 + inference/core/interfaces/http/http_api.py | 5 + .../interfaces/stream/inference_pipeline.py | 4 + .../workflows_step_error_handlers.py | 275 ++++++++++ inference/core/logger.py | 41 +- .../analytics/detection_event_log/v1.py | 4 +- .../detection_event_log/v1_tensor.py | 4 +- .../workflows/core_steps/common/openrouter.py | 8 +- .../core_steps/common/serializers.py | 4 +- .../core/workflows/core_steps/common/utils.py | 4 +- .../vlm_as_detector/muse_detection_parsing.py | 4 +- .../vlm_as_detector/qwen_detection_parsing.py | 4 +- .../formatters/vlm_as_detector/v2_tensor.py | 3 +- .../detections_classes_replacement/v1.py | 4 +- .../v1_tensor.py | 4 +- .../fusion/detections_consensus/v1_tensor.py | 4 +- .../detections_list_rollup/v1_tensor.py | 4 +- .../fusion/detections_stitch/v1_tensor.py | 4 +- .../roboflow/visual_search_classifier/v1.py | 2 +- .../visual_search_classifier/v1_tensor.py | 2 +- inference/core/workflows/core_steps/loader.py | 1 + .../core_steps/math/cosine_similarity/v1.py | 2 +- .../anthropic_claude/model_capabilities.py | 3 +- .../models/foundation/anthropic_claude/v1.py | 7 +- .../models/foundation/anthropic_claude/v2.py | 7 +- .../models/foundation/anthropic_claude/v3.py | 7 +- .../models/foundation/anthropic_claude/v4.py | 7 +- .../core_steps/models/foundation/clip/v1.py | 6 +- .../models/foundation/clip/v1_tensor.py | 6 +- .../models/foundation/clip_comparison/v1.py | 4 +- .../foundation/clip_comparison/v1_tensor.py | 4 +- .../models/foundation/clip_comparison/v2.py | 4 +- .../foundation/clip_comparison/v2_tensor.py | 4 +- .../models/foundation/cog_vlm/v1.py | 5 +- .../models/foundation/cosmos3/v1.py | 4 +- .../models/foundation/depth_estimation/v1.py | 4 +- .../foundation/depth_estimation/v1_tensor.py | 4 +- .../models/foundation/easy_ocr/v1.py | 6 +- .../models/foundation/easy_ocr/v1_tensor.py | 4 +- .../models/foundation/florence2/v1.py | 4 +- .../models/foundation/florence2/v1_tensor.py | 4 +- .../core_steps/models/foundation/gaze/v1.py | 5 +- .../models/foundation/glm_ocr/v1.py | 4 +- .../models/foundation/google_gemini/v1.py | 5 +- .../models/foundation/google_gemini/v2.py | 5 +- .../models/foundation/google_gemini/v3.py | 5 +- .../models/foundation/google_gemini/v4.py | 5 +- .../models/foundation/google_gemini/v5.py | 5 +- .../models/foundation/google_gemma/v1.py | 9 +- .../models/foundation/kimi_openrouter/v1.py | 9 +- .../models/foundation/llama_vision/v1.py | 9 +- .../core_steps/models/foundation/lmm/v1.py | 5 +- .../models/foundation/lmm_classifier/v1.py | 5 +- .../models/foundation/moondream2/v1.py | 4 +- .../models/foundation/moondream2/v1_tensor.py | 4 +- .../core_steps/models/foundation/ocr/v1.py | 4 +- .../models/foundation/ocr/v1_tensor.py | 4 +- .../core_steps/models/foundation/openai/v1.py | 5 +- .../core_steps/models/foundation/openai/v2.py | 5 +- .../core_steps/models/foundation/openai/v3.py | 5 +- .../core_steps/models/foundation/openai/v4.py | 5 +- .../core_steps/models/foundation/openai/v5.py | 5 +- .../core_steps/models/foundation/openai/v6.py | 5 +- .../models/foundation/openai_compatible/v1.py | 4 +- .../foundation/perception_encoder/v1.py | 6 +- .../perception_encoder/v1_tensor.py | 6 +- .../core_steps/models/foundation/pp_ocr/v1.py | 4 +- .../models/foundation/pp_ocr/v1_tensor.py | 6 +- .../core_steps/models/foundation/qwen/v1.py | 4 +- .../foundation/qwen3_5_openrouter/v1.py | 9 +- .../models/foundation/qwen3_5vl/v1.py | 4 +- .../models/foundation/qwen3_5vl/v1_tensor.py | 4 +- .../models/foundation/qwen3_5vl/v2.py | 4 +- .../foundation/qwen3_6_openrouter/v1.py | 9 +- .../models/foundation/qwen3vl/v1.py | 4 +- .../models/foundation/qwen3vl/v1_tensor.py | 4 +- .../models/foundation/qwen_vlm/v1.py | 4 +- .../models/foundation/qwen_vlm/v2.py | 4 +- .../models/foundation/qwen_vlm/v3.py | 4 +- .../models/foundation/seg_preview/v1.py | 5 +- .../foundation/seg_preview/v1_tensor.py | 5 +- .../models/foundation/segment_anything2/v1.py | 4 +- .../foundation/segment_anything2/v1_tensor.py | 4 +- .../foundation/segment_anything2_video/v1.py | 4 +- .../segment_anything2_video/v1_tensor.py | 4 +- .../models/foundation/segment_anything3/v1.py | 8 +- .../foundation/segment_anything3/v1_tensor.py | 4 +- .../models/foundation/segment_anything3/v2.py | 8 +- .../foundation/segment_anything3/v2_tensor.py | 4 +- .../models/foundation/segment_anything3/v3.py | 8 +- .../foundation/segment_anything3/v3_tensor.py | 8 +- .../foundation/segment_anything3_3d/v1.py | 4 +- .../segment_anything3_3d/v1_tensor.py | 4 +- .../segment_anything3_interactive/v1.py | 8 +- .../v1_tensor.py | 10 +- .../foundation/segment_anything3_video/v1.py | 4 +- .../segment_anything3_video/v1_tensor.py | 4 +- .../models/foundation/smolvlm/v1.py | 4 +- .../models/foundation/smolvlm/v1_tensor.py | 4 +- .../models/foundation/spacexai/v1.py | 5 +- .../models/foundation/spacexai/v2.py | 5 +- .../models/foundation/yolo_world/v1.py | 4 +- .../models/foundation/yolo_world/v1_tensor.py | 5 +- .../models/roboflow/action_recognition/v1.py | 9 +- .../roboflow/instance_segmentation/v1.py | 4 +- .../instance_segmentation/v1_tensor.py | 6 +- .../roboflow/instance_segmentation/v2.py | 4 +- .../instance_segmentation/v2_tensor.py | 6 +- .../roboflow/instance_segmentation/v3.py | 4 +- .../instance_segmentation/v3_tensor.py | 6 +- .../roboflow/instance_segmentation/v4.py | 4 +- .../instance_segmentation/v4_tensor.py | 6 +- .../models/roboflow/keypoint_detection/v1.py | 4 +- .../roboflow/keypoint_detection/v1_tensor.py | 6 +- .../models/roboflow/keypoint_detection/v2.py | 4 +- .../roboflow/keypoint_detection/v2_tensor.py | 6 +- .../models/roboflow/keypoint_detection/v3.py | 4 +- .../roboflow/keypoint_detection/v3_tensor.py | 6 +- .../roboflow/multi_class_classification/v1.py | 4 +- .../multi_class_classification/v1_tensor.py | 6 +- .../roboflow/multi_class_classification/v2.py | 4 +- .../multi_class_classification/v2_tensor.py | 6 +- .../roboflow/multi_class_classification/v3.py | 4 +- .../multi_class_classification/v3_tensor.py | 6 +- .../roboflow/multi_label_classification/v1.py | 4 +- .../multi_label_classification/v1_tensor.py | 6 +- .../roboflow/multi_label_classification/v2.py | 4 +- .../multi_label_classification/v2_tensor.py | 6 +- .../roboflow/multi_label_classification/v3.py | 4 +- .../multi_label_classification/v3_tensor.py | 6 +- .../models/roboflow/object_detection/v1.py | 4 +- .../roboflow/object_detection/v1_tensor.py | 6 +- .../models/roboflow/object_detection/v2.py | 4 +- .../roboflow/object_detection/v2_tensor.py | 6 +- .../models/roboflow/object_detection/v3.py | 4 +- .../roboflow/object_detection/v3_tensor.py | 6 +- .../roboflow/semantic_segmentation/v1.py | 4 +- .../semantic_segmentation/v1_tensor.py | 4 +- .../roboflow/semantic_segmentation/v2.py | 4 +- .../semantic_segmentation/v2_tensor.py | 4 +- .../sampling/identify_changes/v1.py | 2 +- .../core_steps/sinks/email_notification/v1.py | 4 +- .../core_steps/sinks/email_notification/v2.py | 4 +- .../core_steps/sinks/onvif_movement/v1.py | 6 +- .../sinks/onvif_movement/v1_tensor.py | 6 +- .../roboflow/asset_library_attributes/v1.py | 6 +- .../sinks/roboflow/custom_metadata/v1.py | 12 +- .../roboflow/custom_metadata/v1_tensor.py | 12 +- .../sinks/roboflow/dataset_upload/v1.py | 16 +- .../roboflow/dataset_upload/v1_tensor.py | 16 +- .../sinks/roboflow/dataset_upload/v2.py | 12 +- .../roboflow/dataset_upload/v2_tensor.py | 12 +- .../v1.py | 12 +- .../v1_tensor.py | 12 +- .../sinks/roboflow/vision_events/v1.py | 8 +- .../sinks/roboflow/vision_events/v1_tensor.py | 8 +- .../sinks/roboflow/vision_events_bundle/v1.py | 8 +- .../vision_events_bundle/v1_tensor.py | 8 +- .../core_steps/sinks/slack/notification/v1.py | 8 +- .../core_steps/sinks/twilio/sms/v1.py | 8 +- .../core_steps/sinks/twilio/sms/v2.py | 4 +- .../workflows/core_steps/sinks/webhook/v1.py | 4 +- .../workflows/core_steps/trackers/_base.py | 4 +- .../core_steps/trackers/_base_tensor.py | 4 +- .../core_steps/trackers/botsort/v1.py | 4 +- .../core_steps/trackers/botsort/v1_tensor.py | 4 +- .../transformations/byte_tracker/v2.py | 4 +- .../transformations/byte_tracker/v2_tensor.py | 4 +- .../transformations/byte_tracker/v3.py | 4 +- .../transformations/byte_tracker/v3_tensor.py | 4 +- .../transformations/stitch_images/v1.py | 4 +- .../visualizations/bounding_box/v1_tensor.py | 4 +- .../core_steps/visualizations/grid/v1.py | 2 +- .../visualizations/label/v1_tensor.py | 4 +- .../visualizations/polygon_zone/v1.py | 2 +- inference/core/workflows/errors.py | 10 + .../introspection/blocks_loader.py | 31 +- .../v1/compiler/graph_constructor.py | 4 +- .../v1/compiler/syntactic_parser.py | 4 +- .../workflows/execution_engine/v1/core.py | 15 +- .../execution_engine/v1/debugger/core.py | 3 +- .../v1/dynamic_blocks/block_scaffolding.py | 4 +- .../v1/dynamic_blocks/modal_executor.py | 16 +- .../execution_engine/v1/executor/core.py | 4 +- .../execution_data_manager/manager.py | 4 +- .../v1/executor/output_constructor.py | 4 +- .../v1/step_error_handlers.py | 260 +--------- .../workflows/prototypes/background_tasks.py | 13 + inference/core/workflows/prototypes/cache.py | 20 + .../workflows/prototypes/models_provider.py | 50 ++ inference/core/workflows/utils/__init__.py | 0 inference/core/workflows/utils/images.py | 64 +++ inference/core/workflows/utils/lru_cache.py | 39 ++ inference/core/workflows/utils/text.py | 81 +++ inference/core/workflows/warnings.py | 2 + .../workflows/enterprise_blocks/loader.py | 9 + .../lib/workflows/local_image_adapter.py | 4 + scripts/relocate_logger.py | 88 ++++ scripts/workflows_isolation_probe.py | 476 ++++++++++++++++++ .../test_step_error_handler_selection.py | 89 ++++ .../test_workflows_step_error_handlers.py} | 8 +- .../core/test_enterprise_blocks_plugin.py | 148 ++++++ .../test_workflows_logging_parity.py | 74 +++ ...orkflow_with_detections_consensus_block.py | 4 + .../execution/test_workflow_with_filtering.py | 4 + .../test_workflow_with_gaze_deprecated.py | 4 + .../test_workflow_with_mqtt_writer.py | 57 ++- .../test_workflow_with_single_model.py | 4 + tests/workflows/isolation/__init__.py | 0 .../isolation/test_isolation_probe.py | 125 +++++ .../foundation/test_cog_vlm_deprecated.py | 3 - .../models/foundation/test_gaze_deprecated.py | 4 - .../test_offline_remote_execution.py | 12 +- .../roboflow/action_recognition/test_v1.py | 4 - .../unit_tests/decontamination_baseline.txt | 378 ++++++++++++++ .../introspection/test_plugin_block_source.py | 50 ++ .../test_dependencies_pre_loading.py | 15 + .../test_step_error_handler_default.py | 39 ++ .../unit_tests/prototypes/__init__.py | 0 .../prototypes/test_background_tasks.py | 30 ++ .../unit_tests/prototypes/test_cache.py | 35 ++ .../prototypes/test_models_provider.py | 77 +++ .../unit_tests/test_decontamination_lint.py | 187 +++++++ tests/workflows/unit_tests/utils/__init__.py | 0 .../workflows/unit_tests/utils/test_images.py | 38 ++ .../unit_tests/utils/test_lru_cache.py | 41 ++ tests/workflows/unit_tests/utils/test_text.py | 72 +++ 228 files changed, 3163 insertions(+), 793 deletions(-) create mode 100644 inference/core/interfaces/workflows_step_error_handlers.py create mode 100644 inference/core/workflows/prototypes/background_tasks.py create mode 100644 inference/core/workflows/prototypes/cache.py create mode 100644 inference/core/workflows/prototypes/models_provider.py create mode 100644 inference/core/workflows/utils/__init__.py create mode 100644 inference/core/workflows/utils/images.py create mode 100644 inference/core/workflows/utils/lru_cache.py create mode 100644 inference/core/workflows/utils/text.py create mode 100644 inference/core/workflows/warnings.py create mode 100644 scripts/relocate_logger.py create mode 100644 scripts/workflows_isolation_probe.py create mode 100644 tests/inference/unit_tests/core/interfaces/test_step_error_handler_selection.py rename tests/{workflows/unit_tests/execution_engine/test_step_error_handlers.py => inference/unit_tests/core/interfaces/test_workflows_step_error_handlers.py} (99%) create mode 100644 tests/inference/unit_tests/core/test_enterprise_blocks_plugin.py create mode 100644 tests/inference/unit_tests/test_workflows_logging_parity.py create mode 100644 tests/workflows/isolation/__init__.py create mode 100644 tests/workflows/isolation/test_isolation_probe.py create mode 100644 tests/workflows/unit_tests/decontamination_baseline.txt create mode 100644 tests/workflows/unit_tests/execution_engine/introspection/test_plugin_block_source.py create mode 100644 tests/workflows/unit_tests/execution_engine/test_step_error_handler_default.py create mode 100644 tests/workflows/unit_tests/prototypes/__init__.py create mode 100644 tests/workflows/unit_tests/prototypes/test_background_tasks.py create mode 100644 tests/workflows/unit_tests/prototypes/test_cache.py create mode 100644 tests/workflows/unit_tests/prototypes/test_models_provider.py create mode 100644 tests/workflows/unit_tests/test_decontamination_lint.py create mode 100644 tests/workflows/unit_tests/utils/__init__.py create mode 100644 tests/workflows/unit_tests/utils/test_images.py create mode 100644 tests/workflows/unit_tests/utils/test_lru_cache.py create mode 100644 tests/workflows/unit_tests/utils/test_text.py diff --git a/inference/core/env.py b/inference/core/env.py index f5da5e3566..d8bb6dbeca 100644 --- a/inference/core/env.py +++ b/inference/core/env.py @@ -1223,6 +1223,26 @@ ) LOAD_ENTERPRISE_BLOCKS = str2bool(os.getenv("LOAD_ENTERPRISE_BLOCKS", "False")) + +# Enterprise blocks load through the generic Workflows plugin mechanism. The +# flag is kept for compatibility and expanded here into WORKFLOWS_PLUGINS, which +# blocks_loader.get_plugin_modules() reads from the process environment. This +# module is imported by inference/core/__init__.py before any workflows module +# can load, so the expansion always precedes the first block load. +# The plugin is PREPENDED, not appended: enterprise blocks used to be merged +# into the core list, so `load_workflow_blocks()` yielded core -> enterprise -> +# custom plugins. Appending would reorder that to core -> custom -> enterprise +# for anyone who also sets WORKFLOWS_PLUGINS. +ENTERPRISE_BLOCKS_PLUGIN = "inference.enterprise.workflows.enterprise_blocks.loader" +if LOAD_ENTERPRISE_BLOCKS: + _workflows_plugins = [ + plugin for plugin in os.getenv("WORKFLOWS_PLUGINS", "").split(",") if plugin + ] + if ENTERPRISE_BLOCKS_PLUGIN not in _workflows_plugins: + os.environ["WORKFLOWS_PLUGINS"] = ",".join( + [ENTERPRISE_BLOCKS_PLUGIN] + _workflows_plugins + ) + TRANSIENT_ROBOFLOW_API_ERRORS = set( int(e) for e in os.getenv("TRANSIENT_ROBOFLOW_API_ERRORS", "").split(",") diff --git a/inference/core/interfaces/http/error_handlers.py b/inference/core/interfaces/http/error_handlers.py index 0efbde761e..5faa2436a7 100644 --- a/inference/core/interfaces/http/error_handlers.py +++ b/inference/core/interfaces/http/error_handlers.py @@ -73,6 +73,7 @@ WorkflowDefinitionError, WorkflowError, WorkflowExecutionEngineVersionError, + WorkflowsInvalidEnvironmentValueError, WorkflowSyntaxError, ) from inference.core.workflows.execution_engine.v1.inner_workflow.errors import ( @@ -394,6 +395,7 @@ def wrapped_route(*args, **kwargs): ) except ( InvalidEnvironmentVariableError, + WorkflowsInvalidEnvironmentValueError, MissingServiceSecretError, ServiceConfigurationError, EnvironmentConfigurationError, @@ -873,6 +875,7 @@ async def wrapped_route(*args, **kwargs): ) except ( InvalidEnvironmentVariableError, + WorkflowsInvalidEnvironmentValueError, MissingServiceSecretError, ServiceConfigurationError, EnvironmentConfigurationError, diff --git a/inference/core/interfaces/http/http_api.py b/inference/core/interfaces/http/http_api.py index 230af1f5fa..9a2554e00b 100644 --- a/inference/core/interfaces/http/http_api.py +++ b/inference/core/interfaces/http/http_api.py @@ -298,6 +298,9 @@ deregister_webrtc_session, refresh_webrtc_session, ) +from inference.core.interfaces.workflows_step_error_handlers import ( + resolve_step_error_handler, +) from inference.core.managers.base import ModelManager from inference.core.managers.cuda_memory_watchdog import CudaMemoryReclamationWatchdog from inference.core.managers.inference_models_cache_watchdog import ( @@ -1612,6 +1615,7 @@ def process_workflow_inference_request( profiler=profiler, executor=self.shared_thread_pool_executor, workflow_id=workflow_request.workflow_id, + step_error_handler=resolve_step_error_handler(), ) is_preview = False if hasattr(workflow_request, "is_preview"): @@ -2557,6 +2561,7 @@ def validate_workflow( init_parameters=workflow_init_parameters, max_concurrent_steps=WORKFLOWS_MAX_CONCURRENT_STEPS, prevent_local_images_loading=True, + step_error_handler=resolve_step_error_handler(), ) return WorkflowValidationStatus(status="ok") diff --git a/inference/core/interfaces/stream/inference_pipeline.py b/inference/core/interfaces/stream/inference_pipeline.py index 176460634f..0fc10e29a5 100644 --- a/inference/core/interfaces/stream/inference_pipeline.py +++ b/inference/core/interfaces/stream/inference_pipeline.py @@ -697,6 +697,9 @@ def init_with_workflow( WorkflowRunner, wrap_workflow_runner_for_stream_pipeline, ) + from inference.core.interfaces.workflows_step_error_handlers import ( + resolve_step_error_handler, + ) from inference.core.roboflow_api import get_workflow_specification from inference.core.workflows.execution_engine.core import ExecutionEngine @@ -752,6 +755,7 @@ def init_with_workflow( profiler=profiler, executor=execution_engine_thread_pool_executor, dependencies_pre_init=workflows_dependencies_pre_init, + step_error_handler=resolve_step_error_handler(), ) workflow_runner = WorkflowRunner( workflows_parameters=workflows_parameters, diff --git a/inference/core/interfaces/workflows_step_error_handlers.py b/inference/core/interfaces/workflows_step_error_handlers.py new file mode 100644 index 0000000000..609c920217 --- /dev/null +++ b/inference/core/interfaces/workflows_step_error_handlers.py @@ -0,0 +1,275 @@ +import os +from typing import Callable, Optional, Union + +from inference.core.exceptions import ( + CannotInitialiseModelDueToInputSizeError, + FeatureDeprecatedError, + InferenceModelNotFound, + InvalidModelIDError, + ModelDeploymentNotSupportedError, + ModelManagerLockAcquisitionError, + PaymentRequiredError, + RoboflowAPIForbiddenError, + RoboflowAPINotAuthorizedError, + RoboflowAPINotNotFoundError, + RoboflowAPIUsagePausedError, +) +from inference.core.workflows.errors import ( + ClientCausedStepExecutionError, + RuntimeLimitsCausedStepExecutionError, +) +from inference_models.errors import ( + ModelNotFoundError, + ModelPackageAlternativesExhaustedError, + ModelPackageRestrictedError, + ModelRetrievalError, + UnauthorizedModelAccessError, +) +from inference_sdk.http.errors import HTTPCallErrorError + +MODEL_ACCESS_ERROR_MESSAGES = { + 402: "Not enough credits to execute step {step_name}. Verify your workspace billing page.", + 403: "Forbidden error occurred while execution of step {step_name}. " + "This error usually means there is a problem with the Roboflow API key.", + 423: "Roboflow API usage is paused while executing step {step_name}. " + "Contact your workspace administrator to re-enable API keys.", +} + + +def legacy_step_error_handler(step_name: str, error: Exception) -> None: + if isinstance(error, FeatureDeprecatedError): + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=410, + public_message=str(error), + context="workflow_execution | step_execution | feature_deprecated", + inner_error=error, + ) from error + if isinstance(error, (ModelManagerLockAcquisitionError, InferenceModelNotFound)): + raise error + return None + + +def extended_roboflow_errors_handler(step_name: str, error: Exception) -> None: + if isinstance(error, FeatureDeprecatedError): + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=410, + public_message=str(error), + context="workflow_execution | step_execution | feature_deprecated", + inner_error=error, + ) from error + if isinstance( + error, + ( + ModelManagerLockAcquisitionError, + InferenceModelNotFound, + ), + ): + raise error + if isinstance(error, CannotInitialiseModelDueToInputSizeError): + raise RuntimeLimitsCausedStepExecutionError( + block_id=step_name, + status_code=507, + public_message=f"Could not complete workflow execution due to configured runtime constraints. " + f"Details: model input size causes runtime memory requirements exceed the limit " + f"configured for the environment.", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + if isinstance(error, ModelPackageRestrictedError): + raise RuntimeLimitsCausedStepExecutionError( + block_id=step_name, + status_code=507, + public_message="Model loading failed due to restrictions of server configuration - " + "usually due to excessive runtime memory requirement of the model (for instance " + "caused by large input size).", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + if isinstance(error, ModelPackageAlternativesExhaustedError) and any( + isinstance(e, ModelPackageRestrictedError) + for e in (error.alternatives_errors or []) + ): + raise RuntimeLimitsCausedStepExecutionError( + block_id=step_name, + status_code=507, + public_message="Model loading failed due to restrictions of server configuration - " + "usually due to excessive runtime memory requirement of the model (for instance " + "caused by large input size).", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + if isinstance(error, InvalidModelIDError): + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=400, + public_message=f"Problem with Workflow Block configuration - {error}", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + if isinstance(error, (RoboflowAPINotAuthorizedError, UnauthorizedModelAccessError)): + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=401, + public_message=f"Unauthorized error occurred while execution of step {step_name} - " + f"details of error: {error}. This error usually mean the problem with Roboflow API key.", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + if isinstance(error, PaymentRequiredError): + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=402, + public_message=f"Not enough credits to execute step {step_name}. " + f"Verify your workspace billing page. Details: {error}", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + if isinstance(error, RoboflowAPIForbiddenError): + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=403, + public_message=f"Forbidden error occurred while execution of step {step_name} - " + f"details of error: {error}. This error usually mean the problem with Roboflow API key.", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + if isinstance(error, RoboflowAPIUsagePausedError): + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=423, + public_message=f"Roboflow API usage is paused while executing step {step_name}. " + f"Contact your workspace administrator to re-enable API keys. Details: {error}", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + if isinstance(error, ModelRetrievalError): + status_code = getattr(error, "status_code", None) + if status_code in MODEL_ACCESS_ERROR_MESSAGES: + public_message = MODEL_ACCESS_ERROR_MESSAGES[status_code].format( + step_name=step_name + ) + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=status_code, + public_message=f"{public_message} Details: {error}", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + if isinstance(error, (RoboflowAPINotNotFoundError, ModelNotFoundError)): + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=404, + public_message=f"Could not find requested Roboflow resource while execution of step {step_name} - " + f"details of error: {error}. This error usually mean the problem with not existing model.", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + if isinstance(error, HTTPCallErrorError): + if error.status_code == 400: + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=400, + public_message=f"Bad request error detected while remote execution of step {step_name} - " + f"details of error: {error}. This error usually mean that the Workflow block configuration is faulty.", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + if error.status_code == 401: + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=401, + public_message=f"Unauthorized error occurred while remote execution of step {step_name} - " + f"details of error: {error}. This error usually mean the problem with Roboflow API key.", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + if error.status_code == 402: + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=402, + public_message=f"Not enough credits to remote execute step {step_name}. " + f"Verify your workspace billing page. Details: {error}", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + if error.status_code == 403: + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=403, + public_message=f"Forbidden error occurred while remote execution of step {step_name} - " + f"details of error: {error}. This error usually mean the problem with Roboflow API key.", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + if error.status_code == 404: + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=404, + public_message=f"Could not find requested Roboflow resource while remote execution of step {step_name} - " + f"details of error: {error}. This error usually mean the problem with not existing model.", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + if error.status_code == 410: + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=410, + public_message=f"Deprecated feature usage detected while remote execution of step {step_name} - " + f"details of error: {error}.", + context="workflow_execution | step_execution | feature_deprecated", + inner_error=error, + ) from error + if error.status_code == 423: + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=423, + public_message=f"Roboflow API usage is paused while remote executing step {step_name}. " + f"Contact your workspace administrator to re-enable API keys. Details: {error}", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + if error.status_code == 501: + public_message = error.api_message or ( + f"Remote execution of step {step_name} is not supported on this deployment." + ) + raise ClientCausedStepExecutionError( + block_id=step_name, + status_code=501, + public_message=public_message, + context="workflow_execution | step_execution | deployment_not_supported", + inner_error=ModelDeploymentNotSupportedError(public_message), + ) from error + if error.status_code == 507: + raise RuntimeLimitsCausedStepExecutionError( + block_id=step_name, + status_code=507, + public_message=f"Could not complete workflow execution due to configured runtime constraints. " + f"Details: {error.api_message}", + context="workflow_execution | step_execution", + inner_error=error, + ) from error + return None + + +SERVER_STEP_ERROR_HANDLERS = { + "legacy": legacy_step_error_handler, + "extended_roboflow_errors": extended_roboflow_errors_handler, +} + + +def resolve_step_error_handler( + name: Optional[str] = None, +) -> Union[str, Callable[[str, Exception], None]]: + """Pick the handler the server passes to ``ExecutionEngine.init``. + + The engine's own default is bound at function-definition time, so the + server must pass its choice explicitly at every composition root. Unknown + names are returned unchanged: the engine raises + ``WorkflowEnvironmentConfigurationError`` for them, exactly as before. + """ + name = name or os.getenv( + "DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER", "extended_roboflow_errors" + ) + return SERVER_STEP_ERROR_HANDLERS.get(name, name) diff --git a/inference/core/logger.py b/inference/core/logger.py index 32f3f09c55..cef83a83db 100644 --- a/inference/core/logger.py +++ b/inference/core/logger.py @@ -72,13 +72,6 @@ def __call__(self, logger, name, event_dict): return event_dict -class NoTracebackFormatter(logging.Formatter): - def format(self, record): - # Remove exc_info before formatting - record.exc_info = None - return super().format(record) - - def structlog_exception_formatter( logger_instance: WrappedLogger, name: str, event_dict: EventDict ) -> EventDict: @@ -128,11 +121,13 @@ def structlog_exception_formatter( from inference.core.telemetry import trace_context_log_processor - processors = [ + # Processors shared by structlog-originated records and by records that + # stdlib loggers emit under the `inference` tree - Workflows logs through + # `logging.getLogger(__name__)` since the decontamination (Phase 1). + shared_processors = [ add_correlation, trace_context_log_processor, add_execution_id, - structlog.stdlib.filter_by_level, structlog.stdlib.PositionalArgumentsFormatter(), structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M.%S"), structlog.processors.StackInfoRenderer(), @@ -145,15 +140,19 @@ def structlog_exception_formatter( ], ), ] - + render_processors = [] if is_gcp_environment: - processors.insert(1, add_gcp_severity) - processors.append(GCPCloudLoggingProcessor()) - - processors.append(structlog.processors.JSONRenderer()) + shared_processors.insert(1, add_gcp_severity) + render_processors.append(GCPCloudLoggingProcessor()) + render_processors.append(structlog.processors.JSONRenderer()) structlog.configure( - processors=processors, + processors=[ + structlog.stdlib.filter_by_level, + *shared_processors, + # hand the event dict to the stdlib handler's ProcessorFormatter + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], wrapper_class=structlog.stdlib.BoundLogger, logger_factory=structlog.stdlib.LoggerFactory(), cache_logger_on_first_use=True, @@ -162,7 +161,17 @@ def structlog_exception_formatter( logger.setLevel(LOG_LEVEL) bounded_logger = logger.bind() handler = logging.StreamHandler() - handler.setFormatter(NoTracebackFormatter("%(message)s")) + handler.setFormatter( + structlog.stdlib.ProcessorFormatter( + # structlog records arrive pre-processed; foreign (stdlib) records + # get the same chain through foreign_pre_chain. Both end as JSON. + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + *render_processors, + ], + foreign_pre_chain=shared_processors, + ) + ) bounded_logger._logger.addHandler(handler) bounded_logger._logger.propagate = False else: diff --git a/inference/core/workflows/core_steps/analytics/detection_event_log/v1.py b/inference/core/workflows/core_steps/analytics/detection_event_log/v1.py index fe6742cf99..deba50995f 100644 --- a/inference/core/workflows/core_steps/analytics/detection_event_log/v1.py +++ b/inference/core/workflows/core_steps/analytics/detection_event_log/v1.py @@ -1,4 +1,5 @@ import heapq +import logging import time from dataclasses import asdict, dataclass from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union @@ -7,7 +8,6 @@ import supervision as sv from pydantic import ConfigDict, Field -from inference.core import logger from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, VideoMetadata, @@ -32,6 +32,8 @@ WorkflowBlockManifest, ) +logger = logging.getLogger(__name__) + OUTPUT_KEY = "event_log" DETECTIONS_OUTPUT_KEY = "detections" MAX_VIDEOS = 100 # Maximum number of video streams to track before evicting oldest diff --git a/inference/core/workflows/core_steps/analytics/detection_event_log/v1_tensor.py b/inference/core/workflows/core_steps/analytics/detection_event_log/v1_tensor.py index 34b81ca15c..d14ead1572 100644 --- a/inference/core/workflows/core_steps/analytics/detection_event_log/v1_tensor.py +++ b/inference/core/workflows/core_steps/analytics/detection_event_log/v1_tensor.py @@ -1,11 +1,11 @@ import heapq +import logging import time from dataclasses import dataclass from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union from pydantic import ConfigDict, Field -from inference.core import logger from inference.core.workflows.execution_engine.constants import CLASS_NAMES_KEY from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, @@ -35,6 +35,8 @@ from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.object_detection import Detections +logger = logging.getLogger(__name__) + OUTPUT_KEY = "event_log" DETECTIONS_OUTPUT_KEY = "detections" MAX_VIDEOS = 100 # Maximum number of video streams to track before evicting oldest diff --git a/inference/core/workflows/core_steps/common/openrouter.py b/inference/core/workflows/core_steps/common/openrouter.py index c18ae94133..6d2fa35f2f 100644 --- a/inference/core/workflows/core_steps/common/openrouter.py +++ b/inference/core/workflows/core_steps/common/openrouter.py @@ -22,6 +22,7 @@ import base64 import json +import logging from dataclasses import dataclass from functools import partial from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Tuple, Union @@ -34,8 +35,6 @@ RoboflowAPIForbiddenError, RoboflowAPIUnsuccessfulRequestError, ) -from inference.core.logger import logger -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.token_usage import ( @@ -54,6 +53,9 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider + +logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Privacy level @@ -255,7 +257,7 @@ class OpenRouterWorkflowBlockBase(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], ): self._model_manager = model_manager diff --git a/inference/core/workflows/core_steps/common/serializers.py b/inference/core/workflows/core_steps/common/serializers.py index 3a1aca5ef4..840771bbfe 100644 --- a/inference/core/workflows/core_steps/common/serializers.py +++ b/inference/core/workflows/core_steps/common/serializers.py @@ -1,3 +1,4 @@ +import logging from copy import copy from datetime import datetime from typing import Any, Dict, List, Optional, Union @@ -6,7 +7,6 @@ import numpy as np import supervision as sv -from inference.core import logger from inference.core.workflows.core_steps.common.keypoints import real_keypoints_count from inference.core.workflows.execution_engine.constants import ( AREA_CONVERTED_KEY_IN_INFERENCE_RESPONSE, @@ -71,6 +71,8 @@ WorkflowImageData, ) +logger = logging.getLogger(__name__) + MIN_SECRET_LENGTH_TO_REVEAL_PREFIX = 8 MIN_POLYGON_POINT_COUNT = 3 diff --git a/inference/core/workflows/core_steps/common/utils.py b/inference/core/workflows/core_steps/common/utils.py index 102f3e057b..a3b6af77d3 100644 --- a/inference/core/workflows/core_steps/common/utils.py +++ b/inference/core/workflows/core_steps/common/utils.py @@ -26,7 +26,6 @@ from inference.core.entities.requests.easy_ocr import EasyOCRInferenceRequest from inference.core.entities.requests.sam2 import Sam2InferenceRequest from inference.core.entities.requests.yolo_world import YOLOWorldInferenceRequest -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.keypoints import ( KEYPOINT_PADDING_CLASS_NAME, @@ -71,12 +70,13 @@ wrap_with_context_snapshot, ) from inference.core.workflows.prototypes.block import BlockResult +from inference.core.workflows.prototypes.models_provider import ModelsProvider T = TypeVar("T") def load_core_model( - model_manager: ModelManager, + model_manager: ModelsProvider, inference_request: Union[ DoctrOCRInferenceRequest, EasyOCRInferenceRequest, diff --git a/inference/core/workflows/core_steps/formatters/vlm_as_detector/muse_detection_parsing.py b/inference/core/workflows/core_steps/formatters/vlm_as_detector/muse_detection_parsing.py index 10aab88bb4..f2a83cb6d0 100644 --- a/inference/core/workflows/core_steps/formatters/vlm_as_detector/muse_detection_parsing.py +++ b/inference/core/workflows/core_steps/formatters/vlm_as_detector/muse_detection_parsing.py @@ -1,3 +1,4 @@ +import logging import math from typing import List, Optional, Union from uuid import uuid4 @@ -6,7 +7,6 @@ import supervision as sv from supervision.config import CLASS_NAME_DATA_FIELD -from inference.core.logger import logger from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_sv_detections, empty_detections_with_image_metadata, @@ -25,6 +25,8 @@ ) from inference.core.workflows.execution_engine.entities.base import WorkflowImageData +logger = logging.getLogger(__name__) + MUSE_BOX_COORDINATE_SCALE = 1000.0 _BOX_FIELDS = ("x_min", "y_min", "x_max", "y_max") diff --git a/inference/core/workflows/core_steps/formatters/vlm_as_detector/qwen_detection_parsing.py b/inference/core/workflows/core_steps/formatters/vlm_as_detector/qwen_detection_parsing.py index 35863e5bea..e5daec7793 100644 --- a/inference/core/workflows/core_steps/formatters/vlm_as_detector/qwen_detection_parsing.py +++ b/inference/core/workflows/core_steps/formatters/vlm_as_detector/qwen_detection_parsing.py @@ -1,3 +1,4 @@ +import logging from typing import List, Optional, Union from uuid import uuid4 @@ -5,7 +6,6 @@ import supervision as sv from supervision.config import CLASS_NAME_DATA_FIELD -from inference.core.logger import logger from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_sv_detections, empty_detections_with_image_metadata, @@ -21,6 +21,8 @@ ) from inference.core.workflows.execution_engine.entities.base import WorkflowImageData +logger = logging.getLogger(__name__) + QWEN_BOX_COORDINATE_SCALE = 1000.0 # Qwen models occasionally drift between the prompted keys and their native diff --git a/inference/core/workflows/core_steps/formatters/vlm_as_detector/v2_tensor.py b/inference/core/workflows/core_steps/formatters/vlm_as_detector/v2_tensor.py index 754e90ce96..e6df757412 100644 --- a/inference/core/workflows/core_steps/formatters/vlm_as_detector/v2_tensor.py +++ b/inference/core/workflows/core_steps/formatters/vlm_as_detector/v2_tensor.py @@ -10,7 +10,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE -from inference.core.logger import logger from inference.core.workflows.core_steps.common.vlm_json import extract_json_payload from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.core_steps.formatters.vlm_as_detector.gemini_detection_parsing import ( @@ -75,6 +74,8 @@ ) from inference_models.models.base.object_detection import Detections +logger = logging.getLogger(__name__) + LONG_DESCRIPTION = """ Parse JSON strings from Visual Language Models (VLMs) and Large Language Models (LLMs) into standardized object detection prediction format by extracting bounding boxes, class names, and confidences, converting normalized coordinates to pixel coordinates, mapping class names to class IDs, and handling multiple model types and task formats to enable VLM-based object detection, LLM detection parsing, and text-to-detection conversion workflows. diff --git a/inference/core/workflows/core_steps/fusion/detections_classes_replacement/v1.py b/inference/core/workflows/core_steps/fusion/detections_classes_replacement/v1.py index f6a0e6d899..9439da81e9 100644 --- a/inference/core/workflows/core_steps/fusion/detections_classes_replacement/v1.py +++ b/inference/core/workflows/core_steps/fusion/detections_classes_replacement/v1.py @@ -1,3 +1,4 @@ +import logging import sys from typing import Dict, List, Literal, Optional, Tuple, Type, Union from uuid import uuid4 @@ -7,7 +8,6 @@ from pydantic import ConfigDict, Field from supervision.config import CLASS_NAME_DATA_FIELD -from inference.core import logger from inference.core.workflows.execution_engine.constants import ( DETECTION_ID_KEY, PARENT_ID_KEY, @@ -32,6 +32,8 @@ WorkflowBlockManifest, ) +logger = logging.getLogger(__name__) + LONG_DESCRIPTION = """ Replace class labels of detection bounding boxes with classes predicted by a classification model applied to cropped regions, combining generic detection results with specialized classification predictions to enable two-stage detection workflows, fine-grained classification, and class refinement workflows where generic detections are refined with specific class labels from specialized classifiers. diff --git a/inference/core/workflows/core_steps/fusion/detections_classes_replacement/v1_tensor.py b/inference/core/workflows/core_steps/fusion/detections_classes_replacement/v1_tensor.py index 5865424585..5da9213d6b 100644 --- a/inference/core/workflows/core_steps/fusion/detections_classes_replacement/v1_tensor.py +++ b/inference/core/workflows/core_steps/fusion/detections_classes_replacement/v1_tensor.py @@ -1,3 +1,4 @@ +import logging import sys from typing import Dict, List, Literal, Optional, Tuple, Type, Union from uuid import uuid4 @@ -5,7 +6,6 @@ import torch from pydantic import ConfigDict, Field -from inference.core import logger from inference.core.workflows.core_steps.common.tensor_native import ( HOST_MIRROR_KEYS, TensorNativeDetections, @@ -45,6 +45,8 @@ ) from inference_models.models.base.instance_segmentation import InstanceDetections +logger = logging.getLogger(__name__) + LONG_DESCRIPTION = """ Replace class labels of detection bounding boxes with classes predicted by a classification model applied to cropped regions, combining generic detection results with specialized classification predictions to enable two-stage detection workflows, fine-grained classification, and class refinement workflows where generic detections are refined with specific class labels from specialized classifiers. diff --git a/inference/core/workflows/core_steps/fusion/detections_consensus/v1_tensor.py b/inference/core/workflows/core_steps/fusion/detections_consensus/v1_tensor.py index 1d496aae30..8ed93b2114 100644 --- a/inference/core/workflows/core_steps/fusion/detections_consensus/v1_tensor.py +++ b/inference/core/workflows/core_steps/fusion/detections_consensus/v1_tensor.py @@ -1,3 +1,4 @@ +import logging import math import statistics from collections import Counter @@ -23,7 +24,6 @@ from pydantic import AliasChoices, ConfigDict, Field, PositiveInt from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE -from inference.core.logger import logger from inference.core.workflows.core_steps.common.tensor_native import ( instance_mask_to_numpy, take_prediction_by_indices, @@ -68,6 +68,8 @@ from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.object_detection import Detections +logger = logging.getLogger(__name__) + # Tensor-native detections handled by this block. The consensus pipeline only # ever needs the bounding-box component, so keypoint predictions (which arrive # as a `(KeyPoints, Detections)` tuple) are reduced to their `Detections` part diff --git a/inference/core/workflows/core_steps/fusion/detections_list_rollup/v1_tensor.py b/inference/core/workflows/core_steps/fusion/detections_list_rollup/v1_tensor.py index 99d37dbbaf..ede78a0c93 100644 --- a/inference/core/workflows/core_steps/fusion/detections_list_rollup/v1_tensor.py +++ b/inference/core/workflows/core_steps/fusion/detections_list_rollup/v1_tensor.py @@ -1,3 +1,4 @@ +import logging import uuid from typing import Any, List, Literal, Optional, Tuple, Type, Union @@ -5,7 +6,6 @@ import torch from pydantic import ConfigDict, Field -from inference.core.logger import logger from inference.core.workflows.core_steps.common.tensor_native import ( build_native_key_points, instance_mask_to_numpy, @@ -48,6 +48,8 @@ from inference_models.models.base.keypoints_detection import KeyPoints from inference_models.models.base.object_detection import Detections +logger = logging.getLogger(__name__) + LONG_DESCRIPTION = """ Rolls up dimensionality from children to parent detections diff --git a/inference/core/workflows/core_steps/fusion/detections_stitch/v1_tensor.py b/inference/core/workflows/core_steps/fusion/detections_stitch/v1_tensor.py index cf678a186d..29897b547d 100644 --- a/inference/core/workflows/core_steps/fusion/detections_stitch/v1_tensor.py +++ b/inference/core/workflows/core_steps/fusion/detections_stitch/v1_tensor.py @@ -1,3 +1,4 @@ +import logging from copy import copy from typing import Dict, List, Literal, Optional, Tuple, Type, Union from uuid import uuid4 @@ -10,7 +11,6 @@ from supervision import OverlapFilter from supervision.config import ORIENTED_BOX_COORDINATES -from inference.core import logger from inference.core.workflows.core_steps.common.tensor_native import ( embed_rle_masks_in_larger_canvas, strip_host_mirror_metadata, @@ -57,6 +57,8 @@ torch_mask_to_coco_rle, ) +logger = logging.getLogger(__name__) + TensorNativeDetections = Union[Detections, InstanceDetections] LONG_DESCRIPTION = """ diff --git a/inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1.py b/inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1.py index 4e65a29788..59cbafe324 100644 --- a/inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1.py +++ b/inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1.py @@ -13,7 +13,6 @@ search_project_images_at_roboflow, ) from inference.core.utils.image_utils import encode_image_to_jpeg_bytes -from inference.core.utils.preprocess import downscale_image_keeping_aspect_ratio from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.integrations.roboflow.visual_search.helpers import ( build_visual_search_candidate_image, @@ -54,6 +53,7 @@ WorkflowBlockManifest, roboflow_platform_project, ) +from inference.core.workflows.utils.images import downscale_image_keeping_aspect_ratio SHORT_DESCRIPTION = ( "Classify an image by finding the most visually similar annotated image." diff --git a/inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1_tensor.py b/inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1_tensor.py index 6f545d9e3b..edddf66498 100644 --- a/inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1_tensor.py +++ b/inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1_tensor.py @@ -55,7 +55,6 @@ search_project_images_at_roboflow, ) from inference.core.utils.image_utils import encode_image_to_jpeg_bytes -from inference.core.utils.preprocess import downscale_image_keeping_aspect_ratio from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.integrations.roboflow.visual_search.helpers import ( build_visual_search_candidate_image, @@ -102,6 +101,7 @@ WorkflowBlockManifest, roboflow_platform_project, ) +from inference.core.workflows.utils.images import downscale_image_keeping_aspect_ratio from inference_models.models.base.classification import ( ClassificationPrediction, MultiLabelClassificationPrediction, diff --git a/inference/core/workflows/core_steps/loader.py b/inference/core/workflows/core_steps/loader.py index a85d09c4b8..e659cf8538 100644 --- a/inference/core/workflows/core_steps/loader.py +++ b/inference/core/workflows/core_steps/loader.py @@ -1615,6 +1615,7 @@ REGISTERED_INITIALIZERS = { "api_key": API_KEY, + # Keep bound to the server cache until Phase 9 injects it at the composition roots - see DECONTAMINATION.PLAN.MD, Phase 4 preamble. "cache": cache, "step_execution_mode": StepExecutionMode(WORKFLOWS_STEP_EXECUTION_MODE), "background_tasks": None, diff --git a/inference/core/workflows/core_steps/math/cosine_similarity/v1.py b/inference/core/workflows/core_steps/math/cosine_similarity/v1.py index 96dd6f3fe6..52de807c18 100644 --- a/inference/core/workflows/core_steps/math/cosine_similarity/v1.py +++ b/inference/core/workflows/core_steps/math/cosine_similarity/v1.py @@ -2,7 +2,6 @@ from pydantic import ConfigDict, Field -from inference.core.utils.postprocess import cosine_similarity from inference.core.workflows.execution_engine.entities.base import OutputDefinition from inference.core.workflows.execution_engine.entities.types import ( EMBEDDING_KIND, @@ -14,6 +13,7 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.utils.text import cosine_similarity LONG_DESCRIPTION = """ Calculate the cosine similarity between two embedding vectors by computing the cosine of the angle between them, measuring directional similarity regardless of magnitude to enable similarity comparison, semantic matching, embedding-based search, and similarity-based filtering workflows. diff --git a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/model_capabilities.py b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/model_capabilities.py index 12aae4dfee..0e02eff0b9 100644 --- a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/model_capabilities.py +++ b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/model_capabilities.py @@ -8,10 +8,11 @@ Roboflow API proxy (``app/functions/services/anthropicProxy``). """ +import logging import re from typing import Dict, FrozenSet, Optional, Set -from inference.core import logger +logger = logging.getLogger(__name__) _MODELS_WITH_LEGACY_CONTROLS: FrozenSet[str] = frozenset( { diff --git a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py index 96193f202c..56cf3edaf4 100644 --- a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py @@ -9,9 +9,7 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image -from inference.core.utils.preprocess import downscale_image_keeping_aspect_ratio from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.core_steps.models.foundation.anthropic_claude.model_capabilities import ( @@ -42,6 +40,7 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.utils.images import downscale_image_keeping_aspect_ratio SUPPORTED_TASK_TYPES_LIST = [ "unconstrained", @@ -282,15 +281,13 @@ class AnthropicClaudeBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py index a591c4c788..cceb75135d 100644 --- a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py @@ -8,9 +8,7 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image -from inference.core.utils.preprocess import downscale_image_keeping_aspect_ratio from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.core_steps.models.foundation.anthropic_claude.model_capabilities import ( @@ -42,6 +40,7 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.utils.images import downscale_image_keeping_aspect_ratio CLAUDE_MODELS = [ { @@ -376,15 +375,13 @@ class AnthropicClaudeBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py index e5f4455666..6790fa98df 100644 --- a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py @@ -9,10 +9,8 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image -from inference.core.utils.preprocess import downscale_image_keeping_aspect_ratio from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.core_steps.models.foundation.anthropic_claude.model_capabilities import ( @@ -45,6 +43,7 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.utils.images import downscale_image_keeping_aspect_ratio CLAUDE_MODELS = [ { @@ -392,15 +391,13 @@ class AnthropicClaudeBlockV3(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py index 133258d398..c416bd9f87 100644 --- a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py +++ b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py @@ -23,10 +23,8 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image -from inference.core.utils.preprocess import downscale_image_keeping_aspect_ratio from inference.core.workflows.core_steps.common.token_usage import ( TOKEN_OUTPUT_DEFINITIONS, parse_responses_api_usage, @@ -66,6 +64,7 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.utils.images import downscale_image_keeping_aspect_ratio CLAUDE_MODELS = [ { @@ -473,15 +472,13 @@ class AnthropicClaudeBlockV4(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/clip/v1.py b/inference/core/workflows/core_steps/models/foundation/clip/v1.py index acaec7e0da..a9531697dc 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/clip/v1.py @@ -3,7 +3,6 @@ from pydantic import ConfigDict, Field -from inference.core.cache.lru_cache import LRUCache from inference.core.entities.requests.clip import ( ClipImageEmbeddingRequest, ClipTextEmbeddingRequest, @@ -14,7 +13,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import load_core_model @@ -36,6 +34,8 @@ is_workflow_selector, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.utils.lru_cache import LRUCache from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -147,7 +147,7 @@ class ClipModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py index 5d883d4928..6113dcf8d2 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py @@ -4,7 +4,6 @@ import torch from pydantic import ConfigDict, Field -from inference.core.cache.lru_cache import LRUCache from inference.core.env import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, @@ -12,7 +11,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( @@ -35,6 +33,8 @@ is_workflow_selector, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.utils.lru_cache import LRUCache from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -146,7 +146,7 @@ class ClipModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py index b2e98e7a24..a76e9dcb95 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py @@ -12,7 +12,6 @@ WORKFLOWS_REMOTE_API_TARGET, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( @@ -45,6 +44,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -131,7 +131,7 @@ class ClipComparisonBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py index 0c0ac983b5..8a8e38a792 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_API_TARGET, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( @@ -45,6 +44,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -131,7 +131,7 @@ class ClipComparisonBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py index 032e91e42e..cddafcf91f 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py @@ -12,7 +12,6 @@ WORKFLOWS_REMOTE_API_TARGET, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( @@ -46,6 +45,7 @@ is_workflow_selector, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -164,7 +164,7 @@ class ClipComparisonBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py index 39e71ca49b..6e9a78bf6d 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_API_TARGET, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import run_in_parallel @@ -49,6 +48,7 @@ is_workflow_selector, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models import ClassificationPrediction from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -168,7 +168,7 @@ class ClipComparisonBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py b/inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py index a966475955..ebc839f043 100644 --- a/inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py @@ -5,7 +5,6 @@ from inference.core.env import LMM_ENABLED from inference.core.exceptions import FeatureDeprecatedError -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, @@ -165,17 +164,15 @@ class CogVLMBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): - self._model_manager = model_manager self._api_key = api_key self._step_execution_mode = step_execution_mode @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["api_key", "step_execution_mode"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py b/inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py index fd40be3449..4d41fe6528 100644 --- a/inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py @@ -10,7 +10,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, @@ -35,6 +34,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient DEFAULT_PROMPT = "Describe what's in this image." @@ -153,7 +153,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class Cosmos3EdgeBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py b/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py index 15eae342d3..d3f8c9b247 100644 --- a/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, @@ -37,6 +36,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -176,7 +176,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class DepthEstimationBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1_tensor.py index 36548cfa73..ead68f3844 100644 --- a/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1_tensor.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, @@ -37,6 +36,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -192,7 +192,7 @@ def _depth_to_visualization( class DepthEstimationBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py index 8a49a39d9b..9e4ca4e825 100644 --- a/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py @@ -6,7 +6,6 @@ import supervision as sv from pydantic import ConfigDict, Field -from inference.core.cache.lru_cache import LRUCache from inference.core.entities.requests.easy_ocr import EasyOCRInferenceRequest from inference.core.env import ( HOSTED_CORE_MODEL_URL, @@ -16,7 +15,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( @@ -44,6 +42,8 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.utils.lru_cache import LRUCache from inference_sdk import InferenceHTTPClient from inference_sdk.http.entities import InferenceConfiguration @@ -180,7 +180,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class EasyOCRBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py index 205f3018ba..ec42cca543 100644 --- a/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( @@ -42,6 +41,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.object_detection import Detections from inference_sdk import InferenceHTTPClient from inference_sdk.http.entities import InferenceConfiguration @@ -182,7 +182,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class EasyOCRBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/florence2/v1.py b/inference/core/workflows/core_steps/models/foundation/florence2/v1.py index 2f0d4c3119..5f0495ae98 100644 --- a/inference/core/workflows/core_steps/models/foundation/florence2/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/florence2/v1.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.execution_engine.entities.base import ( @@ -43,6 +42,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient T = TypeVar("T") @@ -366,7 +366,7 @@ class Florence2BlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/florence2/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/florence2/v1_tensor.py index 36a80f7775..9443ef7885 100644 --- a/inference/core/workflows/core_steps/models/foundation/florence2/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/florence2/v1_tensor.py @@ -16,7 +16,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( split_key_point_prediction, @@ -35,6 +34,7 @@ WorkflowImageData, ) from inference.core.workflows.prototypes.block import BlockResult, WorkflowBlock +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models import Detections, InstanceDetections, KeyPoints from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -50,7 +50,7 @@ class Florence2BlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/gaze/v1.py b/inference/core/workflows/core_steps/models/foundation/gaze/v1.py index e397566f18..5c90e5e75d 100644 --- a/inference/core/workflows/core_steps/models/foundation/gaze/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/gaze/v1.py @@ -4,7 +4,6 @@ from inference.core.env import CORE_MODEL_GAZE_ENABLED from inference.core.exceptions import FeatureDeprecatedError -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, @@ -124,17 +123,15 @@ def get_supported_model_variants(cls) -> Optional[List[str]]: class GazeBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): - self._model_manager = model_manager self._api_key = api_key self._step_execution_mode = step_execution_mode @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["api_key", "step_execution_mode"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py index d705faf15a..eb4e215f74 100644 --- a/inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, @@ -36,6 +35,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient STRUCTURED_ANSWERING_PROMPT_TEMPLATE = ( @@ -299,7 +299,7 @@ def _resolve_prompt( class GLMOCRBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py index 2d3d9b4cb0..43edd3a5ab 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py @@ -9,7 +9,6 @@ from requests import Response from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA @@ -266,15 +265,13 @@ class GoogleGeminiBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py index 68efbaecc6..eba1e2172a 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py @@ -9,7 +9,6 @@ from requests import Response from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA @@ -336,15 +335,13 @@ class GoogleGeminiBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py index 4227ce70db..6829a76413 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py @@ -9,7 +9,6 @@ from requests import Response from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel @@ -394,15 +393,13 @@ class GoogleGeminiBlockV3(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py index 3b84cd8f10..602beec8b2 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py @@ -9,7 +9,6 @@ from requests import Response from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel @@ -402,15 +401,13 @@ class GoogleGeminiBlockV4(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py index 2980cf7679..33ca3fc9a1 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py @@ -9,7 +9,6 @@ from requests import Response from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.reasoning import ( @@ -446,15 +445,13 @@ class GoogleGeminiBlockV5(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py b/inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py index bd14bfeb86..0b965b9369 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py @@ -7,7 +7,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA @@ -294,15 +293,9 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class GoogleGemmaBlockV1(WorkflowBlock): - def __init__( - self, - model_manager: ModelManager, - ): - self._model_manager = model_manager - @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager"] + return [] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py b/inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py index ad9787a09f..77a11d0fb1 100644 --- a/inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py @@ -7,7 +7,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA @@ -294,15 +293,9 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class KimiOpenRouterBlockV1(WorkflowBlock): - def __init__( - self, - model_manager: ModelManager, - ): - self._model_manager = model_manager - @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager"] + return [] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py b/inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py index eb1aa40825..7d9fc13fb3 100644 --- a/inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py @@ -7,7 +7,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA @@ -326,15 +325,9 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class LlamaVisionBlockV1(WorkflowBlock): - def __init__( - self, - model_manager: ModelManager, - ): - self._model_manager = model_manager - @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager"] + return [] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/lmm/v1.py b/inference/core/workflows/core_steps/models/foundation/lmm/v1.py index cbc7e6a3d4..e70a0e602b 100644 --- a/inference/core/workflows/core_steps/models/foundation/lmm/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/lmm/v1.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_API_TARGET, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( @@ -210,17 +209,15 @@ class LMMBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): - self._model_manager = model_manager self._api_key = api_key self._step_execution_mode = step_execution_mode @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["api_key", "step_execution_mode"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/lmm_classifier/v1.py b/inference/core/workflows/core_steps/models/foundation/lmm_classifier/v1.py index e1d5e059c6..adddbf1372 100644 --- a/inference/core/workflows/core_steps/models/foundation/lmm_classifier/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/lmm_classifier/v1.py @@ -3,7 +3,6 @@ from pydantic import ConfigDict, Field from inference.core.env import LMM_ENABLED -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.models.foundation.lmm.v1 import ( GPT_4V_MODEL_TYPE, @@ -162,17 +161,15 @@ class LMMForClassificationBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): - self._model_manager = model_manager self._api_key = api_key self._step_execution_mode = step_execution_mode @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["api_key", "step_execution_mode"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/moondream2/v1.py b/inference/core/workflows/core_steps/models/foundation/moondream2/v1.py index 8d0d9e6e63..3afda9cd2c 100644 --- a/inference/core/workflows/core_steps/models/foundation/moondream2/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/moondream2/v1.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -45,6 +44,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -150,7 +150,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class Moondream2BlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/moondream2/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/moondream2/v1_tensor.py index ee1fdd53d8..2c832f7d04 100644 --- a/inference/core/workflows/core_steps/models/foundation/moondream2/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/moondream2/v1_tensor.py @@ -10,7 +10,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( attach_native_detection_metadata, @@ -41,6 +40,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -142,7 +142,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class Moondream2BlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/ocr/v1.py index 8145804846..e539ddb879 100644 --- a/inference/core/workflows/core_steps/models/foundation/ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/ocr/v1.py @@ -16,7 +16,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( @@ -44,6 +43,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -134,7 +134,7 @@ class OCRModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py index f82aa0769e..ad1fb8d136 100644 --- a/inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( @@ -41,6 +40,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -129,7 +129,7 @@ class OCRModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v1.py b/inference/core/workflows/core_steps/models/foundation/openai/v1.py index 7cadb4bee8..538fa58f66 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v1.py @@ -8,7 +8,6 @@ from pydantic import BaseModel, ConfigDict, Field from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.execution_engine.constants import ( @@ -176,15 +175,13 @@ class OpenAIBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v2.py b/inference/core/workflows/core_steps/models/foundation/openai/v2.py index 17a87f462a..ac791fa9f7 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v2.py @@ -8,7 +8,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA @@ -255,15 +254,13 @@ class OpenAIBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v3.py b/inference/core/workflows/core_steps/models/foundation/openai/v3.py index b110d30c4b..7ba1e00ac7 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v3.py @@ -9,7 +9,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel @@ -265,15 +264,13 @@ class OpenAIBlockV3(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v4.py b/inference/core/workflows/core_steps/models/foundation/openai/v4.py index 0a05847eaf..f5a30a8a5b 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v4.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v4.py @@ -8,7 +8,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel @@ -382,15 +381,13 @@ class OpenAIBlockV4(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v5.py b/inference/core/workflows/core_steps/models/foundation/openai/v5.py index 161e2b1390..6d085f579d 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v5.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v5.py @@ -10,7 +10,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import ( @@ -526,15 +525,13 @@ class OpenAIBlockV5(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v6.py b/inference/core/workflows/core_steps/models/foundation/openai/v6.py index a196202980..3f8be1a4fa 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v6.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v6.py @@ -10,7 +10,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.reasoning import ( @@ -555,15 +554,13 @@ class OpenAIBlockV6(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/openai_compatible/v1.py b/inference/core/workflows/core_steps/models/foundation/openai_compatible/v1.py index aa55a1d713..950ca64398 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai_compatible/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/openai_compatible/v1.py @@ -1,4 +1,5 @@ import base64 +import logging import re from collections import defaultdict from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Type, Union @@ -8,7 +9,6 @@ from pydantic import ConfigDict, Field from inference.core.env import OPENAI_COMPATIBLE_ALLOWED_BASE_URLS -from inference.core.logger import logger from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.query_language.entities.operations import ( AllOperationsType, @@ -36,6 +36,8 @@ third_party_model, ) +logger = logging.getLogger(__name__) + PARAMETER_REGEX = re.compile(r"({{\s*\$parameters\.(\w+)\s*}})") LONG_DESCRIPTION = """ diff --git a/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py b/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py index 0a340e9e58..f47cd0fc17 100644 --- a/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py @@ -3,7 +3,6 @@ from pydantic import ConfigDict, Field -from inference.core.cache.lru_cache import LRUCache from inference.core.entities.requests.perception_encoder import ( PerceptionEncoderImageEmbeddingRequest, PerceptionEncoderTextEmbeddingRequest, @@ -15,7 +14,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import load_core_model @@ -40,6 +38,8 @@ is_workflow_selector, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.utils.lru_cache import LRUCache from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -157,7 +157,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class PerceptionEncoderModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py index e47b0a8365..a34c23f806 100644 --- a/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py @@ -4,7 +4,6 @@ import torch from pydantic import ConfigDict, Field -from inference.core.cache.lru_cache import LRUCache from inference.core.env import ( CORE_MODEL_PE_ENABLED, HOSTED_CORE_MODEL_URL, @@ -13,7 +12,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( @@ -39,6 +37,8 @@ is_workflow_selector, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.utils.lru_cache import LRUCache from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -156,7 +156,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class PerceptionEncoderModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py index e48d728957..c2184233ad 100644 --- a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( @@ -39,6 +38,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceHTTPClient from inference_sdk.http.entities import InferenceConfiguration @@ -160,7 +160,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class PPOCRBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py index 1d1ad633a8..65b12b9417 100644 --- a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py @@ -8,7 +8,7 @@ PP-OCR has no `run_tensor_native_inference` adapter, so both execution modes keep the exact model-calling machinery of `pp_ocr.v1` (a `PPOCRInferenceRequest` served -by the local `ModelManager`, or `InferenceHTTPClient.ocr_image` remotely) and +by the local `ModelsProvider`, or `InferenceHTTPClient.ocr_image` remotely) and produce the standard inference-format `OCRInferenceResponse` dicts. Those dicts are converted to a native `Detections` HERE (no shared util is touched) so the numpy `post_process_ocr_result` / `sv.Detections.from_inference` path is bypassed. @@ -37,7 +37,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( @@ -68,6 +67,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.object_detection import Detections from inference_sdk import InferenceConfiguration, InferenceHTTPClient from inference_sdk.http.entities import InferenceConfiguration @@ -189,7 +189,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class PPOCRBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/qwen/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen/v1.py index fa4a12a117..687848059b 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen/v1.py @@ -12,7 +12,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, @@ -38,6 +37,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -156,7 +156,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class Qwen25VLBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py index b5f7d64dd8..4ab187ef3f 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py @@ -7,7 +7,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA @@ -306,15 +305,9 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class Qwen35OpenRouterBlockV1(WorkflowBlock): - def __init__( - self, - model_manager: ModelManager, - ): - self._model_manager = model_manager - @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager"] + return [] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py index 60e88da860..3732b56bce 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py @@ -12,7 +12,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, @@ -38,6 +37,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -180,7 +180,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class Qwen35VLBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1_tensor.py index 341a4e1517..7882f38289 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1_tensor.py @@ -8,7 +8,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.models.foundation.qwen3_5vl.v1 import ( BlockManifest, @@ -22,13 +21,14 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient class Qwen35VLBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py index 62dfdcbe1e..f5498260de 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py @@ -10,7 +10,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, @@ -34,6 +33,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -154,7 +154,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class Qwen35VLBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py index 4d95893eec..3726df50c2 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py @@ -7,7 +7,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA @@ -303,15 +302,9 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class Qwen36OpenRouterBlockV1(WorkflowBlock): - def __init__( - self, - model_manager: ModelManager, - ): - self._model_manager = model_manager - @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager"] + return [] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py index c3c6f036e3..51b2dc302e 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py @@ -12,7 +12,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, @@ -38,6 +37,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -153,7 +153,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class Qwen3VLBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1_tensor.py index 7c0c732561..05e25f07e4 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1_tensor.py @@ -21,7 +21,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode # Unchanged from v1 — verbatim manifest, class name, type literal, outputs. @@ -37,13 +36,14 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient class Qwen3VLBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py index 40b20c37f0..91135b13b1 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py @@ -34,7 +34,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.openrouter import ( @@ -74,6 +73,7 @@ roboflow_platform_model, third_party_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient # --------------------------------------------------------------------------- @@ -780,7 +780,7 @@ class QwenVlmBlockV1(OpenRouterWorkflowBlockBase): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py index 6fc99c552a..6814a2034d 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py @@ -39,7 +39,6 @@ LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.openrouter import ( @@ -81,6 +80,7 @@ roboflow_platform_model, third_party_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceHTTPClient # --------------------------------------------------------------------------- @@ -1167,7 +1167,7 @@ class QwenVlmBlockV2(OpenRouterWorkflowBlockBase): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py index d00bc6e73a..1e040e37da 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py @@ -42,7 +42,6 @@ LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.openrouter import ( @@ -91,6 +90,7 @@ roboflow_platform_model, third_party_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceHTTPClient # --------------------------------------------------------------------------- @@ -1207,7 +1207,7 @@ class QwenVlmBlockV3(OpenRouterWorkflowBlockBase): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py b/inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py index 62175ca3b5..b7b22d792c 100644 --- a/inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py @@ -16,7 +16,6 @@ ROBOFLOW_INTERNAL_SERVICE_NAME, ROBOFLOW_INTERNAL_SERVICE_SECRET, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import build_roboflow_api_headers from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode @@ -141,17 +140,15 @@ class SegPreviewBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): - self._model_manager = model_manager self._api_key = api_key self._step_execution_mode = step_execution_mode @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["api_key", "step_execution_mode"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py index 8da963e855..78394cf168 100644 --- a/inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py @@ -30,7 +30,6 @@ ROBOFLOW_INTERNAL_SERVICE_SECRET, WORKFLOWS_IMAGE_TENSOR_DEVICE, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import build_roboflow_api_headers from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode @@ -160,17 +159,15 @@ class SegPreviewBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): - self._model_manager = model_manager self._api_key = api_key self._step_execution_mode = step_execution_mode @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["api_key", "step_execution_mode"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py index 1bff9d1e93..1d48ff1d81 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py @@ -24,7 +24,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( @@ -60,6 +59,7 @@ is_workflow_selector, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient T = TypeVar("T") @@ -215,7 +215,7 @@ class SegmentAnything2BlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py index 45c028db9a..1ac1bb08b1 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( @@ -54,6 +53,7 @@ is_workflow_selector, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.types import InstancesRLEMasks from inference_models.models.common.rle_utils import ( @@ -216,7 +216,7 @@ class SegmentAnything2BlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py index c0fdd0cdba..dcfbe937c2 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py @@ -26,7 +26,6 @@ import supervision as sv from pydantic import ConfigDict, Field -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import get_extra_weights_provider_headers from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( @@ -67,6 +66,7 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference.usage_tracking.collector import usage_collector PromptMode = Literal["first_frame", "every_n_frames", "every_frame"] @@ -241,7 +241,7 @@ class SegmentAnything2VideoBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py index 4b0e0c2343..e37167ba7e 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py @@ -32,7 +32,6 @@ from pydantic import ConfigDict, Field from inference.core.env import GCP_SERVERLESS, WORKFLOWS_SAM_VIDEO_MASK_REPRESENTATION -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import get_extra_weights_provider_headers from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.models.foundation.segment_anything_common.streaming_video import ( @@ -72,6 +71,7 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider PromptMode = Literal["first_frame", "every_n_frames", "every_frame"] @@ -230,7 +230,7 @@ class SegmentAnything2VideoBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py index 2d26398f9f..ab775be9a3 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py @@ -1,3 +1,4 @@ +import logging from types import SimpleNamespace from typing import List, Literal, Optional, Type, Union @@ -6,7 +7,6 @@ import supervision as sv from pydantic import ConfigDict, Field -from inference.core import logger from inference.core.entities.requests.sam3 import Sam3Prompt, Sam3SegmentationRequest from inference.core.entities.responses.inference import ( InferenceResponseImage, @@ -26,7 +26,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import build_roboflow_api_headers from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode @@ -66,8 +65,11 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient +logger = logging.getLogger(__name__) + DETECTIONS_CLASS_NAME_FIELD = "class_name" DETECTION_ID_FIELD = "detection_id" @@ -190,7 +192,7 @@ class SegmentAnything3BlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py index e2da54fb51..6680a1d428 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py @@ -40,7 +40,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import build_roboflow_api_headers from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode @@ -79,6 +78,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.types import InstancesRLEMasks from inference_models.models.common.rle_utils import torch_mask_to_coco_rle @@ -198,7 +198,7 @@ class SegmentAnything3BlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py index f6db9a2e2c..5db38188a2 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py @@ -1,3 +1,4 @@ +import logging from types import SimpleNamespace from typing import List, Literal, Optional, Type, Union @@ -6,7 +7,6 @@ import supervision as sv from pydantic import ConfigDict, Field, model_validator, validator -from inference.core import logger from inference.core.entities.requests.sam3 import Sam3Prompt, Sam3SegmentationRequest from inference.core.entities.responses.inference import ( InferenceResponseImage, @@ -26,7 +26,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import build_roboflow_api_headers from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode @@ -66,8 +65,11 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient +logger = logging.getLogger(__name__) + DETECTIONS_CLASS_NAME_FIELD = "class_name" DETECTION_ID_FIELD = "detection_id" @@ -241,7 +243,7 @@ class SegmentAnything3BlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py index 0b93b7dbff..5ac6575163 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py @@ -41,7 +41,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import build_roboflow_api_headers from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode @@ -87,6 +86,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.types import InstancesRLEMasks from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -259,7 +259,7 @@ class SegmentAnything3BlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py index 0aca35bc7a..df6c91e48a 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py @@ -1,3 +1,4 @@ +import logging from typing import Dict, List, Literal, Optional, Type, Union import numpy as np @@ -6,7 +7,6 @@ from pycocotools import mask as mask_utils from pydantic import ConfigDict, Field, model_validator, validator -from inference.core import logger from inference.core.entities.requests.sam3 import Sam3Prompt, Sam3SegmentationRequest from inference.core.entities.responses.inference import ( InferenceResponseImage, @@ -26,7 +26,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import build_roboflow_api_headers from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode @@ -69,8 +68,11 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient +logger = logging.getLogger(__name__) + SHORT_DESCRIPTION = "Run SAM3 with text prompts for zero-shot segmentation." LONG_DESCRIPTION = """ @@ -268,7 +270,7 @@ class SegmentAnything3BlockV3(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py index d3b6e8182b..d683641e8b 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py @@ -37,13 +37,13 @@ no-op on InstanceDetections and cannot be reused). """ +import logging from typing import Dict, List, Literal, Optional, Type, Union import numpy as np import requests from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core import logger from inference.core.entities.requests.sam3 import Sam3Prompt from inference.core.env import ( API_BASE_URL, @@ -56,7 +56,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import build_roboflow_api_headers from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode @@ -105,8 +104,11 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient +logger = logging.getLogger(__name__) + LONG_DESCRIPTION = """ Run Segment Anything 3 (zero-shot, text-prompted) with per-class confidence thresholds, optional cross-prompt NMS, and post-inference class renaming. @@ -296,7 +298,7 @@ class SegmentAnything3BlockV3(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py index f8c5b4f6ea..c82c8983c5 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py @@ -14,7 +14,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, @@ -42,6 +41,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -152,7 +152,7 @@ class SegmentAnything3_3D_ObjectsBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py index 132b8d7b2a..b98f1de2f1 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py @@ -32,7 +32,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( instance_mask_to_numpy, @@ -65,6 +64,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.instance_segmentation import InstanceDetections from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -177,7 +177,7 @@ class SegmentAnything3_3D_ObjectsBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py index 78d6794628..2221b30089 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py @@ -1,3 +1,4 @@ +import logging from dataclasses import dataclass from typing import Any, List, Literal, Optional, Type, Union @@ -5,7 +6,6 @@ import supervision as sv from pydantic import ConfigDict, Field, model_validator -from inference.core import logger from inference.core.entities.requests.sam2 import ( Box, Point, @@ -25,7 +25,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import build_roboflow_api_headers from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode @@ -69,8 +68,11 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient +logger = logging.getLogger(__name__) + DETECTIONS_CLASS_NAME_FIELD = "class_name" DETECTION_ID_FIELD = "detection_id" @@ -244,7 +246,7 @@ class SegmentAnything3InteractiveBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py index 86c303b486..c2a07d085d 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py @@ -10,7 +10,7 @@ tensor, class names resolved via per-box ``CLASS_NAME_KEY`` override or the ``image_metadata[CLASS_NAMES_KEY]`` map, ``detection_id`` from ``bboxes_metadata`` (mirroring ``segment_anything2/v1_tensor.py``). -- LOCAL goes through ``ModelManager.run_tensor_native_inference`` (the +- LOCAL goes through ``ModelsProvider.run_tensor_native_inference`` (the ``InferenceModelsSAM3InteractiveAdapter`` ``action="segment"`` bridge to ``SAM3Torch.segment_with_visual_prompts``): tensor-resident images are handed over directly (RGB; host-only images are flipped BGR→RGB), prompts are @@ -27,6 +27,7 @@ convention keeping numpy-faithful boxes). """ +import logging import uuid from dataclasses import dataclass from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union @@ -36,7 +37,6 @@ import torch from pydantic import ConfigDict, Field, model_validator -from inference.core import logger from inference.core.entities.requests.sam2 import Box, Point, Sam2Prompt, Sam2PromptSet from inference.core.env import ( API_BASE_URL, @@ -50,7 +50,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import build_roboflow_api_headers from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode @@ -97,6 +96,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.types import InstancesRLEMasks from inference_models.models.common.rle_utils import ( @@ -105,6 +105,8 @@ ) from inference_sdk import InferenceConfiguration, InferenceHTTPClient +logger = logging.getLogger(__name__) + DETECTIONS_CLASS_NAME_FIELD = "class_name" DETECTION_ID_FIELD = "detection_id" @@ -285,7 +287,7 @@ class SegmentAnything3InteractiveBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py index bf10aa0d1c..dbfc23a275 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py @@ -24,7 +24,6 @@ import supervision as sv from pydantic import ConfigDict, Field, model_validator -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import get_extra_weights_provider_headers from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( @@ -76,6 +75,7 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference.usage_tracking.collector import usage_collector PromptMode = Literal["first_frame", "every_n_frames", "every_frame"] @@ -336,7 +336,7 @@ class SegmentAnything3VideoBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py index 6c295c7bde..5459261e57 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py @@ -42,7 +42,6 @@ WORKFLOWS_IMAGE_TENSOR_DEVICE, WORKFLOWS_SAM_VIDEO_MASK_REPRESENTATION, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import get_extra_weights_provider_headers from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( @@ -101,6 +100,7 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.types import InstancesRLEMasks from inference_models.models.common.rle_utils import torch_mask_to_coco_rle @@ -376,7 +376,7 @@ class SegmentAnything3VideoBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py b/inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py index 8efa38b66b..48e86e7367 100644 --- a/inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py @@ -10,7 +10,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, @@ -34,6 +33,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -137,7 +137,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: class SmolVLM2BlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/smolvlm/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/smolvlm/v1_tensor.py index 1f6886345c..a23c00670e 100644 --- a/inference/core/workflows/core_steps/models/foundation/smolvlm/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/smolvlm/v1_tensor.py @@ -20,7 +20,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode # Unchanged from v1 — verbatim manifest, class name and type literal. @@ -36,13 +35,14 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient class SmolVLM2BlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/spacexai/v1.py b/inference/core/workflows/core_steps/models/foundation/spacexai/v1.py index 9996f92c4b..dfc0b28902 100644 --- a/inference/core/workflows/core_steps/models/foundation/spacexai/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/spacexai/v1.py @@ -18,7 +18,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel @@ -329,15 +328,13 @@ class SpaceXAIBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/spacexai/v2.py b/inference/core/workflows/core_steps/models/foundation/spacexai/v2.py index 037fb8019f..413ed41d42 100644 --- a/inference/core/workflows/core_steps/models/foundation/spacexai/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/spacexai/v2.py @@ -18,7 +18,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.reasoning import ( @@ -358,15 +357,13 @@ class SpaceXAIBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], ): - self._model_manager = model_manager self._api_key = api_key @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py b/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py index d5cf485a20..25e2e06a1f 100644 --- a/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py @@ -10,7 +10,6 @@ WORKFLOWS_REMOTE_API_TARGET, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( @@ -42,6 +41,7 @@ is_workflow_selector, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient from inference_sdk.http.utils.iterables import make_batches @@ -165,7 +165,7 @@ class YoloWorldModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py index 0365e19504..af8465b54b 100644 --- a/inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py @@ -13,7 +13,6 @@ from pydantic import ConfigDict, Field from inference.core.exceptions import FeatureDeprecatedError -from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( @@ -144,17 +143,15 @@ class YoloWorldModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): - self._model_manager = model_manager self._api_key = api_key self._step_execution_mode = step_execution_mode @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["api_key", "step_execution_mode"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/roboflow/action_recognition/v1.py b/inference/core/workflows/core_steps/models/roboflow/action_recognition/v1.py index f735649b5a..e1c23224aa 100644 --- a/inference/core/workflows/core_steps/models/roboflow/action_recognition/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/action_recognition/v1.py @@ -1,5 +1,6 @@ """Stateful action recognition workflow block.""" +import logging import math from collections import OrderedDict from dataclasses import dataclass, field @@ -9,8 +10,6 @@ import numpy as np from pydantic import ConfigDict, Field, model_validator -from inference.core import logger -from inference.core.managers.base import ModelManager from inference.core.models.action_recognition import merge_window_segments from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.models.foundation.segment_anything_common.streaming_video import ( @@ -53,6 +52,8 @@ effective_max_frame_side, ) +logger = logging.getLogger(__name__) + DEFAULT_SOURCE_FPS = 30.0 # A crop step mints a video identifier per detection per frame, so the # per-video state needs a ceiling of its own. @@ -227,11 +228,9 @@ class ActionRecognitionModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): - self._model_manager = model_manager self._api_key = api_key self._step_execution_mode = step_execution_mode self._model = None @@ -243,7 +242,7 @@ def __init__( @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["api_key", "step_execution_mode"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py index 91a47ab643..1bdf2a19b5 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -50,6 +49,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -208,7 +208,7 @@ class RoboflowInstanceSegmentationModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1_tensor.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1_tensor.py index 751c33e418..87e9d20baa 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1_tensor.py @@ -7,7 +7,7 @@ ``TENSOR_NATIVE_RLE_INSTANCE_SEGMENTATION_PREDICTION_KIND`` instead of ``sv.Detections``. -- LOCAL: ``ModelManager.run_tensor_native_inference`` returns +- LOCAL: ``ModelsProvider.run_tensor_native_inference`` returns ``List[InstanceDetections]`` straight from the adapter. The mask carrier (dense ``torch.Tensor`` vs ``InstancesRLEMasks``) is adapter-decided: the v1 manifest exposes ``enforce_dense_masks_in_inference_models`` and the adapter consumes it @@ -51,7 +51,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( attach_native_detection_metadata, @@ -101,6 +100,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.types import InstancesRLEMasks from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -271,7 +271,7 @@ class RoboflowInstanceSegmentationModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py index 07135b74eb..e306d2377e 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -51,6 +50,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -206,7 +206,7 @@ class RoboflowInstanceSegmentationModelBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2_tensor.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2_tensor.py index 563de0acf0..d3853df0c6 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2_tensor.py @@ -7,7 +7,7 @@ ``TENSOR_NATIVE_RLE_INSTANCE_SEGMENTATION_PREDICTION_KIND`` instead of ``sv.Detections``. -- LOCAL: ``ModelManager.run_tensor_native_inference`` returns +- LOCAL: ``ModelsProvider.run_tensor_native_inference`` returns ``List[InstanceDetections]`` straight from the adapter. The mask carrier (dense ``torch.Tensor`` vs ``InstancesRLEMasks``) is adapter-decided: the v2 manifest exposes ``enforce_dense_masks_in_inference_models`` and the adapter consumes it @@ -51,7 +51,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( attach_native_detection_metadata, @@ -102,6 +101,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.types import InstancesRLEMasks from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -269,7 +269,7 @@ class RoboflowInstanceSegmentationModelBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py index 1fc5801def..fc775ca6da 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py @@ -22,7 +22,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -60,6 +59,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.configuration import get_rfdetr_pipeline_depth from inference_models.models.base.async_handoff import ( get_async_response_context_id, @@ -265,7 +265,7 @@ class RoboflowInstanceSegmentationModelBlockV3(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3_tensor.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3_tensor.py index f1d1a2f1c9..8fa27c4749 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3_tensor.py @@ -7,7 +7,7 @@ ``TENSOR_NATIVE_RLE_INSTANCE_SEGMENTATION_PREDICTION_KIND`` instead of ``sv.Detections``. -- LOCAL: ``ModelManager.run_tensor_native_inference`` returns +- LOCAL: ``ModelsProvider.run_tensor_native_inference`` returns ``List[InstanceDetections]`` straight from the adapter. The mask carrier (dense ``torch.Tensor`` vs ``InstancesRLEMasks``) is adapter-decided: the v3 manifest exposes ``enforce_dense_masks_in_inference_models`` and the adapter consumes it @@ -51,7 +51,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( attach_native_detection_metadata, @@ -102,6 +101,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.types import InstancesRLEMasks from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -306,7 +306,7 @@ class RoboflowInstanceSegmentationModelBlockV3(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py index 6341293cee..4253ea0614 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -52,6 +51,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -242,7 +242,7 @@ class RoboflowInstanceSegmentationModelBlockV4(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4_tensor.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4_tensor.py index fc6b351af0..6bdbe5bd8d 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4_tensor.py @@ -7,7 +7,7 @@ ``TENSOR_NATIVE_RLE_INSTANCE_SEGMENTATION_PREDICTION_KIND`` instead of ``sv.Detections``. -- LOCAL: ``ModelManager.run_tensor_native_inference`` returns +- LOCAL: ``ModelsProvider.run_tensor_native_inference`` returns ``List[InstanceDetections]`` straight from the adapter. The mask carrier (dense ``torch.Tensor`` vs ``InstancesRLEMasks``) is adapter-decided; both are handled downstream by the helpers and the tensor serialiser. The block applies @@ -49,7 +49,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( attach_native_detection_metadata, @@ -100,6 +99,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.types import InstancesRLEMasks from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -299,7 +299,7 @@ class RoboflowInstanceSegmentationModelBlockV4(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py index e8e587f56d..5d93922fc3 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( add_inference_keypoints_to_sv_detections, @@ -51,6 +50,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -190,7 +190,7 @@ class RoboflowKeypointDetectionModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1_tensor.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1_tensor.py index c5154f57fd..95ef0ce8be 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1_tensor.py @@ -27,7 +27,7 @@ ``KeyPoints`` component stays available to downstream tensor-native consumers; only the serialiser unwraps the tuple back to the bbox ``Detections``. -- LOCAL: ``ModelManager.run_tensor_native_inference`` returns +- LOCAL: ``ModelsProvider.run_tensor_native_inference`` returns ``Tuple[List[KeyPoints], List[Detections]]`` from the adapter. ``class_filter`` is applied here natively (the adapter/model does NOT read it on this path) - the slice is applied to the tuple so the ``KeyPoints`` and bbox ``Detections`` stay aligned. @@ -52,7 +52,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.keypoints import ( validate_keypoints_padding, @@ -102,6 +101,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.keypoints_detection import KeyPoints from inference_models.models.base.object_detection import Detections from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -250,7 +250,7 @@ class RoboflowKeypointDetectionModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py index 0b6087c845..b3d33181a9 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( add_inference_keypoints_to_sv_detections, @@ -51,6 +50,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -187,7 +187,7 @@ class RoboflowKeypointDetectionModelBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2_tensor.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2_tensor.py index 506bd1bc91..86c340ab47 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2_tensor.py @@ -28,7 +28,7 @@ ``KeyPoints`` component stays available to downstream tensor-native consumers; only the serialiser unwraps the tuple back to the bbox ``Detections``. -- LOCAL: ``ModelManager.run_tensor_native_inference`` returns +- LOCAL: ``ModelsProvider.run_tensor_native_inference`` returns ``Tuple[List[KeyPoints], List[Detections]]`` from the adapter. ``class_filter`` is applied here natively (the adapter/model does NOT read it on this path) - the slice is applied to the tuple so the ``KeyPoints`` and bbox ``Detections`` stay aligned. @@ -53,7 +53,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.keypoints import ( validate_keypoints_padding, @@ -103,6 +102,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.keypoints_detection import KeyPoints from inference_models.models.base.object_detection import Detections from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -248,7 +248,7 @@ class RoboflowKeypointDetectionModelBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py index f30a777e4e..67b2c5712b 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( add_inference_keypoints_to_sv_detections, @@ -52,6 +51,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -222,7 +222,7 @@ class RoboflowKeypointDetectionModelBlockV3(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3_tensor.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3_tensor.py index 75f1de9f98..d5da875241 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3_tensor.py @@ -22,7 +22,7 @@ ``KeyPoints`` component stays available to downstream tensor-native consumers; only the serialiser unwraps the tuple back to the bbox ``Detections``. -- LOCAL: ``ModelManager.run_tensor_native_inference`` returns +- LOCAL: ``ModelsProvider.run_tensor_native_inference`` returns ``Tuple[List[KeyPoints], List[Detections]]`` from the adapter. ``class_filter`` is applied here natively (the adapter/model does NOT read it on this path) - the slice is applied to the tuple so the ``KeyPoints`` and bbox ``Detections`` stay aligned. @@ -47,7 +47,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.keypoints import ( validate_keypoints_padding, @@ -98,6 +97,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.keypoints_detection import KeyPoints from inference_models.models.base.object_detection import Detections from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -277,7 +277,7 @@ class RoboflowKeypointDetectionModelBlockV3(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py index a3879f913f..751464922d 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.execution_engine.constants import ( @@ -45,6 +44,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -137,7 +137,7 @@ class RoboflowClassificationModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1_tensor.py b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1_tensor.py index 0761d7c34b..8ebc917d7e 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1_tensor.py @@ -12,7 +12,7 @@ is changed to the tensor-native kind. The run bodies follow the ``v3_tensor`` pattern: -- LOCAL: ``ModelManager.run_tensor_native_inference`` returns ONE batched +- LOCAL: ``ModelsProvider.run_tensor_native_inference`` returns ONE batched ``ClassificationPrediction`` (``class_id`` shape ``(bs,)``, ``confidence`` shape ``(bs, num_classes)`` full softmax). The consumer indexes per-image, so the block fans the batched object out into ``bs`` single-row predictions, each @@ -40,7 +40,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, @@ -80,6 +79,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.classification import ClassificationPrediction from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -193,7 +193,7 @@ class RoboflowClassificationModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py index c96d080d1e..2991b57f0f 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.execution_engine.constants import ( @@ -45,6 +44,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -141,7 +141,7 @@ class RoboflowClassificationModelBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2_tensor.py b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2_tensor.py index ed7a056f3e..d7afe1d66d 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2_tensor.py @@ -12,7 +12,7 @@ kind is changed to the tensor-native kind. The run bodies follow the ``v3_tensor`` pattern: -- LOCAL: ``ModelManager.run_tensor_native_inference`` returns ONE batched +- LOCAL: ``ModelsProvider.run_tensor_native_inference`` returns ONE batched ``ClassificationPrediction`` (``class_id`` shape ``(bs,)``, ``confidence`` shape ``(bs, num_classes)`` full softmax). The consumer indexes per-image, so the block fans the batched object out into ``bs`` single-row predictions, each @@ -40,7 +40,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, @@ -80,6 +79,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.classification import ClassificationPrediction from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -197,7 +197,7 @@ class RoboflowClassificationModelBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py index 49fc47944f..7a6d0d3cb2 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.execution_engine.constants import ( @@ -46,6 +45,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -181,7 +181,7 @@ class RoboflowClassificationModelBlockV3(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3_tensor.py b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3_tensor.py index c41f2a0d2c..0ae747e63a 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3_tensor.py @@ -6,7 +6,7 @@ ``TENSOR_NATIVE_CLASSIFICATION_PREDICTION_KIND`` instead of the legacy classification response dict. -- LOCAL: ``ModelManager.run_tensor_native_inference`` returns ONE batched +- LOCAL: ``ModelsProvider.run_tensor_native_inference`` returns ONE batched ``ClassificationPrediction`` (``class_id`` shape ``(bs,)``, ``confidence`` shape ``(bs, num_classes)`` full softmax). The consumer indexes per-image, so the block fans the batched object out into ``bs`` single-row predictions, each @@ -34,7 +34,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, @@ -75,6 +74,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.classification import ClassificationPrediction from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -232,7 +232,7 @@ class RoboflowClassificationModelBlockV3(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py index e169ee71da..06a3956d70 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.execution_engine.constants import ( @@ -45,6 +44,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -143,7 +143,7 @@ class RoboflowMultiLabelClassificationModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1_tensor.py b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1_tensor.py index 5defb29c20..79ebc7bb5f 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1_tensor.py @@ -12,7 +12,7 @@ ``inference_id`` only — NO ``model_id`` output, and ``inference_id`` is a plain ``STRING_KIND``). The result dicts likewise carry no ``model_id`` key. -- LOCAL: ``ModelManager.run_tensor_native_inference`` returns a +- LOCAL: ``ModelsProvider.run_tensor_native_inference`` returns a ``List[MultiLabelClassificationPrediction]`` (one per image) straight from the adapter. Each carries ``class_ids`` (the already-threshold-filtered predicted label ids — the model's ``post_process`` applied the full priority chain, so we @@ -43,7 +43,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, @@ -83,6 +82,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.classification import ( MultiLabelClassificationPrediction, ) @@ -189,7 +189,7 @@ class RoboflowMultiLabelClassificationModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py index cfb0c6b626..8990daffbd 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.execution_engine.constants import ( @@ -45,6 +44,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -140,7 +140,7 @@ class RoboflowMultiLabelClassificationModelBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2_tensor.py b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2_tensor.py index 74445f2942..f4d8b54655 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2_tensor.py @@ -12,7 +12,7 @@ (``predictions`` + ``inference_id`` as ``INFERENCE_ID_KIND`` + ``model_id``). The result dicts carry the ``model_id`` key as v2 does. -- LOCAL: ``ModelManager.run_tensor_native_inference`` returns a +- LOCAL: ``ModelsProvider.run_tensor_native_inference`` returns a ``List[MultiLabelClassificationPrediction]`` (one per image) straight from the adapter. Each carries ``class_ids`` (the already-threshold-filtered predicted label ids — the model's ``post_process`` applied the full priority chain, so we @@ -43,7 +43,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, @@ -83,6 +82,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.classification import ( MultiLabelClassificationPrediction, ) @@ -186,7 +186,7 @@ class RoboflowMultiLabelClassificationModelBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py index be13c0d4d2..0903debc24 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.execution_engine.constants import ( @@ -46,6 +45,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -181,7 +181,7 @@ class RoboflowMultiLabelClassificationModelBlockV3(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3_tensor.py b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3_tensor.py index 3944eeecba..e306baa57c 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3_tensor.py @@ -6,7 +6,7 @@ ``TENSOR_NATIVE_CLASSIFICATION_PREDICTION_KIND`` instead of the standard multi-label classification prediction dict. -- LOCAL: ``ModelManager.run_tensor_native_inference`` returns a +- LOCAL: ``ModelsProvider.run_tensor_native_inference`` returns a ``List[MultiLabelClassificationPrediction]`` (one per image) straight from the adapter. Each carries ``class_ids`` (the already-threshold-filtered predicted label ids — the model's ``post_process`` applied the full priority chain, so we @@ -37,7 +37,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, @@ -78,6 +77,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.classification import ( MultiLabelClassificationPrediction, ) @@ -221,7 +221,7 @@ class RoboflowMultiLabelClassificationModelBlockV3(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py b/inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py index b13b6e3509..5692c72ac9 100644 --- a/inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -48,6 +47,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -180,7 +180,7 @@ class RoboflowObjectDetectionModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/object_detection/v1_tensor.py b/inference/core/workflows/core_steps/models/roboflow/object_detection/v1_tensor.py index 2e64d148cd..694df311c7 100644 --- a/inference/core/workflows/core_steps/models/roboflow/object_detection/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/object_detection/v1_tensor.py @@ -4,7 +4,7 @@ ``inference_models.Detections`` (torch tensors on ``WORKFLOWS_IMAGE_TENSOR_DEVICE``) under ``TENSOR_NATIVE_OBJECT_DETECTION_PREDICTION_KIND`` instead of ``sv.Detections``. -- LOCAL: ``ModelManager.run_tensor_native_inference`` returns ``List[Detections]`` +- LOCAL: ``ModelsProvider.run_tensor_native_inference`` returns ``List[Detections]`` straight from the adapter (xyxy / class_id / confidence only). The block applies ``class_filter`` natively (the adapter/model does NOT read it on this path) and attaches the producer contract (``image_metadata[class_names]`` + per-box @@ -33,7 +33,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( attach_native_detection_metadata, @@ -71,6 +70,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.object_detection import Detections from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -207,7 +207,7 @@ class RoboflowObjectDetectionModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py b/inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py index 5e1c26f076..679f3f3716 100644 --- a/inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -48,6 +47,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -177,7 +177,7 @@ class RoboflowObjectDetectionModelBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/object_detection/v2_tensor.py b/inference/core/workflows/core_steps/models/roboflow/object_detection/v2_tensor.py index a18b8236ae..f104458df4 100644 --- a/inference/core/workflows/core_steps/models/roboflow/object_detection/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/object_detection/v2_tensor.py @@ -4,7 +4,7 @@ ``inference_models.Detections`` (torch tensors on ``WORKFLOWS_IMAGE_TENSOR_DEVICE``) under ``TENSOR_NATIVE_OBJECT_DETECTION_PREDICTION_KIND`` instead of ``sv.Detections``. -- LOCAL: ``ModelManager.run_tensor_native_inference`` returns ``List[Detections]`` +- LOCAL: ``ModelsProvider.run_tensor_native_inference`` returns ``List[Detections]`` straight from the adapter (xyxy / class_id / confidence only). The block applies ``class_filter`` natively (the adapter/model does NOT read it on this path) and attaches the producer contract (``image_metadata[class_names]`` + per-box @@ -32,7 +32,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( attach_native_detection_metadata, @@ -70,6 +69,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.object_detection import Detections from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -203,7 +203,7 @@ class RoboflowObjectDetectionModelBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py b/inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py index a5ac20e34e..d87e878aff 100644 --- a/inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -49,6 +48,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -215,7 +215,7 @@ class RoboflowObjectDetectionModelBlockV3(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/object_detection/v3_tensor.py b/inference/core/workflows/core_steps/models/roboflow/object_detection/v3_tensor.py index fa9df34924..7f381cb5b9 100644 --- a/inference/core/workflows/core_steps/models/roboflow/object_detection/v3_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/object_detection/v3_tensor.py @@ -4,7 +4,7 @@ ``inference_models.Detections`` (torch tensors on ``WORKFLOWS_IMAGE_TENSOR_DEVICE``) under ``TENSOR_NATIVE_OBJECT_DETECTION_PREDICTION_KIND`` instead of ``sv.Detections``. -- LOCAL: ``ModelManager.run_tensor_native_inference`` returns ``List[Detections]`` +- LOCAL: ``ModelsProvider.run_tensor_native_inference`` returns ``List[Detections]`` straight from the adapter (xyxy / class_id / confidence only). The block applies ``class_filter`` natively (the adapter/model does NOT read it on this path) and attaches the producer contract (``image_metadata[class_names]`` + per-box @@ -28,7 +28,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( attach_native_detection_metadata, @@ -67,6 +66,7 @@ roboflow_platform_model, roboflow_platform_project, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.object_detection import Detections from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -237,7 +237,7 @@ class RoboflowObjectDetectionModelBlockV3(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py index cc5c455b7b..28786caad6 100644 --- a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py @@ -19,7 +19,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.constants import ( DETECTION_ID_KEY, @@ -48,6 +47,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -119,7 +119,7 @@ class RoboflowSemanticSegmentationModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1_tensor.py b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1_tensor.py index e9ef4d18d5..c536b7299d 100644 --- a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1_tensor.py @@ -96,7 +96,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, @@ -129,6 +128,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.semantic_segmentation import ( SemanticSegmentationResult, @@ -223,7 +223,7 @@ class RoboflowSemanticSegmentationModelBlockV1(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py index 2e0c0a5d7b..349d24eb57 100644 --- a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py @@ -19,7 +19,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.constants import ( DETECTION_ID_KEY, @@ -51,6 +50,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -167,7 +167,7 @@ class RoboflowSemanticSegmentationModelBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2_tensor.py b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2_tensor.py index b517ff2900..aa6b106c87 100644 --- a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2_tensor.py @@ -90,7 +90,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, @@ -126,6 +125,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.semantic_segmentation import ( SemanticSegmentationResult, @@ -265,7 +265,7 @@ class RoboflowSemanticSegmentationModelBlockV2(WorkflowBlock): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ): diff --git a/inference/core/workflows/core_steps/sampling/identify_changes/v1.py b/inference/core/workflows/core_steps/sampling/identify_changes/v1.py index b067714084..986ccd32ae 100644 --- a/inference/core/workflows/core_steps/sampling/identify_changes/v1.py +++ b/inference/core/workflows/core_steps/sampling/identify_changes/v1.py @@ -4,7 +4,6 @@ import numpy as np from pydantic import ConfigDict, Field, model_validator -from inference.core.utils.postprocess import cosine_similarity from inference.core.workflows.execution_engine.entities.base import OutputDefinition from inference.core.workflows.execution_engine.entities.types import ( BOOLEAN_KIND, @@ -22,6 +21,7 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.utils.text import cosine_similarity LONG_DESCRIPTION = """ Identify changes and detect when data patterns change at unusual rates compared to historical norms by tracking embedding vectors over time, measuring cosine similarity changes, computing rate-of-change statistics, and flagging anomalies when changes occur faster or slower than expected for change detection, anomaly monitoring, rate-of-change analysis, and temporal pattern detection workflows. diff --git a/inference/core/workflows/core_steps/sinks/email_notification/v1.py b/inference/core/workflows/core_steps/sinks/email_notification/v1.py index e7d0b373d7..496b93af49 100644 --- a/inference/core/workflows/core_steps/sinks/email_notification/v1.py +++ b/inference/core/workflows/core_steps/sinks/email_notification/v1.py @@ -13,7 +13,6 @@ from functools import partial from typing import Any, Dict, Generator, List, Literal, Optional, Tuple, Type, Union -from fastapi import BackgroundTasks from pydantic import ConfigDict, Field, field_validator from inference.core.workflows.core_steps.common.query_language.entities.operations import ( @@ -33,6 +32,7 @@ STRING_KIND, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( COOLDOWN_HTTP_SOFT_RESTRICTION, BlockResult, @@ -281,7 +281,7 @@ class EmailNotificationBlockV1(WorkflowBlock): def __init__( self, - background_tasks: Optional[BackgroundTasks], + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], disable_sinks: bool = False, ): diff --git a/inference/core/workflows/core_steps/sinks/email_notification/v2.py b/inference/core/workflows/core_steps/sinks/email_notification/v2.py index 5d49308e07..a8e91e1aea 100644 --- a/inference/core/workflows/core_steps/sinks/email_notification/v2.py +++ b/inference/core/workflows/core_steps/sinks/email_notification/v2.py @@ -6,7 +6,6 @@ from functools import partial from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union -from fastapi import BackgroundTasks from pydantic import ConfigDict, Field, field_validator from inference.core.roboflow_api import post_to_roboflow_api @@ -36,6 +35,7 @@ STRING_KIND, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( COOLDOWN_HTTP_SOFT_RESTRICTION, BlockResult, @@ -437,7 +437,7 @@ class EmailNotificationBlockV2(WorkflowBlock): def __init__( self, - background_tasks: Optional[BackgroundTasks], + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], api_key: Optional[str], disable_sinks: bool = False, diff --git a/inference/core/workflows/core_steps/sinks/onvif_movement/v1.py b/inference/core/workflows/core_steps/sinks/onvif_movement/v1.py index 2e811534b7..ebd3c11fb5 100644 --- a/inference/core/workflows/core_steps/sinks/onvif_movement/v1.py +++ b/inference/core/workflows/core_steps/sinks/onvif_movement/v1.py @@ -1,6 +1,7 @@ import asyncio import concurrent import importlib +import logging import os import threading import time @@ -14,8 +15,6 @@ from pydantic import ConfigDict, Field, PositiveInt from simple_pid import PID -from inference.core import logger -from inference.core.utils.function import experimental from inference.core.workflows.execution_engine.entities.base import OutputDefinition from inference.core.workflows.execution_engine.entities.types import ( BOOLEAN_KIND, @@ -36,6 +35,9 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.utils.text import experimental + +logger = logging.getLogger(__name__) # max number of seconds to switch to zoom only (no xy movement) ZOOM_MODE_SECONDS = 2 diff --git a/inference/core/workflows/core_steps/sinks/onvif_movement/v1_tensor.py b/inference/core/workflows/core_steps/sinks/onvif_movement/v1_tensor.py index c8ab629f23..31791d1258 100644 --- a/inference/core/workflows/core_steps/sinks/onvif_movement/v1_tensor.py +++ b/inference/core/workflows/core_steps/sinks/onvif_movement/v1_tensor.py @@ -1,6 +1,7 @@ import asyncio import concurrent import importlib +import logging import os import threading import time @@ -14,8 +15,6 @@ from pydantic import ConfigDict, Field, PositiveInt from simple_pid import PID -from inference.core import logger -from inference.core.utils.function import experimental from inference.core.workflows.core_steps.common.tensor_native import ( take_prediction_by_indices, take_prediction_by_mask, @@ -48,9 +47,12 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.utils.text import experimental from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.object_detection import Detections +logger = logging.getLogger(__name__) + # max number of seconds to switch to zoom only (no xy movement) ZOOM_MODE_SECONDS = 2 diff --git a/inference/core/workflows/core_steps/sinks/roboflow/asset_library_attributes/v1.py b/inference/core/workflows/core_steps/sinks/roboflow/asset_library_attributes/v1.py index 353dad1105..27c5982026 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/asset_library_attributes/v1.py +++ b/inference/core/workflows/core_steps/sinks/roboflow/asset_library_attributes/v1.py @@ -4,7 +4,6 @@ from pydantic import ConfigDict, Field -from inference.core.cache.base import BaseCache from inference.core.roboflow_api import ( batch_update_image_metadata_at_roboflow, get_roboflow_workspace, @@ -28,6 +27,7 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.cache import WorkflowsCache SHORT_DESCRIPTION = "Update attributes and tags for Asset Library images." @@ -179,7 +179,7 @@ class RoboflowAssetLibraryAttributesBlockV1(WorkflowBlock): def __init__( self, api_key: Optional[str], - cache: BaseCache, + cache: WorkflowsCache, update_attributes_offloader: Optional[ UpdateAssetLibraryAttributesOffloader ] = None, @@ -263,7 +263,7 @@ def run( return effective.results -def get_workspace_name(api_key: str, cache: BaseCache) -> str: +def get_workspace_name(api_key: str, cache: WorkflowsCache) -> str: api_key_hash = hashlib.md5( api_key.encode("utf-8"), usedforsecurity=False ).hexdigest() diff --git a/inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/v1.py b/inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/v1.py index 7560fd8057..082836d786 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/v1.py +++ b/inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/v1.py @@ -6,10 +6,8 @@ import numpy as np import supervision as sv -from fastapi import BackgroundTasks from pydantic import ConfigDict, Field -from inference.core.cache.base import BaseCache from inference.core.roboflow_api import add_custom_metadata, get_roboflow_workspace from inference.core.workflows.core_steps.sinks.noop import disabled_sink_response from inference.core.workflows.execution_engine.constants import INFERENCE_ID_KEY @@ -23,12 +21,14 @@ STRING_KIND, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( AirGappedAvailability, BlockResult, WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.cache import WorkflowsCache SHORT_DESCRIPTION = "Add custom metadata to the Roboflow Model Monitoring dashboard." @@ -182,9 +182,9 @@ class RoboflowCustomMetadataBlockV1(WorkflowBlock): def __init__( self, - cache: BaseCache, + cache: WorkflowsCache, api_key: Optional[str], - background_tasks: Optional[BackgroundTasks], + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], disable_sinks: bool = False, ): @@ -261,7 +261,7 @@ def run( def get_workspace_name( api_key: str, - cache: BaseCache, + cache: WorkflowsCache, ) -> str: # codeql[py/weak-sensitive-data-hashing]: MD5 cache fingerprint; not crypto storage. api_key_hash = hashlib.md5(api_key.encode("utf-8")).hexdigest() @@ -277,7 +277,7 @@ def get_workspace_name( def add_custom_metadata_request( - cache: BaseCache, + cache: WorkflowsCache, api_key: str, inference_ids: List[str], field_name: str, diff --git a/inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/v1_tensor.py b/inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/v1_tensor.py index d7cbeba724..112d68175e 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/v1_tensor.py +++ b/inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/v1_tensor.py @@ -4,10 +4,8 @@ from functools import partial from typing import List, Literal, Optional, Tuple, Type, Union -from fastapi import BackgroundTasks from pydantic import ConfigDict, Field -from inference.core.cache.base import BaseCache from inference.core.roboflow_api import add_custom_metadata, get_roboflow_workspace from inference.core.workflows.core_steps.common.tensor_native import ( KeyPointPrediction, @@ -27,12 +25,14 @@ STRING_KIND, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( AirGappedAvailability, BlockResult, WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.cache import WorkflowsCache from inference_models.models.base.classification import ( ClassificationPrediction, MultiLabelClassificationPrediction, @@ -192,9 +192,9 @@ class RoboflowCustomMetadataBlockV1(WorkflowBlock): def __init__( self, - cache: BaseCache, + cache: WorkflowsCache, api_key: Optional[str], - background_tasks: Optional[BackgroundTasks], + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], disable_sinks: bool = False, ): @@ -300,7 +300,7 @@ def _extract_inference_ids( def get_workspace_name( api_key: str, - cache: BaseCache, + cache: WorkflowsCache, ) -> str: # codeql[py/weak-sensitive-data-hashing]: MD5 cache fingerprint; not crypto storage. api_key_hash = hashlib.md5(api_key.encode("utf-8")).hexdigest() @@ -316,7 +316,7 @@ def get_workspace_name( def add_custom_metadata_request( - cache: BaseCache, + cache: WorkflowsCache, api_key: str, inference_ids: List[str], field_name: str, diff --git a/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1.py b/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1.py index 2f100489e5..3172bcf84d 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1.py +++ b/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1.py @@ -26,7 +26,6 @@ from uuid import uuid4 import supervision as sv -from fastapi import BackgroundTasks from pydantic import AliasChoices, ConfigDict, Field from inference.core.active_learning.cache_operations import ( @@ -41,7 +40,6 @@ StrategyLimit, StrategyLimitType, ) -from inference.core.cache.base import BaseCache from inference.core.roboflow_api import ( annotate_image_at_roboflow, get_roboflow_workspace, @@ -71,6 +69,7 @@ ImageInputField, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( AirGappedAvailability, BlockResult, @@ -79,6 +78,7 @@ WorkflowBlockManifest, roboflow_platform_project, ) +from inference.core.workflows.prototypes.cache import WorkflowsCache SHORT_DESCRIPTION = "Save images and predictions to your Roboflow Dataset." @@ -281,9 +281,9 @@ class RoboflowDatasetUploadBlockV1(WorkflowBlock): def __init__( self, - cache: BaseCache, + cache: WorkflowsCache, api_key: Optional[str], - background_tasks: Optional[BackgroundTasks], + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], disable_sinks: bool = False, ): @@ -395,8 +395,8 @@ def register_datapoint_at_roboflow( fire_and_forget: bool, labeling_batch_prefix: str, new_labeling_batch_frequency: BatchCreationFrequency, - cache: BaseCache, - background_tasks: Optional[BackgroundTasks], + cache: WorkflowsCache, + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], api_key: str, image_name: Optional[str] = None, @@ -445,7 +445,7 @@ def execute_registration( registration_tags: List[str], labeling_batch_prefix: str, new_labeling_batch_frequency: BatchCreationFrequency, - cache: BaseCache, + cache: WorkflowsCache, api_key: str, image_name: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, @@ -531,7 +531,7 @@ def execute_registration( def get_workspace_name( api_key: str, - cache: BaseCache, + cache: WorkflowsCache, ) -> str: api_key_hash = hashlib.md5(api_key.encode("utf-8")).hexdigest() cache_key = f"workflows:api_key_to_workspace:{api_key_hash}" diff --git a/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1_tensor.py b/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1_tensor.py index 50ba19ad22..df1945e121 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1_tensor.py +++ b/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1_tensor.py @@ -29,7 +29,6 @@ import numpy as np import supervision as sv import torch -from fastapi import BackgroundTasks from pydantic import AliasChoices, ConfigDict, Field from inference.core.active_learning.cache_operations import ( @@ -44,7 +43,6 @@ StrategyLimit, StrategyLimitType, ) -from inference.core.cache.base import BaseCache from inference.core.roboflow_api import ( annotate_image_at_roboflow, get_roboflow_workspace, @@ -91,6 +89,7 @@ ImageInputField, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( AirGappedAvailability, BlockResult, @@ -99,6 +98,7 @@ WorkflowBlockManifest, roboflow_platform_project, ) +from inference.core.workflows.prototypes.cache import WorkflowsCache from inference_models.models.base.classification import ( ClassificationPrediction, MultiLabelClassificationPrediction, @@ -319,9 +319,9 @@ class RoboflowDatasetUploadBlockV1(WorkflowBlock): def __init__( self, - cache: BaseCache, + cache: WorkflowsCache, api_key: Optional[str], - background_tasks: Optional[BackgroundTasks], + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], disable_sinks: bool = False, ): @@ -433,8 +433,8 @@ def register_datapoint_at_roboflow( fire_and_forget: bool, labeling_batch_prefix: str, new_labeling_batch_frequency: BatchCreationFrequency, - cache: BaseCache, - background_tasks: Optional[BackgroundTasks], + cache: WorkflowsCache, + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], api_key: str, image_name: Optional[str] = None, @@ -483,7 +483,7 @@ def execute_registration( registration_tags: List[str], labeling_batch_prefix: str, new_labeling_batch_frequency: BatchCreationFrequency, - cache: BaseCache, + cache: WorkflowsCache, api_key: str, image_name: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, @@ -569,7 +569,7 @@ def execute_registration( def get_workspace_name( api_key: str, - cache: BaseCache, + cache: WorkflowsCache, ) -> str: api_key_hash = hashlib.md5(api_key.encode("utf-8")).hexdigest() cache_key = f"workflows:api_key_to_workspace:{api_key_hash}" diff --git a/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v2.py b/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v2.py index 8002413cc2..2da592196e 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v2.py +++ b/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v2.py @@ -3,11 +3,9 @@ from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union import supervision as sv -from fastapi import BackgroundTasks from pydantic import AliasChoices, ConfigDict, Field from typing_extensions import Annotated -from inference.core.cache.base import BaseCache from inference.core.workflows.core_steps.sinks.noop import disabled_sink_message from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload.v1 import ( register_datapoint_at_roboflow, @@ -30,6 +28,7 @@ STRING_KIND, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( AirGappedAvailability, BlockResult, @@ -38,6 +37,7 @@ WorkflowBlockManifest, roboflow_platform_project, ) +from inference.core.workflows.prototypes.cache import WorkflowsCache FloatZeroToHundred = Annotated[float, Field(ge=0.0, le=100.0)] @@ -268,9 +268,9 @@ class RoboflowDatasetUploadBlockV2(WorkflowBlock): def __init__( self, - cache: BaseCache, + cache: WorkflowsCache, api_key: Optional[str], - background_tasks: Optional[BackgroundTasks], + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], disable_sinks: bool = False, ): @@ -387,8 +387,8 @@ def maybe_register_datapoint_at_roboflow( fire_and_forget: bool, labeling_batch_prefix: str, new_labeling_batch_frequency: BatchCreationFrequency, - cache: BaseCache, - background_tasks: Optional[BackgroundTasks], + cache: WorkflowsCache, + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], api_key: str, image_name: Optional[str] = None, diff --git a/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v2_tensor.py b/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v2_tensor.py index e38170decf..daa9355732 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v2_tensor.py +++ b/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v2_tensor.py @@ -2,11 +2,9 @@ from concurrent.futures import ThreadPoolExecutor from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union -from fastapi import BackgroundTasks from pydantic import AliasChoices, ConfigDict, Field from typing_extensions import Annotated -from inference.core.cache.base import BaseCache from inference.core.workflows.core_steps.common.tensor_native import KeyPointPrediction from inference.core.workflows.core_steps.sinks.noop import disabled_sink_message from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload.v1_tensor import ( @@ -32,6 +30,7 @@ STRING_KIND, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( AirGappedAvailability, BlockResult, @@ -40,6 +39,7 @@ WorkflowBlockManifest, roboflow_platform_project, ) +from inference.core.workflows.prototypes.cache import WorkflowsCache from inference_models.models.base.classification import ( ClassificationPrediction, MultiLabelClassificationPrediction, @@ -286,9 +286,9 @@ class RoboflowDatasetUploadBlockV2(WorkflowBlock): def __init__( self, - cache: BaseCache, + cache: WorkflowsCache, api_key: Optional[str], - background_tasks: Optional[BackgroundTasks], + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], disable_sinks: bool = False, ): @@ -405,8 +405,8 @@ def maybe_register_datapoint_at_roboflow( fire_and_forget: bool, labeling_batch_prefix: str, new_labeling_batch_frequency: BatchCreationFrequency, - cache: BaseCache, - background_tasks: Optional[BackgroundTasks], + cache: WorkflowsCache, + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], api_key: str, image_name: Optional[str] = None, diff --git a/inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1.py b/inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1.py index e0b4ecaa0c..ff903e1717 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1.py +++ b/inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1.py @@ -7,10 +7,8 @@ from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union import supervision as sv -from fastapi import BackgroundTasks from pydantic import BaseModel, ConfigDict, Field, field_validator -from inference.core.cache.base import BaseCache from inference.core.env import DEVICE_ID from inference.core.managers.metrics import get_system_info from inference.core.roboflow_api import ( @@ -36,6 +34,7 @@ STRING_KIND, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( STILL_IMAGE_INPUT_SOFT_RESTRICTION, AirGappedAvailability, @@ -50,6 +49,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.cache import WorkflowsCache SHORT_DESCRIPTION = "Periodically report an aggregated sample of inference results to Roboflow Model Monitoring." @@ -311,9 +311,9 @@ class ModelMonitoringInferenceAggregatorBlockV1(WorkflowBlock): def __init__( self, - cache: BaseCache, + cache: WorkflowsCache, api_key: Optional[str], - background_tasks: Optional[BackgroundTasks], + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], disable_sinks: bool = False, ): @@ -401,7 +401,7 @@ def _is_in_reporting_range(self, frequency: int) -> bool: # TODO: maybe make this a helper or decorator, it's used in multiple places def get_workspace_name( api_key: str, - cache: BaseCache, + cache: WorkflowsCache, ) -> str: # codeql[py/weak-sensitive-data-hashing]: MD5 cache fingerprint; not crypto storage. api_key_hash = hashlib.md5(api_key.encode("utf-8")).hexdigest() @@ -415,7 +415,7 @@ def get_workspace_name( def send_to_model_monitoring_request( - cache: BaseCache, + cache: WorkflowsCache, last_report_time_cache_key: str, api_key: str, predictions: List[ParsedPrediction], diff --git a/inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1_tensor.py b/inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1_tensor.py index 35b561247e..5155a4f10e 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1_tensor.py +++ b/inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1_tensor.py @@ -6,10 +6,8 @@ from functools import partial from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union -from fastapi import BackgroundTasks from pydantic import BaseModel, ConfigDict, Field, field_validator -from inference.core.cache.base import BaseCache from inference.core.env import DEVICE_ID from inference.core.managers.metrics import get_system_info from inference.core.roboflow_api import ( @@ -41,6 +39,7 @@ STRING_KIND, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( STILL_IMAGE_INPUT_SOFT_RESTRICTION, AirGappedAvailability, @@ -55,6 +54,7 @@ WorkflowBlockManifest, roboflow_platform_model, ) +from inference.core.workflows.prototypes.cache import WorkflowsCache from inference_models.models.base.classification import ( ClassificationPrediction, MultiLabelClassificationPrediction, @@ -337,9 +337,9 @@ class ModelMonitoringInferenceAggregatorBlockV1(WorkflowBlock): def __init__( self, - cache: BaseCache, + cache: WorkflowsCache, api_key: Optional[str], - background_tasks: Optional[BackgroundTasks], + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], disable_sinks: bool = False, ): @@ -427,7 +427,7 @@ def _is_in_reporting_range(self, frequency: int) -> bool: # TODO: maybe make this a helper or decorator, it's used in multiple places def get_workspace_name( api_key: str, - cache: BaseCache, + cache: WorkflowsCache, ) -> str: # codeql[py/weak-sensitive-data-hashing]: MD5 cache fingerprint; not crypto storage. api_key_hash = hashlib.md5(api_key.encode("utf-8")).hexdigest() @@ -441,7 +441,7 @@ def get_workspace_name( def send_to_model_monitoring_request( - cache: BaseCache, + cache: WorkflowsCache, last_report_time_cache_key: str, api_key: str, predictions: List[ParsedPrediction], diff --git a/inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py b/inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py index 7f07a3a368..363b83ed82 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py +++ b/inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py @@ -1,3 +1,4 @@ +import logging import os from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone @@ -8,11 +9,9 @@ import numpy as np import requests import supervision as sv -from fastapi import BackgroundTasks from pydantic import ConfigDict, Field, NonNegativeFloat, NonNegativeInt from inference.core.env import API_BASE_URL -from inference.core.logger import logger from inference.core.roboflow_api import build_roboflow_api_headers from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.utils.requests import api_key_safe_raise_for_status @@ -44,6 +43,7 @@ STRING_KIND, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( COOLDOWN_HTTP_SOFT_RESTRICTION, BlockResult, @@ -52,6 +52,8 @@ WorkflowBlockManifest, ) +logger = logging.getLogger(__name__) + VALID_EVENT_TYPES = [ "quality_check", "inventory_count", @@ -460,7 +462,7 @@ class RoboflowVisionEventsBlockV1(WorkflowBlock): def __init__( self, api_key: Optional[str], - background_tasks: Optional[BackgroundTasks], + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], disable_sinks: bool = False, ): diff --git a/inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1_tensor.py b/inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1_tensor.py index 8b5377d380..0b7be04c41 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1_tensor.py +++ b/inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1_tensor.py @@ -1,3 +1,4 @@ +import logging import os from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone @@ -7,11 +8,9 @@ import numpy as np import requests -from fastapi import BackgroundTasks from pydantic import ConfigDict, Field, NonNegativeFloat, NonNegativeInt from inference.core.env import API_BASE_URL -from inference.core.logger import logger from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.keypoints import real_keypoints_count @@ -48,6 +47,7 @@ STRING_KIND, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( COOLDOWN_HTTP_SOFT_RESTRICTION, BlockResult, @@ -62,6 +62,8 @@ from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.object_detection import Detections +logger = logging.getLogger(__name__) + # Tensor-native prediction union the block accepts. Detection predictions arrive # as `inference_models` dataclasses (or the keypoint `(KeyPoints, Detections)` # tuple) and classification predictions as the native classification dataclasses, @@ -465,7 +467,7 @@ class RoboflowVisionEventsBlockV1(WorkflowBlock): def __init__( self, api_key: Optional[str], - background_tasks: Optional[BackgroundTasks], + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], disable_sinks: bool = False, ): diff --git a/inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1.py b/inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1.py index 69063e9b89..bd007d3dda 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1.py +++ b/inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1.py @@ -1,6 +1,7 @@ import errno import io import json +import logging import os import re import tarfile @@ -12,7 +13,6 @@ from uuid import uuid4 import supervision as sv -from fastapi import BackgroundTasks from pydantic import ( ConfigDict, Field, @@ -22,7 +22,6 @@ ) from inference.core.env import ALLOW_WORKFLOW_BLOCKS_ACCESSING_LOCAL_STORAGE -from inference.core.logger import logger from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.sinks.local_file.v1 import ( path_is_within_specified_directory, @@ -55,6 +54,7 @@ STRING_KIND, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( COOLDOWN_HTTP_SOFT_RESTRICTION, BlockResult, @@ -66,6 +66,8 @@ is_workflow_selector, ) +logger = logging.getLogger(__name__) + BUNDLE_FORMAT_VERSION = 1 # Companion API enforces a 25 MiB raw bundle limit at ingest time. Bundles @@ -529,7 +531,7 @@ class VisionEventBundleSinkBlockV1(WorkflowBlock): def __init__( self, - background_tasks: Optional[BackgroundTasks], + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], allow_access_to_file_system: bool, allowed_write_directory: Optional[str], diff --git a/inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1_tensor.py b/inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1_tensor.py index 6cc49c29b6..a1e90ea190 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1_tensor.py +++ b/inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1_tensor.py @@ -12,6 +12,7 @@ import errno import io import json +import logging import os import re import tarfile @@ -22,7 +23,6 @@ from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union from uuid import uuid4 -from fastapi import BackgroundTasks from pydantic import ( ConfigDict, Field, @@ -32,7 +32,6 @@ ) from inference.core.env import ALLOW_WORKFLOW_BLOCKS_ACCESSING_LOCAL_STORAGE -from inference.core.logger import logger from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.sinks.local_file.v1 import ( path_is_within_specified_directory, @@ -68,6 +67,7 @@ STRING_KIND, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( COOLDOWN_HTTP_SOFT_RESTRICTION, BlockResult, @@ -79,6 +79,8 @@ is_workflow_selector, ) +logger = logging.getLogger(__name__) + BUNDLE_FORMAT_VERSION = 1 # Companion API enforces a 25 MiB raw bundle limit at ingest time. Bundles @@ -542,7 +544,7 @@ class VisionEventBundleSinkBlockV1(WorkflowBlock): def __init__( self, - background_tasks: Optional[BackgroundTasks], + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], allow_access_to_file_system: bool, allowed_write_directory: Optional[str], diff --git a/inference/core/workflows/core_steps/sinks/slack/notification/v1.py b/inference/core/workflows/core_steps/sinks/slack/notification/v1.py index 7bdbf924ad..9a47d776d5 100644 --- a/inference/core/workflows/core_steps/sinks/slack/notification/v1.py +++ b/inference/core/workflows/core_steps/sinks/slack/notification/v1.py @@ -7,12 +7,10 @@ from functools import partial from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union -from fastapi import BackgroundTasks from pydantic import ConfigDict, Field, field_validator from slack_sdk import WebClient from slack_sdk.errors import SlackApiError -from inference.core.cache.base import BaseCache from inference.core.workflows.core_steps.common.query_language.entities.operations import ( AllOperationsType, ) @@ -29,6 +27,7 @@ STRING_KIND, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( COOLDOWN_HTTP_SOFT_RESTRICTION, AirGappedAvailability, @@ -37,6 +36,7 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.cache import WorkflowsCache CACHE_EXPIRE_TIME = 15 * 60 @@ -224,8 +224,8 @@ class SlackNotificationBlockV1(WorkflowBlock): def __init__( self, - cache: BaseCache, - background_tasks: Optional[BackgroundTasks], + cache: WorkflowsCache, + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], disable_sinks: bool = False, ): diff --git a/inference/core/workflows/core_steps/sinks/twilio/sms/v1.py b/inference/core/workflows/core_steps/sinks/twilio/sms/v1.py index 54b4880c02..25ebb48d22 100644 --- a/inference/core/workflows/core_steps/sinks/twilio/sms/v1.py +++ b/inference/core/workflows/core_steps/sinks/twilio/sms/v1.py @@ -7,11 +7,9 @@ from functools import partial from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union -from fastapi import BackgroundTasks from pydantic import ConfigDict, Field, field_validator from twilio.rest import Client -from inference.core.cache.base import BaseCache from inference.core.workflows.core_steps.common.query_language.entities.operations import ( AllOperationsType, ) @@ -27,6 +25,7 @@ STRING_KIND, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( COOLDOWN_HTTP_SOFT_RESTRICTION, AirGappedAvailability, @@ -35,6 +34,7 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.cache import WorkflowsCache CACHE_EXPIRE_TIME = 15 * 60 TRUNCATION_MARKER = "[...]" @@ -256,8 +256,8 @@ class TwilioSMSNotificationBlockV1(WorkflowBlock): def __init__( self, - cache: BaseCache, - background_tasks: Optional[BackgroundTasks], + cache: WorkflowsCache, + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], disable_sinks: bool = False, ): diff --git a/inference/core/workflows/core_steps/sinks/twilio/sms/v2.py b/inference/core/workflows/core_steps/sinks/twilio/sms/v2.py index c39bb002a9..6c2702c263 100644 --- a/inference/core/workflows/core_steps/sinks/twilio/sms/v2.py +++ b/inference/core/workflows/core_steps/sinks/twilio/sms/v2.py @@ -9,7 +9,6 @@ from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union import requests -from fastapi import BackgroundTasks from pydantic import ConfigDict, Field from twilio.rest import Client @@ -39,6 +38,7 @@ STRING_KIND, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( COOLDOWN_HTTP_SOFT_RESTRICTION, AirGappedAvailability, @@ -337,7 +337,7 @@ class TwilioSMSNotificationBlockV2(WorkflowBlock): def __init__( self, - background_tasks: Optional[BackgroundTasks], + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], api_key: Optional[str], disable_sinks: bool = False, diff --git a/inference/core/workflows/core_steps/sinks/webhook/v1.py b/inference/core/workflows/core_steps/sinks/webhook/v1.py index 33c4e7d6bc..2c741d6ce2 100644 --- a/inference/core/workflows/core_steps/sinks/webhook/v1.py +++ b/inference/core/workflows/core_steps/sinks/webhook/v1.py @@ -6,7 +6,6 @@ from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union import requests -from fastapi import BackgroundTasks from pydantic import ConfigDict, Field from inference.core.workflows.core_steps.common.query_language.entities.operations import ( @@ -30,6 +29,7 @@ TOP_CLASS_KIND, Selector, ) +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler from inference.core.workflows.prototypes.block import ( COOLDOWN_HTTP_SOFT_RESTRICTION, AirGappedAvailability, @@ -369,7 +369,7 @@ class WebhookSinkBlockV1(WorkflowBlock): def __init__( self, - background_tasks: Optional[BackgroundTasks], + background_tasks: Optional[BackgroundTaskScheduler], thread_pool_executor: Optional[ThreadPoolExecutor], disable_sinks: bool = False, ): diff --git a/inference/core/workflows/core_steps/trackers/_base.py b/inference/core/workflows/core_steps/trackers/_base.py index 2cd2a63291..fb7d51f4ad 100644 --- a/inference/core/workflows/core_steps/trackers/_base.py +++ b/inference/core/workflows/core_steps/trackers/_base.py @@ -6,13 +6,13 @@ extra per-frame context (e.g. a video frame for camera motion compensation). """ +import logging from abc import abstractmethod from collections import deque from typing import Any, Dict, List, Type import supervision as sv -from inference.core import logger from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, WorkflowImageData, @@ -29,6 +29,8 @@ WorkflowBlockManifest, ) +logger = logging.getLogger(__name__) + OUTPUT_KEY: str = "tracked_detections" #: Detection kinds accepted as tracker input and declared on tracker output. diff --git a/inference/core/workflows/core_steps/trackers/_base_tensor.py b/inference/core/workflows/core_steps/trackers/_base_tensor.py index 81572e4e8b..460d0ebbb2 100644 --- a/inference/core/workflows/core_steps/trackers/_base_tensor.py +++ b/inference/core/workflows/core_steps/trackers/_base_tensor.py @@ -28,6 +28,7 @@ ``sv``-based) — only ``_run_tracker`` does the native↔sv conversion. """ +import logging from abc import abstractmethod from collections import deque from typing import Any, Dict, List, Optional, Tuple, Type, Union @@ -35,7 +36,6 @@ import numpy as np import supervision as sv -from inference.core import logger from inference.core.workflows.core_steps.common.tensor_native import ( split_key_point_prediction, take_prediction_by_indices, @@ -59,6 +59,8 @@ from inference_models.models.base.keypoints_detection import KeyPoints from inference_models.models.base.object_detection import Detections +logger = logging.getLogger(__name__) + OUTPUT_KEY: str = "tracked_detections" #: Key under which the per-detection row index into the original native input is diff --git a/inference/core/workflows/core_steps/trackers/botsort/v1.py b/inference/core/workflows/core_steps/trackers/botsort/v1.py index b33afc94a8..9670460892 100644 --- a/inference/core/workflows/core_steps/trackers/botsort/v1.py +++ b/inference/core/workflows/core_steps/trackers/botsort/v1.py @@ -1,10 +1,10 @@ +import logging from typing import Any, List, Literal, Optional, Type, Union import supervision as sv from pydantic import ConfigDict, Field from trackers import BoTSORTTracker -from inference.core import logger from inference.core.workflows.core_steps.trackers._base import ( TRACKER_PREDICTION_KINDS, TrackerBlockBase, @@ -23,6 +23,8 @@ ) from inference.core.workflows.prototypes.block import BlockResult, WorkflowBlockManifest +logger = logging.getLogger(__name__) + #: Camera motion compensation (CMC) backend for BoT-SORT. Valid string values: #: #: - ``orb``: ORB keypoints/descriptors with RANSAC affine estimation. diff --git a/inference/core/workflows/core_steps/trackers/botsort/v1_tensor.py b/inference/core/workflows/core_steps/trackers/botsort/v1_tensor.py index 23a608c5fa..058bef5efd 100644 --- a/inference/core/workflows/core_steps/trackers/botsort/v1_tensor.py +++ b/inference/core/workflows/core_steps/trackers/botsort/v1_tensor.py @@ -1,10 +1,10 @@ +import logging from typing import Any, List, Literal, Optional, Type, Union import supervision as sv from pydantic import ConfigDict, Field from trackers import BoTSORTTracker -from inference.core import logger from inference.core.workflows.core_steps.trackers._base_tensor import ( TRACKER_PREDICTION_KINDS, TensorNativeTrackerPrediction, @@ -24,6 +24,8 @@ ) from inference.core.workflows.prototypes.block import BlockResult, WorkflowBlockManifest +logger = logging.getLogger(__name__) + #: Camera motion compensation (CMC) backend for BoT-SORT. Valid string values: #: #: - ``orb``: ORB keypoints/descriptors with RANSAC affine estimation. diff --git a/inference/core/workflows/core_steps/transformations/byte_tracker/v2.py b/inference/core/workflows/core_steps/transformations/byte_tracker/v2.py index 6f9d97bcfa..26f90a8766 100644 --- a/inference/core/workflows/core_steps/transformations/byte_tracker/v2.py +++ b/inference/core/workflows/core_steps/transformations/byte_tracker/v2.py @@ -1,9 +1,9 @@ +import logging from typing import Dict, List, Literal, Optional, Type, Union import supervision as sv from pydantic import ConfigDict, Field -from inference.core import logger from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, WorkflowImageData, @@ -25,6 +25,8 @@ WorkflowBlockManifest, ) +logger = logging.getLogger(__name__) + OUTPUT_KEY: str = "tracked_detections" SHORT_DESCRIPTION = ( "Track and update object positions across video frames using ByteTrack." diff --git a/inference/core/workflows/core_steps/transformations/byte_tracker/v2_tensor.py b/inference/core/workflows/core_steps/transformations/byte_tracker/v2_tensor.py index 9f74bbef08..236da27cdc 100644 --- a/inference/core/workflows/core_steps/transformations/byte_tracker/v2_tensor.py +++ b/inference/core/workflows/core_steps/transformations/byte_tracker/v2_tensor.py @@ -1,10 +1,10 @@ +import logging from typing import Dict, List, Literal, Optional, Type, Union import numpy as np import supervision as sv from pydantic import ConfigDict, Field -from inference.core import logger from inference.core.workflows.core_steps.common.tensor_native import ( split_key_point_prediction, take_prediction_by_indices, @@ -34,6 +34,8 @@ from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.object_detection import Detections +logger = logging.getLogger(__name__) + OUTPUT_KEY: str = "tracked_detections" _INPUT_INDEX_KEY: str = "__byte_tracker_input_index__" diff --git a/inference/core/workflows/core_steps/transformations/byte_tracker/v3.py b/inference/core/workflows/core_steps/transformations/byte_tracker/v3.py index 715f47b921..8817af46ce 100644 --- a/inference/core/workflows/core_steps/transformations/byte_tracker/v3.py +++ b/inference/core/workflows/core_steps/transformations/byte_tracker/v3.py @@ -1,10 +1,10 @@ +import logging from collections import deque from typing import Dict, List, Literal, Optional, Type, Union import supervision as sv from pydantic import ConfigDict, Field -from inference.core import logger from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, WorkflowImageData, @@ -27,6 +27,8 @@ WorkflowBlockManifest, ) +logger = logging.getLogger(__name__) + OUTPUT_KEY: str = "tracked_detections" SHORT_DESCRIPTION = ( "Track and update object positions across video frames using ByteTrack." diff --git a/inference/core/workflows/core_steps/transformations/byte_tracker/v3_tensor.py b/inference/core/workflows/core_steps/transformations/byte_tracker/v3_tensor.py index ac36fca275..f3e16a8fbf 100644 --- a/inference/core/workflows/core_steps/transformations/byte_tracker/v3_tensor.py +++ b/inference/core/workflows/core_steps/transformations/byte_tracker/v3_tensor.py @@ -1,3 +1,4 @@ +import logging from collections import deque from typing import Dict, List, Literal, Optional, Tuple, Type, Union @@ -5,7 +6,6 @@ import supervision as sv from pydantic import ConfigDict, Field -from inference.core import logger from inference.core.workflows.core_steps.common.tensor_native import ( split_key_point_prediction, take_prediction_by_indices, @@ -37,6 +37,8 @@ from inference_models.models.base.keypoints_detection import KeyPoints from inference_models.models.base.object_detection import Detections +logger = logging.getLogger(__name__) + OUTPUT_KEY: str = "tracked_detections" _INPUT_INDEX_KEY: str = "__byte_tracker_input_index__" diff --git a/inference/core/workflows/core_steps/transformations/stitch_images/v1.py b/inference/core/workflows/core_steps/transformations/stitch_images/v1.py index ed605d7f54..9790c9642c 100644 --- a/inference/core/workflows/core_steps/transformations/stitch_images/v1.py +++ b/inference/core/workflows/core_steps/transformations/stitch_images/v1.py @@ -1,10 +1,10 @@ +import logging from typing import List, Literal, Optional, Type, Union import cv2 as cv import numpy as np from pydantic import AliasChoices, ConfigDict, Field -from inference.core.logger import logger from inference.core.workflows.execution_engine.entities.base import ( ImageParentMetadata, OutputDefinition, @@ -22,6 +22,8 @@ WorkflowBlockManifest, ) +logger = logging.getLogger(__name__) + OUTPUT_KEY: str = "stitched_image" LONG_DESCRIPTION = """ Stitch two overlapping images together into a single panoramic image using SIFT (Scale Invariant Feature Transform) feature matching and homography-based image alignment, automatically detecting common features, calculating geometric transformations, and blending images to create seamless panoramic compositions from overlapping scenes. diff --git a/inference/core/workflows/core_steps/visualizations/bounding_box/v1_tensor.py b/inference/core/workflows/core_steps/visualizations/bounding_box/v1_tensor.py index 1382fcbb90..287927622c 100644 --- a/inference/core/workflows/core_steps/visualizations/bounding_box/v1_tensor.py +++ b/inference/core/workflows/core_steps/visualizations/bounding_box/v1_tensor.py @@ -1,3 +1,4 @@ +import logging from functools import lru_cache from typing import Dict, List, Literal, Optional, Tuple, Type, Union @@ -6,7 +7,6 @@ import torch from pydantic import ConfigDict, Field -from inference.core.logger import logger from inference.core.workflows.core_steps.common.tensor_native import ( TensorNativeDetections, TensorNativePrediction, @@ -32,6 +32,8 @@ ) from inference.core.workflows.prototypes.block import BlockResult, WorkflowBlockManifest +logger = logging.getLogger(__name__) + _EMPTY_I64 = np.zeros(0, dtype=np.int64) TYPE: str = "roboflow_core/bounding_box_visualization@v1" diff --git a/inference/core/workflows/core_steps/visualizations/grid/v1.py b/inference/core/workflows/core_steps/visualizations/grid/v1.py index 1b3a104b30..cb2524961a 100644 --- a/inference/core/workflows/core_steps/visualizations/grid/v1.py +++ b/inference/core/workflows/core_steps/visualizations/grid/v1.py @@ -6,7 +6,6 @@ import numpy as np from pydantic import ConfigDict, Field, field_validator -from inference.core.cache.lru_cache import LRUCache from inference.core.workflows.core_steps.visualizations.common.base import ( OUTPUT_IMAGE_KEY, ) @@ -27,6 +26,7 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.utils.lru_cache import LRUCache TYPE: str = "roboflow_core/grid_visualization@v1" SHORT_DESCRIPTION = "Shows an array of images in a grid." diff --git a/inference/core/workflows/core_steps/visualizations/label/v1_tensor.py b/inference/core/workflows/core_steps/visualizations/label/v1_tensor.py index 3f0ead6772..1e25f89b96 100644 --- a/inference/core/workflows/core_steps/visualizations/label/v1_tensor.py +++ b/inference/core/workflows/core_steps/visualizations/label/v1_tensor.py @@ -1,3 +1,4 @@ +import logging from collections import OrderedDict from typing import List, Literal, Optional, Tuple, Type, Union @@ -8,7 +9,6 @@ from pydantic import ConfigDict, Field from supervision.annotators.utils import resolve_text_background_xyxy, wrap_text -from inference.core.logger import logger from inference.core.workflows.core_steps.common.tensor_native import ( TensorNativeDetections, TensorNativePrediction, @@ -37,6 +37,8 @@ ) from inference.core.workflows.prototypes.block import BlockResult, WorkflowBlockManifest +logger = logging.getLogger(__name__) + TYPE: str = "roboflow_core/label_visualization@v1" SHORT_DESCRIPTION = ( "Draw labels on an image at specific coordinates based on provided detections." diff --git a/inference/core/workflows/core_steps/visualizations/polygon_zone/v1.py b/inference/core/workflows/core_steps/visualizations/polygon_zone/v1.py index 3c7aa1af99..0457dee444 100644 --- a/inference/core/workflows/core_steps/visualizations/polygon_zone/v1.py +++ b/inference/core/workflows/core_steps/visualizations/polygon_zone/v1.py @@ -7,7 +7,6 @@ import supervision as sv from pydantic import ConfigDict, Field -from inference.core.cache.lru_cache import LRUCache from inference.core.workflows.core_steps.visualizations.common.base import ( OUTPUT_IMAGE_KEY, VisualizationBlock, @@ -23,6 +22,7 @@ Selector, ) from inference.core.workflows.prototypes.block import BlockResult, WorkflowBlockManifest +from inference.core.workflows.utils.lru_cache import LRUCache TYPE: str = "roboflow_core/polygon_zone_visualization@v1" SHORT_DESCRIPTION = "Apply a mask over a polygon zone in an image." diff --git a/inference/core/workflows/errors.py b/inference/core/workflows/errors.py index 8f8ca90f55..0483130265 100644 --- a/inference/core/workflows/errors.py +++ b/inference/core/workflows/errors.py @@ -269,3 +269,13 @@ class RuntimeInputError(WorkflowExecutionEngineError): class WorkflowExecutionEngineVersionError(WorkflowError): pass + + +class WorkflowsInvalidEnvironmentValueError(ValueError): + """Raised when an environment value cannot be interpreted by workflows helpers. + + Deliberately NOT a `WorkflowError` subclass: `WorkflowError.__init__` requires + `public_message` and `context`, while this error is raised with a single message. + """ + + pass diff --git a/inference/core/workflows/execution_engine/introspection/blocks_loader.py b/inference/core/workflows/execution_engine/introspection/blocks_loader.py index aa41349a18..d1fec639bb 100644 --- a/inference/core/workflows/execution_engine/introspection/blocks_loader.py +++ b/inference/core/workflows/execution_engine/introspection/blocks_loader.py @@ -9,7 +9,6 @@ from packaging.specifiers import SpecifierSet from packaging.version import Version -from inference.core.env import LOAD_ENTERPRISE_BLOCKS from inference.core.workflows.core_steps.loader import ( KINDS_DESERIALIZERS, KINDS_SERIALIZERS, @@ -42,9 +41,6 @@ BLOCK_SOURCE, ) from inference.core.workflows.prototypes.block import WorkflowBlock -from inference.enterprise.workflows.enterprise_blocks.loader import ( - load_enterprise_blocks, -) WORKFLOWS_PLUGINS_ENV = "WORKFLOWS_PLUGINS" WORKFLOWS_CORE_PLUGIN_NAME = "workflows_core" @@ -70,13 +66,13 @@ def _get_restrictions(block: BlockSpecification) -> List[dict]: return [] -def _get_env_configuration_state() -> Tuple[Tuple[str, ...], bool]: +def _get_env_configuration_state() -> Tuple[Tuple[str, ...]]: """ Returns current environment configuration state for cache keying. - This ensures caches are invalidated when plugins or enterprise blocks change. + This ensures caches are invalidated when the configured plugins change. """ plugins = tuple(get_plugin_modules()) - return (plugins, LOAD_ENTERPRISE_BLOCKS) + return (plugins,) def clear_caches() -> None: @@ -146,13 +142,13 @@ def describe_available_blocks( @lru_cache(maxsize=8) def _cached_describe_available_blocks( execution_engine_version: Optional[Union[str, Version]] = None, - env_state: Tuple[Tuple[str, ...], bool] = None, + env_state: Tuple[Tuple[str, ...]] = None, ) -> BlocksDescription: """Cached version for when there are no dynamic blocks (common case). Args: execution_engine_version: Version filter for blocks - env_state: Tuple of (plugins, enterprise_blocks_flag) for cache invalidation + env_state: Tuple of (plugins,) for cache invalidation """ blocks = load_workflow_blocks(execution_engine_version=execution_engine_version) result = [] @@ -252,8 +248,6 @@ def load_workflow_blocks( @lru_cache() def load_core_workflow_blocks() -> List[BlockSpecification]: core_blocks = load_blocks() - if LOAD_ENTERPRISE_BLOCKS: - core_blocks.extend(load_enterprise_blocks()) already_spotted_blocks = set() result = [] for block in core_blocks: @@ -302,6 +296,11 @@ def load_blocks_from_plugin(plugin_name: str) -> List[BlockSpecification]: def _load_blocks_from_plugin(plugin_name: str) -> List[BlockSpecification]: module = importlib.import_module(plugin_name) + # A plugin may ask to be namespaced under another source. The enterprise + # blocks use this to stay under `workflows_core`, which is where the + # server's per-request init parameters (`workflows_core.disable_sinks`, + # ...) and the core initializers are registered. + block_source = getattr(module, "BLOCKS_SOURCE", plugin_name) blocks = module.load_blocks() already_spotted_blocks = set() result = [] @@ -324,7 +323,7 @@ def _load_blocks_from_plugin(plugin_name: str) -> List[BlockSpecification]: continue result.append( BlockSpecification( - block_source=plugin_name, + block_source=block_source, identifier=get_full_type_name(selected_type=block), block_class=block, manifest_class=block.get_manifest(), @@ -396,9 +395,11 @@ def _load_initializers_from_plugin( plugin_name: str, ) -> Dict[str, Callable[[None], Any]]: module = importlib.import_module(plugin_name) + # a plugin's blocks and its initializers must always share a namespace + block_source = getattr(module, "BLOCKS_SOURCE", plugin_name) registered_initializers = getattr(module, "REGISTERED_INITIALIZERS", {}) return { - f"{plugin_name}.{parameter_name}": initializer + f"{block_source}.{parameter_name}": initializer for parameter_name, initializer in registered_initializers.items() } @@ -450,12 +451,12 @@ def _validate_used_kinds_uniqueness(declared_kinds: List[Kind]) -> None: @lru_cache(maxsize=8) def _cached_load_all_defined_kinds( - env_state: Tuple[Tuple[str, ...], bool] = None, + env_state: Tuple[Tuple[str, ...]] = None, ) -> List[Kind]: """Cached version of load_all_defined_kinds. Args: - env_state: Tuple of (plugins, enterprise_blocks_flag) for cache invalidation + env_state: Tuple of (plugins,) for cache invalidation """ return load_all_defined_kinds() diff --git a/inference/core/workflows/execution_engine/v1/compiler/graph_constructor.py b/inference/core/workflows/execution_engine/v1/compiler/graph_constructor.py index 63f7601e71..199cb20253 100644 --- a/inference/core/workflows/execution_engine/v1/compiler/graph_constructor.py +++ b/inference/core/workflows/execution_engine/v1/compiler/graph_constructor.py @@ -1,4 +1,5 @@ import itertools +import logging from collections import defaultdict from copy import copy, deepcopy from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union @@ -7,7 +8,6 @@ import networkx as nx from networkx import DiGraph -from inference.core import logger from inference.core.workflows.errors import ( AssumptionError, BlockInterfaceError, @@ -88,6 +88,8 @@ ) from inference.core.workflows.prototypes.block import WorkflowBlockManifest +logger = logging.getLogger(__name__) + NODE_DEFINITION_KEY = "definition" STEP_INPUT_SELECTORS_PROPERTY = "step_input_selectors" EXCLUDED_FIELDS = {"type", "name"} diff --git a/inference/core/workflows/execution_engine/v1/compiler/syntactic_parser.py b/inference/core/workflows/execution_engine/v1/compiler/syntactic_parser.py index 3d11017905..befde36074 100644 --- a/inference/core/workflows/execution_engine/v1/compiler/syntactic_parser.py +++ b/inference/core/workflows/execution_engine/v1/compiler/syntactic_parser.py @@ -130,12 +130,12 @@ def build_workflow_definition_entity( @lru_cache(maxsize=8) def _cached_workflow_schema( - env_state: Tuple[Tuple[str, ...], bool] = None, + env_state: Tuple[Tuple[str, ...]] = None, ) -> dict: """Cached schema generation - called only when blocks don't change. Args: - env_state: Tuple of (plugins, enterprise_blocks_flag) for cache invalidation + env_state: Tuple of (plugins,) for cache invalidation """ available_blocks = load_workflow_blocks() workflow_definition_class = build_workflow_definition_entity( diff --git a/inference/core/workflows/execution_engine/v1/core.py b/inference/core/workflows/execution_engine/v1/core.py index 4a750d8274..b719ffe627 100644 --- a/inference/core/workflows/execution_engine/v1/core.py +++ b/inference/core/workflows/execution_engine/v1/core.py @@ -1,3 +1,4 @@ +import logging import os from concurrent.futures import ThreadPoolExecutor from typing import Any, Callable, Dict, List, Optional, Set, Union @@ -5,7 +6,6 @@ from packaging.version import Version from inference.core.env import WORKFLOWS_STEP_EXECUTION_MODE -from inference.core.logger import logger from inference.core.workflows.errors import ( RuntimeInputError, WorkflowEnvironmentConfigurationError, @@ -37,7 +37,6 @@ validate_runtime_input, ) from inference.core.workflows.execution_engine.v1.step_error_handlers import ( - extended_roboflow_errors_handler, legacy_step_error_handler, ) from inference.core.workflows.prototypes.block import ( @@ -48,16 +47,18 @@ StepExecutionMode, is_workflow_selector, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider + +logger = logging.getLogger(__name__) EXECUTION_ENGINE_V1_VERSION = Version("1.15.2") DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER = os.getenv( - "DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER", "extended_roboflow_errors" + "DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER", "legacy" ) REGISTERED_STEP_ERROR_HANDLERS = { "legacy": legacy_step_error_handler, - "extended_roboflow_errors": extended_roboflow_errors_handler, } PRE_INIT_SUPPORTED_DEPENDENCIES = {DependentResourceType.ROBOFLOW_PLATFORM_MODEL} @@ -138,7 +139,7 @@ def _is_locally_executed_platform_model( def _verify_pre_loaded_models_presence( - model_manager: Any, expected_model_ids: Set[str] + model_manager: ModelsProvider, expected_model_ids: Set[str] ) -> None: # Registration happens sequentially without capacity reservation — a # size/memory-bounded model manager may evict earlier entries while @@ -162,7 +163,7 @@ def _verify_pre_loaded_models_presence( def _pre_load_roboflow_platform_models( dependencies: List[DependentResource], - model_manager: Any, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ) -> List[DependentResource]: @@ -203,7 +204,7 @@ def _pre_load_roboflow_platform_models( def _resolve_and_pre_load_runtime_dependencies( pending_dependencies: List[DependentResource], runtime_parameters: Dict[str, Any], - model_manager: Any, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, ) -> None: diff --git a/inference/core/workflows/execution_engine/v1/debugger/core.py b/inference/core/workflows/execution_engine/v1/debugger/core.py index c7162d79ed..02894927e3 100644 --- a/inference/core/workflows/execution_engine/v1/debugger/core.py +++ b/inference/core/workflows/execution_engine/v1/debugger/core.py @@ -4,8 +4,7 @@ import networkx as nx -from inference.core.utils.environment import str2bool -from inference.core.utils.file_system import ensure_parent_dir_exists +from inference.core.workflows.utils.text import ensure_parent_dir_exists, str2bool DUMP_EXECUTION_GRAPH_ENV = "DUMP_EXECUTION_GRAPH" WORKFLOWS_DEBUG_DIR_ENV = "WORKFLOWS_DEBUG_DIR_ENV" diff --git a/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py b/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py index 6cbbeb6813..fd8ff9f098 100644 --- a/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py +++ b/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py @@ -1,5 +1,6 @@ import hashlib import json +import logging import sys import threading import time @@ -16,7 +17,6 @@ WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE, ) from inference.core.exceptions import WorkspaceLoadError -from inference.core.logger import logger from inference.core.roboflow_api import get_roboflow_workspace from inference.core.workflows.errors import ( DynamicBlockCodeError, @@ -56,6 +56,8 @@ ) from inference.usage_tracking.collector import usage_collector +logger = logging.getLogger(__name__) + try: from inference_sdk.config import execution_id as _execution_id_ctxvar except ImportError: diff --git a/inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py b/inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py index 8882c44cf4..abd71a8b75 100644 --- a/inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py +++ b/inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py @@ -19,6 +19,7 @@ import gzip import hashlib import json +import logging import os import sys import threading @@ -44,8 +45,12 @@ WEBEXEC_WS_IDLE_RELEASE_SECONDS, WEBEXEC_WS_READ_TIMEOUT_SECONDS, ) -from inference.core.logger import logger from inference.core.utils.image_utils import encode_image_to_jpeg_bytes +from inference.core.workflows.core_steps.common.deserializers import ( + deserialize_image_kind, + deserialize_rle_detections_kind, + deserialize_video_metadata_kind, +) from inference.core.workflows.core_steps.common.serializers import ( serialize_video_metadata_kind, ) @@ -64,6 +69,8 @@ record_measured_block_execution, ) +logger = logging.getLogger(__name__) + # Check if Modal credentials are available if MODAL_TOKEN_ID and MODAL_TOKEN_SECRET: MODAL_AVAILABLE = True @@ -71,13 +78,6 @@ MODAL_AVAILABLE = False logger.info("Modal credentials not configured") -from datetime import datetime - -from inference.core.workflows.core_steps.common.deserializers import ( - deserialize_image_kind, - deserialize_rle_detections_kind, - deserialize_video_metadata_kind, -) _WEBEXEC_EXECUTOR_CLASS_LABEL = "executor" _WEBEXEC_HTTP_METHOD_LABEL = "execute-block" diff --git a/inference/core/workflows/execution_engine/v1/executor/core.py b/inference/core/workflows/execution_engine/v1/executor/core.py index e120fd25e4..6d1aa230d7 100644 --- a/inference/core/workflows/execution_engine/v1/executor/core.py +++ b/inference/core/workflows/execution_engine/v1/executor/core.py @@ -1,3 +1,4 @@ +import logging import os import traceback from concurrent.futures import ThreadPoolExecutor @@ -20,7 +21,6 @@ execution_id = None remote_processing_times = None -from inference.core import logger from inference.core.env import INFERENCE_DEBUG_OUTPUT_DIR from inference.core.telemetry import ( attach_context, @@ -68,6 +68,8 @@ from inference.usage_tracking.collector import usage_collector from inference.usage_tracking.stream_session import stream_session_id +logger = logging.getLogger(__name__) + def _store_crash_info( image: np.ndarray, diff --git a/inference/core/workflows/execution_engine/v1/executor/execution_data_manager/manager.py b/inference/core/workflows/execution_engine/v1/executor/execution_data_manager/manager.py index c10cc6eadd..ae7f8e7e5a 100644 --- a/inference/core/workflows/execution_engine/v1/executor/execution_data_manager/manager.py +++ b/inference/core/workflows/execution_engine/v1/executor/execution_data_manager/manager.py @@ -1,8 +1,8 @@ +import logging from typing import Any, Dict, Generator, List, Optional, Tuple, Union from networkx import DiGraph -from inference.core import logger from inference.core.workflows.errors import AssumptionError, ExecutionEngineRuntimeError from inference.core.workflows.execution_engine.constants import ( NODE_COMPILATION_OUTPUT_PROPERTY, @@ -42,6 +42,8 @@ ) from inference.core.workflows.prototypes.block import BlockResult +logger = logging.getLogger(__name__) + class ExecutionDataManager: diff --git a/inference/core/workflows/execution_engine/v1/executor/output_constructor.py b/inference/core/workflows/execution_engine/v1/executor/output_constructor.py index 064d4e7007..1664ab9788 100644 --- a/inference/core/workflows/execution_engine/v1/executor/output_constructor.py +++ b/inference/core/workflows/execution_engine/v1/executor/output_constructor.py @@ -1,3 +1,4 @@ +import logging import traceback from collections import defaultdict from concurrent.futures import Future @@ -8,7 +9,6 @@ import supervision as sv from networkx import DiGraph -from inference.core import logger from inference.core.env import ENABLE_TENSOR_DATA_REPRESENTATION from inference.core.workflows.core_steps.common.tensor_native import ( native_detections_to_root_coordinates, @@ -54,6 +54,8 @@ ) from inference_models.models.base.object_detection import Detections as NativeDetections +logger = logging.getLogger(__name__) + def construct_workflow_output( workflow_outputs: List[JsonField], diff --git a/inference/core/workflows/execution_engine/v1/step_error_handlers.py b/inference/core/workflows/execution_engine/v1/step_error_handlers.py index 7d3b9fc83d..c339f4ea9c 100644 --- a/inference/core/workflows/execution_engine/v1/step_error_handlers.py +++ b/inference/core/workflows/execution_engine/v1/step_error_handlers.py @@ -1,250 +1,22 @@ -from inference.core.exceptions import ( - CannotInitialiseModelDueToInputSizeError, - FeatureDeprecatedError, - InferenceModelNotFound, - InvalidModelIDError, - ModelDeploymentNotSupportedError, - ModelManagerLockAcquisitionError, - PaymentRequiredError, - RoboflowAPIForbiddenError, - RoboflowAPINotAuthorizedError, - RoboflowAPINotNotFoundError, - RoboflowAPIUsagePausedError, -) -from inference.core.workflows.errors import ( - ClientCausedStepExecutionError, - RuntimeLimitsCausedStepExecutionError, -) -from inference_models.errors import ( - ModelNotFoundError, - ModelPackageAlternativesExhaustedError, - ModelPackageRestrictedError, - ModelRetrievalError, - UnauthorizedModelAccessError, -) -from inference_sdk.http.errors import HTTPCallErrorError +"""Step error handlers owned by the Workflows engine. -MODEL_ACCESS_ERROR_MESSAGES = { - 402: "Not enough credits to execute step {step_name}. Verify your workspace billing page.", - 403: "Forbidden error occurred while execution of step {step_name}. " - "This error usually means there is a problem with the Roboflow API key.", - 423: "Roboflow API usage is paused while executing step {step_name}. " - "Contact your workspace administrator to re-enable API keys.", -} +The engine ships only the mapping-free default below. A direct +``ExecutionEngine.init(...)`` caller that wants the Roboflow exception mappings +(``FeatureDeprecatedError`` to HTTP 410, model-access and remote-execution +status codes) must pass the server handler explicitly via ``step_error_handler`` +-- it lives in ``inference.core.interfaces.workflows_step_error_handlers`` and +is selected by ``resolve_step_error_handler()``, which every server composition +root already does. +""" def legacy_step_error_handler(step_name: str, error: Exception) -> None: - if isinstance(error, FeatureDeprecatedError): - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=410, - public_message=str(error), - context="workflow_execution | step_execution | feature_deprecated", - inner_error=error, - ) from error - if isinstance(error, (ModelManagerLockAcquisitionError, InferenceModelNotFound)): - raise error - return None - + """Standalone default: no mapping. -def extended_roboflow_errors_handler(step_name: str, error: Exception) -> None: - if isinstance(error, FeatureDeprecatedError): - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=410, - public_message=str(error), - context="workflow_execution | step_execution | feature_deprecated", - inner_error=error, - ) from error - if isinstance( - error, - ( - ModelManagerLockAcquisitionError, - InferenceModelNotFound, - ), - ): - raise error - if isinstance(error, CannotInitialiseModelDueToInputSizeError): - raise RuntimeLimitsCausedStepExecutionError( - block_id=step_name, - status_code=507, - public_message=f"Could not complete workflow execution due to configured runtime constraints. " - f"Details: model input size causes runtime memory requirements exceed the limit " - f"configured for the environment.", - context="workflow_execution | step_execution", - inner_error=error, - ) from error - if isinstance(error, ModelPackageRestrictedError): - raise RuntimeLimitsCausedStepExecutionError( - block_id=step_name, - status_code=507, - public_message="Model loading failed due to restrictions of server configuration - " - "usually due to excessive runtime memory requirement of the model (for instance " - "caused by large input size).", - context="workflow_execution | step_execution", - inner_error=error, - ) from error - if isinstance(error, ModelPackageAlternativesExhaustedError) and any( - isinstance(e, ModelPackageRestrictedError) - for e in (error.alternatives_errors or []) - ): - raise RuntimeLimitsCausedStepExecutionError( - block_id=step_name, - status_code=507, - public_message="Model loading failed due to restrictions of server configuration - " - "usually due to excessive runtime memory requirement of the model (for instance " - "caused by large input size).", - context="workflow_execution | step_execution", - inner_error=error, - ) from error - if isinstance(error, InvalidModelIDError): - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=400, - public_message=f"Problem with Workflow Block configuration - {error}", - context="workflow_execution | step_execution", - inner_error=error, - ) from error - if isinstance(error, (RoboflowAPINotAuthorizedError, UnauthorizedModelAccessError)): - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=401, - public_message=f"Unauthorized error occurred while execution of step {step_name} - " - f"details of error: {error}. This error usually mean the problem with Roboflow API key.", - context="workflow_execution | step_execution", - inner_error=error, - ) from error - if isinstance(error, PaymentRequiredError): - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=402, - public_message=f"Not enough credits to execute step {step_name}. " - f"Verify your workspace billing page. Details: {error}", - context="workflow_execution | step_execution", - inner_error=error, - ) from error - if isinstance(error, RoboflowAPIForbiddenError): - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=403, - public_message=f"Forbidden error occurred while execution of step {step_name} - " - f"details of error: {error}. This error usually mean the problem with Roboflow API key.", - context="workflow_execution | step_execution", - inner_error=error, - ) from error - if isinstance(error, RoboflowAPIUsagePausedError): - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=423, - public_message=f"Roboflow API usage is paused while executing step {step_name}. " - f"Contact your workspace administrator to re-enable API keys. Details: {error}", - context="workflow_execution | step_execution", - inner_error=error, - ) from error - if isinstance(error, ModelRetrievalError): - status_code = getattr(error, "status_code", None) - if status_code in MODEL_ACCESS_ERROR_MESSAGES: - public_message = MODEL_ACCESS_ERROR_MESSAGES[status_code].format( - step_name=step_name - ) - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=status_code, - public_message=f"{public_message} Details: {error}", - context="workflow_execution | step_execution", - inner_error=error, - ) from error - if isinstance(error, (RoboflowAPINotNotFoundError, ModelNotFoundError)): - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=404, - public_message=f"Could not find requested Roboflow resource while execution of step {step_name} - " - f"details of error: {error}. This error usually mean the problem with not existing model.", - context="workflow_execution | step_execution", - inner_error=error, - ) from error - if isinstance(error, HTTPCallErrorError): - if error.status_code == 400: - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=400, - public_message=f"Bad request error detected while remote execution of step {step_name} - " - f"details of error: {error}. This error usually mean that the Workflow block configuration is faulty.", - context="workflow_execution | step_execution", - inner_error=error, - ) from error - if error.status_code == 401: - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=401, - public_message=f"Unauthorized error occurred while remote execution of step {step_name} - " - f"details of error: {error}. This error usually mean the problem with Roboflow API key.", - context="workflow_execution | step_execution", - inner_error=error, - ) from error - if error.status_code == 402: - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=402, - public_message=f"Not enough credits to remote execute step {step_name}. " - f"Verify your workspace billing page. Details: {error}", - context="workflow_execution | step_execution", - inner_error=error, - ) from error - if error.status_code == 403: - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=403, - public_message=f"Forbidden error occurred while remote execution of step {step_name} - " - f"details of error: {error}. This error usually mean the problem with Roboflow API key.", - context="workflow_execution | step_execution", - inner_error=error, - ) from error - if error.status_code == 404: - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=404, - public_message=f"Could not find requested Roboflow resource while remote execution of step {step_name} - " - f"details of error: {error}. This error usually mean the problem with not existing model.", - context="workflow_execution | step_execution", - inner_error=error, - ) from error - if error.status_code == 410: - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=410, - public_message=f"Deprecated feature usage detected while remote execution of step {step_name} - " - f"details of error: {error}.", - context="workflow_execution | step_execution | feature_deprecated", - inner_error=error, - ) from error - if error.status_code == 423: - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=423, - public_message=f"Roboflow API usage is paused while remote executing step {step_name}. " - f"Contact your workspace administrator to re-enable API keys. Details: {error}", - context="workflow_execution | step_execution", - inner_error=error, - ) from error - if error.status_code == 501: - public_message = error.api_message or ( - f"Remote execution of step {step_name} is not supported on this deployment." - ) - raise ClientCausedStepExecutionError( - block_id=step_name, - status_code=501, - public_message=public_message, - context="workflow_execution | step_execution | deployment_not_supported", - inner_error=ModelDeploymentNotSupportedError(public_message), - ) from error - if error.status_code == 507: - raise RuntimeLimitsCausedStepExecutionError( - block_id=step_name, - status_code=507, - public_message=f"Could not complete workflow execution due to configured runtime constraints. " - f"Details: {error.api_message}", - context="workflow_execution | step_execution", - inner_error=error, - ) from error + Every mapping the previous version carried (``FeatureDeprecatedError`` to + 410, re-raising ``ModelManagerLockAcquisitionError`` / + ``InferenceModelNotFound``) was for ``inference.core.exceptions`` classes + and now lives in ``inference.core.interfaces.workflows_step_error_handlers``, + which the server passes explicitly to ``ExecutionEngine.init``. + """ return None diff --git a/inference/core/workflows/prototypes/background_tasks.py b/inference/core/workflows/prototypes/background_tasks.py new file mode 100644 index 0000000000..c58e6cf59e --- /dev/null +++ b/inference/core/workflows/prototypes/background_tasks.py @@ -0,0 +1,13 @@ +from typing import Any, Callable, Protocol + + +class BackgroundTaskScheduler(Protocol): + """Fire-and-forget task scheduler injected into sink blocks. + + Satisfied by ``fastapi.BackgroundTasks``, which the HTTP layer injects. + Declared here so the block library does not import FastAPI for an + annotation. ``REGISTERED_INITIALIZERS`` defaults it to ``None`` and every + sink already branches on that, so no default implementation is needed. + """ + + def add_task(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> None: ... diff --git a/inference/core/workflows/prototypes/cache.py b/inference/core/workflows/prototypes/cache.py new file mode 100644 index 0000000000..014752841b --- /dev/null +++ b/inference/core/workflows/prototypes/cache.py @@ -0,0 +1,20 @@ +from typing import Any, Optional, Protocol + + +class WorkflowsCache(Protocol): + """Shared key/value cache injected into Workflow blocks. + + Only ``get`` and ``set`` are declared: that is the entire surface the 9 + calling blocks use. ``inference.core.cache.base.BaseCache`` also offers + sorted-set and lock operations - if a block ever needs one, add it here + first. + + The server's implementation is Redis-backed when ``REDIS_HOST`` is set and + honours ``expire``. There is deliberately NO default implementation here: + see the Phase 4 preamble - the initializer stays bound to the server's + cache until Phase 9 injects it at the composition roots. + """ + + def get(self, key: str) -> Any: ... + + def set(self, key: str, value: Any, expire: Optional[float] = None) -> None: ... diff --git a/inference/core/workflows/prototypes/models_provider.py b/inference/core/workflows/prototypes/models_provider.py new file mode 100644 index 0000000000..384de34e73 --- /dev/null +++ b/inference/core/workflows/prototypes/models_provider.py @@ -0,0 +1,50 @@ +from typing import Any, List, Optional, Protocol + + +class ModelsProvider(Protocol): + """The port through which Workflows reach models. + + Implemented in the Roboflow inference server by + ``inference.core.managers.base.ModelManager``. Declared here so that + ``inference.core.workflows`` does not import the server package for a type + annotation - that import alone pulls in FastAPI, the model registry, the + cache, telemetry and usage tracking. + + Deliberately NOT ``runtime_checkable``: nothing does an ``isinstance`` + check against it, and a protocol carrying a data member cannot support one. + + ``add_model`` keeps ``**kwargs`` rather than naming ``endpoint_type``, + ``countinference`` and ``service_secret`` explicitly - ``endpoint_type`` is + typed ``ModelEndpointType`` from ``inference.core.roboflow_api``, and naming + it here would reintroduce exactly the import this port removes. + + PROVISIONAL MEMBERS. ``infer_from_request_sync`` takes a pydantic request + object built by the caller from ``inference.core.entities`` - it is the + method Phase 11 option 2 removes entirely. ``__getitem__`` returns a raw + model object that seven call sites introspect (``key_points_classes``, + ``flush()``, ``_pipeline_depth``, ``shutdown_pipeline()``); it exists so + Phase 2 stays a mechanical swap, and Phase 11 must replace it with + first-class methods. Do not build new code against either. + """ + + content_addressed_artifact_cache: Any + + def add_model( + self, + model_id: str, + api_key: str, + model_id_alias: Optional[str] = None, + **kwargs: Any, + ) -> None: ... + + def infer_from_request_sync( + self, model_id: str, request: Any, **kwargs: Any + ) -> Any: ... + + def run_tensor_native_inference(self, model_id: str, **kwargs: Any) -> Any: ... + + def get_class_names(self, model_id: str) -> List[str]: ... + + def __contains__(self, model_id: str) -> bool: ... + + def __getitem__(self, key: str) -> Any: ... diff --git a/inference/core/workflows/utils/__init__.py b/inference/core/workflows/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/inference/core/workflows/utils/images.py b/inference/core/workflows/utils/images.py new file mode 100644 index 0000000000..d5e16a9b46 --- /dev/null +++ b/inference/core/workflows/utils/images.py @@ -0,0 +1,64 @@ +"""Workflows-local copy of the numpy image-resizing helpers. + +`_resize_image_keeping_aspect_ratio` is a copy of +`inference.core.utils.preprocess.resize_image_keeping_aspect_ratio` with only the +`isinstance(image, np.ndarray)` branches kept. The torch branch (guarded by +`USE_PYTORCH_FOR_PREPROCESSING` on the server) is deliberately not carried over: +every workflows caller passes a numpy image. +""" + +from typing import Tuple + +import cv2 +import numpy as np + + +def downscale_image_keeping_aspect_ratio( + image: np.ndarray, + desired_size: Tuple[int, int], +) -> np.ndarray: + if image.shape[0] <= desired_size[1] and image.shape[1] <= desired_size[0]: + return image + return _resize_image_keeping_aspect_ratio(image=image, desired_size=desired_size) + + +def _resize_image_keeping_aspect_ratio( + image: np.ndarray, + desired_size: Tuple[int, int], +) -> np.ndarray: + """ + Resize reserving its aspect ratio. + + Parameters: + - image: numpy array representing the image. + - desired_size: tuple (width, height) representing the target dimensions. + """ + if isinstance(image, np.ndarray): + img_ratio = image.shape[1] / image.shape[0] + else: + raise ValueError( + f"Received an image of unknown type, {type(image)}; " + "This is most likely a bug. Contact Roboflow team through github issues " + "(https://github.com/roboflow/inference/issues) providing full context of the problem" + ) + desired_ratio = desired_size[0] / desired_size[1] + + # Determine the new dimensions + if img_ratio >= desired_ratio: + # Resize by width + new_width = desired_size[0] + new_height = int(desired_size[0] / img_ratio) + else: + # Resize by height + new_height = desired_size[1] + new_width = int(desired_size[1] * img_ratio) + + # Resize the image to new dimensions + if isinstance(image, np.ndarray): + return cv2.resize(image, (new_width, new_height)) + else: + raise ValueError( + f"Received an image of unknown type, {type(image)}; " + "This is most likely a bug. Contact Roboflow team through github issues " + "(https://github.com/roboflow/inference/issues) providing full context of the problem" + ) diff --git a/inference/core/workflows/utils/lru_cache.py b/inference/core/workflows/utils/lru_cache.py new file mode 100644 index 0000000000..06c6b0f69c --- /dev/null +++ b/inference/core/workflows/utils/lru_cache.py @@ -0,0 +1,39 @@ +import collections + + +class LRUCache: + """Copied verbatim from ``inference.core.cache.lru_cache``. + + Do not "improve" it. Its eviction is deliberately reproduced: ``set`` + enforces the size bound only when inserting a NEW key, and does so BEFORE + inserting, so the cache transiently holds ``capacity + 1`` entries. A + reimplementation that enforces after insert diverges on the very first + eviction - verified against the original with a differential test. + """ + + def __init__(self, capacity=128): + self.capacity = capacity + self.cache = collections.OrderedDict() + + def set_max_size(self, capacity): + self.capacity = capacity + self.enforce_size() + + def enforce_size(self): + while len(self.cache) > self.capacity: + self.cache.popitem(last=False) + + def get(self, key): + try: + value = self.cache.pop(key) + self.cache[key] = value + return value + except KeyError: + return None + + def set(self, key, value): + try: + self.cache.pop(key) + except KeyError: + self.enforce_size() + self.cache[key] = value diff --git a/inference/core/workflows/utils/text.py b/inference/core/workflows/utils/text.py new file mode 100644 index 0000000000..f4c231a080 --- /dev/null +++ b/inference/core/workflows/utils/text.py @@ -0,0 +1,81 @@ +"""Workflows-local copies of pure helpers previously imported from `inference.core.utils`. + +The bodies are copied verbatim from the server implementations, with the single +intentional difference that the workflows-local exception / warning classes are +raised and emitted instead of the `inference.core.*` ones. +""" + +import functools +import os +import warnings +from typing import Any, Union + +import numpy as np + +from inference.core.workflows.errors import WorkflowsInvalidEnvironmentValueError +from inference.core.workflows.warnings import WorkflowsExperimentalFeatureWarning + + +def str2bool(value: Any) -> bool: + """ + Converts an environment variable to a boolean value. + + Args: + value (str or bool): The environment variable value to be converted. + + Returns: + bool: The converted boolean value. + + Raises: + WorkflowsInvalidEnvironmentValueError: If the value is not 'true', 'false', or a boolean. + """ + if isinstance(value, bool): + return value + if not issubclass(type(value), str): + raise WorkflowsInvalidEnvironmentValueError( + f"Expected a boolean environment variable (true or false) but got '{value}'" + ) + if value.lower() == "true": + return True + elif value.lower() == "false": + return False + else: + raise WorkflowsInvalidEnvironmentValueError( + f"Expected a boolean environment variable (true or false) but got '{value}'" + ) + + +def ensure_parent_dir_exists(path: str) -> None: + absolute_path = os.path.abspath(path) + parent_dir = os.path.dirname(absolute_path) + os.makedirs(parent_dir, exist_ok=True) + + +def cosine_similarity(a: np.ndarray, b: np.ndarray) -> Union[np.number, np.ndarray]: + """ + Compute the cosine similarity between two vectors. + + Args: + a (np.ndarray): Vector A. + b (np.ndarray): Vector B. + + Returns: + float: Cosine similarity between vectors A and B. + """ + return np.dot(a, b) / np.sqrt(np.vdot(a, a) * np.vdot(b, b)) + + +def experimental(reason: str): + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + warnings.warn( + f"{func.__name__} is experimental: {reason}", + category=WorkflowsExperimentalFeatureWarning, + stacklevel=2, + ) + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/inference/core/workflows/warnings.py b/inference/core/workflows/warnings.py new file mode 100644 index 0000000000..8be7e0dafc --- /dev/null +++ b/inference/core/workflows/warnings.py @@ -0,0 +1,2 @@ +class WorkflowsExperimentalFeatureWarning(Warning): + pass diff --git a/inference/enterprise/workflows/enterprise_blocks/loader.py b/inference/enterprise/workflows/enterprise_blocks/loader.py index f54a68ba1a..ee1f6a3c64 100644 --- a/inference/enterprise/workflows/enterprise_blocks/loader.py +++ b/inference/enterprise/workflows/enterprise_blocks/loader.py @@ -27,6 +27,15 @@ PostgreSQLSinkBlockV1, ) +# Plugin interface (see blocks_loader._load_blocks_from_plugin). Enterprise +# blocks keep the core block source so the `workflows_core.*` init parameters +# the server supplies - `disable_sinks` in particular - still resolve for them. +BLOCKS_SOURCE = "workflows_core" + + +def load_blocks() -> List[Type[WorkflowBlock]]: + return load_enterprise_blocks() + def load_enterprise_blocks() -> List[Type[WorkflowBlock]]: return [ diff --git a/inference_cli/lib/workflows/local_image_adapter.py b/inference_cli/lib/workflows/local_image_adapter.py index f2cc490007..117b58e088 100644 --- a/inference_cli/lib/workflows/local_image_adapter.py +++ b/inference_cli/lib/workflows/local_image_adapter.py @@ -9,6 +9,9 @@ from inference.core.cache import cache from inference.core.env import API_KEY, MAX_ACTIVE_MODELS +from inference.core.interfaces.workflows_step_error_handlers import ( + resolve_step_error_handler, +) from inference.core.managers.active_learning import BackgroundTaskActiveLearningManager from inference.core.managers.decorators.base import ModelManagerDecorator from inference.core.managers.decorators.fixed_size_cache import WithFixedSizeCache @@ -456,6 +459,7 @@ def _run_workflow_for_single_image_with_inference( max_concurrent_steps=max_concurrent_workflows_steps, workflow_id=workflow_id, executor=thread_pool_executor, + step_error_handler=resolve_step_error_handler(), ) runtime_parameters = workflow_parameters or {} runtime_parameters[image_input_name] = cv2.imread(image_path) diff --git a/scripts/relocate_logger.py b/scripts/relocate_logger.py new file mode 100644 index 0000000000..41bdce3c47 --- /dev/null +++ b/scripts/relocate_logger.py @@ -0,0 +1,88 @@ +import ast +import sys +import tokenize + +path = sys.argv[1] +# Preserve the file's own newline convention - llama_vision/v1.py is CRLF. +with tokenize.open(path) as fh: + src = fh.read() + newline = fh.newlines if isinstance(fh.newlines, str) else "\n" + +tree = ast.parse(src) +lines = src.splitlines() + + +def _is_docstring(node) -> bool: + return ( + isinstance(node, ast.Expr) + and isinstance(getattr(node, "value", None), ast.Constant) + and isinstance(node.value.value, str) + ) + + +drop = { + n.lineno - 1 + for n in tree.body + if isinstance(n, ast.ImportFrom) + and n.module in ("inference.core", "inference.core.logger") + and any(a.name == "logger" for a in n.names) +} +assert drop, f"no logger import found in {path}" + +# End of the LEADING import block, i.e. the last top-level import that still +# precedes the first top-level statement which is not an import or the module +# docstring. Using the last import in the whole module (as originally scoped) +# breaks `dynamic_blocks/modal_executor.py`, which calls `logger.info(...)` at +# module level between two import groups: the assignment would land after that +# call and raise NameError at import time. +first_code = min( + ( + n.lineno + for n in tree.body + if not isinstance(n, (ast.Import, ast.ImportFrom)) and not _is_docstring(n) + ), + default=len(lines) + 1, +) +imports = [ + n + for n in tree.body + if isinstance(n, (ast.Import, ast.ImportFrom)) and n.lineno < first_code +] +assert imports, f"no leading import block in {path}" +insert_after = max(n.end_lineno for n in imports) + +# Some modules already import `logging` at top level (vlm_as_detector/v2_tensor.py). +# A second `import logging` is an F811 redefinition, so skip the insertion there. +already_imports_logging = any( + isinstance(n, ast.Import) + and any(a.name == "logging" and a.asname is None for a in n.names) + for n in tree.body +) + +# `import logging` goes after the module docstring and any __future__ import. +head = 0 +first = tree.body[0] if tree.body else None +if _is_docstring(first): + head = first.end_lineno +for n in tree.body: + if isinstance(n, ast.ImportFrom) and n.module == "__future__": + head = max(head, n.end_lineno) + +out = [] +for i, line in enumerate(lines): + if i not in drop: + out.append(line) + # The insertion checks run even for dropped lines. When the logger import + # IS the last import - as in anthropic_claude/model_capabilities.py - the + # insertion point is a dropped line, and a `continue` here skips the + # insertion entirely, leaving the file compiling but with no `logger`. + if i + 1 == head and not already_imports_logging: + out.append("import logging") + if i + 1 == insert_after: + out.append("") + out.append("logger = logging.getLogger(__name__)") +if head == 0 and not already_imports_logging: + out.insert(0, "import logging") + +with open(path, "w", encoding="utf-8", newline="") as fh: + fh.write(newline.join(out) + newline) diff --git a/scripts/workflows_isolation_probe.py b/scripts/workflows_isolation_probe.py new file mode 100644 index 0000000000..32a02ca533 --- /dev/null +++ b/scripts/workflows_isolation_probe.py @@ -0,0 +1,476 @@ +"""Prove inference/core/workflows works with the server package unimportable. + +Copies the module next to EMPTY stub parents (`inference/`, `inference/core/`) +in a scratch tree and runs every check in a child interpreter that (a) puts +that tree first on sys.path and (b) installs a meta-path blocker refusing +every other `inference.*` module. The blocker is load-bearing: the venv's +editable-install finder serves direct children such as +`inference.usage_tracking` from the real checkout even when the parent +package is a stub (verified 2026-09-08), so stub parents alone are not +isolation. + +Deviation from the plan text (recorded 2026-09-08): the child's `PYTHONPATH` +is `` **plus** `/inference_models`. `inference_models` and +`inference_sdk` are allowed dependencies of workflows and the probe is +specified to run with both installed, but in this checkout `inference_models` +is importable only through that path entry - the venv's editable install +resolves it from a different checkout. Setting `PYTHONPATH` to the scratch +tree alone would silently swap in the other checkout's copy. +""" + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +MODULE = REPO_ROOT / "inference" / "core" / "workflows" + +CHILD = r""" +import importlib, json, logging, os, pkgutil, shutil, sys, traceback + +# Every check below verifies with `assert`, which `-O` / PYTHONOPTIMIZE strips. +# A plain `assert sys.flags.optimize == 0` would be stripped too, so the guard +# is an `if` and it runs before anything else - a probe that cannot verify must +# report, not pass quietly. The parent also pins PYTHONOPTIMIZE=0. +if sys.flags.optimize: + print(json.dumps([{ + "check": "no_optimize", + "status": "fail", + "detail": "child ran with optimization enabled; assert statements are stripped", + }])) + sys.exit(1) + +T, TENSOR_MODE = sys.argv[1], sys.argv[2] == "on" +# The parent owns the check list and hands it over as JSON - one source of +# truth, so a check the parent expects can never silently go unrun. +EXPECTED_CHECKS = json.loads(sys.argv[3]) +PACKAGE = "inference.core.workflows" +results = [] + + +def _allowed(name): + return ( + name in ("inference", "inference.core") + or name == PACKAGE + or name.startswith(PACKAGE + ".") + ) + + +class ServerImportBlocker: + # First on sys.meta_path: refuses every `inference.*` module outside the + # copied package - including what the editable finder would otherwise + # serve - and records the attempt, so a `try: import ... except + # ImportError` inside workflows is still reported. + attempted = [] + + def find_spec(self, fullname, path=None, target=None): + if (fullname == "inference" or fullname.startswith("inference.")) and not _allowed(fullname): + self.attempted.append(fullname) + raise ImportError(f"blocked: {fullname} is the inference server package") + return None + + +sys.meta_path.insert(0, ServerImportBlocker()) + + +def check(name, fn): + try: + fn() + results.append({"check": name, "status": "ok"}) + except BaseException: # noqa: BLE001 - report every failure, keep going + results.append({"check": name, "status": "fail", "detail": traceback.format_exc()}) + + +def _inside_copy(file): + from pathlib import Path + try: + Path(file).resolve().relative_to(Path(T).resolve()) + return True + except ValueError: + return False + + +def assert_only_copied_modules_loaded(): + leaked = sorted( + name for name in sys.modules + if (name == "inference" or name.startswith("inference.")) and not _allowed(name) + ) + # Directory containment on resolved paths, not a string prefix - `T-other/` + # would otherwise pass. A module without __file__ is reported, not skipped. + foreign = sorted( + name for name, module in sys.modules.items() + if (name.startswith(PACKAGE + ".") or name == PACKAGE) + and not (getattr(module, "__file__", None) and _inside_copy(module.__file__)) + ) + stubs = { + "inference": os.path.join(T, "inference", "__init__.py"), + "inference.core": os.path.join(T, "inference", "core", "__init__.py"), + } + wrong_stubs = { + name: getattr(sys.modules.get(name), "__file__", None) + for name, expected in stubs.items() + if name in sys.modules and os.path.realpath(sys.modules[name].__file__ or "") != os.path.realpath(expected) + } + assert not leaked and not foreign and not wrong_stubs, json.dumps( + {"leaked_server_modules": leaked, "not_from_copy": foreign, "wrong_stubs": wrong_stubs}, + indent=1, + ) + + +def import_everything(): + import inference + # Directory containment on resolved paths, not a string prefix: on macOS + # `tempfile.mkdtemp` hands back `/var/...` while the child resolves the + # copy to `/private/var/...`, so a prefix test aborts the walk on the + # correct tree (probe-code fix, recorded 2026-09-08). + assert _inside_copy(inference.__file__), f"real package leaked in: {inference.__file__}" + import inference.core.workflows as workflows + failures = {} + for info in pkgutil.walk_packages( + workflows.__path__, + PACKAGE + ".", + onerror=lambda name: failures.setdefault(name, "package import raised"), + ): + try: + importlib.import_module(info.name) + except Exception as error: # noqa: BLE001 + failures[info.name] = repr(error) + assert not failures, json.dumps({"import_failures": failures}, indent=1) + assert_only_copied_modules_loaded() + + +def load_blocks(): + from inference.core.workflows.execution_engine.introspection.blocks_loader import ( + describe_available_blocks, load_workflow_blocks, + ) + identifiers = {b.identifier for b in load_workflow_blocks()} + assert identifiers, "no blocks loaded" + # Effective tensor configuration, not just the requested one: the blur + # block registers from `v1_tensor` only in tensor mode. + tensor_blur = PACKAGE + ".core_steps.classical_cv.image_blur.v1_tensor.ImageBlurBlockV1" + assert (tensor_blur in identifiers) == TENSOR_MODE, ( + TENSOR_MODE, sorted(i for i in identifiers if "image_blur" in i) + ) + describe_available_blocks(dynamic_blocks=[]).model_dump_json() + + +def _run(definition, **runtime): + from inference.core.workflows.execution_engine.core import ExecutionEngine + engine = ExecutionEngine.init(workflow_definition=definition, init_parameters={}) + return engine.run(runtime_parameters=runtime, serialize_results=True) + + +def _image_input(): + # Tensor mode feeds a raw CHW tensor (deserializers_tensor.py:94 accepts + # it); numpy mode feeds an ndarray. Same zero canvas either way. + if TENSOR_MODE: + import torch + return torch.zeros((3, 64, 64), dtype=torch.uint8) + import numpy as np + return np.zeros((64, 64, 3), dtype=np.uint8) + + +MODEL_FREE_WORKFLOW = { + "version": "1.0", + "inputs": [ + {"type": "WorkflowImage", "name": "image"}, + {"type": "WorkflowBatchInput", "name": "detections", + "kind": ["object_detection_prediction"]}, + ], + "steps": [ + {"type": "roboflow_core/image_blur@v1", "name": "blur", + "image": "$inputs.image", "blur_type": "gaussian", "kernel_size": 5}, + # rich_label needs predictions and a text CHOICE (rich_label/v1.py:136); + # the synthetic detection's class name is what gets rendered. Never + # pass empty predictions: the block returns before font resolution + # (rich_label/v1.py:327). + {"type": "roboflow_core/rich_label_visualization@v1", "name": "label", + "image": "$steps.blur.image", "predictions": "$inputs.detections", + "text": "Class", "font_family": "Geist Mono"}, + ], + "outputs": [ + {"type": "JsonField", "name": "blurred", "selector": "$steps.blur.image"}, + {"type": "JsonField", "name": "labelled", "selector": "$steps.label.image"}, + ], +} +SYNTHETIC_DETECTIONS = { + "image": {"width": 64, "height": 64}, + "predictions": [{"x": 32, "y": 32, "width": 20, "height": 20, + "confidence": 0.9, "class": "isolation", "class_id": 0}], +} + + +def run_model_free_workflow(): + # Blur, then render a label with a packaged font - a real offline render, + # in both representations. + from inference.core.workflows.execution_engine.core import ExecutionEngine + engine = ExecutionEngine.init(workflow_definition=MODEL_FREE_WORKFLOW, init_parameters={}) + runtime = {"image": _image_input(), "detections": SYNTHETIC_DETECTIONS} + raw = engine.run(runtime_parameters=runtime, serialize_results=False)[0] + # Tensor mode must take the tensor path, not the numpy fallback + # (v1_tensor.py:81): gaussian/5 is supported natively. + assert raw["blurred"].is_tensor_materialised() == TENSOR_MODE, TENSOR_MODE + assert raw["labelled"].numpy_image.any(), "the label rendered nothing onto the zero canvas" + serialised = engine.run(runtime_parameters=runtime, serialize_results=True)[0] + assert "labelled" in serialised and "blurred" in serialised, serialised + + +def run_dynamic_block_workflow(): + # Exercises block_scaffolding's generated code - where the exec'd + # `from inference.core.env import ...` string used to live (Phase 5). + out = _run( + { + "version": "1.0", + "inputs": [{"type": "WorkflowParameter", "name": "x"}], + "dynamic_blocks_definitions": [ + { + "type": "DynamicBlockDefinition", + "manifest": { + "type": "ManifestDescription", + "block_type": "Doubler", + "inputs": {"x": {"type": "DynamicInputDefinition", + "selector_types": ["input_parameter"]}}, + "outputs": {"y": {"type": "DynamicOutputDefinition", "kind": []}}, + }, + "code": { + "type": "PythonCode", + "run_function_code": "def run(self, x: int) -> BlockResult:\n return {\"y\": x * 2}\n", + }, + } + ], + "steps": [{"type": "Doubler", "name": "double", "x": "$inputs.x"}], + "outputs": [{"type": "JsonField", "name": "y", "selector": "$steps.double.y"}], + }, + x=21, + ) + assert out and out[0]["y"] == 42, out + + +def standalone_logging(): + records = [] + handler = logging.Handler() + handler.emit = records.append + tree_logger = logging.getLogger(PACKAGE) + tree_logger.addHandler(handler) + tree_logger.setLevel(logging.DEBUG) + run_model_free_workflow() + assert records, "no log record reached a plain stdlib handler" + + +def fonts_offline(): + from inference.core.workflows.core_steps.visualizations.common.fonts import ( + resolve_font_path, + ) + font = resolve_font_path("Geist Mono") + assert font.is_file() and _inside_copy(font), font + assert (font.parent / "OFL.txt").is_file(), "font licence not packaged next to the font" + assets = os.path.join(T, "inference", "core", "workflows", "core_steps", + "visualizations", "common", "fonts", "assets") + shutil.rmtree(assets) + try: + resolve_font_path("Geist Mono") + except RuntimeError as error: + assert "download_fonts" in str(error), error + else: + raise AssertionError("a missing packaged font was silently tolerated") + + +def no_blocked_import_attempts(): + # Runs last: covers imports attempted during block loading and execution, + # not only during the import walk. + attempted = sorted(set(ServerImportBlocker.attempted)) + assert not attempted, json.dumps({"blocked_import_attempts": attempted}, indent=1) + assert_only_copied_modules_loaded() + + +CHECKS = { + "import_everything": import_everything, + "load_blocks": load_blocks, + "run_model_free_workflow": run_model_free_workflow, + "run_dynamic_block_workflow": run_dynamic_block_workflow, + "standalone_logging": standalone_logging, + "fonts_offline": fonts_offline, # deletes the assets - keep it after the render + "no_blocked_import_attempts": no_blocked_import_attempts, # last +} +# Ordering comes from the parent's EXPECTED_CHECKS, which is authoritative. +for name in EXPECTED_CHECKS: + if name not in CHECKS: + results.append({"check": name, "status": "fail", + "detail": "the child has no implementation for this check"}) + continue + check(name, CHECKS[name]) +print(json.dumps(results)) +""" + +# The checks the child must run, in order. `fonts_offline` deletes the copied +# assets so it comes after the render; `no_blocked_import_attempts` is last so +# it sees every attempt. The parent both hands this to the child and validates +# the returned result set against it. +EXPECTED_CHECKS = ( + "import_everything", + "load_blocks", + "run_model_free_workflow", + "run_dynamic_block_workflow", + "standalone_logging", + "fonts_offline", + "no_blocked_import_attempts", +) + + +def build_tree(target: Path) -> None: + (target / "inference" / "core").mkdir(parents=True) + (target / "inference" / "__init__.py").write_text("") + (target / "inference" / "core" / "__init__.py").write_text("") + shutil.copytree( + MODULE, + target / "inference" / "core" / "workflows", + ignore=shutil.ignore_patterns("__pycache__"), + ) + + +def _child_diagnostics(proc: subprocess.CompletedProcess) -> str: + return ( + f"exit code {proc.returncode}\n" + f"--- child stderr (tail) ---\n{proc.stderr[-4000:]}\n" + f"--- child stdout (tail) ---\n{proc.stdout[-2000:]}" + ) + + +def _validate_results(raw: list, proc: subprocess.CompletedProcess) -> list: + """Keep only well-formed rows and flag anything the child owed us. + + Missing results must never read as success: an empty list, a truncated run + and a malformed row all have to surface as a failing `results_complete` + row, because `main()` decides purely on the rows it is handed. + """ + results, problems, seen = [], [], set() + for entry in raw: + if ( + isinstance(entry, dict) + and isinstance(entry.get("check"), str) + and entry.get("status") in ("ok", "fail") + ): + results.append(entry) + seen.add(entry["check"]) + else: + problems.append(f"malformed result entry: {entry!r}") + missing = [name for name in EXPECTED_CHECKS if name not in seen] + if missing: + problems.append(f"checks missing from the child's results: {missing}") + unexpected = sorted(seen - set(EXPECTED_CHECKS)) + if unexpected: + problems.append(f"unexpected checks in the child's results: {unexpected}") + if problems: + results.append( + { + "check": "results_complete", + "status": "fail", + "detail": "\n".join(problems) + "\n" + _child_diagnostics(proc), + } + ) + return results + + +def _child_env(tree: Path, tensor_mode: bool) -> dict: + env = { + **os.environ, + # See the module docstring: the scratch tree first, then this + # checkout's `inference_models` (an allowed dependency that the venv + # would otherwise resolve from a different checkout). + "PYTHONPATH": str(tree) + os.pathsep + str(REPO_ROOT / "inference_models"), + "MODEL_CACHE_DIR": str(tree / "cache"), # no previously cached fonts + # Standalone configuration. Phase 5 decides how the workflows-local + # default reads these; until then they are the env names the module + # consumes today. Adjust here if Phase 5 moves them. + "ENABLE_TENSOR_DATA_REPRESENTATION": "True" if tensor_mode else "False", + "ALLOW_CUSTOM_PYTHON_EXECUTION_IN_WORKFLOWS": "True", + "WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE": "local", + "ALLOW_WORKFLOWS_FONTS_DOWNLOAD": "False", + # The checks verify with `assert`; an inherited PYTHONOPTIMIZE would + # strip every one of them and turn the probe green by deleting it. + "PYTHONOPTIMIZE": "0", + } + # Never inherit the server's plugin list (Task 7.1 expands the enterprise + # plugin into it); the probe loads core blocks only. + env.pop("WORKFLOWS_PLUGINS", None) + # Likewise the server's step-error handler: it selects a handler the + # standalone engine does not register, so an inherited value would fail + # `ExecutionEngine.init` for a reason that has nothing to do with + # contamination. The child takes the workflows-local default. + env.pop("DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER", None) + return env + + +def run_probe(tree: Path, tensor_mode: bool) -> list: + env = _child_env(tree, tensor_mode) + proc = subprocess.run( + [ + sys.executable, + "-c", + CHILD, + str(tree), + "on" if tensor_mode else "off", + json.dumps(list(EXPECTED_CHECKS)), + ], + cwd=tree, + env=env, + capture_output=True, + text=True, + ) + line = proc.stdout.strip().splitlines()[-1] if proc.stdout.strip() else "" + try: + raw = json.loads(line) + if not isinstance(raw, list): + raise ValueError(f"results line is {type(raw).__name__}, not a list") + except ValueError as error: # includes json.JSONDecodeError + # No parseable results at all: report the child's own failure rather + # than crashing on it, and never let silence read as success. + return [ + { + "check": "child_process", + "status": "fail", + "detail": f"no/invalid results line ({error})\n{_child_diagnostics(proc)}", + } + ] + results = _validate_results(raw, proc) + if proc.returncode != 0: + results.append( + { + "check": "child_process", + "status": "fail", + "detail": _child_diagnostics(proc), + } + ) + return results + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--tensor-mode", choices=["off", "on", "both"], default="both") + parser.add_argument("--keep-tree", action="store_true") + args = parser.parse_args() + modes = {"off": [False], "on": [True], "both": [False, True]}[args.tensor_mode] + failed = False + for tensor_mode in modes: + tree = Path(tempfile.mkdtemp(prefix="workflows-isolation-")) + build_tree(tree) + for result in run_probe(tree, tensor_mode): + label = "on" if tensor_mode else "off" + print(f"tensor={label} {result['check']}: {result['status']}") + if result["status"] != "ok": + failed = True + print(result.get("detail", "")) + if args.keep_tree: + print(f"tree kept at {tree}") + else: + shutil.rmtree(tree) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/inference/unit_tests/core/interfaces/test_step_error_handler_selection.py b/tests/inference/unit_tests/core/interfaces/test_step_error_handler_selection.py new file mode 100644 index 0000000000..ce2ee99ea1 --- /dev/null +++ b/tests/inference/unit_tests/core/interfaces/test_step_error_handler_selection.py @@ -0,0 +1,89 @@ +import ast +from pathlib import Path + +import pytest + +from inference.core.exceptions import FeatureDeprecatedError +from inference.core.interfaces.workflows_step_error_handlers import ( + extended_roboflow_errors_handler, + legacy_step_error_handler, + resolve_step_error_handler, +) +from inference.core.workflows.errors import ( + ClientCausedStepExecutionError, + WorkflowEnvironmentConfigurationError, +) +from inference.core.workflows.execution_engine.core import ExecutionEngine +from inference.core.workflows.execution_engine.v1 import core as ee_core + +# tests/inference/unit_tests/core/interfaces/ -> five levels up is the repo root +REPO_ROOT = Path(__file__).resolve().parents[5] +COMPOSITION_ROOTS = [ + "inference/core/interfaces/http/http_api.py", + "inference/core/interfaces/stream/inference_pipeline.py", + "inference_cli/lib/workflows/local_image_adapter.py", +] + + +def test_server_default_is_the_extended_handler(monkeypatch) -> None: + monkeypatch.delenv("DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER", raising=False) + assert resolve_step_error_handler() is extended_roboflow_errors_handler + + +def test_explicit_legacy_keeps_the_server_legacy_behaviour(monkeypatch) -> None: + monkeypatch.setenv("DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER", "legacy") + assert resolve_step_error_handler() is legacy_step_error_handler + + +def test_unknown_name_is_passed_through_and_the_engine_still_rejects_it( + monkeypatch, +) -> None: + monkeypatch.setenv("DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER", "nope") + assert resolve_step_error_handler() == "nope" + with pytest.raises(WorkflowEnvironmentConfigurationError): + ExecutionEngine.init( + workflow_definition={}, step_error_handler=resolve_step_error_handler() + ) + + +def test_direct_engine_default_maps_nothing_while_server_handler_maps_deprecation( + monkeypatch, +) -> None: + # The Roboflow mapping left workflows, so the two sides now differ and that + # difference is authorised: a direct `ExecutionEngine.init(...)` caller that + # does not pass `step_error_handler` gets the mapping-free workflows + # default, while every server composition root passes the resolver below + # and keeps the mapping. Pinned here so the divergence cannot drift. + monkeypatch.delenv("DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER", raising=False) + error = FeatureDeprecatedError(feature="example/feature@v1", reason="gone") + + direct_engine_handler = ee_core.REGISTERED_STEP_ERROR_HANDLERS[ + ee_core.DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER + ] + assert direct_engine_handler("step-id", error) is None + + with pytest.raises(ClientCausedStepExecutionError) as captured: + resolve_step_error_handler()("step-id", error) + assert captured.value.status_code == 410 + assert captured.value.inner_error is error + + +def test_every_server_composition_root_passes_a_handler() -> None: + # The default argument is bound at definition time inside workflows, so + # the server must pass its choice explicitly at every ExecutionEngine.init. + for relative in COMPOSITION_ROOTS: + tree = ast.parse((REPO_ROOT / relative).read_text(encoding="utf-8")) + calls = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "init" + and getattr(node.func.value, "id", None) == "ExecutionEngine" + ] + assert calls, relative + for call in calls: + assert any(k.arg == "step_error_handler" for k in call.keywords), ( + relative, + call.lineno, + ) diff --git a/tests/workflows/unit_tests/execution_engine/test_step_error_handlers.py b/tests/inference/unit_tests/core/interfaces/test_workflows_step_error_handlers.py similarity index 99% rename from tests/workflows/unit_tests/execution_engine/test_step_error_handlers.py rename to tests/inference/unit_tests/core/interfaces/test_workflows_step_error_handlers.py index 86535f27c7..e5f86238d1 100644 --- a/tests/workflows/unit_tests/execution_engine/test_step_error_handlers.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_step_error_handlers.py @@ -13,14 +13,14 @@ RoboflowAPINotNotFoundError, RoboflowAPIUsagePausedError, ) +from inference.core.interfaces.workflows_step_error_handlers import ( + extended_roboflow_errors_handler, + legacy_step_error_handler, +) from inference.core.workflows.errors import ( ClientCausedStepExecutionError, RuntimeLimitsCausedStepExecutionError, ) -from inference.core.workflows.execution_engine.v1.step_error_handlers import ( - extended_roboflow_errors_handler, - legacy_step_error_handler, -) from inference_models.errors import ( ModelPackageAlternativesExhaustedError, ModelPackageRestrictedError, diff --git a/tests/inference/unit_tests/core/test_enterprise_blocks_plugin.py b/tests/inference/unit_tests/core/test_enterprise_blocks_plugin.py new file mode 100644 index 0000000000..2b2424b177 --- /dev/null +++ b/tests/inference/unit_tests/core/test_enterprise_blocks_plugin.py @@ -0,0 +1,148 @@ +import json +import os +import subprocess +import sys + +from inference.core.env import ENTERPRISE_BLOCKS_PLUGIN as ENTERPRISE_PLUGIN + +MQTT_BLOCK = ( + "inference.enterprise.workflows.enterprise_blocks.sinks.mqtt_writer.v1" + ".MQTTWriterSinkBlockV1" +) +FAKE_PLUGIN = "fake_workflows_plugin" +FAKE_BLOCK = "__main__.FakePluginBlockV1" + +# Installs `FAKE_PLUGIN` in sys.modules before anything imports the loader, so +# `WORKFLOWS_PLUGINS=fake_workflows_plugin` resolves without a real distribution. +_FAKE_PLUGIN_SETUP = """ +import sys +import types +from typing import List, Literal, Type + +from inference.core.workflows.execution_engine.entities.base import OutputDefinition +from inference.core.workflows.prototypes.block import ( + BlockResult, + WorkflowBlock, + WorkflowBlockManifest, +) + + +class FakePluginManifest(WorkflowBlockManifest): + type: Literal["fake_plugin_block@v1"] + + @classmethod + def describe_outputs(cls) -> List[OutputDefinition]: + return [OutputDefinition(name="value")] + + +class FakePluginBlockV1(WorkflowBlock): + @classmethod + def get_manifest(cls) -> Type[WorkflowBlockManifest]: + return FakePluginManifest + + def run(self) -> BlockResult: + return {"value": 1} + + +_module = types.ModuleType("fake_workflows_plugin") +_module.load_blocks = lambda: [FakePluginBlockV1] +sys.modules["fake_workflows_plugin"] = _module +""" + +_LOAD_AND_REPORT = """ +import json +import os + +from inference.core.workflows.execution_engine.introspection.blocks_loader import ( + load_workflow_blocks, +) + +print( + json.dumps( + { + "plugins": os.environ.get("WORKFLOWS_PLUGINS"), + "blocks": [[b.identifier, b.block_source] for b in load_workflow_blocks()], + } + ) +) +""" + +PROBE = _LOAD_AND_REPORT +ORDERING_PROBE = _FAKE_PLUGIN_SETUP + _LOAD_AND_REPORT + + +def _run_probe(probe: str, **env: str) -> dict: + child_env = {**os.environ, "DISABLE_VERSION_CHECK": "True", **env} + # Never inherit the parent's plugin list: env.py may already have expanded + # the enterprise plugin into it, which would mask the "disabled" case. + child_env.pop("WORKFLOWS_PLUGINS", None) + child_env.update(env) # ... unless the case sets it explicitly + result = subprocess.run( + [sys.executable, "-c", probe], env=child_env, capture_output=True, text=True + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip().splitlines()[-1]) + + +def _block_sources(**env: str) -> dict: + return dict(_run_probe(PROBE, **env)["blocks"]) + + +def test_enterprise_blocks_load_under_the_core_source_when_enabled() -> None: + sources = _block_sources(LOAD_ENTERPRISE_BLOCKS="True") + assert sources[MQTT_BLOCK] == "workflows_core" + + +def test_enterprise_blocks_are_absent_when_disabled() -> None: + sources = _block_sources(LOAD_ENTERPRISE_BLOCKS="False") + assert MQTT_BLOCK not in sources + + +def test_enterprise_blocks_keep_their_position_between_core_and_custom_plugins() -> ( + None +): + # Before this refactor enterprise blocks were merged into the core list, so + # the order was core -> enterprise -> custom plugins. Appending the plugin + # to WORKFLOWS_PLUGINS would silently flip that to core -> custom -> + # enterprise for anyone who also configures their own plugin. + report = _run_probe( + ORDERING_PROBE, + LOAD_ENTERPRISE_BLOCKS="True", + WORKFLOWS_PLUGINS=FAKE_PLUGIN, + ) + identifiers = [identifier for identifier, _ in report["blocks"]] + sources = dict(report["blocks"]) + + assert sources[MQTT_BLOCK] == "workflows_core" + assert sources[FAKE_BLOCK] == FAKE_PLUGIN + # Enterprise blocks are tagged `workflows_core` too (that is the whole + # point of BLOCKS_SOURCE), so the genuine core blocks have to be picked + # out by module path - otherwise MQTT -> core -> fake also satisfies the + # assertions and the ordering regression slips through. + genuine_core_positions = [ + index + for index, (identifier, source) in enumerate(report["blocks"]) + if source == "workflows_core" + and not identifier.startswith("inference.enterprise.") + ] + mqtt_position = identifiers.index(MQTT_BLOCK) + fake_position = identifiers.index(FAKE_BLOCK) + + # every genuine core block precedes the enterprise block ... + assert max(genuine_core_positions) < mqtt_position + # ... which in turn precedes the custom plugin's block. + assert mqtt_position < fake_position + assert report["plugins"] == f"{ENTERPRISE_PLUGIN},{FAKE_PLUGIN}" + + +def test_env_expansion_does_not_duplicate_an_already_listed_enterprise_plugin() -> None: + # A duplicated plugin name loads the same blocks twice and trips the + # manifest-identifier clash check in describe_available_blocks. + report = _run_probe( + PROBE, + LOAD_ENTERPRISE_BLOCKS="True", + WORKFLOWS_PLUGINS=ENTERPRISE_PLUGIN, + ) + assert report["plugins"] == ENTERPRISE_PLUGIN + identifiers = [identifier for identifier, _ in report["blocks"]] + assert identifiers.count(MQTT_BLOCK) == 1 diff --git a/tests/inference/unit_tests/test_workflows_logging_parity.py b/tests/inference/unit_tests/test_workflows_logging_parity.py new file mode 100644 index 0000000000..1877534dba --- /dev/null +++ b/tests/inference/unit_tests/test_workflows_logging_parity.py @@ -0,0 +1,74 @@ +import json +import os +import subprocess +import sys + +# Runs in a child interpreter: logger configuration is decided at import time +# from API_LOGGING_ENABLED, and the structured branch installs a StreamHandler +# on stderr with propagate=False. +PROBE = r""" +import logging + +from asgi_correlation_id import correlation_id +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider + +from inference_sdk.config import execution_id + +# A real provider, so spans carry trace/span ids instead of the no-op proxy's zeros. +trace.set_tracer_provider(TracerProvider()) + +from inference.core.logger import logger as server_logger # noqa: E402 +from inference.core.telemetry import start_span # noqa: E402 + +correlation_id.set("corr-123") +execution_id.set("exec-456") +with start_span("parity"): + server_logger.info("from-server") + logging.getLogger("inference.core.workflows.smoke").info("from-workflows") +""" + + +def _run(api_logging_enabled: str, stream: str) -> list: + env = { + **os.environ, + "API_LOGGING_ENABLED": api_logging_enabled, + "LOG_LEVEL": "INFO", # env.py:576 defaults to WARNING - INFO records would vanish + "CORRELATION_ID_LOG_KEY": "request_id", + "GCP_SERVERLESS": "False", # the GCP branch renames `event` to `message` + "DISABLE_VERSION_CHECK": "True", + "OFFLINE_MODE": "False", # telemetry helpers are no-ops in offline mode + } + env.pop("DEDICATED_DEPLOYMENT_ID", None) # also selects the GCP branch + proc = subprocess.run( + [sys.executable, "-c", PROBE], env=env, capture_output=True, text=True + ) + assert proc.returncode == 0, proc.stderr + return [ + line + for line in getattr(proc, stream).splitlines() + if "from-server" in line or "from-workflows" in line + ] + + +def test_structured_mode_keeps_context_for_workflow_loggers() -> None: + # The structured branch installs logging.StreamHandler() -> stderr (logger.py:164). + lines = _run("True", stream="stderr") + assert len(lines) == 2, f"expected exactly one record each, got: {lines}" + server, workflows = (json.loads(line) for line in lines) + assert workflows["event"] == "from-workflows" + # Context established in the probe must survive the stdlib path. + assert workflows["request_id"] == "corr-123" == server["request_id"] + assert workflows["execution_id"] == "exec-456" == server["execution_id"] + assert workflows["trace_id"] == server["trace_id"] + assert workflows["span_id"] == server["span_id"] + # Same envelope: everything except callsite-specific fields. + volatile = {"event", "timestamp", "filename", "func_name", "lineno"} + assert set(server) - volatile == set(workflows) - volatile + + +def test_plain_mode_still_reaches_the_rich_handler_exactly_once() -> None: + # RichHandler's console writes to stdout (logger.py:171). + lines = _run("False", stream="stdout") + assert len(lines) == 2, f"expected exactly one record each, got: {lines}" + assert "from-workflows" in lines[1] and "INFO" in lines[1] diff --git a/tests/workflows/integration_tests/execution/test_workflow_with_detections_consensus_block.py b/tests/workflows/integration_tests/execution/test_workflow_with_detections_consensus_block.py index bb7bbce827..0d1dea4817 100644 --- a/tests/workflows/integration_tests/execution/test_workflow_with_detections_consensus_block.py +++ b/tests/workflows/integration_tests/execution/test_workflow_with_detections_consensus_block.py @@ -3,6 +3,9 @@ import supervision as sv from inference.core.env import WORKFLOWS_MAX_CONCURRENT_STEPS +from inference.core.interfaces.workflows_step_error_handlers import ( + resolve_step_error_handler, +) from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.errors import ( @@ -274,6 +277,7 @@ def test_consensus_workflow_when_model_id_cannot_be_resolved_to_valid_model( workflow_definition=CONSENSUS_WORKFLOW, init_parameters=workflow_init_parameters, max_concurrent_steps=WORKFLOWS_MAX_CONCURRENT_STEPS, + step_error_handler=resolve_step_error_handler(), ) # when diff --git a/tests/workflows/integration_tests/execution/test_workflow_with_filtering.py b/tests/workflows/integration_tests/execution/test_workflow_with_filtering.py index 9c40d6e506..b40cf3ac08 100644 --- a/tests/workflows/integration_tests/execution/test_workflow_with_filtering.py +++ b/tests/workflows/integration_tests/execution/test_workflow_with_filtering.py @@ -3,6 +3,9 @@ import supervision as sv from inference.core.env import WORKFLOWS_MAX_CONCURRENT_STEPS +from inference.core.interfaces.workflows_step_error_handlers import ( + resolve_step_error_handler, +) from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.query_language.errors import ( @@ -311,6 +314,7 @@ def test_filtering_workflow_when_model_id_cannot_be_resolved_to_valid_model( workflow_definition=FILTERING_WORKFLOW, init_parameters=workflow_init_parameters, max_concurrent_steps=WORKFLOWS_MAX_CONCURRENT_STEPS, + step_error_handler=resolve_step_error_handler(), ) # when diff --git a/tests/workflows/integration_tests/execution/test_workflow_with_gaze_deprecated.py b/tests/workflows/integration_tests/execution/test_workflow_with_gaze_deprecated.py index 682d3cbbf5..eb37b3c337 100644 --- a/tests/workflows/integration_tests/execution/test_workflow_with_gaze_deprecated.py +++ b/tests/workflows/integration_tests/execution/test_workflow_with_gaze_deprecated.py @@ -13,6 +13,9 @@ from inference.core.env import WORKFLOWS_MAX_CONCURRENT_STEPS from inference.core.exceptions import FeatureDeprecatedError +from inference.core.interfaces.workflows_step_error_handlers import ( + resolve_step_error_handler, +) from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.errors import ClientCausedStepExecutionError @@ -88,6 +91,7 @@ def test_gaze_workflow_compiles_and_raises_410_via_executor_middleware( workflow_definition=GAZE_DETECTION_WORKFLOW, init_parameters=workflow_init_parameters, max_concurrent_steps=WORKFLOWS_MAX_CONCURRENT_STEPS, + step_error_handler=resolve_step_error_handler(), ) image = np.zeros((32, 32, 3), dtype=np.uint8) diff --git a/tests/workflows/integration_tests/execution/test_workflow_with_mqtt_writer.py b/tests/workflows/integration_tests/execution/test_workflow_with_mqtt_writer.py index 26ace3738c..cf313ce021 100644 --- a/tests/workflows/integration_tests/execution/test_workflow_with_mqtt_writer.py +++ b/tests/workflows/integration_tests/execution/test_workflow_with_mqtt_writer.py @@ -1,4 +1,5 @@ import copy +import os import socket import threading import time @@ -7,6 +8,7 @@ import pytest from inference.core.env import WORKFLOWS_MAX_CONCURRENT_STEPS +from inference.core.workflows.core_steps.sinks.noop import disabled_sink_message from inference.core.workflows.execution_engine.core import ExecutionEngine from inference.enterprise.workflows.enterprise_blocks.sinks.mqtt_writer import v1 from inference.enterprise.workflows.enterprise_blocks.sinks.mqtt_writer.v1 import ( @@ -275,18 +277,20 @@ def test_second_broker_is_rejected_instead_of_publishing_to_first(fake_mqtt_brok @pytest.fixture -def enterprise_blocks_enabled(): +def enterprise_blocks_enabled(monkeypatch): + from inference.core.env import ENTERPRISE_BLOCKS_PLUGIN from inference.core.workflows.execution_engine.introspection import blocks_loader from inference.core.workflows.execution_engine.v1.compiler.core import ( COMPILATION_CACHE, ) - previous_value = blocks_loader.LOAD_ENTERPRISE_BLOCKS - blocks_loader.LOAD_ENTERPRISE_BLOCKS = True - blocks_loader.load_core_workflow_blocks.cache_clear() + plugins = [p for p in os.getenv("WORKFLOWS_PLUGINS", "").split(",") if p] + if ENTERPRISE_BLOCKS_PLUGIN not in plugins: # env.py may have expanded it already; + plugins.append(ENTERPRISE_BLOCKS_PLUGIN) # a duplicate trips the clash check + monkeypatch.setenv("WORKFLOWS_PLUGINS", ",".join(plugins)) + blocks_loader.clear_caches() yield - blocks_loader.LOAD_ENTERPRISE_BLOCKS = previous_value - blocks_loader.load_core_workflow_blocks.cache_clear() + blocks_loader.clear_caches() # drop graphs compiled with enterprise blocks from the process-wide cache with COMPILATION_CACHE._cache_lock: COMPILATION_CACHE._cache.clear() @@ -380,3 +384,44 @@ def test_workflow_with_failing_mqtt_sink_and_fail_fast_raises( "message": "frame payload", } ) + + +@pytest.mark.timeout(15) +def test_workflows_core_disable_sinks_still_reaches_the_enterprise_sink( + enterprise_blocks_enabled, +): + # given - the server supplies `disable_sinks` namespaced under the core + # block source (http_api.py, inference_pipeline.py). An enterprise block + # re-tagged with its plugin module name would never see it and compilation + # would fail with BlockInitParameterNotProvidedError. + workflow_definition = copy.deepcopy(MQTT_SINK_WORKFLOW) + workflow_definition["outputs"].append( + { + "type": "JsonField", + "name": "message", + "selector": "$steps.mqtt_sink.message", + } + ) + execution_engine = ExecutionEngine.init( + workflow_definition=workflow_definition, + init_parameters={"workflows_core.disable_sinks": True}, + max_concurrent_steps=WORKFLOWS_MAX_CONCURRENT_STEPS, + ) + + # when - nothing listens on the port, so a sink that tried to connect + # would report a failure instead of the disabled response + with patch.object(v1.mqtt, "Client") as mqtt_client: + result = execution_engine.run( + runtime_parameters={ + "host": "localhost", + "port": closed_port(), + "message": "frame payload", + } + ) + + # then - no connection attempted, disabled response returned + mqtt_client.assert_not_called() + assert result[0]["status"] is False + assert result[0]["message"] == disabled_sink_message( + disabled_by_execution_policy=True + ) diff --git a/tests/workflows/integration_tests/execution/test_workflow_with_single_model.py b/tests/workflows/integration_tests/execution/test_workflow_with_single_model.py index 5faf6af7ce..15a75bfe69 100644 --- a/tests/workflows/integration_tests/execution/test_workflow_with_single_model.py +++ b/tests/workflows/integration_tests/execution/test_workflow_with_single_model.py @@ -3,6 +3,9 @@ import supervision as sv from inference.core.env import USE_INFERENCE_MODELS, WORKFLOWS_MAX_CONCURRENT_STEPS +from inference.core.interfaces.workflows_step_error_handlers import ( + resolve_step_error_handler, +) from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.errors import ( @@ -458,6 +461,7 @@ def test_object_detection_workflow_when_model_id_cannot_be_resolved_to_valid_mod workflow_definition=_build_object_detection_workflow(block_type), init_parameters=workflow_init_parameters, max_concurrent_steps=WORKFLOWS_MAX_CONCURRENT_STEPS, + step_error_handler=resolve_step_error_handler(), ) # when diff --git a/tests/workflows/isolation/__init__.py b/tests/workflows/isolation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/workflows/isolation/test_isolation_probe.py b/tests/workflows/isolation/test_isolation_probe.py new file mode 100644 index 0000000000..6271edda2c --- /dev/null +++ b/tests/workflows/isolation/test_isolation_probe.py @@ -0,0 +1,125 @@ +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +PROBE = Path(__file__).resolve().parents[3] / "scripts" / "workflows_isolation_probe.py" + + +def _load_probe(): + spec = importlib.util.spec_from_file_location("workflows_isolation_probe", PROBE) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class _FakeChild: + def __init__(self, stdout: str, returncode: int = 0, stderr: str = "") -> None: + self.stdout, self.stderr, self.returncode = stdout, stderr, returncode + + +@pytest.mark.slow +@pytest.mark.xfail(strict=True, reason="decontamination in progress") +def test_workflows_module_works_in_isolation(bundled_fonts) -> None: + # `bundled_fonts` (tests/workflows/conftest.py) provisions the assets the + # probe copies; the probe itself never downloads anything. + result = subprocess.run( + [sys.executable, str(PROBE), "--tensor-mode", "both"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize( + "stdout", + [ + "", # the child printed nothing at all + "[]\n", # the child printed an empty result set + '[{"check": "import_everything", "status": "ok"}]\n', # only one check ran + "not json at all\n", # the results line is malformed + '[{"check": "import_everything"}]\n', # a row without a status + ], +) +def test_run_probe_never_reads_missing_results_as_success( + tmp_path, monkeypatch, stdout +) -> None: + # A child that dies, is truncated or garbles its output must fail the probe. + # Without this, empty stdout parsed as `[]` and `main()` returned 0. + probe = _load_probe() + monkeypatch.setattr(probe.subprocess, "run", lambda *a, **k: _FakeChild(stdout)) + results = probe.run_probe(tmp_path, tensor_mode=False) + assert results, "an unusable child must still produce a reported failure" + assert [r for r in results if r["status"] != "ok"], results + + +def test_run_probe_accepts_a_complete_result_set(tmp_path, monkeypatch) -> None: + # The guard above must not block the run that finally passes. + probe = _load_probe() + complete = [{"check": name, "status": "ok"} for name in probe.EXPECTED_CHECKS] + monkeypatch.setattr( + probe.subprocess, "run", lambda *a, **k: _FakeChild(json.dumps(complete) + "\n") + ) + assert probe.run_probe(tmp_path, tensor_mode=False) == complete + + +def test_main_reports_failure_when_the_child_returns_nothing(monkeypatch) -> None: + probe = _load_probe() + monkeypatch.setattr(probe, "build_tree", lambda target: None) + monkeypatch.setattr(probe.subprocess, "run", lambda *a, **k: _FakeChild("")) + monkeypatch.setattr( + sys, "argv", ["workflows_isolation_probe.py", "--tensor-mode", "off"] + ) + assert probe.main() == 1 + + +@pytest.mark.parametrize( + "variable, value", + [ + ("WORKFLOWS_PLUGINS", "inference.enterprise.workflows.enterprise_blocks"), + ("DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER", "extended_roboflow_errors"), + ], +) +def test_child_env_drops_server_only_configuration( + tmp_path, monkeypatch, variable, value +) -> None: + # Both are server settings the child must not inherit: the plugin list + # would load enterprise blocks, and the step-error handler names a handler + # the standalone engine does not register - which would fail + # `ExecutionEngine.init` for a reason unrelated to contamination. + probe = _load_probe() + monkeypatch.setenv(variable, value) + env = probe._child_env(tmp_path, tensor_mode=False) + assert variable not in env + assert "WORKFLOWS_PLUGINS" not in env + assert "DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER" not in env + + +def test_child_refuses_to_run_with_assertions_stripped(tmp_path, monkeypatch) -> None: + # PYTHONOPTIMIZE strips every `assert` the checks verify with. The parent + # pins it to "0"; this proves the child's own guard fires if it ever leaks. + probe = _load_probe() + proc = subprocess.run( + [ + sys.executable, + "-c", + probe.CHILD, + str(tmp_path), + "off", + json.dumps(list(probe.EXPECTED_CHECKS)), + ], + capture_output=True, + text=True, + env={**dict(PATH=""), "PYTHONOPTIMIZE": "1"}, + ) + assert proc.returncode == 1, proc.stdout + proc.stderr + assert json.loads(proc.stdout.strip().splitlines()[-1]) == [ + { + "check": "no_optimize", + "status": "fail", + "detail": "child ran with optimization enabled; assert statements are stripped", + } + ] diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_cog_vlm_deprecated.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_cog_vlm_deprecated.py index 4f709eb956..f5e5c50887 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_cog_vlm_deprecated.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_cog_vlm_deprecated.py @@ -1,7 +1,5 @@ """Behavioural tests for the deprecated CogVLM workflow block.""" -from unittest.mock import MagicMock - import pytest from inference.core.exceptions import FeatureDeprecatedError @@ -16,7 +14,6 @@ def test_cog_vlm_block_run_raises_feature_deprecated_error_with_removal_release( ): # given block = CogVLMBlockV1( - model_manager=MagicMock(), api_key=None, step_execution_mode=StepExecutionMode.LOCAL, ) diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_gaze_deprecated.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_gaze_deprecated.py index 1ff64eb94b..f4c00220e7 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_gaze_deprecated.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_gaze_deprecated.py @@ -1,7 +1,5 @@ """Behavioural tests for the deprecated GazeBlockV1 stub.""" -from unittest.mock import MagicMock - import pytest from inference.core.exceptions import FeatureDeprecatedError @@ -26,7 +24,6 @@ def test_gaze_block_manifest_is_marked_deprecated_in_ui_manifest() -> None: def test_gaze_block_run_raises_feature_deprecated_error() -> None: # given block = GazeBlockV1( - model_manager=MagicMock(), api_key=None, step_execution_mode=StepExecutionMode.LOCAL, ) @@ -42,7 +39,6 @@ def test_gaze_block_run_raises_feature_deprecated_error() -> None: def test_gaze_block_run_raises_in_remote_execution_mode_as_well() -> None: # given block = GazeBlockV1( - model_manager=MagicMock(), api_key=None, step_execution_mode=StepExecutionMode.REMOTE, ) diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_offline_remote_execution.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_offline_remote_execution.py index c6d1296f07..e0fcdf120c 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_offline_remote_execution.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_offline_remote_execution.py @@ -110,11 +110,13 @@ def test_builtin_remote_inference_leaves_fail_closed_offline( kwargs: Dict[str, Any], ) -> None: monkeypatch.setattr(offline, "OFFLINE_MODE", True) - block = block_type( - model_manager=MagicMock(), - api_key="test-api-key", - step_execution_mode=StepExecutionMode.REMOTE, - ) + init_kwargs: Dict[str, Any] = { + "api_key": "test-api-key", + "step_execution_mode": StepExecutionMode.REMOTE, + } + if "model_manager" in block_type.get_init_parameters(): + init_kwargs["model_manager"] = MagicMock() + block = block_type(**init_kwargs) with pytest.raises(RuntimeError, match="OFFLINE_MODE"): getattr(block, method_name)(images=[], **kwargs) diff --git a/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_v1.py b/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_v1.py index d634bbb1c1..c20a288167 100644 --- a/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_v1.py +++ b/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_v1.py @@ -164,7 +164,6 @@ def _make_block( TensorActionRecognitionModelBlockV1 if tensor else ActionRecognitionModelBlockV1 ) block = block_type( - model_manager=MagicMock(), api_key=None, step_execution_mode=StepExecutionMode.LOCAL, ) @@ -222,7 +221,6 @@ def test_get_model_wraps_hosted_cosmos3_reasoner(monkeypatch): load_model = MagicMock(return_value=reasoner) monkeypatch.setattr(AutoModel, "from_pretrained", load_model) block = ActionRecognitionModelBlockV1( - model_manager=MagicMock(), api_key=None, step_execution_mode=StepExecutionMode.LOCAL, ) @@ -242,7 +240,6 @@ def test_get_model_rejects_model_without_video_classification_support(monkeypatc MagicMock(return_value=object()), ) block = ActionRecognitionModelBlockV1( - model_manager=MagicMock(), api_key=None, step_execution_mode=StepExecutionMode.LOCAL, ) @@ -995,7 +992,6 @@ def test_video_identifier_can_be_reused_after_rollback_reset(): def test_remote_mode_raises(): block = ActionRecognitionModelBlockV1( - model_manager=MagicMock(), api_key=None, step_execution_mode=StepExecutionMode.REMOTE, ) diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt new file mode 100644 index 0000000000..2f55bfd964 --- /dev/null +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -0,0 +1,378 @@ +# Remaining `inference.*` imports inside inference/core/workflows. +# This list may only shrink. See DECONTAMINATION.PLAN.MD. +# Count: 375 +inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/common/deserializers.py inference.core.utils.image_utils +inference/core/workflows/core_steps/common/deserializers_tensor.py inference.core.env +inference/core/workflows/core_steps/common/openrouter.py inference.core.env +inference/core/workflows/core_steps/common/openrouter.py inference.core.exceptions +inference/core/workflows/core_steps/common/openrouter.py inference.core.roboflow_api +inference/core/workflows/core_steps/common/openrouter.py inference.core.utils.image_utils +inference/core/workflows/core_steps/common/query_language/operations/classification_results/base.py inference.core.env +inference/core/workflows/core_steps/common/query_language/operations/detection/base.py inference.core.env +inference/core/workflows/core_steps/common/query_language/operations/detections/base.py inference.core.env +inference/core/workflows/core_steps/common/tensor_native.py inference.core.env +inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.clip +inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.doctr +inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.easy_ocr +inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.sam2 +inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.yolo_world +inference/core/workflows/core_steps/common/utils.py inference.core.roboflow_api +inference/core/workflows/core_steps/formatters/vlm_as_classifier/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/formatters/vlm_as_classifier/v2_tensor.py inference.core.env +inference/core/workflows/core_steps/formatters/vlm_as_detector/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/formatters/vlm_as_detector/v2_tensor.py inference.core.env +inference/core/workflows/core_steps/fusion/detections_consensus/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/fusion/image_stack/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/integrations/roboflow/visual_search/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1.py inference.core.env +inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1_tensor.py inference.core.utils.image_utils +inference/core/workflows/core_steps/loader.py inference.core.cache +inference/core/workflows/core_steps/loader.py inference.core.env +inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py inference.core.env +inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py inference.core.env +inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py inference.core.env +inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.entities.requests.clip +inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py inference.core.entities.requests.clip +inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py inference.core.entities.requests.clip +inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py inference.core.env +inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py inference.core.exceptions +inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/depth_estimation/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py inference.core.entities.requests.easy_ocr +inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/florence2/v1.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/foundation/florence2/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/florence2/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/gaze/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/gaze/v1.py inference.core.exceptions +inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py inference.core.env +inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py inference.core.env +inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py inference.core.env +inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py inference.core.env +inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/lmm/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/lmm/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/lmm_classifier/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/meta_vlm/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/meta_vlm/v2.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/moondream2/v1.py inference.core.entities.requests.moondream2 +inference/core/workflows/core_steps/models/foundation/moondream2/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/moondream2/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/ocr/v1.py inference.core.entities.requests.doctr +inference/core/workflows/core_steps/models/foundation/ocr/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/ocr/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/openai/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/openai/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/openai/v2.py inference.core.env +inference/core/workflows/core_steps/models/foundation/openai/v2.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/openai/v3.py inference.core.env +inference/core/workflows/core_steps/models/foundation/openai/v3.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/openai/v3.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/openai/v4.py inference.core.env +inference/core/workflows/core_steps/models/foundation/openai/v4.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/openai/v4.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/openai/v5.py inference.core.env +inference/core/workflows/core_steps/models/foundation/openai/v5.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/openai/v5.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/openai/v6.py inference.core.env +inference/core/workflows/core_steps/models/foundation/openai/v6.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/openai/v6.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/openai_compatible/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py inference.core.entities.requests.perception_encoder +inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py inference.core.entities.requests.pp_ocr +inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py inference.core.entities.requests.pp_ocr +inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/qwen/v1.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/foundation/qwen/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py inference.core.env +inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/qwen3vl/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py inference.core.env +inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py inference.core.env +inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py inference.core.entities.responses.inference +inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py inference.core.utils.url_utils +inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py inference.core.utils.url_utils +inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.requests.sam2 +inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.responses.inference +inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.responses.sam2 +inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py inference.usage_tracking.collector +inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.requests.sam3 +inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.responses.inference +inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.responses.sam3 +inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.utils.url_utils +inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py inference.core.utils.url_utils +inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.requests.sam3 +inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.responses.inference +inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.responses.sam3 +inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.env +inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.utils.url_utils +inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py inference.core.entities.requests.sam3 +inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py inference.core.utils.url_utils +inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.entities.requests.sam3 +inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.entities.responses.inference +inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.env +inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.utils.url_utils +inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py inference.core.entities.requests.sam3 +inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py inference.core.utils.url_utils +inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py inference.core.entities.requests.sam3_3d +inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py inference.core.entities.responses.sam3_3d +inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py inference.core.entities.requests.sam3_3d +inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py inference.core.entities.responses.sam3_3d +inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.entities.requests.sam2 +inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.entities.responses.sam2 +inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.utils.url_utils +inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.entities.requests.sam2 +inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.utils.url_utils +inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py inference.usage_tracking.collector +inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/segment_anything_common/streaming_video_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/smolvlm/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/foundation/spacexai/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/spacexai/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/spacexai/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/spacexai/v2.py inference.core.env +inference/core/workflows/core_steps/models/foundation/spacexai/v2.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/spacexai/v2.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.entities.requests.yolo_world +inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.env +inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py inference.core.exceptions +inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/models/foundation/zai_vlm/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/models/roboflow/action_recognition/v1.py inference.core.models.action_recognition +inference/core/workflows/core_steps/models/roboflow/action_recognition/v1.py inference.core.models.inference_models_adapters +inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py inference.core.entities.responses.inference +inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/object_detection/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/object_detection/v2_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/object_detection/v3_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py inference.core.entities.requests.inference +inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py inference.core.env +inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2_tensor.py inference.core.env +inference/core/workflows/core_steps/models/third_party/barcode_detection/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/models/third_party/qr_code_detection/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/secrets_providers/environment_secrets_store/v1.py inference.core.env +inference/core/workflows/core_steps/sinks/email_notification/v2.py inference.core.exceptions +inference/core/workflows/core_steps/sinks/email_notification/v2.py inference.core.roboflow_api +inference/core/workflows/core_steps/sinks/email_notification/v2.py inference.core.utils.image_utils +inference/core/workflows/core_steps/sinks/local_file/v1.py inference.core.env +inference/core/workflows/core_steps/sinks/roboflow/asset_library_attributes/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1.py inference.core.active_learning.cache_operations +inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1.py inference.core.active_learning.core +inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1.py inference.core.active_learning.entities +inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1_tensor.py inference.core.active_learning.cache_operations +inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1_tensor.py inference.core.active_learning.core +inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1_tensor.py inference.core.active_learning.entities +inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1.py inference.core.env +inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1.py inference.core.managers.metrics +inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1.py inference.core.version +inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1_tensor.py inference.core.managers.metrics +inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1_tensor.py inference.core.roboflow_api +inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1_tensor.py inference.core.version +inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py inference.core.env +inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py inference.core.roboflow_api +inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py inference.core.utils.requests +inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py inference.core.utils.url_utils +inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1_tensor.py inference.core.utils.image_utils +inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1_tensor.py inference.core.utils.url_utils +inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1.py inference.core.env +inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1.py inference.core.utils.image_utils +inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1_tensor.py inference.core.utils.image_utils +inference/core/workflows/core_steps/sinks/twilio/sms/v2.py inference.core.exceptions +inference/core/workflows/core_steps/sinks/twilio/sms/v2.py inference.core.roboflow_api +inference/core/workflows/core_steps/sinks/twilio/sms/v2.py inference.core.utils.image_utils +inference/core/workflows/core_steps/transformations/detections_combine/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/transformations/detections_merge/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/transformations/detections_transformation/v1.py inference.core.env +inference/core/workflows/core_steps/transformations/stabilize_detections/v1_tensor.py inference.core.env +inference/core/workflows/core_steps/visualizations/common/base_tensor.py inference.core.env +inference/core/workflows/core_steps/visualizations/common/fonts/__init__.py inference.core.env +inference/core/workflows/execution_engine/entities/base.py inference.core.entities.responses.action_recognition +inference/core/workflows/execution_engine/entities/base.py inference.core.env +inference/core/workflows/execution_engine/entities/base.py inference.core.utils.image_utils +inference/core/workflows/execution_engine/v1/compiler/syntactic_parser.py inference.core.entities.responses.workflows +inference/core/workflows/execution_engine/v1/core.py inference.core.env +inference/core/workflows/execution_engine/v1/dynamic_blocks/block_assembler.py inference.core.env +inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.env +inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.env (exec'd string) +inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.exceptions +inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.roboflow_api +inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.usage_tracking.block_execution +inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.usage_tracking.collector +inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py inference.core.env +inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py inference.core.utils.image_utils +inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py inference.usage_tracking.block_execution +inference/core/workflows/execution_engine/v1/dynamic_blocks/representation_boundary.py inference.core.env +inference/core/workflows/execution_engine/v1/executor/core.py inference.core.env +inference/core/workflows/execution_engine/v1/executor/core.py inference.core.telemetry +inference/core/workflows/execution_engine/v1/executor/core.py inference.usage_tracking.collector +inference/core/workflows/execution_engine/v1/executor/core.py inference.usage_tracking.stream_session +inference/core/workflows/execution_engine/v1/executor/output_constructor.py inference.core.env +inference/core/workflows/execution_engine/v1/executor/utils.py inference.core.env +inference/core/workflows/execution_engine/v1/inner_workflow/compiler_bridge.py inference.core.env +inference/core/workflows/execution_engine/v1/inner_workflow/reference_resolution.py inference.core.roboflow_api +inference/core/workflows/offline.py inference.core.env diff --git a/tests/workflows/unit_tests/execution_engine/introspection/test_plugin_block_source.py b/tests/workflows/unit_tests/execution_engine/introspection/test_plugin_block_source.py new file mode 100644 index 0000000000..0aa71ac616 --- /dev/null +++ b/tests/workflows/unit_tests/execution_engine/introspection/test_plugin_block_source.py @@ -0,0 +1,50 @@ +import sys +import types +from pathlib import Path + +from inference.core.workflows.core_steps.transformations.dynamic_crop.v1 import ( + DynamicCropBlockV1, +) +from inference.core.workflows.execution_engine.introspection import blocks_loader + + +def _install_fake_plugin(monkeypatch, name: str, **attributes) -> None: + module = types.ModuleType(name) + module.load_blocks = lambda: [DynamicCropBlockV1] + for key, value in attributes.items(): + setattr(module, key, value) + monkeypatch.setitem(sys.modules, name, module) + + +def test_plugin_blocks_are_tagged_with_the_module_name_by_default(monkeypatch) -> None: + _install_fake_plugin(monkeypatch, "fake_plugin_default") + blocks = blocks_loader._load_blocks_from_plugin("fake_plugin_default") + assert [b.block_source for b in blocks] == ["fake_plugin_default"] + + +def test_plugin_may_declare_its_block_source(monkeypatch) -> None: + # Enterprise blocks use this to stay under `workflows_core`, so the + # server's `workflows_core.disable_sinks` override keeps reaching them. + _install_fake_plugin( + monkeypatch, "fake_plugin_core", BLOCKS_SOURCE="workflows_core" + ) + blocks = blocks_loader._load_blocks_from_plugin("fake_plugin_core") + assert [b.block_source for b in blocks] == ["workflows_core"] + + +def test_plugin_initializers_follow_the_declared_source(monkeypatch) -> None: + _install_fake_plugin( + monkeypatch, + "fake_plugin_init", + BLOCKS_SOURCE="workflows_core", + REGISTERED_INITIALIZERS={"thing": 1}, + ) + assert blocks_loader._load_initializers_from_plugin("fake_plugin_init") == { + "workflows_core.thing": 1 + } + + +def test_blocks_loader_does_not_import_the_server_package() -> None: + source = Path(blocks_loader.__file__).read_text(encoding="utf-8") + assert "inference.enterprise" not in source + assert "from inference.core.env import" not in source diff --git a/tests/workflows/unit_tests/execution_engine/test_dependencies_pre_loading.py b/tests/workflows/unit_tests/execution_engine/test_dependencies_pre_loading.py index 51a137314b..9d5023e2ad 100644 --- a/tests/workflows/unit_tests/execution_engine/test_dependencies_pre_loading.py +++ b/tests/workflows/unit_tests/execution_engine/test_dependencies_pre_loading.py @@ -4,6 +4,7 @@ declared through `$inputs.` selectors). """ +import inspect from unittest.mock import MagicMock, NonCallableMagicMock import networkx as nx @@ -17,6 +18,7 @@ RuntimeInputError, WorkflowEnvironmentConfigurationError, ) +from inference.core.workflows.execution_engine.v1 import core as ee_core from inference.core.workflows.execution_engine.v1.compiler.entities import ( CompiledWorkflow, ParsedWorkflowDefinition, @@ -41,6 +43,7 @@ roboflow_platform_project, third_party_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider def _object_detection_manifest(name: str, model_id: str) -> ObjectDetectionV3Manifest: @@ -659,3 +662,15 @@ def test_execution_engine_init_without_pre_init_dependencies_does_not_touch_mana model_manager.add_model.assert_not_called() assert engine._pending_runtime_dependencies == [] + + +def test_preloading_helpers_are_typed_against_the_port() -> None: + # The engine drives the model manager itself (add_model / __contains__). + # Typing it `Any` hid that dependency from import-based tooling. + for fn in ( + ee_core._pre_load_roboflow_platform_models, + ee_core._resolve_and_pre_load_runtime_dependencies, + ee_core._verify_pre_loaded_models_presence, + ): + annotation = inspect.signature(fn).parameters["model_manager"].annotation + assert annotation is ModelsProvider, (fn.__name__, annotation) diff --git a/tests/workflows/unit_tests/execution_engine/test_step_error_handler_default.py b/tests/workflows/unit_tests/execution_engine/test_step_error_handler_default.py new file mode 100644 index 0000000000..070f62457a --- /dev/null +++ b/tests/workflows/unit_tests/execution_engine/test_step_error_handler_default.py @@ -0,0 +1,39 @@ +import ast +import os +from pathlib import Path + +from inference.core.workflows.execution_engine.v1 import core as ee_core +from inference.core.workflows.execution_engine.v1 import step_error_handlers + + +def test_standalone_registry_only_knows_legacy() -> None: + assert set(ee_core.REGISTERED_STEP_ERROR_HANDLERS) == {"legacy"} + assert ee_core.DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER == os.getenv( + "DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER", "legacy" + ) + + +def test_legacy_handler_maps_nothing_on_its_own() -> None: + # Every mapping it used to carry was for server exception classes and now + # lives in inference.core.interfaces.workflows_step_error_handlers. + assert ( + step_error_handlers.legacy_step_error_handler("step", RuntimeError("x")) is None + ) + + +def test_step_error_handlers_module_has_no_server_imports() -> None: + # Import nodes, not source text: the new docstring names the old module. + tree = ast.parse(Path(step_error_handlers.__file__).read_text(encoding="utf-8")) + imported = { + node.module for node in ast.walk(tree) if isinstance(node, ast.ImportFrom) + } | { + alias.name + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + } + assert not { + name + for name in imported + if name and name.startswith("inference.core.exceptions") + } diff --git a/tests/workflows/unit_tests/prototypes/__init__.py b/tests/workflows/unit_tests/prototypes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/workflows/unit_tests/prototypes/test_background_tasks.py b/tests/workflows/unit_tests/prototypes/test_background_tasks.py new file mode 100644 index 0000000000..7201556866 --- /dev/null +++ b/tests/workflows/unit_tests/prototypes/test_background_tasks.py @@ -0,0 +1,30 @@ +import inspect + +from fastapi import BackgroundTasks + +from inference.core.workflows.prototypes.background_tasks import BackgroundTaskScheduler + + +def test_protocol_declares_add_task() -> None: + assert hasattr(BackgroundTaskScheduler, "add_task") + + +def test_fastapi_background_tasks_satisfies_the_protocol() -> None: + # FastAPI's BackgroundTasks is what the HTTP layer injects in production. + assert hasattr(BackgroundTasks, "add_task") + + +def _params(method): + # Skip `self`; the port is the source of truth for which names to iterate. + return list(inspect.signature(method).parameters.values())[1:] + + +def test_add_task_signature_matches_fastapi_background_tasks() -> None: + # Annotations deliberately not compared: the port uses Any, fastapi uses + # ParamSpec-typed Callable/args/kwargs. + port_params = _params(BackgroundTaskScheduler.add_task) + real_params = _params(BackgroundTasks.add_task) + assert [p.name for p in port_params] == [p.name for p in real_params] + for port_param, real_param in zip(port_params, real_params): + assert port_param.kind == real_param.kind + assert port_param.default == real_param.default diff --git a/tests/workflows/unit_tests/prototypes/test_cache.py b/tests/workflows/unit_tests/prototypes/test_cache.py new file mode 100644 index 0000000000..97a56ff34d --- /dev/null +++ b/tests/workflows/unit_tests/prototypes/test_cache.py @@ -0,0 +1,35 @@ +import inspect + +from inference.core.cache.base import BaseCache +from inference.core.workflows.prototypes.cache import WorkflowsCache + + +def test_protocol_covers_only_what_blocks_use() -> None: + assert hasattr(WorkflowsCache, "get") + assert hasattr(WorkflowsCache, "set") + + +def test_server_cache_satisfies_the_protocol() -> None: + # Transitional: pins the server class to the port while both exist. + assert hasattr(BaseCache, "get") + assert hasattr(BaseCache, "set") + + +def _params(method): + # Skip `self`; the port is the source of truth for which names to iterate. + return list(inspect.signature(method).parameters.values())[1:] + + +def test_get_and_set_signatures_match_the_server_cache() -> None: + # Guards against a drifted port (e.g. a dropped `expire` or a renamed + # `key`) going undetected. model_monitoring_inference_aggregator/v1.py:413 + # calls cache.set(key=..., value=..., expire=900) by keyword, so kind and + # default matter, not just presence. Annotations deliberately not + # compared: the port uses Any/Optional[float], the server uses str/float. + for method_name in ("get", "set"): + port_params = _params(getattr(WorkflowsCache, method_name)) + real_params = _params(getattr(BaseCache, method_name)) + assert [p.name for p in port_params] == [p.name for p in real_params] + for port_param, real_param in zip(port_params, real_params): + assert port_param.kind == real_param.kind + assert port_param.default == real_param.default diff --git a/tests/workflows/unit_tests/prototypes/test_models_provider.py b/tests/workflows/unit_tests/prototypes/test_models_provider.py new file mode 100644 index 0000000000..7713fb738d --- /dev/null +++ b/tests/workflows/unit_tests/prototypes/test_models_provider.py @@ -0,0 +1,77 @@ +import ast +import inspect +import textwrap +from typing import get_type_hints + +from inference.core.managers.base import ModelManager +from inference.core.workflows.prototypes.models_provider import ModelsProvider + +REQUIRED_METHODS = [ + "add_model", + "infer_from_request_sync", + "run_tensor_native_inference", + "get_class_names", + "__contains__", + "__getitem__", +] + + +def test_protocol_declares_every_member_workflows_uses() -> None: + for member in REQUIRED_METHODS: + assert hasattr(ModelsProvider, member), member + assert "content_addressed_artifact_cache" in get_type_hints(ModelsProvider) + + +def test_model_manager_is_structurally_compatible_with_the_port() -> None: + # `hasattr` alone proves almost nothing - it would pass against a stub with + # the wrong signature. Check that ModelManager can actually be CALLED the + # way the port declares, so a signature drift in the server surfaces here + # rather than at runtime inside a block. + for member in REQUIRED_METHODS: + assert hasattr(ModelManager, member), member + port_add = inspect.signature(ModelsProvider.add_model) + real_add = inspect.signature(ModelManager.add_model) + for name in ("model_id", "api_key", "model_id_alias"): + assert name in real_add.parameters, name + # Everything the port passes positionally/by-name must be bindable. + real_add.bind_partial(None, model_id="m/1", api_key="k", model_id_alias=None) + # A default that drifts between the port and the real signature (e.g. the + # port declaring `api_key: Optional[str] = None` while the real method + # requires it) would still bind_partial cleanly above, so check defaults + # explicitly. + for name, port_param in port_add.parameters.items(): + if name in ("self", "kwargs"): + continue + assert port_param.default == real_add.parameters[name].default, name + assert "kwargs" in port_add.parameters + # The dunders are called by subscription/`in`, i.e. positionally, so a name + # mismatch is invisible at runtime - but it is still a signature drift, and + # anything that binds by keyword (a mock, a wrapper, a future refactor) + # would break. Compare ordered parameter NAMES only; annotations differ + # deliberately (the port returns `Any`, ModelManager returns `Model`). + for member in ("__getitem__", "__contains__"): + port_names = [ + name + for name in inspect.signature(getattr(ModelsProvider, member)).parameters + if name != "self" + ] + real_names = [ + name + for name in inspect.signature(getattr(ModelManager, member)).parameters + if name != "self" + ] + assert port_names == real_names, (member, port_names, real_names) + init_source = textwrap.dedent(inspect.getsource(ModelManager.__init__)) + init_tree = ast.parse(init_source) + assigns_content_addressed_artifact_cache = any( + isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id == "self" + and target.attr == "content_addressed_artifact_cache" + for node in ast.walk(init_tree) + if isinstance(node, (ast.Assign, ast.AnnAssign)) + for target in ( + [node.target] if isinstance(node, ast.AnnAssign) else node.targets + ) + ) + assert assigns_content_addressed_artifact_cache diff --git a/tests/workflows/unit_tests/test_decontamination_lint.py b/tests/workflows/unit_tests/test_decontamination_lint.py new file mode 100644 index 0000000000..c8fac28a03 --- /dev/null +++ b/tests/workflows/unit_tests/test_decontamination_lint.py @@ -0,0 +1,187 @@ +"""Locks in Workflows decontamination progress. + +`inference/core/workflows` must stop importing the `inference` server package +(it stays in place - see DECONTAMINATION.PLAN.MD). The remaining violations +are listed in `decontamination_baseline.txt`; this test fails if a new one +appears, and also if a listed one disappears without the baseline being +updated - so the list can only shrink. + +This test is necessary but not sufficient: the string scan only sees +single-line quoted `from|import inference...` literals, so a triple-quoted +multi-line code template is invisible to it, and so is +`importlib.import_module()`. The Phase 13 isolation probe is +the backstop for what this lint misses. + +Create the baseline the first time, and regenerate it after removing +violations, with the same command: + UPDATE_DECONTAMINATION_BASELINE=1 pytest \ + tests/workflows/unit_tests/test_decontamination_lint.py + +Once a baseline exists, the regeneration path REFUSES to write when new +violations are present, so it cannot be used to bless a regression. Never +delete the baseline to "reset" it - regenerating from a missing baseline +recreates it from whatever is present, silently absorbing any regression. +""" + +import ast +import os +import re +from pathlib import Path +from typing import Optional, Set, Tuple + +REPO_ROOT = Path(__file__).resolve().parents[3] +WORKFLOWS_ROOT = REPO_ROOT / "inference" / "core" / "workflows" +BASELINE_PATH = Path(__file__).parent / "decontamination_baseline.txt" + +# `inference_models` and `inference_sdk` are separately published +# distributions, not part of the server package - they are allowed. +ALLOWED_PREFIX = "inference.core.workflows" + +# Imports hidden inside string literals that are later exec()'d into +# dynamically assembled blocks. `ast` sees a string, not an import, so they +# have to be matched textually. This is not a corner case: +# `dynamic_blocks/block_scaffolding.py:92` smuggles +# `"from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE"` this way, +# and `modal/modal_app.py` mirrors the same list into the Modal sandbox. +_STRING_IMPORT = re.compile( + # The negative lookahead is load-bearing: without it, the string + # "from inference_models.models.base..." matches the bare `inference` + # alternative and is reported as a forbidden import. Verified: it produced + # a false positive on block_scaffolding.py lines 93-96. + r"""["'](?:from|import)\s+(inference(?![A-Za-z0-9_])(?:\.[A-Za-z0-9_]+)*)""" +) + + +def _is_forbidden(module: str) -> bool: + if module == "inference": # bare `import inference` pulls the package in + return True + if not module.startswith("inference."): + return False + return module != ALLOWED_PREFIX and not module.startswith(ALLOWED_PREFIX + ".") + + +def _resolve_relative(module: str, level: int, path: Path) -> str: + """Turn a relative import into its absolute dotted path. + + A `from ...core.env import X` inside workflows climbs out of the package + and is exactly as contaminating as the absolute form, so it must resolve + to the same string the baseline records. + """ + pkg_parts = path.relative_to(REPO_ROOT).with_suffix("").parts[:-1] + base = pkg_parts[: len(pkg_parts) - (level - 1)] if level > 1 else pkg_parts + return ".".join([*base, *(module.split(".") if module else [])]) + + +def _imported_modules(tree: ast.AST, path: Path) -> Set[str]: + # ast.walk (not tree.body) so function-local imports are caught too - + # `dynamic_blocks/modal_executor.py` hides four of them. + modules = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + modules.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + if node.level == 0: + if node.module: + modules.add(node.module) + else: + modules.add(_resolve_relative(node.module or "", node.level, path)) + return modules + + +def collect_violations() -> Set[Tuple[str, str]]: + violations = set() + for path in sorted(WORKFLOWS_ROOT.rglob("*.py")): + source = path.read_text(encoding="utf-8") + relative = path.relative_to(REPO_ROOT).as_posix() + for module in _imported_modules(ast.parse(source, filename=str(path)), path): + if _is_forbidden(module): + violations.add((relative, module)) + for module in _STRING_IMPORT.findall(source): + if _is_forbidden(module): + violations.add((relative, f"{module} (exec'd string)")) + return violations + + +def _updating() -> bool: + return os.getenv("UPDATE_DECONTAMINATION_BASELINE", "").strip().lower() in { + "1", + "true", + } + + +def _read_baseline() -> Optional[Set[Tuple[str, str]]]: + # `None` (absent) is distinct from an empty set (nothing left to remove): + # an absent baseline must be creatable, an empty one must be enforced. + if not BASELINE_PATH.exists(): + return None + declared_count: Optional[int] = None + entries = set() + for line in BASELINE_PATH.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + if line.startswith("#"): + count_match = re.match(r"#\s*Count:\s*(\d+)", line) + if count_match: + declared_count = int(count_match.group(1)) + continue + fields = line.split("\t") + assert ( + len(fields) == 2 + ), f"Malformed baseline row (expected 'pathmodule'): {line!r}" + path, module = fields + entries.add((path, module)) + if declared_count is not None and not _updating(): + assert declared_count == len(entries), ( + f"{BASELINE_PATH} header declares Count: {declared_count} but " + f"{len(entries)} rows were parsed - regenerate with " + "UPDATE_DECONTAMINATION_BASELINE=1" + ) + return entries + + +def _write_baseline(violations: Set[Tuple[str, str]]) -> None: + lines = [f"{path}\t{module}" for path, module in sorted(violations)] + header = ( + "# Remaining `inference.*` imports inside inference/core/workflows.\n" + "# This list may only shrink. See DECONTAMINATION.PLAN.MD.\n" + f"# Count: {len(lines)}\n" + ) + BASELINE_PATH.write_text(header + "\n".join(lines) + "\n", encoding="utf-8") + + +def test_no_new_inference_imports_in_workflows() -> None: + actual = collect_violations() + baseline = _read_baseline() + updating = _updating() + if baseline is None: + # First run: nothing to compare against, so creation is the only + # sensible action - but only when asked for explicitly, so a deleted + # baseline cannot be silently re-blessed by an ordinary test run. + assert updating, ( + f"{BASELINE_PATH} is missing. Create it with " + "UPDATE_DECONTAMINATION_BASELINE=1 and commit it." + ) + _write_baseline(actual) + return + added = sorted(actual - baseline) + removed = sorted(baseline - actual) + if updating: + # Regeneration is for recording progress, never for absorbing a + # regression - refuse to write while anything new is present. + assert not added, ( + "Refusing to regenerate the baseline: these are NEW violations, " + "not resolved ones:\n " + "\n ".join(f"{p} -> {m}" for p, m in added) + ) + _write_baseline(actual) + return + assert ( + not added + ), "New `inference.*` imports inside inference/core/workflows:\n " + "\n ".join( + f"{p} -> {m}" for p, m in added + ) + assert not removed, ( + "These baseline entries are gone - good. Regenerate the baseline with " + "UPDATE_DECONTAMINATION_BASELINE=1 and commit it:\n " + + "\n ".join(f"{p} -> {m}" for p, m in removed) + ) diff --git a/tests/workflows/unit_tests/utils/__init__.py b/tests/workflows/unit_tests/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/workflows/unit_tests/utils/test_images.py b/tests/workflows/unit_tests/utils/test_images.py new file mode 100644 index 0000000000..9a600b3a32 --- /dev/null +++ b/tests/workflows/unit_tests/utils/test_images.py @@ -0,0 +1,38 @@ +import numpy as np +import pytest + +from inference.core.utils.preprocess import ( + downscale_image_keeping_aspect_ratio as server_downscale, +) +from inference.core.workflows.utils.images import ( + _resize_image_keeping_aspect_ratio, + downscale_image_keeping_aspect_ratio, +) + + +def test_downscale_matches_the_server_implementation() -> None: + image = np.arange(200 * 100 * 3, dtype=np.uint8).reshape(100, 200, 3) + ours, theirs = ( + fn(image, (50, 50)) + for fn in (downscale_image_keeping_aspect_ratio, server_downscale) + ) + assert ours.shape == theirs.shape and np.array_equal(ours, theirs) + small = np.zeros((10, 10, 3), dtype=np.uint8) + assert downscale_image_keeping_aspect_ratio(small, (50, 50)) is small + + +def test_downscale_matches_the_server_implementation_for_portrait_images() -> None: + image = np.arange(100 * 200 * 3, dtype=np.uint8).reshape(200, 100, 3) + ours, theirs = ( + fn(image, (50, 50)) + for fn in (downscale_image_keeping_aspect_ratio, server_downscale) + ) + assert ours.shape == theirs.shape and np.array_equal(ours, theirs) + + +def test_resize_image_keeping_aspect_ratio_rejects_non_ndarray_input() -> None: + with pytest.raises( + ValueError, + match="Received an image of unknown type, ", + ): + _resize_image_keeping_aspect_ratio(image=[1, 2, 3], desired_size=(50, 50)) diff --git a/tests/workflows/unit_tests/utils/test_lru_cache.py b/tests/workflows/unit_tests/utils/test_lru_cache.py new file mode 100644 index 0000000000..88c3243367 --- /dev/null +++ b/tests/workflows/unit_tests/utils/test_lru_cache.py @@ -0,0 +1,41 @@ +from inference.core.cache.lru_cache import LRUCache as ServerLRUCache +from inference.core.workflows.utils.lru_cache import LRUCache + +OPS = [ + ("set", "a", 1), + ("set", "b", 2), + ("get", "a", None), + ("set", "c", 3), + ("get", "b", None), + ("get", "c", None), + ("set_max_size", 1, None), + ("get", "c", None), + ("get", "a", None), + ("get", "missing", None), +] + + +def _run(cache): + seen = [] + for op, key, value in OPS: + if op == "set": + cache.set(key, value) + elif op == "get": + seen.append((key, cache.get(key))) + else: + cache.set_max_size(key) + return seen + + +def test_copy_matches_the_server_lru_cache_exactly() -> None: + assert _run(LRUCache(capacity=2)) == _run(ServerLRUCache(capacity=2)) + + +def test_copy_carries_set_max_size() -> None: + # visualizations/grid/v1.py:189 calls set_max_size(len(images) + 1). + cache = LRUCache(capacity=2) + cache.set("a", 1) + cache.set("b", 2) + cache.set_max_size(1) + assert cache.capacity == 1 + assert len(cache.cache) == 1 diff --git a/tests/workflows/unit_tests/utils/test_text.py b/tests/workflows/unit_tests/utils/test_text.py new file mode 100644 index 0000000000..bf93964c68 --- /dev/null +++ b/tests/workflows/unit_tests/utils/test_text.py @@ -0,0 +1,72 @@ +import numpy as np +import pytest + +from inference.core.utils.environment import str2bool as server_str2bool +from inference.core.utils.file_system import ( + ensure_parent_dir_exists as server_ensure_parent_dir_exists, +) +from inference.core.utils.function import experimental as server_experimental +from inference.core.utils.postprocess import cosine_similarity as server_cosine +from inference.core.warnings import InferenceExperimentalFeatureWarning +from inference.core.workflows.errors import WorkflowsInvalidEnvironmentValueError +from inference.core.workflows.utils.text import ( + cosine_similarity, + ensure_parent_dir_exists, + experimental, + str2bool, +) +from inference.core.workflows.warnings import WorkflowsExperimentalFeatureWarning + + +@pytest.mark.parametrize( + "value", ["true", "True", "TRUE", "false", "False", True, False] +) +def test_str2bool_matches_the_server_implementation(value) -> None: + assert str2bool(value) == server_str2bool(value) + + +def test_str2bool_rejects_non_boolean_spellings() -> None: + # BOTH implementations raise here - the original plan asserted equality on + # "1" and "0", which cannot pass because neither returns a value for them. + # Only the exception TYPE differs, and that difference is the point of the + # workflows-local class. + with pytest.raises(WorkflowsInvalidEnvironmentValueError): + str2bool("1") + + +def test_cosine_similarity_matches_the_server_implementation() -> None: + a = np.array([1.0, 2.0, 3.0]) + b = np.array([4.0, 5.0, 6.0]) + assert cosine_similarity(a, b) == server_cosine(a, b) + + +def test_experimental_matches_the_server_implementation() -> None: + reason = "pinned by this test" + + def some_function() -> int: + return 7 + + workflows_decorated = experimental(reason=reason)(some_function) + + def some_function() -> int: + return 7 + + server_decorated = server_experimental(reason=reason)(some_function) + + with pytest.warns(WorkflowsExperimentalFeatureWarning) as workflows_record: + assert workflows_decorated() == 7 + with pytest.warns(InferenceExperimentalFeatureWarning) as server_record: + assert server_decorated() == 7 + + assert str(workflows_record[0].message) == str(server_record[0].message) + + +def test_ensure_parent_dir_exists_creates_missing_parents(tmp_path) -> None: + target = tmp_path / "a" / "b" / "file.txt" + server_target = tmp_path / "c" / "d" / "file.txt" + + ensure_parent_dir_exists(str(target)) + server_ensure_parent_dir_exists(str(server_target)) + + assert target.parent.is_dir() + assert server_target.parent.is_dir() From 1410e2f7da5eaa5dd7a65f8cecaf32db7e12fdaa Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:45:59 +0200 Subject: [PATCH 02/86] CI --- .github/workflows/unit_tests_workflows_x86.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests_workflows_x86.yml b/.github/workflows/unit_tests_workflows_x86.yml index ed8b58b1a9..26407cd28e 100644 --- a/.github/workflows/unit_tests_workflows_x86.yml +++ b/.github/workflows/unit_tests_workflows_x86.yml @@ -61,4 +61,4 @@ jobs: run: python build_scripts/download_fonts.py - name: 🧪 Unit Tests of Workflows timeout-minutes: 30 - run: python -m pytest tests/workflows/unit_tests + run: python -m pytest tests/workflows/unit_tests tests/workflows/isolation From 98fc1927bb5a961d88f1d6671b6e88babeff496f Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:40:34 +0200 Subject: [PATCH 03/86] refactor(workflows): relocate Roboflow-platform blocks to an in-repo plugin The dataset-upload, custom-metadata, model-monitoring, vision-events, asset-library and visual-search blocks move to inference/roboflow_workflows_plugin/, loaded through WORKFLOWS_PLUGINS with BLOCKS_SOURCE = "workflows_core" so workflows_core.* init parameters keep resolving. The plugin applies WORKFLOW_DISABLED_BLOCK_TYPES/PATTERNS itself, including patterns written against the blocks' old module path. Removes 38 baseline rows. Release notes: - fully_qualified_block_class_name for these 9 blocks changes to inference.roboflow_workflows_plugin.*. - describe_available_blocks ordering: they now follow the core list. Block type identifiers and the block-disable policy are unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/env.py | 19 + inference/core/workflows/core_steps/loader.py | 77 ----- .../__init__.py | 0 .../integrations}/__init__.py | 0 .../integrations}/visual_search/__init__.py | 0 .../integrations}/visual_search/helpers.py | 0 .../integrations}/visual_search/v1.py | 8 +- .../visual_search_classifier/__init__.py | 0 .../classification_annotations.py | 0 .../visual_search_classifier/v1.py | 14 +- .../visual_search_classifier/v1_tensor.py | 14 +- inference/roboflow_workflows_plugin/loader.py | 135 ++++++++ .../sinks}/__init__.py | 0 .../asset_library_attributes/__init__.py | 0 .../sinks}/asset_library_attributes/v1.py | 0 .../sinks}/custom_metadata/__init__.py | 0 .../sinks}/custom_metadata/v1.py | 0 .../sinks}/custom_metadata/v1_tensor.py | 0 .../sinks}/dataset_upload/__init__.py | 0 .../sinks}/dataset_upload/v1.py | 0 .../sinks}/dataset_upload/v1_tensor.py | 0 .../sinks}/dataset_upload/v2.py | 6 +- .../sinks}/dataset_upload/v2_tensor.py | 6 +- .../__init__.py | 0 .../v1.py | 0 .../v1_tensor.py | 0 .../sinks}/vision_events/__init__.py | 0 .../sinks}/vision_events/v1.py | 0 .../sinks}/vision_events/v1_tensor.py | 0 .../sinks}/vision_events_bundle/__init__.py | 0 .../sinks}/vision_events_bundle/v1.py | 20 +- .../sinks}/vision_events_bundle/v1_tensor.py | 22 +- scripts/phase9_move_roboflow_plugin.py | 76 ++++ .../core/test_enterprise_blocks_plugin.py | 21 +- .../core/test_roboflow_plugin_blocks.py | 229 ++++++++++++ .../core/test_roboflow_sink_acceptance.py | 325 ++++++++++++++++++ ..._workflow_with_asset_library_attributes.py | 2 +- ...t_workflow_with_dataset_upload_metadata.py | 2 +- ..._workflow_with_visual_search_classifier.py | 4 +- .../test_dataset_upload.py | 4 +- .../test_model_monitoring.py | 2 +- .../dependent_resources/test_visual_search.py | 2 +- .../test_visual_search_classifier.py | 2 +- .../roboflow/visual_search/test_helpers.py | 2 +- .../roboflow/visual_search/test_v1.py | 4 +- .../test_classification_annotations.py | 2 +- .../visual_search_classifier/test_v1.py | 4 +- .../asset_library_attributes/test_v1.py | 4 +- .../roboflow_dataset_upload/test_v1.py | 8 +- .../roboflow_dataset_upload/test_v2.py | 6 +- ...t_model_monitoring_inference_aggregator.py | 26 +- .../roboflow/test_roboflow_custom_metadata.py | 20 +- .../sinks/roboflow/vision_events/test_v1.py | 60 ++-- .../roboflow/vision_events_bundle/test_v1.py | 14 +- .../core_steps/sinks/test_execution_policy.py | 25 +- .../core_steps/test_dependent_resources.py | 23 +- .../unit_tests/decontamination_baseline.txt | 40 +-- 57 files changed, 973 insertions(+), 255 deletions(-) rename inference/{core/workflows/core_steps/integrations => roboflow_workflows_plugin}/__init__.py (100%) rename inference/{core/workflows/core_steps/integrations/roboflow => roboflow_workflows_plugin/integrations}/__init__.py (100%) rename inference/{core/workflows/core_steps/integrations/roboflow => roboflow_workflows_plugin/integrations}/visual_search/__init__.py (100%) rename inference/{core/workflows/core_steps/integrations/roboflow => roboflow_workflows_plugin/integrations}/visual_search/helpers.py (100%) rename inference/{core/workflows/core_steps/integrations/roboflow => roboflow_workflows_plugin/integrations}/visual_search/v1.py (98%) rename inference/{core/workflows/core_steps/integrations/roboflow => roboflow_workflows_plugin/integrations}/visual_search_classifier/__init__.py (100%) rename inference/{core/workflows/core_steps/integrations/roboflow => roboflow_workflows_plugin/integrations}/visual_search_classifier/classification_annotations.py (100%) rename inference/{core/workflows/core_steps/integrations/roboflow => roboflow_workflows_plugin/integrations}/visual_search_classifier/v1.py (98%) rename inference/{core/workflows/core_steps/integrations/roboflow => roboflow_workflows_plugin/integrations}/visual_search_classifier/v1_tensor.py (99%) create mode 100644 inference/roboflow_workflows_plugin/loader.py rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/__init__.py (100%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/asset_library_attributes/__init__.py (100%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/asset_library_attributes/v1.py (100%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/custom_metadata/__init__.py (100%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/custom_metadata/v1.py (100%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/custom_metadata/v1_tensor.py (100%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/dataset_upload/__init__.py (100%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/dataset_upload/v1.py (100%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/dataset_upload/v1_tensor.py (100%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/dataset_upload/v2.py (99%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/dataset_upload/v2_tensor.py (99%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/model_monitoring_inference_aggregator/__init__.py (100%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/model_monitoring_inference_aggregator/v1.py (100%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/model_monitoring_inference_aggregator/v1_tensor.py (100%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/vision_events/__init__.py (100%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/vision_events/v1.py (100%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/vision_events/v1_tensor.py (100%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/vision_events_bundle/__init__.py (100%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/vision_events_bundle/v1.py (99%) rename inference/{core/workflows/core_steps/sinks/roboflow => roboflow_workflows_plugin/sinks}/vision_events_bundle/v1_tensor.py (99%) create mode 100644 scripts/phase9_move_roboflow_plugin.py create mode 100644 tests/inference/unit_tests/core/test_roboflow_plugin_blocks.py create mode 100644 tests/inference/unit_tests/core/test_roboflow_sink_acceptance.py diff --git a/inference/core/env.py b/inference/core/env.py index d8bb6dbeca..2d3599220d 100644 --- a/inference/core/env.py +++ b/inference/core/env.py @@ -1243,6 +1243,25 @@ [ENTERPRISE_BLOCKS_PLUGIN] + _workflows_plugins ) +# The Roboflow-platform blocks (dataset upload, custom metadata, model +# monitoring, vision events, asset-library attributes, visual search) live in +# their own package so `inference/core/workflows` stops importing +# `roboflow_api` and `active_learning`. They are always listed - they were +# always part of the core block set - so there is no enable flag to honour; +# the block-DISABLE policy (WORKFLOW_DISABLED_BLOCK_TYPES / _PATTERNS) is +# applied inside the plugin's load_blocks(), exactly as the core loader does. +# PREPENDED after the enterprise expansion so the resulting order is +# roboflow -> enterprise -> user plugins, matching the historical +# core-then-enterprise ordering of `load_workflow_blocks()`. +ROBOFLOW_BLOCKS_PLUGIN = "inference.roboflow_workflows_plugin.loader" +_workflows_plugins = [ + plugin for plugin in os.getenv("WORKFLOWS_PLUGINS", "").split(",") if plugin +] +if ROBOFLOW_BLOCKS_PLUGIN not in _workflows_plugins: + os.environ["WORKFLOWS_PLUGINS"] = ",".join( + [ROBOFLOW_BLOCKS_PLUGIN] + _workflows_plugins + ) + TRANSIENT_ROBOFLOW_API_ERRORS = set( int(e) for e in os.getenv("TRANSIENT_ROBOFLOW_API_ERRORS", "").split(",") diff --git a/inference/core/workflows/core_steps/loader.py b/inference/core/workflows/core_steps/loader.py index e659cf8538..ef9484e259 100644 --- a/inference/core/workflows/core_steps/loader.py +++ b/inference/core/workflows/core_steps/loader.py @@ -460,20 +460,6 @@ CosineSimilarityBlockV1, ) -# visual_search emits only dict/scalar/image outputs, so it needs no _tensor sibling. -from inference.core.workflows.core_steps.integrations.roboflow.visual_search.v1 import ( - RoboflowVisualSearchBlockV1, -) - -if not ENABLE_TENSOR_DATA_REPRESENTATION: - from inference.core.workflows.core_steps.integrations.roboflow.visual_search_classifier.v1 import ( - RoboflowVisualSearchClassifierBlockV1, - ) -else: - from inference.core.workflows.core_steps.integrations.roboflow.visual_search_classifier.v1_tensor import ( - RoboflowVisualSearchClassifierBlockV1, - ) - from inference.core.workflows.core_steps.models.foundation.anthropic_claude.v1 import ( AnthropicClaudeBlockV1, ) @@ -1066,60 +1052,6 @@ ONVIFSinkBlockV1, ) -from inference.core.workflows.core_steps.sinks.roboflow.asset_library_attributes.v1 import ( - RoboflowAssetLibraryAttributesBlockV1, -) - -if not ENABLE_TENSOR_DATA_REPRESENTATION: - from inference.core.workflows.core_steps.sinks.roboflow.custom_metadata.v1 import ( - RoboflowCustomMetadataBlockV1, - ) -else: - from inference.core.workflows.core_steps.sinks.roboflow.custom_metadata.v1_tensor import ( - RoboflowCustomMetadataBlockV1, - ) -if not ENABLE_TENSOR_DATA_REPRESENTATION: - from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload.v1 import ( - RoboflowDatasetUploadBlockV1, - ) -else: - from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload.v1_tensor import ( - RoboflowDatasetUploadBlockV1, - ) -if not ENABLE_TENSOR_DATA_REPRESENTATION: - from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload.v2 import ( - RoboflowDatasetUploadBlockV2, - ) -else: - from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload.v2_tensor import ( - RoboflowDatasetUploadBlockV2, - ) -if not ENABLE_TENSOR_DATA_REPRESENTATION: - from inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1 import ( - ModelMonitoringInferenceAggregatorBlockV1, - ) -else: - from inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1_tensor import ( - ModelMonitoringInferenceAggregatorBlockV1, - ) -if not ENABLE_TENSOR_DATA_REPRESENTATION: - from inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1 import ( - RoboflowVisionEventsBlockV1, - ) -else: - from inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1_tensor import ( - RoboflowVisionEventsBlockV1, - ) - -if not ENABLE_TENSOR_DATA_REPRESENTATION: - from inference.core.workflows.core_steps.sinks.roboflow.vision_events_bundle.v1 import ( - VisionEventBundleSinkBlockV1, - ) -else: - from inference.core.workflows.core_steps.sinks.roboflow.vision_events_bundle.v1_tensor import ( - VisionEventBundleSinkBlockV1, - ) - from inference.core.workflows.core_steps.sinks.s3.v1 import S3SinkBlockV1 from inference.core.workflows.core_steps.sinks.slack.notification.v1 import ( SlackNotificationBlockV1, @@ -1782,13 +1714,9 @@ def load_blocks() -> List[Type[WorkflowBlock]]: DetectionOffsetBlockV1, PerClassConfidenceFilterBlockV1, DepthEstimationBlockV1, - RoboflowVisualSearchBlockV1, - RoboflowVisualSearchClassifierBlockV1, ByteTrackerBlockV1, RelativeStaticCropBlockV1, DetectionsTransformationBlockV1, - RoboflowDatasetUploadBlockV1, - RoboflowAssetLibraryAttributesBlockV1, ContinueIfBlockV1, InnerWorkflowBlockV1, RateLimiterBlockV1, @@ -1901,9 +1829,6 @@ def load_blocks() -> List[Type[WorkflowBlock]]: PolygonZoneVisualizationBlockV1, QRCodeDetectorBlockV1, RoboflowClassificationModelBlockV1, - RoboflowCustomMetadataBlockV1, - ModelMonitoringInferenceAggregatorBlockV1, - RoboflowDatasetUploadBlockV2, RoboflowInstanceSegmentationModelBlockV1, RoboflowKeypointDetectionModelBlockV1, RoboflowMultiLabelClassificationModelBlockV1, @@ -2011,8 +1936,6 @@ def load_blocks() -> List[Type[WorkflowBlock]]: Moondream2BlockV1, OverlapBlockV1, ONVIFSinkBlockV1, - RoboflowVisionEventsBlockV1, - VisionEventBundleSinkBlockV1, GLMOCRBlockV1, EasyOCRBlockV1, PPOCRBlockV1, diff --git a/inference/core/workflows/core_steps/integrations/__init__.py b/inference/roboflow_workflows_plugin/__init__.py similarity index 100% rename from inference/core/workflows/core_steps/integrations/__init__.py rename to inference/roboflow_workflows_plugin/__init__.py diff --git a/inference/core/workflows/core_steps/integrations/roboflow/__init__.py b/inference/roboflow_workflows_plugin/integrations/__init__.py similarity index 100% rename from inference/core/workflows/core_steps/integrations/roboflow/__init__.py rename to inference/roboflow_workflows_plugin/integrations/__init__.py diff --git a/inference/core/workflows/core_steps/integrations/roboflow/visual_search/__init__.py b/inference/roboflow_workflows_plugin/integrations/visual_search/__init__.py similarity index 100% rename from inference/core/workflows/core_steps/integrations/roboflow/visual_search/__init__.py rename to inference/roboflow_workflows_plugin/integrations/visual_search/__init__.py diff --git a/inference/core/workflows/core_steps/integrations/roboflow/visual_search/helpers.py b/inference/roboflow_workflows_plugin/integrations/visual_search/helpers.py similarity index 100% rename from inference/core/workflows/core_steps/integrations/roboflow/visual_search/helpers.py rename to inference/roboflow_workflows_plugin/integrations/visual_search/helpers.py diff --git a/inference/core/workflows/core_steps/integrations/roboflow/visual_search/v1.py b/inference/roboflow_workflows_plugin/integrations/visual_search/v1.py similarity index 98% rename from inference/core/workflows/core_steps/integrations/roboflow/visual_search/v1.py rename to inference/roboflow_workflows_plugin/integrations/visual_search/v1.py index b941405551..822d6a05f0 100644 --- a/inference/core/workflows/core_steps/integrations/roboflow/visual_search/v1.py +++ b/inference/roboflow_workflows_plugin/integrations/visual_search/v1.py @@ -4,10 +4,6 @@ from typing_extensions import Annotated from inference.core.roboflow_api import search_project_images_at_roboflow -from inference.core.workflows.core_steps.integrations.roboflow.visual_search.helpers import ( - build_visual_search_candidate_image, - format_visual_search_candidate, -) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, @@ -31,6 +27,10 @@ WorkflowBlockManifest, roboflow_platform_project, ) +from inference.roboflow_workflows_plugin.integrations.visual_search.helpers import ( + build_visual_search_candidate_image, + format_visual_search_candidate, +) SHORT_DESCRIPTION = "Find visually similar image candidates in a Roboflow project." diff --git a/inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/__init__.py b/inference/roboflow_workflows_plugin/integrations/visual_search_classifier/__init__.py similarity index 100% rename from inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/__init__.py rename to inference/roboflow_workflows_plugin/integrations/visual_search_classifier/__init__.py diff --git a/inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/classification_annotations.py b/inference/roboflow_workflows_plugin/integrations/visual_search_classifier/classification_annotations.py similarity index 100% rename from inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/classification_annotations.py rename to inference/roboflow_workflows_plugin/integrations/visual_search_classifier/classification_annotations.py diff --git a/inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1.py b/inference/roboflow_workflows_plugin/integrations/visual_search_classifier/v1.py similarity index 98% rename from inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1.py rename to inference/roboflow_workflows_plugin/integrations/visual_search_classifier/v1.py index 59cbafe324..adfbb03ef1 100644 --- a/inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1.py +++ b/inference/roboflow_workflows_plugin/integrations/visual_search_classifier/v1.py @@ -14,13 +14,6 @@ ) from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.utils import run_in_parallel -from inference.core.workflows.core_steps.integrations.roboflow.visual_search.helpers import ( - build_visual_search_candidate_image, - format_visual_search_candidate, -) -from inference.core.workflows.core_steps.integrations.roboflow.visual_search_classifier.classification_annotations import ( - parse_visual_search_classification, -) from inference.core.workflows.execution_engine.constants import ( INFERENCE_ID_KEY, PARENT_ID_KEY, @@ -54,6 +47,13 @@ roboflow_platform_project, ) from inference.core.workflows.utils.images import downscale_image_keeping_aspect_ratio +from inference.roboflow_workflows_plugin.integrations.visual_search.helpers import ( + build_visual_search_candidate_image, + format_visual_search_candidate, +) +from inference.roboflow_workflows_plugin.integrations.visual_search_classifier.classification_annotations import ( + parse_visual_search_classification, +) SHORT_DESCRIPTION = ( "Classify an image by finding the most visually similar annotated image." diff --git a/inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1_tensor.py b/inference/roboflow_workflows_plugin/integrations/visual_search_classifier/v1_tensor.py similarity index 99% rename from inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1_tensor.py rename to inference/roboflow_workflows_plugin/integrations/visual_search_classifier/v1_tensor.py index edddf66498..b774c0a721 100644 --- a/inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1_tensor.py +++ b/inference/roboflow_workflows_plugin/integrations/visual_search_classifier/v1_tensor.py @@ -56,13 +56,6 @@ ) from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.utils import run_in_parallel -from inference.core.workflows.core_steps.integrations.roboflow.visual_search.helpers import ( - build_visual_search_candidate_image, - format_visual_search_candidate, -) -from inference.core.workflows.core_steps.integrations.roboflow.visual_search_classifier.classification_annotations import ( - parse_visual_search_classification, -) from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, CLASSIFICATION_STYLE_KEY, @@ -102,6 +95,13 @@ roboflow_platform_project, ) from inference.core.workflows.utils.images import downscale_image_keeping_aspect_ratio +from inference.roboflow_workflows_plugin.integrations.visual_search.helpers import ( + build_visual_search_candidate_image, + format_visual_search_candidate, +) +from inference.roboflow_workflows_plugin.integrations.visual_search_classifier.classification_annotations import ( + parse_visual_search_classification, +) from inference_models.models.base.classification import ( ClassificationPrediction, MultiLabelClassificationPrediction, diff --git a/inference/roboflow_workflows_plugin/loader.py b/inference/roboflow_workflows_plugin/loader.py new file mode 100644 index 0000000000..10f84e1c8d --- /dev/null +++ b/inference/roboflow_workflows_plugin/loader.py @@ -0,0 +1,135 @@ +"""Workflows plugin holding the Roboflow-platform blocks. + +These blocks talk to the Roboflow API (dataset upload, custom metadata, model +monitoring, vision events, asset-library attributes, visual search). They used +to live in `inference/core/workflows/core_steps/{sinks,integrations}/roboflow` +and were the last reason that package imported `inference.core.roboflow_api` +and `inference.core.active_learning`. + +`BLOCKS_SOURCE` is deliberately the core source name: the server supplies these +blocks' init parameters as `workflows_core.{cache,api_key,background_tasks, +thread_pool_executor,update_attributes_offloader,disable_sinks}`, and +`retrieve_init_parameter_values` resolves `{block_source}.{param}` first. +Re-tagging it breaks every one of them - see the regression test in +`tests/inference/unit_tests/core/test_roboflow_plugin_blocks.py`. + +The block-disable policy is applied HERE. `core_steps/loader.load_blocks()` +filters its own list through `_should_filter_block` (WORKFLOW_DISABLED_BLOCK_TYPES +/ WORKFLOW_DISABLED_BLOCK_PATTERNS against block type, class name, display name +and module path), but `blocks_loader.load_workflow_blocks` applies only +engine-version compatibility to plugin blocks. Moving the blocks out of the core +list must not silently re-enable them, so `load_blocks()` runs the same predicate +- and, because the predicate matches the CURRENT module path, also matches every +pattern against the blocks' pre-move path (`…core_steps.sinks.roboflow.…`, +`…core_steps.integrations.roboflow.…`) so an operator's existing pattern keeps +disabling the same blocks. +""" + +from typing import List, Type + +from inference.core.env import ENABLE_TENSOR_DATA_REPRESENTATION +from inference.core.workflows.core_steps import loader as core_loader +from inference.core.workflows.prototypes.block import WorkflowBlock +from inference.roboflow_workflows_plugin.integrations.visual_search.v1 import ( + RoboflowVisualSearchBlockV1, +) +from inference.roboflow_workflows_plugin.sinks.asset_library_attributes.v1 import ( + RoboflowAssetLibraryAttributesBlockV1, +) + +if not ENABLE_TENSOR_DATA_REPRESENTATION: + from inference.roboflow_workflows_plugin.integrations.visual_search_classifier.v1 import ( + RoboflowVisualSearchClassifierBlockV1, + ) + from inference.roboflow_workflows_plugin.sinks.custom_metadata.v1 import ( + RoboflowCustomMetadataBlockV1, + ) + from inference.roboflow_workflows_plugin.sinks.dataset_upload.v1 import ( + RoboflowDatasetUploadBlockV1, + ) + from inference.roboflow_workflows_plugin.sinks.dataset_upload.v2 import ( + RoboflowDatasetUploadBlockV2, + ) + from inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1 import ( + ModelMonitoringInferenceAggregatorBlockV1, + ) + from inference.roboflow_workflows_plugin.sinks.vision_events.v1 import ( + RoboflowVisionEventsBlockV1, + ) + from inference.roboflow_workflows_plugin.sinks.vision_events_bundle.v1 import ( + VisionEventBundleSinkBlockV1, + ) +else: + from inference.roboflow_workflows_plugin.integrations.visual_search_classifier.v1_tensor import ( + RoboflowVisualSearchClassifierBlockV1, + ) + from inference.roboflow_workflows_plugin.sinks.custom_metadata.v1_tensor import ( + RoboflowCustomMetadataBlockV1, + ) + from inference.roboflow_workflows_plugin.sinks.dataset_upload.v1_tensor import ( + RoboflowDatasetUploadBlockV1, + ) + from inference.roboflow_workflows_plugin.sinks.dataset_upload.v2_tensor import ( + RoboflowDatasetUploadBlockV2, + ) + from inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1_tensor import ( + ModelMonitoringInferenceAggregatorBlockV1, + ) + from inference.roboflow_workflows_plugin.sinks.vision_events.v1_tensor import ( + RoboflowVisionEventsBlockV1, + ) + from inference.roboflow_workflows_plugin.sinks.vision_events_bundle.v1_tensor import ( + VisionEventBundleSinkBlockV1, + ) + +BLOCKS_SOURCE = "workflows_core" + +# Where these modules lived before Phase 9. WORKFLOW_DISABLED_BLOCK_PATTERNS +# written against the old path must keep matching. +_LEGACY_MODULE_PREFIXES = ( + ( + "inference.roboflow_workflows_plugin.sinks.", + "inference.core.workflows.core_steps.sinks.roboflow.", + ), + ( + "inference.roboflow_workflows_plugin.integrations.", + "inference.core.workflows.core_steps.integrations.roboflow.", + ), +) + + +def _legacy_module_name(block_class: Type[WorkflowBlock]) -> str: + module = block_class.__module__ + for new_prefix, old_prefix in _LEGACY_MODULE_PREFIXES: + if module.startswith(new_prefix): + return old_prefix + module[len(new_prefix) :] + return module + + +def _is_disabled(block_class: Type[WorkflowBlock]) -> bool: + # The core policy - block type, class name, display name and the CURRENT + # module path - through the very predicate core_steps/loader.py uses, read + # through the module so a test's mock.patch.object on the core loader's + # WORKFLOW_DISABLED_BLOCK_* names applies here too. + if core_loader._should_filter_block(block_class): + return True + legacy_module = _legacy_module_name(block_class).lower() + return any( + pattern.lower() in legacy_module + for pattern in core_loader.WORKFLOW_DISABLED_BLOCK_PATTERNS + ) + + +def load_blocks() -> List[Type[WorkflowBlock]]: + blocks = [ + RoboflowVisualSearchBlockV1, + RoboflowVisualSearchClassifierBlockV1, + RoboflowDatasetUploadBlockV1, + RoboflowAssetLibraryAttributesBlockV1, + RoboflowCustomMetadataBlockV1, + ModelMonitoringInferenceAggregatorBlockV1, + RoboflowDatasetUploadBlockV2, + RoboflowVisionEventsBlockV1, + VisionEventBundleSinkBlockV1, + ] + return [block for block in blocks if not _is_disabled(block)] diff --git a/inference/core/workflows/core_steps/sinks/roboflow/__init__.py b/inference/roboflow_workflows_plugin/sinks/__init__.py similarity index 100% rename from inference/core/workflows/core_steps/sinks/roboflow/__init__.py rename to inference/roboflow_workflows_plugin/sinks/__init__.py diff --git a/inference/core/workflows/core_steps/sinks/roboflow/asset_library_attributes/__init__.py b/inference/roboflow_workflows_plugin/sinks/asset_library_attributes/__init__.py similarity index 100% rename from inference/core/workflows/core_steps/sinks/roboflow/asset_library_attributes/__init__.py rename to inference/roboflow_workflows_plugin/sinks/asset_library_attributes/__init__.py diff --git a/inference/core/workflows/core_steps/sinks/roboflow/asset_library_attributes/v1.py b/inference/roboflow_workflows_plugin/sinks/asset_library_attributes/v1.py similarity index 100% rename from inference/core/workflows/core_steps/sinks/roboflow/asset_library_attributes/v1.py rename to inference/roboflow_workflows_plugin/sinks/asset_library_attributes/v1.py diff --git a/inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/__init__.py b/inference/roboflow_workflows_plugin/sinks/custom_metadata/__init__.py similarity index 100% rename from inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/__init__.py rename to inference/roboflow_workflows_plugin/sinks/custom_metadata/__init__.py diff --git a/inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/v1.py b/inference/roboflow_workflows_plugin/sinks/custom_metadata/v1.py similarity index 100% rename from inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/v1.py rename to inference/roboflow_workflows_plugin/sinks/custom_metadata/v1.py diff --git a/inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/v1_tensor.py b/inference/roboflow_workflows_plugin/sinks/custom_metadata/v1_tensor.py similarity index 100% rename from inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/v1_tensor.py rename to inference/roboflow_workflows_plugin/sinks/custom_metadata/v1_tensor.py diff --git a/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/__init__.py b/inference/roboflow_workflows_plugin/sinks/dataset_upload/__init__.py similarity index 100% rename from inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/__init__.py rename to inference/roboflow_workflows_plugin/sinks/dataset_upload/__init__.py diff --git a/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1.py b/inference/roboflow_workflows_plugin/sinks/dataset_upload/v1.py similarity index 100% rename from inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1.py rename to inference/roboflow_workflows_plugin/sinks/dataset_upload/v1.py diff --git a/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1_tensor.py b/inference/roboflow_workflows_plugin/sinks/dataset_upload/v1_tensor.py similarity index 100% rename from inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1_tensor.py rename to inference/roboflow_workflows_plugin/sinks/dataset_upload/v1_tensor.py diff --git a/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v2.py b/inference/roboflow_workflows_plugin/sinks/dataset_upload/v2.py similarity index 99% rename from inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v2.py rename to inference/roboflow_workflows_plugin/sinks/dataset_upload/v2.py index 2da592196e..7030640ac2 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v2.py +++ b/inference/roboflow_workflows_plugin/sinks/dataset_upload/v2.py @@ -7,9 +7,6 @@ from typing_extensions import Annotated from inference.core.workflows.core_steps.sinks.noop import disabled_sink_message -from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload.v1 import ( - register_datapoint_at_roboflow, -) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, @@ -38,6 +35,9 @@ roboflow_platform_project, ) from inference.core.workflows.prototypes.cache import WorkflowsCache +from inference.roboflow_workflows_plugin.sinks.dataset_upload.v1 import ( + register_datapoint_at_roboflow, +) FloatZeroToHundred = Annotated[float, Field(ge=0.0, le=100.0)] diff --git a/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v2_tensor.py b/inference/roboflow_workflows_plugin/sinks/dataset_upload/v2_tensor.py similarity index 99% rename from inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v2_tensor.py rename to inference/roboflow_workflows_plugin/sinks/dataset_upload/v2_tensor.py index daa9355732..2762920ee9 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v2_tensor.py +++ b/inference/roboflow_workflows_plugin/sinks/dataset_upload/v2_tensor.py @@ -7,9 +7,6 @@ from inference.core.workflows.core_steps.common.tensor_native import KeyPointPrediction from inference.core.workflows.core_steps.sinks.noop import disabled_sink_message -from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload.v1_tensor import ( - register_datapoint_at_roboflow, -) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, @@ -40,6 +37,9 @@ roboflow_platform_project, ) from inference.core.workflows.prototypes.cache import WorkflowsCache +from inference.roboflow_workflows_plugin.sinks.dataset_upload.v1_tensor import ( + register_datapoint_at_roboflow, +) from inference_models.models.base.classification import ( ClassificationPrediction, MultiLabelClassificationPrediction, diff --git a/inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/__init__.py b/inference/roboflow_workflows_plugin/sinks/model_monitoring_inference_aggregator/__init__.py similarity index 100% rename from inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/__init__.py rename to inference/roboflow_workflows_plugin/sinks/model_monitoring_inference_aggregator/__init__.py diff --git a/inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1.py b/inference/roboflow_workflows_plugin/sinks/model_monitoring_inference_aggregator/v1.py similarity index 100% rename from inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1.py rename to inference/roboflow_workflows_plugin/sinks/model_monitoring_inference_aggregator/v1.py diff --git a/inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1_tensor.py b/inference/roboflow_workflows_plugin/sinks/model_monitoring_inference_aggregator/v1_tensor.py similarity index 100% rename from inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1_tensor.py rename to inference/roboflow_workflows_plugin/sinks/model_monitoring_inference_aggregator/v1_tensor.py diff --git a/inference/core/workflows/core_steps/sinks/roboflow/vision_events/__init__.py b/inference/roboflow_workflows_plugin/sinks/vision_events/__init__.py similarity index 100% rename from inference/core/workflows/core_steps/sinks/roboflow/vision_events/__init__.py rename to inference/roboflow_workflows_plugin/sinks/vision_events/__init__.py diff --git a/inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py b/inference/roboflow_workflows_plugin/sinks/vision_events/v1.py similarity index 100% rename from inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py rename to inference/roboflow_workflows_plugin/sinks/vision_events/v1.py diff --git a/inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1_tensor.py b/inference/roboflow_workflows_plugin/sinks/vision_events/v1_tensor.py similarity index 100% rename from inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1_tensor.py rename to inference/roboflow_workflows_plugin/sinks/vision_events/v1_tensor.py diff --git a/inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/__init__.py b/inference/roboflow_workflows_plugin/sinks/vision_events_bundle/__init__.py similarity index 100% rename from inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/__init__.py rename to inference/roboflow_workflows_plugin/sinks/vision_events_bundle/__init__.py diff --git a/inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1.py b/inference/roboflow_workflows_plugin/sinks/vision_events_bundle/v1.py similarity index 99% rename from inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1.py rename to inference/roboflow_workflows_plugin/sinks/vision_events_bundle/v1.py index bd007d3dda..bbec12de21 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1.py +++ b/inference/roboflow_workflows_plugin/sinks/vision_events_bundle/v1.py @@ -27,16 +27,6 @@ path_is_within_specified_directory, ) from inference.core.workflows.core_steps.sinks.noop import disabled_sink_message -from inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1 import ( - ALL_DATA_SCHEMAS_RELEVANT, - CUSTOM_RELEVANT, - INVENTORY_COUNT_RELEVANT, - OPERATOR_FEEDBACK_RELEVANT, - QUALITY_CHECK_RELEVANT, - SAFETY_ALERT_RELEVANT, - _build_event_data, - _convert_predictions_to_annotations, -) from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, WorkflowImageData, @@ -65,6 +55,16 @@ WorkflowBlockManifest, is_workflow_selector, ) +from inference.roboflow_workflows_plugin.sinks.vision_events.v1 import ( + ALL_DATA_SCHEMAS_RELEVANT, + CUSTOM_RELEVANT, + INVENTORY_COUNT_RELEVANT, + OPERATOR_FEEDBACK_RELEVANT, + QUALITY_CHECK_RELEVANT, + SAFETY_ALERT_RELEVANT, + _build_event_data, + _convert_predictions_to_annotations, +) logger = logging.getLogger(__name__) diff --git a/inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1_tensor.py b/inference/roboflow_workflows_plugin/sinks/vision_events_bundle/v1_tensor.py similarity index 99% rename from inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1_tensor.py rename to inference/roboflow_workflows_plugin/sinks/vision_events_bundle/v1_tensor.py index a1e90ea190..bc843a0101 100644 --- a/inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1_tensor.py +++ b/inference/roboflow_workflows_plugin/sinks/vision_events_bundle/v1_tensor.py @@ -37,17 +37,6 @@ path_is_within_specified_directory, ) from inference.core.workflows.core_steps.sinks.noop import disabled_sink_message -from inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1_tensor import ( - ALL_DATA_SCHEMAS_RELEVANT, - CUSTOM_RELEVANT, - INVENTORY_COUNT_RELEVANT, - OPERATOR_FEEDBACK_RELEVANT, - QUALITY_CHECK_RELEVANT, - SAFETY_ALERT_RELEVANT, - TensorNativePrediction, - _build_event_data, - _convert_predictions_to_annotations, -) from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, WorkflowImageData, @@ -78,6 +67,17 @@ WorkflowBlockManifest, is_workflow_selector, ) +from inference.roboflow_workflows_plugin.sinks.vision_events.v1_tensor import ( + ALL_DATA_SCHEMAS_RELEVANT, + CUSTOM_RELEVANT, + INVENTORY_COUNT_RELEVANT, + OPERATOR_FEEDBACK_RELEVANT, + QUALITY_CHECK_RELEVANT, + SAFETY_ALERT_RELEVANT, + TensorNativePrediction, + _build_event_data, + _convert_predictions_to_annotations, +) logger = logging.getLogger(__name__) diff --git a/scripts/phase9_move_roboflow_plugin.py b/scripts/phase9_move_roboflow_plugin.py new file mode 100644 index 0000000000..b9eaeaaf75 --- /dev/null +++ b/scripts/phase9_move_roboflow_plugin.py @@ -0,0 +1,76 @@ +"""Rewrite module paths after Phase 9's `git mv` of the Roboflow-platform blocks. + +Two prefix rewrites, applied to the moved sources (they import each other) and +to every test that names them. Order-independent: if another phase already +repointed some references the count is lower, and `--expected` is the number +the caller measured immediately before. Every rewritten file is re-parsed with +`ast` before it is written, and the post-state (no stale prefix anywhere) is +verified after every write. +""" + +import argparse +import ast +import pathlib +import sys + +REPLACEMENTS = ( + ( + "inference.core.workflows.core_steps.sinks.roboflow", + "inference.roboflow_workflows_plugin.sinks", + ), + ( + "inference.core.workflows.core_steps.integrations.roboflow", + "inference.roboflow_workflows_plugin.integrations", + ), +) + + +def rewrite(path: pathlib.Path) -> int: + original = path.read_text(encoding="utf-8") + updated = original + hits = 0 + for old, new in REPLACEMENTS: + hits += updated.count(old) + updated = updated.replace(old, new) + if not hits: + return 0 + try: + ast.parse(updated) + except SyntaxError as error: + print(f"FAIL: {path} would not parse: {error}", file=sys.stderr) + raise SystemExit(2) + path.write_text(updated, encoding="utf-8") + return hits + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("roots", nargs="+") + parser.add_argument("--expected", type=int, required=True) + args = parser.parse_args() + total, touched, remaining = 0, [], [] + for root in args.roots: + base = pathlib.Path(root) + files = [base] if base.is_file() else sorted(base.rglob("*.py")) + for path in files: + if "__pycache__" in path.parts: + continue + hits = rewrite(path) + if hits: + total += hits + touched.append((str(path), hits)) + text = path.read_text(encoding="utf-8") + if any(old in text for old, _ in REPLACEMENTS): + remaining.append(str(path)) + for name, hits in touched: + print(f"{hits:4d} {name}") + print(f"TOTAL {total} replacements in {len(touched)} files") + print(f"POST-STATE stale references remaining: {len(remaining)} {remaining}") + if total != args.expected or remaining: + print("FAIL", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/inference/unit_tests/core/test_enterprise_blocks_plugin.py b/tests/inference/unit_tests/core/test_enterprise_blocks_plugin.py index 2b2424b177..8d400f1694 100644 --- a/tests/inference/unit_tests/core/test_enterprise_blocks_plugin.py +++ b/tests/inference/unit_tests/core/test_enterprise_blocks_plugin.py @@ -4,6 +4,7 @@ import sys from inference.core.env import ENTERPRISE_BLOCKS_PLUGIN as ENTERPRISE_PLUGIN +from inference.core.env import ROBOFLOW_BLOCKS_PLUGIN as ROBOFLOW_PLUGIN MQTT_BLOCK = ( "inference.enterprise.workflows.enterprise_blocks.sinks.mqtt_writer.v1" @@ -124,6 +125,7 @@ def test_enterprise_blocks_keep_their_position_between_core_and_custom_plugins() for index, (identifier, source) in enumerate(report["blocks"]) if source == "workflows_core" and not identifier.startswith("inference.enterprise.") + and not identifier.startswith("inference.roboflow_workflows_plugin.") ] mqtt_position = identifiers.index(MQTT_BLOCK) fake_position = identifiers.index(FAKE_BLOCK) @@ -132,7 +134,10 @@ def test_enterprise_blocks_keep_their_position_between_core_and_custom_plugins() assert max(genuine_core_positions) < mqtt_position # ... which in turn precedes the custom plugin's block. assert mqtt_position < fake_position - assert report["plugins"] == f"{ENTERPRISE_PLUGIN},{FAKE_PLUGIN}" + # The Roboflow-platform plugin is expanded unconditionally and prepended + # ahead of the enterprise one (env.py), so the historical core -> roboflow + # -> enterprise -> custom order is preserved. + assert report["plugins"] == f"{ROBOFLOW_PLUGIN},{ENTERPRISE_PLUGIN},{FAKE_PLUGIN}" def test_env_expansion_does_not_duplicate_an_already_listed_enterprise_plugin() -> None: @@ -143,6 +148,18 @@ def test_env_expansion_does_not_duplicate_an_already_listed_enterprise_plugin() LOAD_ENTERPRISE_BLOCKS="True", WORKFLOWS_PLUGINS=ENTERPRISE_PLUGIN, ) - assert report["plugins"] == ENTERPRISE_PLUGIN + assert report["plugins"] == f"{ROBOFLOW_PLUGIN},{ENTERPRISE_PLUGIN}" identifiers = [identifier for identifier, _ in report["blocks"]] assert identifiers.count(MQTT_BLOCK) == 1 + + +def test_env_expansion_does_not_duplicate_an_already_listed_roboflow_plugin() -> None: + report = _run_probe(PROBE, WORKFLOWS_PLUGINS=ROBOFLOW_PLUGIN) + assert report["plugins"] == ROBOFLOW_PLUGIN + identifiers = [identifier for identifier, _ in report["blocks"]] + roboflow_blocks = [ + identifier + for identifier in identifiers + if identifier.startswith("inference.roboflow_workflows_plugin.") + ] + assert len(roboflow_blocks) == len(set(roboflow_blocks)) == 9 diff --git a/tests/inference/unit_tests/core/test_roboflow_plugin_blocks.py b/tests/inference/unit_tests/core/test_roboflow_plugin_blocks.py new file mode 100644 index 0000000000..64ea6bbd6d --- /dev/null +++ b/tests/inference/unit_tests/core/test_roboflow_plugin_blocks.py @@ -0,0 +1,229 @@ +"""Phase 9: the Roboflow-platform blocks load as a plugin, unchanged - and the +block-disable policy still reaches them.""" + +import json +import os +import pathlib +import subprocess +import sys +from unittest import mock + +import pytest + +from inference.core.workflows.core_steps import loader as core_loader +from inference.core.workflows.execution_engine.introspection.blocks_loader import ( + describe_available_blocks, +) +from inference.roboflow_workflows_plugin import loader as plugin_loader + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[4] + +# Read off the blocks' `type` Literals in the tree - the public contract. +RELOCATED_BLOCKS = { + "roboflow_core/roboflow_dataset_upload@v1", + "roboflow_core/roboflow_dataset_upload@v2", + "roboflow_core/roboflow_custom_metadata@v1", + "roboflow_core/model_monitoring_inference_aggregator@v1", + "roboflow_core/asset_library_attributes@v1", + "roboflow_core/roboflow_vision_events@v1", + "roboflow_core/vision_event_bundle@v1", + "roboflow_core/visual_search@v1", + "roboflow_core/visual_search_classifier@v1", +} +RELOCATED_SINKS = RELOCATED_BLOCKS - { + "roboflow_core/visual_search@v1", + "roboflow_core/visual_search_classifier@v1", +} + + +def _type_of(block_class) -> str: + return block_class.get_manifest().model_fields["type"].annotation.__args__[0] + + +@pytest.fixture +def described_blocks(): + return describe_available_blocks(dynamic_blocks=[]).blocks + + +def test_every_relocated_block_is_still_available(described_blocks) -> None: + available = {b.manifest_type_identifier for b in described_blocks} + missing = RELOCATED_BLOCKS - available + assert not missing, f"relocated blocks disappeared: {missing}" + + +def test_relocated_blocks_keep_the_core_block_source(described_blocks) -> None: + for block in described_blocks: + if block.manifest_type_identifier in RELOCATED_BLOCKS: + assert block.block_source == "workflows_core", ( + f"{block.manifest_type_identifier} reports " + f"block_source={block.block_source!r}" + ) + + +def test_relocated_blocks_now_live_in_the_plugin_package(described_blocks) -> None: + for block in described_blocks: + if block.manifest_type_identifier in RELOCATED_BLOCKS: + assert block.fully_qualified_block_class_name.startswith( + "inference.roboflow_workflows_plugin." + ) + + +def test_relocated_blocks_are_contiguous_at_the_head_of_the_plugin_section( + described_blocks, +) -> None: + positions = [ + index + for index, block in enumerate(described_blocks) + if block.manifest_type_identifier in RELOCATED_BLOCKS + ] + assert len(positions) == len(RELOCATED_BLOCKS) + assert positions == list(range(positions[0], positions[0] + len(positions))) + + +def test_workflows_core_init_parameters_still_resolve_for_a_relocated_block() -> None: + """R4 through the real resolution path. + + `retrieve_init_parameters_values` looks up `{block_source}.{param}` first, + and the core defaults are registered ONLY under `workflows_core.*` + (`blocks_loader.load_core_blocks_initializers`). A plugin tagged with any + other source fails to resolve `cache` - Step 14 mutation-checks that. + """ + from inference.core.workflows.execution_engine.introspection.blocks_loader import ( + load_initializers, + ) + from inference.core.workflows.execution_engine.v1.compiler.steps_initialiser import ( + retrieve_init_parameters_values, + ) + from inference.roboflow_workflows_plugin.loader import BLOCKS_SOURCE + from inference.roboflow_workflows_plugin.sinks.asset_library_attributes.v1 import ( + RoboflowAssetLibraryAttributesBlockV1, + ) + + class _Cache: + def get(self, key): + return None + + def set(self, key, value, expire=None): + return None + + shared_cache = _Cache() + values = retrieve_init_parameters_values( + block_name="attributes", + block_init_parameters=RoboflowAssetLibraryAttributesBlockV1.get_init_parameters(), + block_source=BLOCKS_SOURCE, + explicit_init_parameters={ + "workflows_core.cache": shared_cache, + "workflows_core.api_key": "fake-key", + "workflows_core.disable_sinks": True, + }, + initializers=load_initializers(), + ) + assert values["cache"] is shared_cache + assert values["disable_sinks"] is True + assert RoboflowAssetLibraryAttributesBlockV1(**values)._cache is shared_cache + + +# (types, patterns, identifiers that must survive plugin_loader.load_blocks()) - +# the same shapes core_steps/loader._should_filter_block honours, plus the +# pre-move module path. Patched on the CORE loader module because that is +# where the policy lives and where the plugin reads it from. +POLICY_CASES = [ + (["sink"], [], RELOCATED_BLOCKS - RELOCATED_SINKS), + ([], ["sinks.roboflow"], RELOCATED_BLOCKS - RELOCATED_SINKS), # legacy path + ([], ["integrations.roboflow"], RELOCATED_SINKS), # legacy path + ([], ["roboflow_workflows_plugin.sinks"], RELOCATED_BLOCKS - RELOCATED_SINKS), + ( + [], + ["roboflowdatasetuploadblockv1"], + RELOCATED_BLOCKS - {"roboflow_core/roboflow_dataset_upload@v1"}, + ), + # NOTE: "visual search" is also a substring of visual_search_classifier's + # display name ("Roboflow Visual Search Classifier"), so both blocks are + # filtered - verified directly against core_loader._should_filter_block. + ( + [], + ["visual search"], + RELOCATED_BLOCKS + - { + "roboflow_core/visual_search@v1", + "roboflow_core/visual_search_classifier@v1", + }, + ), + ([], ["nonexistent_pattern"], RELOCATED_BLOCKS), + ([], [], RELOCATED_BLOCKS), +] + + +@pytest.mark.parametrize("types,patterns,expected", POLICY_CASES) +def test_plugin_load_blocks_honours_the_disable_policy( + types, patterns, expected +) -> None: + with mock.patch.object( + core_loader, "WORKFLOW_DISABLED_BLOCK_TYPES", types + ), mock.patch.object(core_loader, "WORKFLOW_DISABLED_BLOCK_PATTERNS", patterns): + loaded = {_type_of(block) for block in plugin_loader.load_blocks()} + assert loaded == expected + + +NEW_PATH_PROBE = """ +import json +import os + +from inference.core.workflows.execution_engine.introspection.blocks_loader import ( + describe_available_blocks, +) +import inference.core.env as env_module + +# CR-1: prove the child actually landed in the requested tensor mode. +requested_tensor_mode = os.environ["ENABLE_TENSOR_DATA_REPRESENTATION"] == "True" +assert env_module.ENABLE_TENSOR_DATA_REPRESENTATION == requested_tensor_mode, ( + env_module.ENABLE_TENSOR_DATA_REPRESENTATION, + requested_tensor_mode, +) + +survivors = { + b.manifest_type_identifier: b.fully_qualified_block_class_name + for b in describe_available_blocks(dynamic_blocks=[]).blocks + if b.fully_qualified_block_class_name.startswith("inference.roboflow_workflows_plugin.") +} + +# CR-1: of the two relocated blocks that survive this pattern, one +# (visual_search_classifier) has a _tensor sibling and one (visual_search) +# does not - assert the tensor-mode branch actually selected the right module +# for each, the same way the acceptance test's sink-workflow probe does. +classifier_module = survivors["roboflow_core/visual_search_classifier@v1"] +classifier_leaf = classifier_module.rsplit(".", 1)[0].rsplit(".", 1)[-1] +assert classifier_leaf.endswith("_tensor") == requested_tensor_mode, classifier_module + +visual_search_module = survivors["roboflow_core/visual_search@v1"] +visual_search_leaf = visual_search_module.rsplit(".", 1)[0].rsplit(".", 1)[-1] +assert visual_search_leaf == "v1", visual_search_module + +print(json.dumps(sorted(survivors))) +""" + + +@pytest.mark.parametrize("tensor_mode", ["False", "True"]) +def test_new_module_path_pattern_disables_the_sinks_in_both_modes(tensor_mode) -> None: + """The post-move counterpart of the acceptance test's `sinks.roboflow` + case: a pattern written against the NEW package also works, and the + plugin's tensor-mode branch selects the right classes in both modes.""" + env = { + **os.environ, + "DISABLE_VERSION_CHECK": "True", + "ENABLE_TENSOR_DATA_REPRESENTATION": tensor_mode, + # CR-1: pin the effective-mode input; see test_roboflow_sink_acceptance.py. + "USE_INFERENCE_MODELS": "True", + "WORKFLOW_DISABLED_BLOCK_PATTERNS": "roboflow_workflows_plugin.sinks", + "PYTHONPATH": os.pathsep.join( + [str(REPO_ROOT), str(REPO_ROOT / "inference_models")] + ), + } + env.pop("WORKFLOW_DISABLED_BLOCK_TYPES", None) + result = subprocess.run( + [sys.executable, "-c", NEW_PATH_PROBE], env=env, capture_output=True, text=True + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout.strip().splitlines()[-1]) == sorted( + RELOCATED_BLOCKS - RELOCATED_SINKS + ) diff --git a/tests/inference/unit_tests/core/test_roboflow_sink_acceptance.py b/tests/inference/unit_tests/core/test_roboflow_sink_acceptance.py new file mode 100644 index 0000000000..ece0c4b229 --- /dev/null +++ b/tests/inference/unit_tests/core/test_roboflow_sink_acceptance.py @@ -0,0 +1,325 @@ +"""Acceptance test for Phase 9's relocation (spec-phase9.md:22) and for the +block-disable policy the relocation must not bypass (round-3 defect 1). + +Part 1 compiles and runs a real Roboflow-sink workflow with +`workflows_core.disable_sinks=True` and an injected shared cache, in BOTH +tensor modes, and compares against a recorded baseline. The same file runs +before and after the move: the blocks' `type` identifiers do not change. + +Part 2 records which relocated block identifiers survive discovery under +`WORKFLOW_DISABLED_BLOCK_TYPES` / `WORKFLOW_DISABLED_BLOCK_PATTERNS`, and +whether a workflow naming a disabled one still compiles. One case uses the OLD +module path (`sinks.roboflow`): an operator's existing pattern has to keep +working after the move. + +The workflow is the one the existing network-free integration test already +compiles and runs +(`tests/workflows/integration_tests/execution/test_workflow_with_asset_library_attributes.py:12-36`), +so the "before" side is real, not invented. + +Every case needs a subprocess: `ENABLE_TENSOR_DATA_REPRESENTATION` and the two +policy variables are read at import time by `core_steps/loader.py` (and, after +the move, by the plugin loader). The child's PYTHONPATH must carry the REPO +ROOT as well as `inference_models` - with `inference_models` alone the venv's +editable .pth resolves `inference` from a different checkout and the child dies +on `ModuleNotFoundError: inference_models.utils.performance`. + +CR-1: the effective tensor mode is `ENABLE_TENSOR_DATA_REPRESENTATION AND +USE_INFERENCE_MODELS` (inference/core/env.py:1486), so a subprocess builder +that sets only the former can silently run NumPy in both "modes" if it +inherits `USE_INFERENCE_MODELS=False` from the shell. `_run()` below pins +`USE_INFERENCE_MODELS=True` and every child asserts, from inside the child, +that `inference.core.env.ENABLE_TENSOR_DATA_REPRESENTATION` equals the +requested mode. The sink-workflow probe additionally asserts which module was +actually selected for the 7 blocks with a `_tensor` sibling, and that the 2 +blocks with no sibling resolve to the same module in both modes. +""" + +import json +import os +import pathlib +import subprocess +import sys + +import pytest + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[4] + +WORKFLOW = { + "version": "1.3.0", + "inputs": [ + {"type": "WorkflowBatchInput", "name": "source_id", "kind": ["string"]}, + {"type": "WorkflowParameter", "name": "location"}, + {"type": "WorkflowParameter", "name": "extra_tag"}, + ], + "steps": [ + { + "type": "roboflow_core/asset_library_attributes@v1", + "name": "asset_library_attributes", + "source_id": "$inputs.source_id", + "metadata": {"location": "$inputs.location"}, + "tags": ["$inputs.extra_tag"], + "disable_sink": False, + } + ], + "outputs": [ + { + "type": "JsonField", + "name": "message", + "selector": "$steps.asset_library_attributes.message", + } + ], +} + +PROBE = """ +import json +import os + +from inference.core.workflows.execution_engine.core import ExecutionEngine +from inference.core.workflows.execution_engine.introspection.blocks_loader import ( + describe_available_blocks, +) +import inference.core.env as env_module + +# CR-1: prove the child actually landed in the requested tensor mode, not +# just that it read the env var back unmodified. +requested_tensor_mode = os.environ["ENABLE_TENSOR_DATA_REPRESENTATION"] == "True" +assert env_module.ENABLE_TENSOR_DATA_REPRESENTATION == requested_tensor_mode, ( + env_module.ENABLE_TENSOR_DATA_REPRESENTATION, + requested_tensor_mode, +) + +WORKFLOW = json.loads(%s) + + +class SharedCache: + def __init__(self): + self.storage = {} + + def get(self, key): + return self.storage.get(key) + + def set(self, key, value, expire=None): + self.storage[key] = value + + +cache = SharedCache() +engine = ExecutionEngine.init( + workflow_definition=WORKFLOW, + init_parameters={ + "workflows_core.model_manager": None, + "workflows_core.api_key": "my_api_key", + "workflows_core.cache": cache, + "workflows_core.update_attributes_offloader": None, + "workflows_core.disable_sinks": True, + }, +) +# v1/core.py:367 stores `_compiled_workflow`; CompiledWorkflow.steps holds the +# InitialisedStep records (compiler/entities.py:60). +step = engine._engine._compiled_workflow.steps["asset_library_attributes"].step +result = engine.run( + runtime_parameters={ + "source_id": ["img-1", "img-2"], + "location": "warehouse_a", + "extra_tag": "auto-labeled", + } +) + +# CR-1: assert which module the tensor-mode branch actually selected for the +# 7 blocks with a _tensor sibling, and that the 2 blocks with no sibling +# resolve to the same module regardless of mode. +PAIRED_IDENTIFIERS = { + "roboflow_core/model_monitoring_inference_aggregator@v1", + "roboflow_core/roboflow_custom_metadata@v1", + "roboflow_core/roboflow_dataset_upload@v1", + "roboflow_core/roboflow_dataset_upload@v2", + "roboflow_core/roboflow_vision_events@v1", + "roboflow_core/vision_event_bundle@v1", + "roboflow_core/visual_search_classifier@v1", +} +UNPAIRED_IDENTIFIERS = { + "roboflow_core/asset_library_attributes@v1", + "roboflow_core/visual_search@v1", +} +modules_by_identifier = { + b.manifest_type_identifier: b.fully_qualified_block_class_name + for b in describe_available_blocks(dynamic_blocks=[]).blocks + if b.manifest_type_identifier in PAIRED_IDENTIFIERS | UNPAIRED_IDENTIFIERS +} +assert ( + set(modules_by_identifier) == PAIRED_IDENTIFIERS | UNPAIRED_IDENTIFIERS +), modules_by_identifier +# `fully_qualified_block_class_name` is "."; the +# module's own leaf (v1 / v1_tensor / v2 / v2_tensor) is the second-to-last +# dotted segment, not the last (which is the class name). +for identifier in PAIRED_IDENTIFIERS: + module_name = modules_by_identifier[identifier] + module_leaf = module_name.split(".")[-2] + is_tensor_module = module_leaf.endswith("_tensor") + assert is_tensor_module == requested_tensor_mode, (identifier, module_name) +for identifier in UNPAIRED_IDENTIFIERS: + module_name = modules_by_identifier[identifier] + module_leaf = module_name.split(".")[-2] + assert module_leaf == "v1", (identifier, module_name) + +print( + json.dumps( + { + "outputs": result, + "cache_is_injected": step._cache is cache, + "cache_untouched": cache.storage == {}, + } + ) +) +""" % ("'''" + json.dumps(WORKFLOW) + "'''") + +DISABLED_MESSAGE = "Sink was disabled by workflow execution policy" +EXPECTED = { + "outputs": [{"message": DISABLED_MESSAGE}, {"message": DISABLED_MESSAGE}], + "cache_is_injected": True, + "cache_untouched": True, +} + +RELOCATED_IDENTIFIERS = [ + "roboflow_core/asset_library_attributes@v1", + "roboflow_core/model_monitoring_inference_aggregator@v1", + "roboflow_core/roboflow_custom_metadata@v1", + "roboflow_core/roboflow_dataset_upload@v1", + "roboflow_core/roboflow_dataset_upload@v2", + "roboflow_core/roboflow_vision_events@v1", + "roboflow_core/vision_event_bundle@v1", + "roboflow_core/visual_search@v1", + "roboflow_core/visual_search_classifier@v1", +] + +POLICY_PROBE = """ +import json +import os + +from inference.core.workflows.execution_engine.core import ExecutionEngine +from inference.core.workflows.execution_engine.introspection.blocks_loader import ( + describe_available_blocks, +) +import inference.core.env as env_module + +# CR-1: prove the child actually landed in the requested tensor mode. +requested_tensor_mode = os.environ["ENABLE_TENSOR_DATA_REPRESENTATION"] == "True" +assert env_module.ENABLE_TENSOR_DATA_REPRESENTATION == requested_tensor_mode, ( + env_module.ENABLE_TENSOR_DATA_REPRESENTATION, + requested_tensor_mode, +) + +RELOCATED = json.loads(%s) +available = { + block.manifest_type_identifier + for block in describe_available_blocks(dynamic_blocks=[]).blocks +} +WORKFLOW = { + "version": "1.3.0", + "inputs": [{"type": "WorkflowBatchInput", "name": "source_id", "kind": ["string"]}], + "steps": [ + { + "type": "roboflow_core/asset_library_attributes@v1", + "name": "attrs", + "source_id": "$inputs.source_id", + "metadata": {}, + "tags": [], + "disable_sink": False, + } + ], + "outputs": [{"type": "JsonField", "name": "message", "selector": "$steps.attrs.message"}], +} +try: + ExecutionEngine.init( + workflow_definition=WORKFLOW, init_parameters={"workflows_core.api_key": "k"} + ) + outcome = "compiled" +except Exception as error: + outcome = type(error).__name__ +print( + json.dumps( + { + "present": sorted(i for i in RELOCATED if i in available), + "asset_library_compile": outcome, + } + ) +) +""" % ("'''" + json.dumps(RELOCATED_IDENTIFIERS) + "'''") + +_VISUAL_SEARCH_ONLY = [ + "roboflow_core/visual_search@v1", + "roboflow_core/visual_search_classifier@v1", +] +# (extra environment, identifiers that survive discovery, compile outcome) - +# every row measured at HEAD in both tensor modes (evidence E13). +POLICY_CASES = [ + ( + {"WORKFLOW_DISABLED_BLOCK_TYPES": "sink"}, + _VISUAL_SEARCH_ONLY, + "WorkflowSyntaxError", + ), + # The OLD module path of the relocated sinks: an operator's existing + # pattern must keep working after the move. + ( + {"WORKFLOW_DISABLED_BLOCK_PATTERNS": "sinks.roboflow"}, + _VISUAL_SEARCH_ONLY, + "WorkflowSyntaxError", + ), + # Class name (lower-cased) and display name. + ( + { + "WORKFLOW_DISABLED_BLOCK_PATTERNS": "roboflowdatasetuploadblockv1,visual_search" + }, + [ + "roboflow_core/asset_library_attributes@v1", + "roboflow_core/model_monitoring_inference_aggregator@v1", + "roboflow_core/roboflow_custom_metadata@v1", + "roboflow_core/roboflow_dataset_upload@v2", + "roboflow_core/roboflow_vision_events@v1", + "roboflow_core/vision_event_bundle@v1", + ], + "compiled", + ), + ({}, RELOCATED_IDENTIFIERS, "compiled"), +] + + +def _run(probe: str, tensor_mode: str, extra_env: dict) -> dict: + env = { + **os.environ, + "DISABLE_VERSION_CHECK": "True", + "ENABLE_TENSOR_DATA_REPRESENTATION": tensor_mode, + # CR-1: the effective tensor mode is `ENABLE_TENSOR_DATA_REPRESENTATION + # AND USE_INFERENCE_MODELS` (inference/core/env.py:1486); pin this so + # "both modes" actually exercises the tensor code path rather than + # running NumPy twice under an inherited USE_INFERENCE_MODELS=False. + "USE_INFERENCE_MODELS": "True", + "PYTHONPATH": os.pathsep.join( + [str(REPO_ROOT), str(REPO_ROOT / "inference_models")] + ), + } + # A case that sets neither variable must not inherit one from the shell. + env.pop("WORKFLOW_DISABLED_BLOCK_TYPES", None) + env.pop("WORKFLOW_DISABLED_BLOCK_PATTERNS", None) + env.update(extra_env) + result = subprocess.run( + [sys.executable, "-c", probe], env=env, capture_output=True, text=True + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip().splitlines()[-1]) + + +@pytest.mark.parametrize("tensor_mode", ["False", "True"]) +def test_roboflow_sink_workflow_is_unchanged_by_the_relocation(tensor_mode) -> None: + assert _run(PROBE, tensor_mode, {}) == EXPECTED + + +@pytest.mark.parametrize("tensor_mode", ["False", "True"]) +@pytest.mark.parametrize("extra_env,present,compile_outcome", POLICY_CASES) +def test_disable_policy_keeps_applying_to_the_relocated_blocks( + tensor_mode, extra_env, present, compile_outcome +) -> None: + assert _run(POLICY_PROBE, tensor_mode, extra_env) == { + "present": present, + "asset_library_compile": compile_outcome, + } diff --git a/tests/workflows/integration_tests/execution/test_workflow_with_asset_library_attributes.py b/tests/workflows/integration_tests/execution/test_workflow_with_asset_library_attributes.py index ee903d1512..a361776211 100644 --- a/tests/workflows/integration_tests/execution/test_workflow_with_asset_library_attributes.py +++ b/tests/workflows/integration_tests/execution/test_workflow_with_asset_library_attributes.py @@ -4,7 +4,7 @@ from inference.core.env import WORKFLOWS_MAX_CONCURRENT_STEPS from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.sinks.roboflow.asset_library_attributes import ( +from inference.roboflow_workflows_plugin.sinks.asset_library_attributes import ( v1, ) from inference.core.workflows.execution_engine.core import ExecutionEngine diff --git a/tests/workflows/integration_tests/execution/test_workflow_with_dataset_upload_metadata.py b/tests/workflows/integration_tests/execution/test_workflow_with_dataset_upload_metadata.py index a59b9e2d6f..52c109ac26 100644 --- a/tests/workflows/integration_tests/execution/test_workflow_with_dataset_upload_metadata.py +++ b/tests/workflows/integration_tests/execution/test_workflow_with_dataset_upload_metadata.py @@ -10,7 +10,7 @@ ) from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload import ( +from inference.roboflow_workflows_plugin.sinks.dataset_upload import ( v2, v2_tensor, ) diff --git a/tests/workflows/integration_tests/execution/test_workflow_with_visual_search_classifier.py b/tests/workflows/integration_tests/execution/test_workflow_with_visual_search_classifier.py index 77a2010c00..59895aeebc 100644 --- a/tests/workflows/integration_tests/execution/test_workflow_with_visual_search_classifier.py +++ b/tests/workflows/integration_tests/execution/test_workflow_with_visual_search_classifier.py @@ -12,10 +12,10 @@ # sibling, which binds its own copies of the roboflow_api helpers - patch the # module that actually runs. _VISUAL_SEARCH_CLASSIFIER_MODULE = ( - "inference.core.workflows.core_steps.integrations.roboflow." + "inference.roboflow_workflows_plugin.integrations." "visual_search_classifier.v1_tensor" if ENABLE_TENSOR_DATA_REPRESENTATION - else "inference.core.workflows.core_steps.integrations.roboflow." + else "inference.roboflow_workflows_plugin.integrations." "visual_search_classifier.v1" ) diff --git a/tests/workflows/unit_tests/core_steps/dependent_resources/test_dataset_upload.py b/tests/workflows/unit_tests/core_steps/dependent_resources/test_dataset_upload.py index 7445b7c7f0..f9bbe8bcb0 100644 --- a/tests/workflows/unit_tests/core_steps/dependent_resources/test_dataset_upload.py +++ b/tests/workflows/unit_tests/core_steps/dependent_resources/test_dataset_upload.py @@ -7,10 +7,10 @@ verbatim. """ -from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload.v1 import ( +from inference.roboflow_workflows_plugin.sinks.dataset_upload.v1 import ( BlockManifest as DatasetUploadV1Manifest, ) -from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload.v2 import ( +from inference.roboflow_workflows_plugin.sinks.dataset_upload.v2 import ( BlockManifest as DatasetUploadV2Manifest, ) from inference.core.workflows.prototypes.block import roboflow_platform_project diff --git a/tests/workflows/unit_tests/core_steps/dependent_resources/test_model_monitoring.py b/tests/workflows/unit_tests/core_steps/dependent_resources/test_model_monitoring.py index ce6dcb8b54..5877767c31 100644 --- a/tests/workflows/unit_tests/core_steps/dependent_resources/test_model_monitoring.py +++ b/tests/workflows/unit_tests/core_steps/dependent_resources/test_model_monitoring.py @@ -11,7 +11,7 @@ import pytest from pydantic import ValidationError -from inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1 import ( +from inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1 import ( BlockManifest as ModelMonitoringV1Manifest, ) from inference.core.workflows.prototypes.block import ( diff --git a/tests/workflows/unit_tests/core_steps/dependent_resources/test_visual_search.py b/tests/workflows/unit_tests/core_steps/dependent_resources/test_visual_search.py index f571268e5f..b81640304a 100644 --- a/tests/workflows/unit_tests/core_steps/dependent_resources/test_visual_search.py +++ b/tests/workflows/unit_tests/core_steps/dependent_resources/test_visual_search.py @@ -7,7 +7,7 @@ resource — it scopes the API call, but the dependency is the project. """ -from inference.core.workflows.core_steps.integrations.roboflow.visual_search.v1 import ( +from inference.roboflow_workflows_plugin.integrations.visual_search.v1 import ( BlockManifest as VisualSearchV1Manifest, ) from inference.core.workflows.prototypes.block import roboflow_platform_project diff --git a/tests/workflows/unit_tests/core_steps/dependent_resources/test_visual_search_classifier.py b/tests/workflows/unit_tests/core_steps/dependent_resources/test_visual_search_classifier.py index 5062dddfee..14e5f2d49d 100644 --- a/tests/workflows/unit_tests/core_steps/dependent_resources/test_visual_search_classifier.py +++ b/tests/workflows/unit_tests/core_steps/dependent_resources/test_visual_search_classifier.py @@ -7,7 +7,7 @@ dependent resource, whether left unset or explicitly provided. """ -from inference.core.workflows.core_steps.integrations.roboflow.visual_search_classifier.v1 import ( +from inference.roboflow_workflows_plugin.integrations.visual_search_classifier.v1 import ( BlockManifest as VisualSearchClassifierV1Manifest, ) from inference.core.workflows.prototypes.block import roboflow_platform_project diff --git a/tests/workflows/unit_tests/core_steps/integrations/roboflow/visual_search/test_helpers.py b/tests/workflows/unit_tests/core_steps/integrations/roboflow/visual_search/test_helpers.py index 2cbbcb7b68..3adbac5ba3 100644 --- a/tests/workflows/unit_tests/core_steps/integrations/roboflow/visual_search/test_helpers.py +++ b/tests/workflows/unit_tests/core_steps/integrations/roboflow/visual_search/test_helpers.py @@ -2,7 +2,7 @@ import numpy as np -from inference.core.workflows.core_steps.integrations.roboflow.visual_search.helpers import ( +from inference.roboflow_workflows_plugin.integrations.visual_search.helpers import ( build_visual_search_candidate_image, format_visual_search_candidate, ) diff --git a/tests/workflows/unit_tests/core_steps/integrations/roboflow/visual_search/test_v1.py b/tests/workflows/unit_tests/core_steps/integrations/roboflow/visual_search/test_v1.py index 6121aa7b7b..310e0f1af1 100644 --- a/tests/workflows/unit_tests/core_steps/integrations/roboflow/visual_search/test_v1.py +++ b/tests/workflows/unit_tests/core_steps/integrations/roboflow/visual_search/test_v1.py @@ -7,8 +7,8 @@ serialise_image, serialize_wildcard_kind, ) -from inference.core.workflows.core_steps.integrations.roboflow.visual_search import v1 -from inference.core.workflows.core_steps.integrations.roboflow.visual_search.v1 import ( +from inference.roboflow_workflows_plugin.integrations.visual_search import v1 +from inference.roboflow_workflows_plugin.integrations.visual_search.v1 import ( BlockManifest, RoboflowVisualSearchBlockV1, ) diff --git a/tests/workflows/unit_tests/core_steps/integrations/roboflow/visual_search_classifier/test_classification_annotations.py b/tests/workflows/unit_tests/core_steps/integrations/roboflow/visual_search_classifier/test_classification_annotations.py index 5090e26ea8..3966ac9e9d 100644 --- a/tests/workflows/unit_tests/core_steps/integrations/roboflow/visual_search_classifier/test_classification_annotations.py +++ b/tests/workflows/unit_tests/core_steps/integrations/roboflow/visual_search_classifier/test_classification_annotations.py @@ -1,4 +1,4 @@ -from inference.core.workflows.core_steps.integrations.roboflow.visual_search_classifier.classification_annotations import ( +from inference.roboflow_workflows_plugin.integrations.visual_search_classifier.classification_annotations import ( parse_classification_annotation, parse_visual_search_classification, ) diff --git a/tests/workflows/unit_tests/core_steps/integrations/roboflow/visual_search_classifier/test_v1.py b/tests/workflows/unit_tests/core_steps/integrations/roboflow/visual_search_classifier/test_v1.py index 75671f334f..041ac84c76 100644 --- a/tests/workflows/unit_tests/core_steps/integrations/roboflow/visual_search_classifier/test_v1.py +++ b/tests/workflows/unit_tests/core_steps/integrations/roboflow/visual_search_classifier/test_v1.py @@ -11,10 +11,10 @@ from inference.core.workflows.core_steps.common.query_language.operations.core import ( execute_operations, ) -from inference.core.workflows.core_steps.integrations.roboflow.visual_search_classifier import ( +from inference.roboflow_workflows_plugin.integrations.visual_search_classifier import ( v1, ) -from inference.core.workflows.core_steps.integrations.roboflow.visual_search_classifier.v1 import ( +from inference.roboflow_workflows_plugin.integrations.visual_search_classifier.v1 import ( BlockManifest, RoboflowVisualSearchClassifierBlockV1, ) diff --git a/tests/workflows/unit_tests/core_steps/sinks/roboflow/asset_library_attributes/test_v1.py b/tests/workflows/unit_tests/core_steps/sinks/roboflow/asset_library_attributes/test_v1.py index 3e650ea74c..454c6ab685 100644 --- a/tests/workflows/unit_tests/core_steps/sinks/roboflow/asset_library_attributes/test_v1.py +++ b/tests/workflows/unit_tests/core_steps/sinks/roboflow/asset_library_attributes/test_v1.py @@ -5,10 +5,10 @@ import pytest from inference.core.cache import MemoryCache -from inference.core.workflows.core_steps.sinks.roboflow.asset_library_attributes import ( +from inference.roboflow_workflows_plugin.sinks.asset_library_attributes import ( v1, ) -from inference.core.workflows.core_steps.sinks.roboflow.asset_library_attributes.v1 import ( +from inference.roboflow_workflows_plugin.sinks.asset_library_attributes.v1 import ( SKIPPED_EMPTY_UPDATE_MESSAGE, UPDATE_SUCCESS_MESSAGE, BlockManifest, diff --git a/tests/workflows/unit_tests/core_steps/sinks/roboflow/roboflow_dataset_upload/test_v1.py b/tests/workflows/unit_tests/core_steps/sinks/roboflow/roboflow_dataset_upload/test_v1.py index 2bc4612c0d..3b7c749b0d 100644 --- a/tests/workflows/unit_tests/core_steps/sinks/roboflow/roboflow_dataset_upload/test_v1.py +++ b/tests/workflows/unit_tests/core_steps/sinks/roboflow/roboflow_dataset_upload/test_v1.py @@ -12,11 +12,11 @@ from inference.core.cache import MemoryCache from inference.core.env import ENABLE_TENSOR_DATA_REPRESENTATION -from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload import ( +from inference.roboflow_workflows_plugin.sinks.dataset_upload import ( v1, v1_tensor, ) -from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload.v1 import ( +from inference.roboflow_workflows_plugin.sinks.dataset_upload.v1 import ( BatchCreationFrequency, RoboflowDatasetUploadBlockV1, encode_prediction, @@ -26,10 +26,10 @@ is_prediction_registration_forbidden, register_datapoint, ) -from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload.v1_tensor import ( +from inference.roboflow_workflows_plugin.sinks.dataset_upload.v1_tensor import ( RoboflowDatasetUploadBlockV1 as TensorRoboflowDatasetUploadBlockV1, ) -from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload.v1_tensor import ( +from inference.roboflow_workflows_plugin.sinks.dataset_upload.v1_tensor import ( execute_registration as tensor_execute_registration, ) from inference.core.workflows.execution_engine.entities.base import ( diff --git a/tests/workflows/unit_tests/core_steps/sinks/roboflow/roboflow_dataset_upload/test_v2.py b/tests/workflows/unit_tests/core_steps/sinks/roboflow/roboflow_dataset_upload/test_v2.py index 72fa643d5d..881aa1a857 100644 --- a/tests/workflows/unit_tests/core_steps/sinks/roboflow/roboflow_dataset_upload/test_v2.py +++ b/tests/workflows/unit_tests/core_steps/sinks/roboflow/roboflow_dataset_upload/test_v2.py @@ -9,13 +9,13 @@ from inference.core.cache import MemoryCache from inference.core.env import ENABLE_TENSOR_DATA_REPRESENTATION -from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload import v2 -from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload.v2 import ( +from inference.roboflow_workflows_plugin.sinks.dataset_upload import v2 +from inference.roboflow_workflows_plugin.sinks.dataset_upload.v2 import ( BlockManifest, RoboflowDatasetUploadBlockV2, maybe_register_datapoint_at_roboflow, ) -from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload.v2_tensor import ( +from inference.roboflow_workflows_plugin.sinks.dataset_upload.v2_tensor import ( RoboflowDatasetUploadBlockV2 as TensorRoboflowDatasetUploadBlockV2, ) from inference.core.workflows.execution_engine.entities.base import ( diff --git a/tests/workflows/unit_tests/core_steps/sinks/roboflow/test_model_monitoring_inference_aggregator.py b/tests/workflows/unit_tests/core_steps/sinks/roboflow/test_model_monitoring_inference_aggregator.py index 858641b74f..d567898386 100644 --- a/tests/workflows/unit_tests/core_steps/sinks/roboflow/test_model_monitoring_inference_aggregator.py +++ b/tests/workflows/unit_tests/core_steps/sinks/roboflow/test_model_monitoring_inference_aggregator.py @@ -7,14 +7,14 @@ from fastapi import BackgroundTasks from inference.core.cache import MemoryCache -from inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1 import ( +from inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1 import ( ModelMonitoringInferenceAggregatorBlockV1, ) @patch("inference.core.roboflow_api.send_inference_results_to_model_monitoring") @patch( - "inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1.get_roboflow_workspace" + "inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1.get_roboflow_workspace" ) def test_run_not_in_reporting_range_success( get_roboflow_workspace_mock: MagicMock, @@ -72,10 +72,10 @@ def test_run_not_in_reporting_range_success( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1.send_inference_results_to_model_monitoring" + "inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1.send_inference_results_to_model_monitoring" ) @patch( - "inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1.get_roboflow_workspace" + "inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1.get_roboflow_workspace" ) def test_run_in_reporting_range_success_with_object_detection( get_roboflow_workspace_mock: MagicMock, @@ -177,10 +177,10 @@ def test_run_in_reporting_range_success_with_object_detection( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1.send_inference_results_to_model_monitoring" + "inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1.send_inference_results_to_model_monitoring" ) @patch( - "inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1.get_roboflow_workspace" + "inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1.get_roboflow_workspace" ) def test_run_in_reporting_range_success_with_single_label_classification( get_roboflow_workspace_mock: MagicMock, @@ -263,10 +263,10 @@ def test_run_in_reporting_range_success_with_single_label_classification( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1.send_inference_results_to_model_monitoring" + "inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1.send_inference_results_to_model_monitoring" ) @patch( - "inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1.get_roboflow_workspace" + "inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1.get_roboflow_workspace" ) def test_run_in_reporting_range_success_with_multi_label_classification( get_roboflow_workspace_mock: MagicMock, @@ -369,10 +369,10 @@ def test_run_in_reporting_range_success_with_multi_label_classification( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1.send_inference_results_to_model_monitoring" + "inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1.send_inference_results_to_model_monitoring" ) @patch( - "inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1.get_roboflow_workspace" + "inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1.get_roboflow_workspace" ) def test_send_inference_results_to_model_monitoring_failure( get_roboflow_workspace_mock: MagicMock, @@ -434,7 +434,7 @@ def test_send_inference_results_to_model_monitoring_failure( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1.get_roboflow_workspace" + "inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1.get_roboflow_workspace" ) @patch("inference.core.roboflow_api.send_inference_results_to_model_monitoring") def test_run_when_not_in_reporting_range( @@ -497,7 +497,7 @@ def test_run_when_not_in_reporting_range( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1.get_roboflow_workspace" + "inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1.get_roboflow_workspace" ) @patch("inference.core.roboflow_api.send_inference_results_to_model_monitoring") def test_run_when_fire_and_forget_with_background_tasks( @@ -563,7 +563,7 @@ def test_run_when_fire_and_forget_with_background_tasks( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1.get_roboflow_workspace" + "inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1.get_roboflow_workspace" ) @patch("inference.core.roboflow_api.send_inference_results_to_model_monitoring") def test_run_when_fire_and_forget_with_thread_pool( diff --git a/tests/workflows/unit_tests/core_steps/sinks/roboflow/test_roboflow_custom_metadata.py b/tests/workflows/unit_tests/core_steps/sinks/roboflow/test_roboflow_custom_metadata.py index e5b86f05ee..7776f5e759 100644 --- a/tests/workflows/unit_tests/core_steps/sinks/roboflow/test_roboflow_custom_metadata.py +++ b/tests/workflows/unit_tests/core_steps/sinks/roboflow/test_roboflow_custom_metadata.py @@ -8,7 +8,7 @@ from fastapi import BackgroundTasks from inference.core.cache import MemoryCache -from inference.core.workflows.core_steps.sinks.roboflow.custom_metadata.v1 import ( +from inference.roboflow_workflows_plugin.sinks.custom_metadata.v1 import ( RoboflowCustomMetadataBlockV1, add_custom_metadata_request, get_workspace_name, @@ -34,7 +34,7 @@ def test_get_workspace_name_when_cache_contains_workspace_name() -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.custom_metadata.v1.get_roboflow_workspace" + "inference.roboflow_workflows_plugin.sinks.custom_metadata.v1.get_roboflow_workspace" ) def test_get_workspace_name_when_cache_does_not_contain_workspace_name( get_roboflow_workspace_mock: MagicMock, @@ -60,7 +60,7 @@ def test_get_workspace_name_when_cache_does_not_contain_workspace_name( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.custom_metadata.v1.add_custom_metadata" + "inference.roboflow_workflows_plugin.sinks.custom_metadata.v1.add_custom_metadata" ) def test_add_custom_metadata_request_success( add_custom_metadata_mock: MagicMock, @@ -97,7 +97,7 @@ def test_add_custom_metadata_request_success( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.custom_metadata.v1.add_custom_metadata" + "inference.roboflow_workflows_plugin.sinks.custom_metadata.v1.add_custom_metadata" ) def test_add_custom_metadata_request_failure( add_custom_metadata_mock: MagicMock, @@ -182,7 +182,7 @@ def test_run_when_no_inference_ids() -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.custom_metadata.v1.add_custom_metadata_request" + "inference.roboflow_workflows_plugin.sinks.custom_metadata.v1.add_custom_metadata_request" ) def test_run_when_fire_and_forget_with_background_tasks( add_custom_metadata_request_mock: MagicMock, @@ -222,7 +222,7 @@ def test_run_when_fire_and_forget_with_background_tasks( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.custom_metadata.v1.add_custom_metadata_request" + "inference.roboflow_workflows_plugin.sinks.custom_metadata.v1.add_custom_metadata_request" ) def test_run_with_classification_results( add_custom_metadata_request_mock: MagicMock, @@ -258,7 +258,7 @@ def test_run_with_classification_results( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.custom_metadata.v1.add_custom_metadata_request" + "inference.roboflow_workflows_plugin.sinks.custom_metadata.v1.add_custom_metadata_request" ) def test_run_with_classification_results_when_inference_id_is_not_given( add_custom_metadata_request_mock: MagicMock, @@ -295,7 +295,7 @@ def test_run_with_classification_results_when_inference_id_is_not_given( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.custom_metadata.v1.add_custom_metadata_request" + "inference.roboflow_workflows_plugin.sinks.custom_metadata.v1.add_custom_metadata_request" ) def test_run_when_fire_and_forget_with_thread_pool( add_custom_metadata_request_mock: MagicMock, @@ -335,7 +335,7 @@ def test_run_when_fire_and_forget_with_thread_pool( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.custom_metadata.v1.add_custom_metadata_request" + "inference.roboflow_workflows_plugin.sinks.custom_metadata.v1.add_custom_metadata_request" ) def test_run_when_not_fire_and_forget( add_custom_metadata_request_mock: MagicMock, @@ -375,7 +375,7 @@ def test_run_when_not_fire_and_forget( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.custom_metadata.v1.add_custom_metadata_request" + "inference.roboflow_workflows_plugin.sinks.custom_metadata.v1.add_custom_metadata_request" ) def test_run_with_field_value( add_custom_metadata_request_mock: MagicMock, diff --git a/tests/workflows/unit_tests/core_steps/sinks/roboflow/vision_events/test_v1.py b/tests/workflows/unit_tests/core_steps/sinks/roboflow/vision_events/test_v1.py index 35f0361769..0956b910af 100644 --- a/tests/workflows/unit_tests/core_steps/sinks/roboflow/vision_events/test_v1.py +++ b/tests/workflows/unit_tests/core_steps/sinks/roboflow/vision_events/test_v1.py @@ -5,8 +5,8 @@ import pytest import supervision as sv -from inference.core.workflows.core_steps.sinks.roboflow.vision_events import v1 -from inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1 import ( +from inference.roboflow_workflows_plugin.sinks.vision_events import v1 +from inference.roboflow_workflows_plugin.sinks.vision_events.v1 import ( BlockManifest, RoboflowVisionEventsBlockV1, _build_event_data, @@ -417,7 +417,7 @@ def test_build_event_payload_minimal() -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1.requests.post" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1.requests.post" ) def test_upload_image_success(mock_post: MagicMock) -> None: mock_response = MagicMock() @@ -442,7 +442,7 @@ def test_upload_image_success(mock_post: MagicMock) -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1.requests.post" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1.requests.post" ) def test_upload_image_failure(mock_post: MagicMock) -> None: import requests @@ -505,7 +505,7 @@ def test_run_disabled() -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._execute_vision_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._execute_vision_event" ) def test_run_fire_and_forget_background_tasks(mock_execute: MagicMock) -> None: background_tasks = MagicMock() @@ -532,7 +532,7 @@ def test_run_fire_and_forget_background_tasks(mock_execute: MagicMock) -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._execute_vision_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._execute_vision_event" ) def test_run_fire_and_forget_thread_pool(mock_execute: MagicMock) -> None: thread_pool = MagicMock() @@ -559,7 +559,7 @@ def test_run_fire_and_forget_thread_pool(mock_execute: MagicMock) -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._execute_vision_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._execute_vision_event" ) def test_run_synchronous(mock_execute: MagicMock) -> None: mock_execute.return_value = (False, "Vision event sent successfully", "evt-123") @@ -636,7 +636,7 @@ def test_convert_predictions_to_annotations_none() -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._execute_local_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._execute_local_event" ) def test_run_write_to_event_store_does_not_require_api_key( mock_execute: MagicMock, @@ -671,7 +671,7 @@ def test_run_write_to_event_store_does_not_require_api_key( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._execute_local_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._execute_local_event" ) def test_run_write_to_event_store_passes_url(mock_execute: MagicMock) -> None: mock_execute.return_value = (False, "ok", "") @@ -697,7 +697,7 @@ def test_run_write_to_event_store_passes_url(mock_execute: MagicMock) -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._send_local_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._send_local_event" ) def test_execute_local_event_builds_v2_payload(mock_send: MagicMock) -> None: mock_send.return_value = (False, "ok", "evt-local-42") @@ -738,7 +738,7 @@ def test_execute_local_event_builds_v2_payload(mock_send: MagicMock) -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._send_local_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._send_local_event" ) def test_execute_local_event_no_images(mock_send: MagicMock) -> None: mock_send.return_value = (False, "ok", "") @@ -761,7 +761,7 @@ def test_execute_local_event_no_images(mock_send: MagicMock) -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._send_local_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._send_local_event" ) def test_execute_local_event_operator_feedback(mock_send: MagicMock) -> None: """operator_feedback is a valid schema in the local event store (v2 API).""" @@ -787,7 +787,7 @@ def test_execute_local_event_operator_feedback(mock_send: MagicMock) -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._send_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._send_event" ) def test_execute_vision_event_returns_generated_event_id( mock_send: MagicMock, @@ -815,7 +815,7 @@ def test_execute_vision_event_returns_generated_event_id( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._send_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._send_event" ) def test_execute_vision_event_no_event_id_on_error(mock_send: MagicMock) -> None: mock_send.return_value = (True, "boom") @@ -837,7 +837,7 @@ def test_execute_vision_event_no_event_id_on_error(mock_send: MagicMock) -> None @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1.requests.post" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1.requests.post" ) def test_send_local_event_success_no_api_key(mock_post: MagicMock) -> None: mock_response = MagicMock() @@ -862,7 +862,7 @@ def test_send_local_event_success_no_api_key(mock_post: MagicMock) -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1.requests.post" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1.requests.post" ) def test_send_local_event_sets_api_key_header(mock_post: MagicMock) -> None: mock_response = MagicMock() @@ -878,7 +878,7 @@ def test_send_local_event_sets_api_key_header(mock_post: MagicMock) -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1.requests.post" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1.requests.post" ) def test_send_local_event_backpressure_529(mock_post: MagicMock) -> None: """529 from the Event Ingestion Service is surfaced as a clear backpressure message.""" @@ -901,7 +901,7 @@ def test_send_local_event_backpressure_529(mock_post: MagicMock) -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1.requests.post" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1.requests.post" ) def test_send_local_event_http_error(mock_post: MagicMock) -> None: mock_response = MagicMock() @@ -919,7 +919,7 @@ def test_send_local_event_http_error(mock_post: MagicMock) -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1.requests.post" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1.requests.post" ) def test_send_local_event_timeout(mock_post: MagicMock) -> None: import requests @@ -1057,7 +1057,7 @@ def test_manifest_declares_cooldown_restriction() -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._execute_vision_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._execute_vision_event" ) def test_run_throttles_second_event_within_cooldown(mock_execute: MagicMock) -> None: mock_execute.return_value = (False, "Vision event sent successfully", "evt-123") @@ -1090,7 +1090,7 @@ def test_run_throttles_second_event_within_cooldown(mock_execute: MagicMock) -> @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._execute_vision_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._execute_vision_event" ) def test_run_sends_again_once_cooldown_expires(mock_execute: MagicMock) -> None: """The cooldown window reopens after cooldown_seconds, and throttled calls @@ -1129,7 +1129,7 @@ def test_run_sends_again_once_cooldown_expires(mock_execute: MagicMock) -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._execute_vision_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._execute_vision_event" ) def test_run_throttled_call_does_not_refresh_cooldown_timestamp( mock_execute: MagicMock, @@ -1162,7 +1162,7 @@ def test_run_throttled_call_does_not_refresh_cooldown_timestamp( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._execute_vision_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._execute_vision_event" ) def test_run_cooldown_zero_disables_rate_limiting(mock_execute: MagicMock) -> None: mock_execute.return_value = (False, "Vision event sent successfully", "evt-123") @@ -1192,7 +1192,7 @@ def test_run_cooldown_zero_disables_rate_limiting(mock_execute: MagicMock) -> No @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._execute_local_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._execute_local_event" ) def test_run_cooldown_applies_to_local_event_store(mock_execute: MagicMock) -> None: mock_execute.return_value = ( @@ -1227,7 +1227,7 @@ def test_run_cooldown_applies_to_local_event_store(mock_execute: MagicMock) -> N @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._execute_vision_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._execute_vision_event" ) def test_run_throttled_when_disabled_does_not_start_cooldown( mock_execute: MagicMock, @@ -1272,7 +1272,7 @@ def test_manifest_cooldown_rejects_negative_values(cooldown_seconds) -> None: @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1._execute_vision_event" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1._execute_vision_event" ) def test_run_negative_cooldown_treated_as_disabled(mock_execute: MagicMock) -> None: """Selector-resolved negative values bypass manifest validation; the block @@ -1307,7 +1307,7 @@ def test_run_negative_cooldown_treated_as_disabled(mock_execute: MagicMock) -> N @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1.requests.post" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1.requests.post" ) def test_upload_image_sends_shared_roboflow_api_headers(mock_post: MagicMock) -> None: """Batch processing authenticates with a header injected by the shared builder. @@ -1336,7 +1336,7 @@ def test_upload_image_sends_shared_roboflow_api_headers(mock_post: MagicMock) -> @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1.requests.post" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1.requests.post" ) def test_send_event_sends_shared_roboflow_api_headers(mock_post: MagicMock) -> None: mock_response = MagicMock() @@ -1366,7 +1366,7 @@ def test_send_event_sends_shared_roboflow_api_headers(mock_post: MagicMock) -> N @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1.requests.post" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1.requests.post" ) def test_configured_authorization_header_is_not_overridden( mock_post: MagicMock, @@ -1391,7 +1391,7 @@ def test_configured_authorization_header_is_not_overridden( @patch( - "inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1.requests.post" + "inference.roboflow_workflows_plugin.sinks.vision_events.v1.requests.post" ) def test_api_key_is_not_placed_in_request_url(mock_post: MagicMock) -> None: """Query strings reach proxy, gateway, and access logs; the key stays in a header.""" diff --git a/tests/workflows/unit_tests/core_steps/sinks/roboflow/vision_events_bundle/test_v1.py b/tests/workflows/unit_tests/core_steps/sinks/roboflow/vision_events_bundle/test_v1.py index a54f94af42..451d7594e1 100644 --- a/tests/workflows/unit_tests/core_steps/sinks/roboflow/vision_events_bundle/test_v1.py +++ b/tests/workflows/unit_tests/core_steps/sinks/roboflow/vision_events_bundle/test_v1.py @@ -13,11 +13,11 @@ import supervision as sv from pydantic import ValidationError -from inference.core.workflows.core_steps.sinks.roboflow import vision_events_bundle -from inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1 import ( +from inference.roboflow_workflows_plugin.sinks import vision_events_bundle +from inference.roboflow_workflows_plugin.sinks.vision_events.v1 import ( _convert_predictions_to_annotations, ) -from inference.core.workflows.core_steps.sinks.roboflow.vision_events_bundle.v1 import ( +from inference.roboflow_workflows_plugin.sinks.vision_events_bundle.v1 import ( BUNDLE_FORMAT_VERSION, MAX_ANNOTATIONS_PER_LIST, MAX_BUNDLE_SIZE_BYTES, @@ -854,7 +854,7 @@ def test_interrupted_write_leaves_no_marker_blocking_the_name( def test_publish_never_replaces_an_existing_bundle(tmp_path) -> None: # the foreground check is skipped here on purpose, so this exercises the # publish itself - the guarantee that survives a lost race - from inference.core.workflows.core_steps.sinks.roboflow.vision_events_bundle.v1 import ( + from inference.roboflow_workflows_plugin.sinks.vision_events_bundle.v1 import ( _publish_bundle, ) @@ -887,7 +887,7 @@ def test_publish_falls_back_when_hard_links_are_unavailable( def test_fallback_still_refuses_an_existing_bundle(tmp_path, monkeypatch) -> None: - from inference.core.workflows.core_steps.sinks.roboflow.vision_events_bundle.v1 import ( + from inference.roboflow_workflows_plugin.sinks.vision_events_bundle.v1 import ( _publish_bundle, ) @@ -975,10 +975,10 @@ def test_sync_write_with_native_detections_tensor_native(tmp_path) -> None: # test_sync_write_with_both_images_and_predictions import torch - from inference.core.workflows.core_steps.sinks.roboflow.vision_events.v1_tensor import ( + from inference.roboflow_workflows_plugin.sinks.vision_events.v1_tensor import ( _convert_predictions_to_annotations as tensor_convert_predictions, ) - from inference.core.workflows.core_steps.sinks.roboflow.vision_events_bundle.v1_tensor import ( + from inference.roboflow_workflows_plugin.sinks.vision_events_bundle.v1_tensor import ( VisionEventBundleSinkBlockV1 as TensorVisionEventBundleSinkBlockV1, ) from inference.core.workflows.execution_engine.constants import CLASS_NAMES_KEY diff --git a/tests/workflows/unit_tests/core_steps/sinks/test_execution_policy.py b/tests/workflows/unit_tests/core_steps/sinks/test_execution_policy.py index 08d9c48b79..6a22f5154b 100644 --- a/tests/workflows/unit_tests/core_steps/sinks/test_execution_policy.py +++ b/tests/workflows/unit_tests/core_steps/sinks/test_execution_policy.py @@ -14,11 +14,16 @@ from inference.enterprise.workflows.enterprise_blocks.sinks.PLC_modbus.v1 import ( ModbusTCPBlockV1, ) +from inference.roboflow_workflows_plugin.loader import ( + load_blocks as load_roboflow_platform_blocks, +) def _load_sink_blocks(): result = [] - for block in load_blocks() + load_enterprise_blocks(): + for block in ( + load_blocks() + load_enterprise_blocks() + load_roboflow_platform_blocks() + ): block_type = block.get_manifest().model_json_schema().get("block_type") if block_type in {"sink", "sinks"}: result.append(block) @@ -29,6 +34,24 @@ def _load_sink_blocks(): SINK_BLOCKS = _load_sink_blocks() +RELOCATED_SINK_TYPES = { + "roboflow_core/roboflow_dataset_upload@v1", + "roboflow_core/roboflow_dataset_upload@v2", + "roboflow_core/roboflow_custom_metadata@v1", + "roboflow_core/model_monitoring_inference_aggregator@v1", + "roboflow_core/asset_library_attributes@v1", + "roboflow_core/roboflow_vision_events@v1", + "roboflow_core/vision_event_bundle@v1", +} + + +def test_relocated_roboflow_sinks_are_still_in_the_policy_inventory() -> None: + covered = { + block.get_manifest().model_fields["type"].annotation.__args__[0] + for block in SINK_BLOCKS + } + assert RELOCATED_SINK_TYPES <= covered, RELOCATED_SINK_TYPES - covered + def test_sink_disabling_defaults_to_false() -> None: assert REGISTERED_INITIALIZERS["disable_sinks"] is False diff --git a/tests/workflows/unit_tests/core_steps/test_dependent_resources.py b/tests/workflows/unit_tests/core_steps/test_dependent_resources.py index 1feee1ddc1..65955fbc1e 100644 --- a/tests/workflows/unit_tests/core_steps/test_dependent_resources.py +++ b/tests/workflows/unit_tests/core_steps/test_dependent_resources.py @@ -37,15 +37,10 @@ from inference.core.workflows.core_steps.models.roboflow.object_detection.v3 import ( BlockManifest as ObjectDetectionV3Manifest, ) -from inference.core.workflows.core_steps.sinks.roboflow.dataset_upload.v2 import ( - BlockManifest as DatasetUploadV2Manifest, -) -from inference.core.workflows.core_steps.sinks.roboflow.model_monitoring_inference_aggregator.v1 import ( - BlockManifest as ModelMonitoringV1Manifest, -) from inference.core.workflows.errors import BlockInterfaceError from inference.core.workflows.execution_engine.entities.base import OutputDefinition from inference.core.workflows.execution_engine.introspection.blocks_loader import ( + load_blocks_from_plugin, load_core_workflow_blocks, ) from inference.core.workflows.execution_engine.introspection.schema_parser import ( @@ -64,6 +59,12 @@ roboflow_platform_project, third_party_model, ) +from inference.roboflow_workflows_plugin.sinks.dataset_upload.v2 import ( + BlockManifest as DatasetUploadV2Manifest, +) +from inference.roboflow_workflows_plugin.sinks.model_monitoring_inference_aggregator.v1 import ( + BlockManifest as ModelMonitoringV1Manifest, +) # --------------------------------------------------------------------------- # Envelope @@ -516,7 +517,9 @@ def _declares_resource_kind_field(manifest_class) -> bool: def test_every_block_with_resource_kind_fields_declares_dependencies() -> None: flagged_types, missing_declarations = [], [] - for block in load_core_workflow_blocks(): + for block in load_core_workflow_blocks() + load_blocks_from_plugin( + plugin_name="inference.roboflow_workflows_plugin.loader" + ): if not _declares_resource_kind_field(block.manifest_class): continue block_type = _canonical_block_type(block.manifest_class) @@ -534,6 +537,12 @@ def test_every_block_with_resource_kind_fields_declares_dependencies() -> None: "Blocks declaring roboflow_model_id / roboflow_project fields without " f"discover_dependent_resources() override: {sorted(missing_declarations)}" ) + assert { + "roboflow_core/visual_search@v1", + "roboflow_core/visual_search_classifier@v1", + "roboflow_core/roboflow_dataset_upload@v2", + "roboflow_core/model_monitoring_inference_aggregator@v1", + } <= set(flagged_types), "relocated blocks left the dependent-resources guard" # --------------------------------------------------------------------------- diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 2f55bfd964..6bc1ac63b9 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 375 +# Count: 337 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env @@ -26,13 +26,6 @@ inference/core/workflows/core_steps/formatters/vlm_as_detector/v1_tensor.py infe inference/core/workflows/core_steps/formatters/vlm_as_detector/v2_tensor.py inference.core.env inference/core/workflows/core_steps/fusion/detections_consensus/v1_tensor.py inference.core.env inference/core/workflows/core_steps/fusion/image_stack/v1.py inference.core.utils.image_utils -inference/core/workflows/core_steps/integrations/roboflow/visual_search/v1.py inference.core.roboflow_api -inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1.py inference.core.env -inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1.py inference.core.roboflow_api -inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1.py inference.core.utils.image_utils -inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1_tensor.py inference.core.roboflow_api -inference/core/workflows/core_steps/integrations/roboflow/visual_search_classifier/v1_tensor.py inference.core.utils.image_utils inference/core/workflows/core_steps/loader.py inference.core.cache inference/core/workflows/core_steps/loader.py inference.core.env inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py inference.core.env @@ -311,37 +304,6 @@ inference/core/workflows/core_steps/sinks/email_notification/v2.py inference.cor inference/core/workflows/core_steps/sinks/email_notification/v2.py inference.core.roboflow_api inference/core/workflows/core_steps/sinks/email_notification/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/sinks/local_file/v1.py inference.core.env -inference/core/workflows/core_steps/sinks/roboflow/asset_library_attributes/v1.py inference.core.roboflow_api -inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/v1.py inference.core.roboflow_api -inference/core/workflows/core_steps/sinks/roboflow/custom_metadata/v1_tensor.py inference.core.roboflow_api -inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1.py inference.core.active_learning.cache_operations -inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1.py inference.core.active_learning.core -inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1.py inference.core.active_learning.entities -inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1.py inference.core.roboflow_api -inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1_tensor.py inference.core.active_learning.cache_operations -inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1_tensor.py inference.core.active_learning.core -inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1_tensor.py inference.core.active_learning.entities -inference/core/workflows/core_steps/sinks/roboflow/dataset_upload/v1_tensor.py inference.core.roboflow_api -inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1.py inference.core.env -inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1.py inference.core.managers.metrics -inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1.py inference.core.roboflow_api -inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1.py inference.core.version -inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1_tensor.py inference.core.managers.metrics -inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1_tensor.py inference.core.roboflow_api -inference/core/workflows/core_steps/sinks/roboflow/model_monitoring_inference_aggregator/v1_tensor.py inference.core.version -inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py inference.core.env -inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py inference.core.roboflow_api -inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py inference.core.utils.image_utils -inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py inference.core.utils.requests -inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py inference.core.utils.url_utils -inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1_tensor.py inference.core.utils.image_utils -inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1_tensor.py inference.core.utils.url_utils -inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1.py inference.core.env -inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1.py inference.core.utils.image_utils -inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/sinks/roboflow/vision_events_bundle/v1_tensor.py inference.core.utils.image_utils inference/core/workflows/core_steps/sinks/twilio/sms/v2.py inference.core.exceptions inference/core/workflows/core_steps/sinks/twilio/sms/v2.py inference.core.roboflow_api inference/core/workflows/core_steps/sinks/twilio/sms/v2.py inference.core.utils.image_utils From 935a05af2f111582a5d3c50cd694aca16918e3d9 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:10:42 +0200 Subject: [PATCH 04/86] fix(workflows): round-1 review fixes for the Roboflow plugin relocation Codemod now buffers every rewrite in memory and validates the aggregate count and post-state before writing any file to disk, instead of writing each file as it goes and only failing the aggregate check afterward. env.py's WORKFLOWS_PLUGINS expansion for the Roboflow plugin now normalises (removes any existing occurrence, then prepends once) instead of only prepending when absent, so an operator who already listed the plugin anywhere in WORKFLOWS_PLUGINS still gets the required roboflow -> enterprise -> user-plugin order. CR-1 completed: the acceptance test's two probes and the plugin-blocks regression test's new-path probe now PRINT the effective tensor mode and the exact module leaf per relocated identifier as JSON, and the parent test functions assert those exact values - not just an internal child assert. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/env.py | 22 ++- scripts/phase9_move_roboflow_plugin.py | 60 ++++++-- .../core/test_enterprise_blocks_plugin.py | 39 +++++ .../core/test_roboflow_plugin_blocks.py | 56 ++++--- .../core/test_roboflow_sink_acceptance.py | 137 +++++++++++------- 5 files changed, 216 insertions(+), 98 deletions(-) diff --git a/inference/core/env.py b/inference/core/env.py index 2d3599220d..0790cac27b 100644 --- a/inference/core/env.py +++ b/inference/core/env.py @@ -1250,17 +1250,23 @@ # always part of the core block set - so there is no enable flag to honour; # the block-DISABLE policy (WORKFLOW_DISABLED_BLOCK_TYPES / _PATTERNS) is # applied inside the plugin's load_blocks(), exactly as the core loader does. -# PREPENDED after the enterprise expansion so the resulting order is -# roboflow -> enterprise -> user plugins, matching the historical -# core-then-enterprise ordering of `load_workflow_blocks()`. +# NORMALISED (not just prepended-if-absent) after the enterprise expansion: +# any occurrence already in WORKFLOWS_PLUGINS - wherever it sits, e.g. because +# an operator listed it explicitly - is removed and the plugin is prepended +# exactly once, so the resulting order is always roboflow -> enterprise -> +# user plugins, matching the historical core-then-enterprise ordering of +# `load_workflow_blocks()`. Only prepending when absent would leave an +# explicitly-listed entry wherever the operator put it (e.g. after enterprise, +# or after a custom plugin), silently violating that order. ROBOFLOW_BLOCKS_PLUGIN = "inference.roboflow_workflows_plugin.loader" _workflows_plugins = [ - plugin for plugin in os.getenv("WORKFLOWS_PLUGINS", "").split(",") if plugin + plugin + for plugin in os.getenv("WORKFLOWS_PLUGINS", "").split(",") + if plugin and plugin != ROBOFLOW_BLOCKS_PLUGIN ] -if ROBOFLOW_BLOCKS_PLUGIN not in _workflows_plugins: - os.environ["WORKFLOWS_PLUGINS"] = ",".join( - [ROBOFLOW_BLOCKS_PLUGIN] + _workflows_plugins - ) +os.environ["WORKFLOWS_PLUGINS"] = ",".join( + [ROBOFLOW_BLOCKS_PLUGIN] + _workflows_plugins +) TRANSIENT_ROBOFLOW_API_ERRORS = set( int(e) diff --git a/scripts/phase9_move_roboflow_plugin.py b/scripts/phase9_move_roboflow_plugin.py index b9eaeaaf75..4804d4bd69 100644 --- a/scripts/phase9_move_roboflow_plugin.py +++ b/scripts/phase9_move_roboflow_plugin.py @@ -3,9 +3,15 @@ Two prefix rewrites, applied to the moved sources (they import each other) and to every test that names them. Order-independent: if another phase already repointed some references the count is lower, and `--expected` is the number -the caller measured immediately before. Every rewritten file is re-parsed with -`ast` before it is written, and the post-state (no stale prefix anywhere) is -verified after every write. +the caller measured immediately before. + +Every rewrite is computed fully in memory first: the original source is +parsed with `ast` (to catch a file that was already broken before we touched +it), the rewritten source is parsed too, and the aggregate `--expected` count +plus the post-state (no stale prefix anywhere) are both validated against the +in-memory results. Only once all of that passes does the script write +anything to disk - a count mismatch or a parse failure never leaves any file +half-rewritten. """ import argparse @@ -25,7 +31,14 @@ ) -def rewrite(path: pathlib.Path) -> int: +def compute_rewrite(path: pathlib.Path): + """Compute the rewritten text for `path` without writing anything. + + Returns `(hits, updated)`. `updated` is `None` when `hits == 0` (nothing + to do). Both the original and the rewritten source are parsed with `ast` + so a syntax problem - pre-existing or introduced by the rewrite - is + caught before any file on disk is touched. + """ original = path.read_text(encoding="utf-8") updated = original hits = 0 @@ -33,14 +46,18 @@ def rewrite(path: pathlib.Path) -> int: hits += updated.count(old) updated = updated.replace(old, new) if not hits: - return 0 + return 0, None + try: + ast.parse(original) + except SyntaxError as error: + print(f"FAIL: {path} original source would not parse: {error}", file=sys.stderr) + raise SystemExit(2) try: ast.parse(updated) except SyntaxError as error: - print(f"FAIL: {path} would not parse: {error}", file=sys.stderr) + print(f"FAIL: {path} would not parse after rewrite: {error}", file=sys.stderr) raise SystemExit(2) - path.write_text(updated, encoding="utf-8") - return hits + return hits, updated def main() -> int: @@ -48,27 +65,40 @@ def main() -> int: parser.add_argument("roots", nargs="+") parser.add_argument("--expected", type=int, required=True) args = parser.parse_args() - total, touched, remaining = 0, [], [] + + total, touched, remaining, pending_writes = 0, [], [], [] for root in args.roots: base = pathlib.Path(root) files = [base] if base.is_file() else sorted(base.rglob("*.py")) for path in files: if "__pycache__" in path.parts: continue - hits = rewrite(path) - if hits: - total += hits - touched.append((str(path), hits)) - text = path.read_text(encoding="utf-8") - if any(old in text for old, _ in REPLACEMENTS): + hits, updated = compute_rewrite(path) + if not hits: + continue + total += hits + touched.append((str(path), hits)) + pending_writes.append((path, updated)) + if any(old in updated for old, _ in REPLACEMENTS): remaining.append(str(path)) + for name, hits in touched: print(f"{hits:4d} {name}") print(f"TOTAL {total} replacements in {len(touched)} files") print(f"POST-STATE stale references remaining: {len(remaining)} {remaining}") + if total != args.expected or remaining: print("FAIL", file=sys.stderr) return 1 + + if not pending_writes: + print("SKIP: no-op, nothing to write") + return 0 + + # Validation above passed for the aggregate and every individual file - + # only now do we write, and only the files that actually changed. + for path, updated in pending_writes: + path.write_text(updated, encoding="utf-8") return 0 diff --git a/tests/inference/unit_tests/core/test_enterprise_blocks_plugin.py b/tests/inference/unit_tests/core/test_enterprise_blocks_plugin.py index 8d400f1694..061dbdcebc 100644 --- a/tests/inference/unit_tests/core/test_enterprise_blocks_plugin.py +++ b/tests/inference/unit_tests/core/test_enterprise_blocks_plugin.py @@ -163,3 +163,42 @@ def test_env_expansion_does_not_duplicate_an_already_listed_roboflow_plugin() -> if identifier.startswith("inference.roboflow_workflows_plugin.") ] assert len(roboflow_blocks) == len(set(roboflow_blocks)) == 9 + + +def test_env_expansion_moves_an_already_listed_roboflow_plugin_ahead_of_enterprise() -> ( + None +): + # An operator who already listed the roboflow plugin explicitly must not + # end up with it stuck behind the enterprise expansion: normalisation + # removes it from wherever it sits and prepends it exactly once. + report = _run_probe( + PROBE, + LOAD_ENTERPRISE_BLOCKS="True", + WORKFLOWS_PLUGINS=ROBOFLOW_PLUGIN, + ) + assert report["plugins"] == f"{ROBOFLOW_PLUGIN},{ENTERPRISE_PLUGIN}" + + +def test_env_expansion_moves_roboflow_ahead_of_enterprise_and_a_custom_plugin() -> None: + # Same normalisation, this time with the roboflow plugin listed AFTER a + # custom plugin - the custom plugin's relative position must be preserved + # (it still follows enterprise), only roboflow moves to the front. + report = _run_probe( + ORDERING_PROBE, + LOAD_ENTERPRISE_BLOCKS="True", + WORKFLOWS_PLUGINS=f"{FAKE_PLUGIN},{ROBOFLOW_PLUGIN}", + ) + assert report["plugins"] == f"{ROBOFLOW_PLUGIN},{ENTERPRISE_PLUGIN},{FAKE_PLUGIN}" + + identifiers = [identifier for identifier, _ in report["blocks"]] + roboflow_positions = [ + index + for index, identifier in enumerate(identifiers) + if identifier.startswith("inference.roboflow_workflows_plugin.") + ] + assert len(roboflow_positions) == 9 + mqtt_position = identifiers.index(MQTT_BLOCK) + fake_position = identifiers.index(FAKE_BLOCK) + # The 9 roboflow blocks precede the enterprise (MQTT) block, which in turn + # precedes the custom plugin's block. + assert max(roboflow_positions) < mqtt_position < fake_position diff --git a/tests/inference/unit_tests/core/test_roboflow_plugin_blocks.py b/tests/inference/unit_tests/core/test_roboflow_plugin_blocks.py index 64ea6bbd6d..f304bbf92f 100644 --- a/tests/inference/unit_tests/core/test_roboflow_plugin_blocks.py +++ b/tests/inference/unit_tests/core/test_roboflow_plugin_blocks.py @@ -165,6 +165,14 @@ def test_plugin_load_blocks_honours_the_disable_policy( assert loaded == expected +# The 2 identifiers that survive `WORKFLOW_DISABLED_BLOCK_PATTERNS= +# "roboflow_workflows_plugin.sinks"`: visual_search@v1 has no _tensor sibling +# (its module leaf is always "v1"); visual_search_classifier@v1 does. +_SURVIVOR_EXPECTED_LEAF = { + "roboflow_core/visual_search@v1": {"False": "v1", "True": "v1"}, + "roboflow_core/visual_search_classifier@v1": {"False": "v1", "True": "v1_tensor"}, +} + NEW_PATH_PROBE = """ import json import os @@ -174,32 +182,28 @@ def test_plugin_load_blocks_honours_the_disable_policy( ) import inference.core.env as env_module -# CR-1: prove the child actually landed in the requested tensor mode. -requested_tensor_mode = os.environ["ENABLE_TENSOR_DATA_REPRESENTATION"] == "True" -assert env_module.ENABLE_TENSOR_DATA_REPRESENTATION == requested_tensor_mode, ( - env_module.ENABLE_TENSOR_DATA_REPRESENTATION, - requested_tensor_mode, -) - survivors = { b.manifest_type_identifier: b.fully_qualified_block_class_name for b in describe_available_blocks(dynamic_blocks=[]).blocks if b.fully_qualified_block_class_name.startswith("inference.roboflow_workflows_plugin.") } +# CR-1: report - do not just self-assert - the effective tensor flag and the +# exact module leaf selected for each surviving identifier, so the PARENT can +# independently verify them (mirrors test_roboflow_sink_acceptance.py). +module_leaf_by_identifier = { + identifier: module_name.rsplit(".", 1)[0].rsplit(".", 1)[-1] + for identifier, module_name in survivors.items() +} -# CR-1: of the two relocated blocks that survive this pattern, one -# (visual_search_classifier) has a _tensor sibling and one (visual_search) -# does not - assert the tensor-mode branch actually selected the right module -# for each, the same way the acceptance test's sink-workflow probe does. -classifier_module = survivors["roboflow_core/visual_search_classifier@v1"] -classifier_leaf = classifier_module.rsplit(".", 1)[0].rsplit(".", 1)[-1] -assert classifier_leaf.endswith("_tensor") == requested_tensor_mode, classifier_module - -visual_search_module = survivors["roboflow_core/visual_search@v1"] -visual_search_leaf = visual_search_module.rsplit(".", 1)[0].rsplit(".", 1)[-1] -assert visual_search_leaf == "v1", visual_search_module - -print(json.dumps(sorted(survivors))) +print( + json.dumps( + { + "identifiers": sorted(survivors), + "effective_tensor_mode": env_module.ENABLE_TENSOR_DATA_REPRESENTATION, + "module_leaf_by_identifier": module_leaf_by_identifier, + } + ) +) """ @@ -224,6 +228,12 @@ def test_new_module_path_pattern_disables_the_sinks_in_both_modes(tensor_mode) - [sys.executable, "-c", NEW_PATH_PROBE], env=env, capture_output=True, text=True ) assert result.returncode == 0, result.stderr - assert json.loads(result.stdout.strip().splitlines()[-1]) == sorted( - RELOCATED_BLOCKS - RELOCATED_SINKS - ) + survivor_identifiers = sorted(RELOCATED_BLOCKS - RELOCATED_SINKS) + assert json.loads(result.stdout.strip().splitlines()[-1]) == { + "identifiers": survivor_identifiers, + "effective_tensor_mode": tensor_mode == "True", + "module_leaf_by_identifier": { + identifier: _SURVIVOR_EXPECTED_LEAF[identifier][tensor_mode] + for identifier in survivor_identifiers + }, + } diff --git a/tests/inference/unit_tests/core/test_roboflow_sink_acceptance.py b/tests/inference/unit_tests/core/test_roboflow_sink_acceptance.py index ece0c4b229..72c9779dca 100644 --- a/tests/inference/unit_tests/core/test_roboflow_sink_acceptance.py +++ b/tests/inference/unit_tests/core/test_roboflow_sink_acceptance.py @@ -28,11 +28,13 @@ USE_INFERENCE_MODELS` (inference/core/env.py:1486), so a subprocess builder that sets only the former can silently run NumPy in both "modes" if it inherits `USE_INFERENCE_MODELS=False` from the shell. `_run()` below pins -`USE_INFERENCE_MODELS=True` and every child asserts, from inside the child, -that `inference.core.env.ENABLE_TENSOR_DATA_REPRESENTATION` equals the -requested mode. The sink-workflow probe additionally asserts which module was -actually selected for the 7 blocks with a `_tensor` sibling, and that the 2 -blocks with no sibling resolve to the same module in both modes. +`USE_INFERENCE_MODELS=True`. Every child PRINTS - as part of its JSON output, +not just an internal `assert` - the effective +`inference.core.env.ENABLE_TENSOR_DATA_REPRESENTATION` and, for every +relocated identifier it can still see, the exact module leaf that was +selected (`v1`/`v1_tensor`/`v2`/`v2_tensor`). The PARENT test functions then +assert those values against `_expected_module_leaf()` below, so the check +does not depend solely on the child trusting itself. """ import json @@ -45,6 +47,35 @@ REPO_ROOT = pathlib.Path(__file__).resolve().parents[4] +RELOCATED_IDENTIFIERS = [ + "roboflow_core/asset_library_attributes@v1", + "roboflow_core/model_monitoring_inference_aggregator@v1", + "roboflow_core/roboflow_custom_metadata@v1", + "roboflow_core/roboflow_dataset_upload@v1", + "roboflow_core/roboflow_dataset_upload@v2", + "roboflow_core/roboflow_vision_events@v1", + "roboflow_core/vision_event_bundle@v1", + "roboflow_core/visual_search@v1", + "roboflow_core/visual_search_classifier@v1", +] + +# The 2 blocks with no `_tensor` sibling - their module leaf is the same +# regardless of tensor mode. +UNPAIRED_IDENTIFIERS = { + "roboflow_core/asset_library_attributes@v1", + "roboflow_core/visual_search@v1", +} + + +def _expected_module_leaf(identifier: str, tensor_mode: bool) -> str: + """The exact module leaf (`v1`/`v1_tensor`/`v2`/`v2_tensor`) a relocated + identifier must resolve to under the given tensor mode.""" + base = "v2" if identifier == "roboflow_core/roboflow_dataset_upload@v2" else "v1" + if identifier in UNPAIRED_IDENTIFIERS or not tensor_mode: + return base + return f"{base}_tensor" + + WORKFLOW = { "version": "1.3.0", "inputs": [ @@ -90,6 +121,7 @@ ) WORKFLOW = json.loads(%s) +RELOCATED_IDENTIFIERS = json.loads(%s) class SharedCache: @@ -125,42 +157,22 @@ def set(self, key, value, expire=None): } ) -# CR-1: assert which module the tensor-mode branch actually selected for the -# 7 blocks with a _tensor sibling, and that the 2 blocks with no sibling -# resolve to the same module regardless of mode. -PAIRED_IDENTIFIERS = { - "roboflow_core/model_monitoring_inference_aggregator@v1", - "roboflow_core/roboflow_custom_metadata@v1", - "roboflow_core/roboflow_dataset_upload@v1", - "roboflow_core/roboflow_dataset_upload@v2", - "roboflow_core/roboflow_vision_events@v1", - "roboflow_core/vision_event_bundle@v1", - "roboflow_core/visual_search_classifier@v1", -} -UNPAIRED_IDENTIFIERS = { - "roboflow_core/asset_library_attributes@v1", - "roboflow_core/visual_search@v1", -} +# CR-1: report - do not just self-assert - the module leaf actually selected +# for every relocated identifier, and the effective tensor flag, so the +# PARENT can independently verify them. modules_by_identifier = { b.manifest_type_identifier: b.fully_qualified_block_class_name for b in describe_available_blocks(dynamic_blocks=[]).blocks - if b.manifest_type_identifier in PAIRED_IDENTIFIERS | UNPAIRED_IDENTIFIERS + if b.manifest_type_identifier in RELOCATED_IDENTIFIERS } -assert ( - set(modules_by_identifier) == PAIRED_IDENTIFIERS | UNPAIRED_IDENTIFIERS -), modules_by_identifier +assert set(modules_by_identifier) == set(RELOCATED_IDENTIFIERS), modules_by_identifier # `fully_qualified_block_class_name` is "."; the # module's own leaf (v1 / v1_tensor / v2 / v2_tensor) is the second-to-last # dotted segment, not the last (which is the class name). -for identifier in PAIRED_IDENTIFIERS: - module_name = modules_by_identifier[identifier] - module_leaf = module_name.split(".")[-2] - is_tensor_module = module_leaf.endswith("_tensor") - assert is_tensor_module == requested_tensor_mode, (identifier, module_name) -for identifier in UNPAIRED_IDENTIFIERS: - module_name = modules_by_identifier[identifier] - module_leaf = module_name.split(".")[-2] - assert module_leaf == "v1", (identifier, module_name) +module_leaf_by_identifier = { + identifier: modules_by_identifier[identifier].split(".")[-2] + for identifier in RELOCATED_IDENTIFIERS +} print( json.dumps( @@ -168,10 +180,15 @@ def set(self, key, value, expire=None): "outputs": result, "cache_is_injected": step._cache is cache, "cache_untouched": cache.storage == {}, + "effective_tensor_mode": env_module.ENABLE_TENSOR_DATA_REPRESENTATION, + "module_leaf_by_identifier": module_leaf_by_identifier, } ) ) -""" % ("'''" + json.dumps(WORKFLOW) + "'''") +""" % ( + "'''" + json.dumps(WORKFLOW) + "'''", + "'''" + json.dumps(RELOCATED_IDENTIFIERS) + "'''", +) DISABLED_MESSAGE = "Sink was disabled by workflow execution policy" EXPECTED = { @@ -180,18 +197,6 @@ def set(self, key, value, expire=None): "cache_untouched": True, } -RELOCATED_IDENTIFIERS = [ - "roboflow_core/asset_library_attributes@v1", - "roboflow_core/model_monitoring_inference_aggregator@v1", - "roboflow_core/roboflow_custom_metadata@v1", - "roboflow_core/roboflow_dataset_upload@v1", - "roboflow_core/roboflow_dataset_upload@v2", - "roboflow_core/roboflow_vision_events@v1", - "roboflow_core/vision_event_bundle@v1", - "roboflow_core/visual_search@v1", - "roboflow_core/visual_search_classifier@v1", -] - POLICY_PROBE = """ import json import os @@ -210,9 +215,19 @@ def set(self, key, value, expire=None): ) RELOCATED = json.loads(%s) -available = { - block.manifest_type_identifier - for block in describe_available_blocks(dynamic_blocks=[]).blocks +described = describe_available_blocks(dynamic_blocks=[]).blocks +available = {block.manifest_type_identifier for block in described} +# CR-1: for whichever relocated identifiers survive discovery, report the +# exact module leaf that was selected, so the PARENT can assert it too. +modules_by_identifier = { + block.manifest_type_identifier: block.fully_qualified_block_class_name + for block in described + if block.manifest_type_identifier in RELOCATED +} +module_leaf_by_identifier = { + identifier: modules_by_identifier[identifier].split(".")[-2] + for identifier in RELOCATED + if identifier in modules_by_identifier } WORKFLOW = { "version": "1.3.0", @@ -241,6 +256,8 @@ def set(self, key, value, expire=None): { "present": sorted(i for i in RELOCATED if i in available), "asset_library_compile": outcome, + "effective_tensor_mode": env_module.ENABLE_TENSOR_DATA_REPRESENTATION, + "module_leaf_by_identifier": module_leaf_by_identifier, } ) ) @@ -311,7 +328,16 @@ def _run(probe: str, tensor_mode: str, extra_env: dict) -> dict: @pytest.mark.parametrize("tensor_mode", ["False", "True"]) def test_roboflow_sink_workflow_is_unchanged_by_the_relocation(tensor_mode) -> None: - assert _run(PROBE, tensor_mode, {}) == EXPECTED + tensor_mode_bool = tensor_mode == "True" + expected = { + **EXPECTED, + "effective_tensor_mode": tensor_mode_bool, + "module_leaf_by_identifier": { + identifier: _expected_module_leaf(identifier, tensor_mode_bool) + for identifier in RELOCATED_IDENTIFIERS + }, + } + assert _run(PROBE, tensor_mode, {}) == expected @pytest.mark.parametrize("tensor_mode", ["False", "True"]) @@ -319,7 +345,14 @@ def test_roboflow_sink_workflow_is_unchanged_by_the_relocation(tensor_mode) -> N def test_disable_policy_keeps_applying_to_the_relocated_blocks( tensor_mode, extra_env, present, compile_outcome ) -> None: - assert _run(POLICY_PROBE, tensor_mode, extra_env) == { + tensor_mode_bool = tensor_mode == "True" + expected = { "present": present, "asset_library_compile": compile_outcome, + "effective_tensor_mode": tensor_mode_bool, + "module_leaf_by_identifier": { + identifier: _expected_module_leaf(identifier, tensor_mode_bool) + for identifier in present + }, } + assert _run(POLICY_PROBE, tensor_mode, extra_env) == expected From f6cd30dbb6740cfd6414f820a9978b60cf3daa6a Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:40:09 +0200 Subject: [PATCH 05/86] refactor(workflows): replace ModelEndpointType with a local string constant All 37 uses inside Workflows were CORE_MODEL. Blocks pass the plain string and the two server functions that read endpoint_type coerce it back into the enum. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/registries/roboflow.py | 2 + inference/core/roboflow_api.py | 4 + .../core/workflows/core_steps/common/utils.py | 8 +- .../core_steps/models/foundation/clip/v1.py | 12 +-- .../models/foundation/clip/v1_tensor.py | 14 +-- .../models/foundation/clip_comparison/v1.py | 10 +-- .../foundation/clip_comparison/v1_tensor.py | 12 +-- .../models/foundation/clip_comparison/v2.py | 12 +-- .../foundation/clip_comparison/v2_tensor.py | 14 +-- .../models/foundation/easy_ocr/v1.py | 10 +-- .../models/foundation/easy_ocr/v1_tensor.py | 12 +-- .../core_steps/models/foundation/ocr/v1.py | 10 +-- .../models/foundation/ocr/v1_tensor.py | 12 +-- .../foundation/perception_encoder/v1.py | 12 +-- .../perception_encoder/v1_tensor.py | 16 ++-- .../core_steps/models/foundation/pp_ocr/v1.py | 10 +-- .../models/foundation/pp_ocr/v1_tensor.py | 10 +-- .../models/foundation/segment_anything2/v1.py | 12 +-- .../foundation/segment_anything2/v1_tensor.py | 14 +-- .../models/foundation/yolo_world/v1.py | 12 +-- .../models/foundation/yolo_world/v1_tensor.py | 8 +- .../workflows/prototypes/models_provider.py | 10 +++ scripts/phase9_endpoint_type_constant.py | 90 +++++++++++++++++++ .../core/test_endpoint_type_coercion.py | 79 ++++++++++++++++ .../core_steps/test_dependent_resources.py | 2 +- .../unit_tests/decontamination_baseline.txt | 21 +---- 26 files changed, 297 insertions(+), 131 deletions(-) create mode 100644 scripts/phase9_endpoint_type_constant.py create mode 100644 tests/inference/unit_tests/core/test_endpoint_type_coercion.py diff --git a/inference/core/registries/roboflow.py b/inference/core/registries/roboflow.py index 3cfcb32772..419081406b 100644 --- a/inference/core/registries/roboflow.py +++ b/inference/core/registries/roboflow.py @@ -356,6 +356,8 @@ def _check_if_api_key_has_access_to_model( countinference: Optional[bool] = None, service_secret: Optional[str] = None, ) -> bool: + # Same reason as in roboflow_api.get_roboflow_model_data. + endpoint_type = ModelEndpointType(endpoint_type) model_id = resolve_roboflow_model_alias(model_id=model_id) pipeline_definition = _get_model_pipeline_definition(model_id=model_id) if pipeline_definition is not None: diff --git a/inference/core/roboflow_api.py b/inference/core/roboflow_api.py index 62a7a99c56..707bdb2e49 100644 --- a/inference/core/roboflow_api.py +++ b/inference/core/roboflow_api.py @@ -594,6 +594,10 @@ def get_roboflow_model_data( countinference: Optional[bool] = None, service_secret: Optional[str] = None, ) -> dict: + # Workflow blocks pass the plain string `core_model` so they do not have to + # import this enum (prototypes/models_provider.CORE_MODEL_ENDPOINT_TYPE). + # `ModelEndpointType(member)` is the identity for real members. + endpoint_type = ModelEndpointType(endpoint_type) api_data_cache_key = f"roboflow_api_data:{endpoint_type.value}:{model_id}" api_data = None if not MODELS_CACHE_AUTH_ENABLED: diff --git a/inference/core/workflows/core_steps/common/utils.py b/inference/core/workflows/core_steps/common/utils.py index a3b6af77d3..dcbee5eec1 100644 --- a/inference/core/workflows/core_steps/common/utils.py +++ b/inference/core/workflows/core_steps/common/utils.py @@ -26,7 +26,6 @@ from inference.core.entities.requests.easy_ocr import EasyOCRInferenceRequest from inference.core.entities.requests.sam2 import Sam2InferenceRequest from inference.core.entities.requests.yolo_world import YOLOWorldInferenceRequest -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.keypoints import ( KEYPOINT_PADDING_CLASS_NAME, validate_keypoints_padding, @@ -70,7 +69,10 @@ wrap_with_context_snapshot, ) from inference.core.workflows.prototypes.block import BlockResult -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) T = TypeVar("T") @@ -93,7 +95,7 @@ def load_core_model( model_manager.add_model( core_model_id, inference_request.api_key, - endpoint_type=ModelEndpointType.CORE_MODEL, + endpoint_type=CORE_MODEL_ENDPOINT_TYPE, ) return core_model_id diff --git a/inference/core/workflows/core_steps/models/foundation/clip/v1.py b/inference/core/workflows/core_steps/models/foundation/clip/v1.py index a9531697dc..467b878444 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/clip/v1.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import load_core_model from inference.core.workflows.execution_engine.entities.base import ( @@ -34,7 +33,10 @@ is_workflow_selector, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference.core.workflows.utils.lru_cache import LRUCache from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -112,16 +114,14 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: model_id=self.version, model_id_resolver=lambda version: f"clip/{version}", model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL + "endpoint_type": CORE_MODEL_ENDPOINT_TYPE }, ) ] return [ roboflow_platform_model( model_id=f"clip/{self.version}", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] diff --git a/inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py index 6113dcf8d2..4c63ffa73e 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, @@ -33,7 +32,10 @@ is_workflow_selector, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference.core.workflows.utils.lru_cache import LRUCache from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -111,16 +113,14 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: model_id=self.version, model_id_resolver=lambda version: f"clip/{version}", model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL + "endpoint_type": CORE_MODEL_ENDPOINT_TYPE }, ) ] return [ roboflow_platform_model( model_id=f"clip/{self.version}", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] @@ -185,7 +185,7 @@ def run_locally( self._model_manager.add_model( clip_model_id, self._api_key, - endpoint_type=ModelEndpointType.CORE_MODEL, + endpoint_type=CORE_MODEL_ENDPOINT_TYPE, ) if isinstance(data, str): hash_key = hashlib.md5((version + data).encode("utf-8")).hexdigest() diff --git a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py index a76e9dcb95..e1b6ff6eea 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py @@ -12,7 +12,6 @@ WORKFLOWS_REMOTE_API_TARGET, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( load_core_model, @@ -44,7 +43,10 @@ WorkflowBlockManifest, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -120,9 +122,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: return [ roboflow_platform_model( model_id=f"clip/{CLIP_VERSION_ID}", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] diff --git a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py index 8a8e38a792..e6fa1d2824 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_API_TARGET, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( remove_unexpected_keys_from_dictionary, @@ -44,7 +43,10 @@ WorkflowBlockManifest, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -120,9 +122,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: return [ roboflow_platform_model( model_id=f"clip/{CLIP_VERSION_ID}", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] @@ -170,7 +170,7 @@ def run_locally( self._model_manager.add_model( clip_model_id, self._api_key, - endpoint_type=ModelEndpointType.CORE_MODEL, + endpoint_type=CORE_MODEL_ENDPOINT_TYPE, ) text_embeddings = F.normalize( self._model_manager.run_tensor_native_inference( diff --git a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py index cddafcf91f..1b39f97947 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py @@ -12,7 +12,6 @@ WORKFLOWS_REMOTE_API_TARGET, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( load_core_model, @@ -45,7 +44,10 @@ is_workflow_selector, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -146,16 +148,14 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: model_id=self.version, model_id_resolver=lambda version: f"clip/{version}", model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL + "endpoint_type": CORE_MODEL_ENDPOINT_TYPE }, ) ] return [ roboflow_platform_model( model_id=f"clip/{self.version}", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] diff --git a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py index 6e9a78bf6d..fc406783f0 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_API_TARGET, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.execution_engine.constants import ( @@ -48,7 +47,10 @@ is_workflow_selector, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference_models import ClassificationPrediction from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -150,16 +152,14 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: model_id=self.version, model_id_resolver=lambda version: f"clip/{version}", model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL + "endpoint_type": CORE_MODEL_ENDPOINT_TYPE }, ) ] return [ roboflow_platform_model( model_id=f"clip/{self.version}", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] @@ -209,7 +209,7 @@ def run_locally( self._model_manager.add_model( clip_model_id, self._api_key, - endpoint_type=ModelEndpointType.CORE_MODEL, + endpoint_type=CORE_MODEL_ENDPOINT_TYPE, ) class_embeddings = F.normalize( self._model_manager.run_tensor_native_inference( diff --git a/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py index 9e4ca4e825..5fdb057904 100644 --- a/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py @@ -15,7 +15,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( load_core_model, @@ -42,7 +41,10 @@ WorkflowBlockManifest, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference.core.workflows.utils.lru_cache import LRUCache from inference_sdk import InferenceHTTPClient from inference_sdk.http.entities import InferenceConfiguration @@ -170,9 +172,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: return [ roboflow_platform_model( model_id=f"easy_ocr/{version}", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] diff --git a/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py index ec42cca543..bbdfcf31a3 100644 --- a/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( attach_native_detection_metadata, @@ -41,7 +40,10 @@ WorkflowBlockManifest, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference_models.models.base.object_detection import Detections from inference_sdk import InferenceHTTPClient from inference_sdk.http.entities import InferenceConfiguration @@ -172,9 +174,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: return [ roboflow_platform_model( model_id=f"easy_ocr/{version}", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] @@ -239,7 +239,7 @@ def run_locally( self._model_manager.add_model( model_id, self._api_key, - endpoint_type=ModelEndpointType.CORE_MODEL, + endpoint_type=CORE_MODEL_ENDPOINT_TYPE, ) results = [] for single_image in images: diff --git a/inference/core/workflows/core_steps/models/foundation/ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/ocr/v1.py index e539ddb879..463d15afd8 100644 --- a/inference/core/workflows/core_steps/models/foundation/ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/ocr/v1.py @@ -16,7 +16,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( load_core_model, @@ -43,7 +42,10 @@ WorkflowBlockManifest, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -122,9 +124,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: return [ roboflow_platform_model( model_id="doctr/default", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] diff --git a/inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py index ad1fb8d136..e7fe611eec 100644 --- a/inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( attach_native_detection_metadata, @@ -40,7 +39,10 @@ WorkflowBlockManifest, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference_sdk import InferenceConfiguration, InferenceHTTPClient LONG_DESCRIPTION = """ @@ -118,9 +120,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: return [ roboflow_platform_model( model_id="doctr/default", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] @@ -166,7 +166,7 @@ def run_locally( self._model_manager.add_model( doctr_model_id, self._api_key, - endpoint_type=ModelEndpointType.CORE_MODEL, + endpoint_type=CORE_MODEL_ENDPOINT_TYPE, ) predictions = [] for single_image in images: diff --git a/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py b/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py index f47cd0fc17..940a493155 100644 --- a/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py @@ -14,7 +14,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import load_core_model from inference.core.workflows.execution_engine.entities.base import ( @@ -38,7 +37,10 @@ is_workflow_selector, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference.core.workflows.utils.lru_cache import LRUCache from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -137,16 +139,14 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: model_id=self.version, model_id_resolver=lambda version: f"perception_encoder/{version}", model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL + "endpoint_type": CORE_MODEL_ENDPOINT_TYPE }, ) ] return [ roboflow_platform_model( model_id=f"perception_encoder/{self.version}", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] diff --git a/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py index a34c23f806..a8d3c049c4 100644 --- a/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py @@ -12,7 +12,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, @@ -37,7 +36,10 @@ is_workflow_selector, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference.core.workflows.utils.lru_cache import LRUCache from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -136,16 +138,14 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: model_id=self.version, model_id_resolver=lambda version: f"perception_encoder/{version}", model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL + "endpoint_type": CORE_MODEL_ENDPOINT_TYPE }, ) ] return [ roboflow_platform_model( model_id=f"perception_encoder/{self.version}", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] @@ -203,7 +203,7 @@ def run_locally( self._model_manager.add_model( pe_model_id, self._api_key, - endpoint_type=ModelEndpointType.CORE_MODEL, + endpoint_type=CORE_MODEL_ENDPOINT_TYPE, ) embeddings = self._model_manager.run_tensor_native_inference( pe_model_id, @@ -217,7 +217,7 @@ def run_locally( self._model_manager.add_model( pe_model_id, self._api_key, - endpoint_type=ModelEndpointType.CORE_MODEL, + endpoint_type=CORE_MODEL_ENDPOINT_TYPE, ) if data.is_tensor_materialised(): model_image, image_color_format = data.tensor_image, "rgb" diff --git a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py index c2184233ad..8d0b8aa4b3 100644 --- a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py @@ -11,7 +11,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( load_core_model, @@ -38,7 +37,10 @@ WorkflowBlockManifest, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference_sdk import InferenceHTTPClient from inference_sdk.http.entities import InferenceConfiguration @@ -150,9 +152,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: return [ roboflow_platform_model( model_id=f"pp_ocr/{self.text_detection}-{self.text_recognition}", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] diff --git a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py index 65b12b9417..4611112ed8 100644 --- a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py @@ -37,7 +37,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( native_detections_from_inference_predictions, @@ -67,7 +66,10 @@ WorkflowBlockManifest, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference_models.models.base.object_detection import Detections from inference_sdk import InferenceConfiguration, InferenceHTTPClient from inference_sdk.http.entities import InferenceConfiguration @@ -179,9 +181,7 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: return [ roboflow_platform_model( model_id=f"pp_ocr/{self.text_detection}-{self.text_recognition}", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py index 1d48ff1d81..ee07da8ceb 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py @@ -24,7 +24,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -59,7 +58,10 @@ is_workflow_selector, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference_sdk import InferenceConfiguration, InferenceHTTPClient T = TypeVar("T") @@ -197,16 +199,14 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: model_id=self.version, model_id_resolver=lambda version: f"sam2/{version}", model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL + "endpoint_type": CORE_MODEL_ENDPOINT_TYPE }, ) ] return [ roboflow_platform_model( model_id=f"sam2/{self.version}", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py index 1ac1bb08b1..768dbf6e99 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, @@ -53,7 +52,10 @@ is_workflow_selector, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.types import InstancesRLEMasks from inference_models.models.common.rle_utils import ( @@ -198,16 +200,14 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: model_id=self.version, model_id_resolver=lambda version: f"sam2/{version}", model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL + "endpoint_type": CORE_MODEL_ENDPOINT_TYPE }, ) ] return [ roboflow_platform_model( model_id=f"sam2/{self.version}", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] @@ -276,7 +276,7 @@ def run_locally( ) -> BlockResult: sam_model_id = f"sam2/{version}" self._model_manager.add_model( - sam_model_id, self._api_key, endpoint_type=ModelEndpointType.CORE_MODEL + sam_model_id, self._api_key, endpoint_type=CORE_MODEL_ENDPOINT_TYPE ) boxes_iter = boxes if boxes is not None else [None] * len(images) diff --git a/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py b/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py index 25e2e06a1f..f460159bce 100644 --- a/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py @@ -10,7 +10,6 @@ WORKFLOWS_REMOTE_API_TARGET, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -41,7 +40,10 @@ is_workflow_selector, roboflow_platform_model, ) -from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.models_provider import ( + CORE_MODEL_ENDPOINT_TYPE, + ModelsProvider, +) from inference_sdk import InferenceConfiguration, InferenceHTTPClient from inference_sdk.http.utils.iterables import make_batches @@ -147,16 +149,14 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: model_id=self.version, model_id_resolver=lambda version: f"yolo_world/{version}", model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL + "endpoint_type": CORE_MODEL_ENDPOINT_TYPE }, ) ] return [ roboflow_platform_model( model_id=f"yolo_world/{self.version}", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] diff --git a/inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py index af8465b54b..d3095e4b17 100644 --- a/inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py @@ -13,7 +13,6 @@ from pydantic import ConfigDict, Field from inference.core.exceptions import FeatureDeprecatedError -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, @@ -38,6 +37,7 @@ is_workflow_selector, roboflow_platform_model, ) +from inference.core.workflows.prototypes.models_provider import CORE_MODEL_ENDPOINT_TYPE LONG_DESCRIPTION = """ **DEPRECATED.** YOLO-World is deprecated in the tensor-native Workflows pipeline. @@ -125,16 +125,14 @@ def discover_dependent_resources(self) -> Optional[List[DependentResource]]: model_id=self.version, model_id_resolver=lambda version: f"yolo_world/{version}", model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL + "endpoint_type": CORE_MODEL_ENDPOINT_TYPE }, ) ] return [ roboflow_platform_model( model_id=f"yolo_world/{self.version}", - model_registration_kwargs={ - "endpoint_type": ModelEndpointType.CORE_MODEL - }, + model_registration_kwargs={"endpoint_type": CORE_MODEL_ENDPOINT_TYPE}, ) ] diff --git a/inference/core/workflows/prototypes/models_provider.py b/inference/core/workflows/prototypes/models_provider.py index 384de34e73..4560b39a10 100644 --- a/inference/core/workflows/prototypes/models_provider.py +++ b/inference/core/workflows/prototypes/models_provider.py @@ -48,3 +48,13 @@ def get_class_names(self, model_id: str) -> List[str]: ... def __contains__(self, model_id: str) -> bool: ... def __getitem__(self, key: str) -> Any: ... + + +# The `endpoint_type` value every core-model block registers with. It is the +# string form of `inference.core.roboflow_api.ModelEndpointType.CORE_MODEL`; +# importing that enum here would pull the Roboflow API client into every model +# block. `ModelManager.add_model` forwards it untouched and the two server +# functions that read it - `roboflow_api.get_roboflow_model_data` and +# `registries.roboflow._check_if_api_key_has_access_to_model` - coerce it back +# into the enum. All 37 uses inside Workflows were CORE_MODEL. +CORE_MODEL_ENDPOINT_TYPE: str = "core_model" diff --git a/scripts/phase9_endpoint_type_constant.py b/scripts/phase9_endpoint_type_constant.py new file mode 100644 index 0000000000..444f68e144 --- /dev/null +++ b/scripts/phase9_endpoint_type_constant.py @@ -0,0 +1,90 @@ +"""Replace `ModelEndpointType.CORE_MODEL` with the workflows-local constant. + +BSD `sed` here does not honour `\\b`, so this is a Python codemod: it locates +the attribute accesses through `ast`, counts them, re-parses its own output and +verifies the post-state (no `ModelEndpointType` reference left in the file). +Idempotent: a file with no matches contributes zero and still passes. +""" + +import argparse +import ast +import pathlib +import sys + +NEW_IMPORT = ( + "from inference.core.workflows.prototypes.models_provider import (\n" + " CORE_MODEL_ENDPOINT_TYPE,\n" + ")" +) + + +def patch(path: pathlib.Path) -> tuple[int, int]: + source = path.read_text(encoding="utf-8") + newline = "\r\n" if "\r\n" in source else "\n" + lines = source.split(newline) + tree = ast.parse(source) + + usages = [] + for node in ast.walk(tree): + if ( + isinstance(node, ast.Attribute) + and node.attr == "CORE_MODEL" + and isinstance(node.value, ast.Name) + and node.value.id == "ModelEndpointType" + ): + usages.append(node) + import_span = None + for node in tree.body: + if ( + isinstance(node, ast.ImportFrom) + and node.module == "inference.core.roboflow_api" + and [a.name for a in node.names] == ["ModelEndpointType"] + ): + import_span = (node.lineno, node.end_lineno) + if not usages and import_span is None: + print(f"SKIP (already repointed): {path}") + return 0, 0 + + for node in sorted(usages, key=lambda n: (n.lineno, n.col_offset), reverse=True): + index = node.lineno - 1 + line = lines[index] + assert line[node.col_offset : node.end_col_offset] == "ModelEndpointType.CORE_MODEL" + lines[index] = ( + line[: node.col_offset] + + "CORE_MODEL_ENDPOINT_TYPE" + + line[node.end_col_offset :] + ) + if import_span is not None: + start, end = import_span + lines[start - 1 : end] = NEW_IMPORT.split("\n") + updated = newline.join(lines) + if "ModelEndpointType" in updated: + print(f"FAIL: {path} still mentions ModelEndpointType", file=sys.stderr) + raise SystemExit(2) + ast.parse(updated) + path.write_text(updated, encoding="utf-8") + return (1 if import_span else 0), len(usages) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("files", nargs="+") + parser.add_argument("--expected-imports", type=int, required=True) + parser.add_argument("--expected-usages", type=int, required=True) + args = parser.parse_args() + total_i = total_u = 0 + for name in args.files: + i, u = patch(pathlib.Path(name)) + total_i += i + total_u += u + print(f"{i:3d} imports {u:3d} usages {name}") + print(f"TOTAL {total_i} imports, {total_u} usages") + print("POST-STATE verified: no ModelEndpointType reference remains") + if total_i != args.expected_imports or total_u != args.expected_usages: + print("FAIL: counts do not match", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/inference/unit_tests/core/test_endpoint_type_coercion.py b/tests/inference/unit_tests/core/test_endpoint_type_coercion.py new file mode 100644 index 0000000000..2803f7714a --- /dev/null +++ b/tests/inference/unit_tests/core/test_endpoint_type_coercion.py @@ -0,0 +1,79 @@ +"""Workflows passes `endpoint_type` as a plain string; the server coerces it. + +`get_roboflow_model_data` reads an api-data cache when +`MODELS_CACHE_AUTH_ENABLED` is FALSE (`roboflow_api.py:597-601`), so the +two-call comparison below patches it to TRUE - otherwise the second call is a +cache hit and never builds a URL. +""" + +from unittest import mock + +from inference.core.roboflow_api import ModelEndpointType +from inference.core.workflows.prototypes.models_provider import CORE_MODEL_ENDPOINT_TYPE + + +def test_constant_matches_the_server_enum_value() -> None: + assert CORE_MODEL_ENDPOINT_TYPE == ModelEndpointType.CORE_MODEL.value + + +def test_enum_construction_from_the_constant_is_the_core_member() -> None: + assert ModelEndpointType(CORE_MODEL_ENDPOINT_TYPE) is ModelEndpointType.CORE_MODEL + + +def test_enum_construction_is_idempotent_for_real_members() -> None: + for member in ModelEndpointType: + assert ModelEndpointType(member) is member + + +def test_get_roboflow_model_data_builds_the_same_url_for_string_and_enum() -> None: + import inference.core.roboflow_api as roboflow_api + + urls = [] + + def fake_get_from_url(url: str, json_response: bool = True): + urls.append(url) + return {"ok": True} + + with mock.patch.object( + roboflow_api, "_get_from_url", fake_get_from_url + ), mock.patch.object(roboflow_api, "MODELS_CACHE_AUTH_ENABLED", True): + roboflow_api.get_roboflow_model_data( + api_key="k", + model_id="clip/ViT-B-16", + endpoint_type=CORE_MODEL_ENDPOINT_TYPE, + device_id="d", + ) + roboflow_api.get_roboflow_model_data( + api_key="k", + model_id="clip/ViT-B-16", + endpoint_type=ModelEndpointType.CORE_MODEL, + device_id="d", + ) + + assert len(urls) == 2, "the api-data cache must be bypassed for this comparison" + assert urls[0] == urls[1] + assert "/core_model/" in urls[0] + + +def test_access_check_treats_the_string_like_the_enum() -> None: + import inference.core.registries.roboflow as registries + + seen = [] + + def fake_get_roboflow_model_data(**kwargs): + seen.append(kwargs["endpoint_type"]) + return {} + + with mock.patch.object( + registries, "get_roboflow_model_data", fake_get_roboflow_model_data + ), mock.patch.object(registries, "USE_INFERENCE_MODELS", False): + registries._check_if_api_key_has_access_to_model.__wrapped__( + api_key="k", + model_id="yolo_world/l", + endpoint_type=CORE_MODEL_ENDPOINT_TYPE, + ) + + # The `yolo_world` legacy-auth branch is selected by + # `endpoint_type == ModelEndpointType.CORE_MODEL`, so reaching + # get_roboflow_model_data at all proves the coercion happened. + assert seen == [ModelEndpointType.CORE_MODEL] diff --git a/tests/workflows/unit_tests/core_steps/test_dependent_resources.py b/tests/workflows/unit_tests/core_steps/test_dependent_resources.py index 65955fbc1e..f6516e733e 100644 --- a/tests/workflows/unit_tests/core_steps/test_dependent_resources.py +++ b/tests/workflows/unit_tests/core_steps/test_dependent_resources.py @@ -327,7 +327,7 @@ def test_clip_v1_synthesizes_core_model_id_from_version() -> None: # Core models must register in the model manager the same way # load_core_model() does. assert resources[0].metadata.model_registration_kwargs == { - "endpoint_type": ModelEndpointType.CORE_MODEL + "endpoint_type": "core_model" } diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 6bc1ac63b9..4e80d530d4 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 337 +# Count: 318 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env @@ -19,7 +19,6 @@ inference/core/workflows/core_steps/common/utils.py inference.core.entities.requ inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.easy_ocr inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.yolo_world -inference/core/workflows/core_steps/common/utils.py inference.core.roboflow_api inference/core/workflows/core_steps/formatters/vlm_as_classifier/v1_tensor.py inference.core.env inference/core/workflows/core_steps/formatters/vlm_as_classifier/v2_tensor.py inference.core.env inference/core/workflows/core_steps/formatters/vlm_as_detector/v1_tensor.py inference.core.env @@ -40,19 +39,13 @@ inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py inf inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py inference.core.env -inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py inference.core.exceptions inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py inference.core.entities.requests.inference @@ -62,9 +55,7 @@ inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py inf inference/core/workflows/core_steps/models/foundation/depth_estimation/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py inference.core.entities.requests.easy_ocr inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/florence2/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/florence2/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/florence2/v1_tensor.py inference.core.env @@ -104,9 +95,7 @@ inference/core/workflows/core_steps/models/foundation/moondream2/v1.py inference inference/core/workflows/core_steps/models/foundation/moondream2/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/ocr/v1.py inference.core.entities.requests.doctr inference/core/workflows/core_steps/models/foundation/ocr/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/ocr/v1.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/openai/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/openai/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/openai/v2.py inference.core.env @@ -126,15 +115,11 @@ inference/core/workflows/core_steps/models/foundation/openai/v6.py inference.cor inference/core/workflows/core_steps/models/foundation/openai_compatible/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py inference.core.entities.requests.perception_encoder inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py inference.core.entities.requests.pp_ocr inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py inference.core.entities.requests.pp_ocr inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/qwen/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/qwen/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py inference.core.env @@ -170,9 +155,7 @@ inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py in inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.responses.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py inference.usage_tracking.collector inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py inference.core.env @@ -236,9 +219,7 @@ inference/core/workflows/core_steps/models/foundation/spacexai/v2.py inference.c inference/core/workflows/core_steps/models/foundation/spacexai/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.entities.requests.yolo_world inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py inference.core.exceptions -inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/zai_vlm/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/roboflow/action_recognition/v1.py inference.core.models.action_recognition inference/core/workflows/core_steps/models/roboflow/action_recognition/v1.py inference.core.models.inference_models_adapters From efdb9fbdc8a592f26ebe6b799d7f9e95f34db160 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:54:30 +0200 Subject: [PATCH 06/86] refactor(workflows): fix cache key collision in endpoint_type coercion Move the endpoint_type coercion before the @ttl_cache decorator in _check_if_api_key_has_access_to_model so that string and enum forms share a single cache entry. Fixes Finding 1 (cache efficiency). Update docstring in models_provider.py (Finding 3), remove unused ModelEndpointType import in test_dependent_resources.py (Finding 2), and add cache-aware tests for the coercion behavior. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/registries/roboflow.py | 28 +++++++++++++-- inference/core/roboflow_api.py | 2 +- .../workflows/prototypes/models_provider.py | 7 ++-- .../core/test_endpoint_type_coercion.py | 36 ++++++++++++++++++- .../core_steps/test_dependent_resources.py | 4 +-- 5 files changed, 67 insertions(+), 10 deletions(-) diff --git a/inference/core/registries/roboflow.py b/inference/core/registries/roboflow.py index 419081406b..32870f43c6 100644 --- a/inference/core/registries/roboflow.py +++ b/inference/core/registries/roboflow.py @@ -348,16 +348,33 @@ def get_model( return self.registry_dict[model_type] -@ttl_cache(ttl=MODELS_CACHE_AUTH_CACHE_TTL, maxsize=MODELS_CACHE_AUTH_CACHE_MAX_SIZE) def _check_if_api_key_has_access_to_model( + api_key: str, + model_id: str, + endpoint_type: Union[str, ModelEndpointType] = ModelEndpointType.ORT, + countinference: Optional[bool] = None, + service_secret: Optional[str] = None, +) -> bool: + # Workflow blocks pass the plain string `core_model` + # (prototypes/models_provider.CORE_MODEL_ENDPOINT_TYPE); coerce BEFORE the + # ttl cache so the string and the enum share one cache entry. + return _check_if_api_key_has_access_to_model_cached( + api_key=api_key, + model_id=model_id, + endpoint_type=ModelEndpointType(endpoint_type), + countinference=countinference, + service_secret=service_secret, + ) + + +@ttl_cache(ttl=MODELS_CACHE_AUTH_CACHE_TTL, maxsize=MODELS_CACHE_AUTH_CACHE_MAX_SIZE) +def _check_if_api_key_has_access_to_model_cached( api_key: str, model_id: str, endpoint_type: ModelEndpointType = ModelEndpointType.ORT, countinference: Optional[bool] = None, service_secret: Optional[str] = None, ) -> bool: - # Same reason as in roboflow_api.get_roboflow_model_data. - endpoint_type = ModelEndpointType(endpoint_type) model_id = resolve_roboflow_model_alias(model_id=model_id) pipeline_definition = _get_model_pipeline_definition(model_id=model_id) if pipeline_definition is not None: @@ -409,6 +426,11 @@ def _check_if_api_key_has_access_to_model( return True +_check_if_api_key_has_access_to_model.cache_clear = ( + _check_if_api_key_has_access_to_model_cached.cache_clear +) + + def _get_local_model_type(model_id: str) -> Optional[Tuple[TaskType, ModelType]]: """Returns model metadata read from a local `inference_models` package directory. diff --git a/inference/core/roboflow_api.py b/inference/core/roboflow_api.py index 707bdb2e49..1464fd2420 100644 --- a/inference/core/roboflow_api.py +++ b/inference/core/roboflow_api.py @@ -589,7 +589,7 @@ class ModelEndpointType(Enum): def get_roboflow_model_data( api_key: str, model_id: str, - endpoint_type: ModelEndpointType, + endpoint_type: Union[str, ModelEndpointType], device_id: str, countinference: Optional[bool] = None, service_secret: Optional[str] = None, diff --git a/inference/core/workflows/prototypes/models_provider.py b/inference/core/workflows/prototypes/models_provider.py index 4560b39a10..593e7ef2fd 100644 --- a/inference/core/workflows/prototypes/models_provider.py +++ b/inference/core/workflows/prototypes/models_provider.py @@ -14,9 +14,10 @@ class ModelsProvider(Protocol): check against it, and a protocol carrying a data member cannot support one. ``add_model`` keeps ``**kwargs`` rather than naming ``endpoint_type``, - ``countinference`` and ``service_secret`` explicitly - ``endpoint_type`` is - typed ``ModelEndpointType`` from ``inference.core.roboflow_api``, and naming - it here would reintroduce exactly the import this port removes. + ``countinference`` and ``service_secret`` explicitly. Workflows now pass + the plain string constant ``CORE_MODEL_ENDPOINT_TYPE`` for ``endpoint_type`` + to avoid importing the server's ``ModelEndpointType`` enum; the server + coerces it back as needed. PROVISIONAL MEMBERS. ``infer_from_request_sync`` takes a pydantic request object built by the caller from ``inference.core.entities`` - it is the diff --git a/tests/inference/unit_tests/core/test_endpoint_type_coercion.py b/tests/inference/unit_tests/core/test_endpoint_type_coercion.py index 2803f7714a..53d4408b1c 100644 --- a/tests/inference/unit_tests/core/test_endpoint_type_coercion.py +++ b/tests/inference/unit_tests/core/test_endpoint_type_coercion.py @@ -58,6 +58,8 @@ def fake_get_from_url(url: str, json_response: bool = True): def test_access_check_treats_the_string_like_the_enum() -> None: import inference.core.registries.roboflow as registries + registries._check_if_api_key_has_access_to_model.cache_clear() + seen = [] def fake_get_roboflow_model_data(**kwargs): @@ -67,7 +69,7 @@ def fake_get_roboflow_model_data(**kwargs): with mock.patch.object( registries, "get_roboflow_model_data", fake_get_roboflow_model_data ), mock.patch.object(registries, "USE_INFERENCE_MODELS", False): - registries._check_if_api_key_has_access_to_model.__wrapped__( + registries._check_if_api_key_has_access_to_model( api_key="k", model_id="yolo_world/l", endpoint_type=CORE_MODEL_ENDPOINT_TYPE, @@ -77,3 +79,35 @@ def fake_get_roboflow_model_data(**kwargs): # `endpoint_type == ModelEndpointType.CORE_MODEL`, so reaching # get_roboflow_model_data at all proves the coercion happened. assert seen == [ModelEndpointType.CORE_MODEL] + + +def test_access_check_string_and_enum_share_one_cache_entry() -> None: + import inference.core.registries.roboflow as registries + + registries._check_if_api_key_has_access_to_model.cache_clear() + + seen = [] + + def fake_get_roboflow_model_data(**kwargs): + seen.append(kwargs["endpoint_type"]) + return {} + + with mock.patch.object( + registries, "get_roboflow_model_data", fake_get_roboflow_model_data + ), mock.patch.object(registries, "USE_INFERENCE_MODELS", False): + # First call with the string constant + registries._check_if_api_key_has_access_to_model( + api_key="k", + model_id="yolo_world/m", + endpoint_type=CORE_MODEL_ENDPOINT_TYPE, + ) + # Second call with the enum should be a cache hit + registries._check_if_api_key_has_access_to_model( + api_key="k", + model_id="yolo_world/m", + endpoint_type=ModelEndpointType.CORE_MODEL, + ) + + # Both calls should use the same cache entry, so get_roboflow_model_data + # is only called once (the second is a cache hit). + assert seen == [ModelEndpointType.CORE_MODEL] diff --git a/tests/workflows/unit_tests/core_steps/test_dependent_resources.py b/tests/workflows/unit_tests/core_steps/test_dependent_resources.py index f6516e733e..bd00ff3b07 100644 --- a/tests/workflows/unit_tests/core_steps/test_dependent_resources.py +++ b/tests/workflows/unit_tests/core_steps/test_dependent_resources.py @@ -18,7 +18,6 @@ import pytest -from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.core_steps.models.foundation.clip.v1 import ( BlockManifest as ClipV1Manifest, ) @@ -59,6 +58,7 @@ roboflow_platform_project, third_party_model, ) +from inference.core.workflows.prototypes.models_provider import CORE_MODEL_ENDPOINT_TYPE from inference.roboflow_workflows_plugin.sinks.dataset_upload.v2 import ( BlockManifest as DatasetUploadV2Manifest, ) @@ -327,7 +327,7 @@ def test_clip_v1_synthesizes_core_model_id_from_version() -> None: # Core models must register in the model manager the same way # load_core_model() does. assert resources[0].metadata.model_registration_kwargs == { - "endpoint_type": "core_model" + "endpoint_type": CORE_MODEL_ENDPOINT_TYPE } From f7ea33522f9e55ee93273a40f6b356ce9f6b814c Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:16:02 +0200 Subject: [PATCH 07/86] feat(workflows): add the RoboflowPlatformClient and WorkspaceResolver ports Ports, a standalone offline default, a server adapter forwarding to roboflow_api/url_utils without reimplementing wrap_url, the header policy or the api-key redaction, and setdefault bindings at all four composition roots observed at ExecutionEngine.init. No baseline rows yet. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/interfaces/http/http_api.py | 37 +-- .../interfaces/roboflow_platform_client.py | 132 ++++++++++ .../interfaces/stream/inference_pipeline.py | 6 + inference/core/workflows/core_steps/loader.py | 6 + .../workflows/prototypes/platform_client.py | 97 ++++++++ .../prototypes/workspace_resolver.py | 26 ++ .../lib/workflows/local_image_adapter.py | 4 + .../test_workflows_platform_bindings.py | 229 ++++++++++++++++++ .../prototypes/platform_client_double.py | 92 +++++++ .../prototypes/test_platform_client.py | 191 +++++++++++++++ 10 files changed, 805 insertions(+), 15 deletions(-) create mode 100644 inference/core/interfaces/roboflow_platform_client.py create mode 100644 inference/core/workflows/prototypes/platform_client.py create mode 100644 inference/core/workflows/prototypes/workspace_resolver.py create mode 100644 tests/inference/unit_tests/core/interfaces/test_workflows_platform_bindings.py create mode 100644 tests/workflows/unit_tests/prototypes/platform_client_double.py create mode 100644 tests/workflows/unit_tests/prototypes/test_platform_client.py diff --git a/inference/core/interfaces/http/http_api.py b/inference/core/interfaces/http/http_api.py index 9a2554e00b..57c5bbaf0f 100644 --- a/inference/core/interfaces/http/http_api.py +++ b/inference/core/interfaces/http/http_api.py @@ -270,6 +270,9 @@ GCPServerlessMiddleware, build_model_response_headers, ) +from inference.core.interfaces.roboflow_platform_client import ( + install_workflows_platform_bindings, +) from inference.core.interfaces.stream_manager.api.entities import ( CommandContext, CommandResponse, @@ -1594,15 +1597,17 @@ def process_workflow_inference_request( if workflow_request.workflow_id: request_workflow_id.set(workflow_request.workflow_id) - workflow_init_parameters = { - "workflows_core.model_manager": model_manager, - "workflows_core.api_key": workflow_request.api_key, - "workflows_core.background_tasks": background_tasks, - "workflows_core.disable_sinks": workflow_request.disable_sinks, - "workflows_core.inner_workflow_dispatch_depth": ( - workflow_request.inner_workflow_dispatch_depth - ), - } + workflow_init_parameters = install_workflows_platform_bindings( + { + "workflows_core.model_manager": model_manager, + "workflows_core.api_key": workflow_request.api_key, + "workflows_core.background_tasks": background_tasks, + "workflows_core.disable_sinks": workflow_request.disable_sinks, + "workflows_core.inner_workflow_dispatch_depth": ( + workflow_request.inner_workflow_dispatch_depth + ), + } + ) with start_span( "workflow.init", {"workflow.id": workflow_request.workflow_id or ""}, @@ -2550,12 +2555,14 @@ def validate_workflow( # TODO: get rid of async: https://github.com/roboflow/inference/issues/569 api_key = api_key_fallback(api_key) step_execution_mode = StepExecutionMode(WORKFLOWS_STEP_EXECUTION_MODE) - workflow_init_parameters = { - "workflows_core.model_manager": model_manager, - "workflows_core.api_key": api_key, - "workflows_core.background_tasks": None, - "workflows_core.step_execution_mode": step_execution_mode, - } + workflow_init_parameters = install_workflows_platform_bindings( + { + "workflows_core.model_manager": model_manager, + "workflows_core.api_key": api_key, + "workflows_core.background_tasks": None, + "workflows_core.step_execution_mode": step_execution_mode, + } + ) _ = ExecutionEngine.init( workflow_definition=specification, init_parameters=workflow_init_parameters, diff --git a/inference/core/interfaces/roboflow_platform_client.py b/inference/core/interfaces/roboflow_platform_client.py new file mode 100644 index 0000000000..d160babe59 --- /dev/null +++ b/inference/core/interfaces/roboflow_platform_client.py @@ -0,0 +1,132 @@ +"""Server implementations of the Workflows Roboflow-platform ports. + +Everything forwards to `inference.core.roboflow_api` and +`inference.core.utils.url_utils`; no security control is reimplemented. Calls +go through the module objects rather than attributes captured at construction, +so a test that monkeypatches SECURE_GATEWAY, the transport or the header-policy +flags observes what production does. +""" + +from typing import Any, Dict, List, Optional, Tuple, Union + +import inference.core.roboflow_api as roboflow_api +import inference.core.utils.url_utils as url_utils +from inference.core.cache import cache as server_cache +from inference.core.exceptions import WorkspaceLoadError +from inference.core.workflows.errors import WorkflowDefinitionError +from inference.core.workflows.prototypes.platform_client import HttpErrorHandlers + + +class ServerRoboflowPlatformClient: + def post( + self, + endpoint: str, + api_key: Optional[str], + payload: Optional[dict] = None, + params: Optional[List[Tuple[str, str]]] = None, + http_errors_handlers: Optional[HttpErrorHandlers] = None, + ) -> dict: + return roboflow_api.post_to_roboflow_api( + endpoint=endpoint, + api_key=api_key, + payload=payload, + params=params, + http_errors_handlers=http_errors_handlers, + ) + + def build_api_headers( + self, explicit_headers: Optional[Dict[str, Union[str, List[str]]]] = None + ) -> Dict[str, Union[str, List[str]]]: + return roboflow_api.build_roboflow_api_headers( + explicit_headers=explicit_headers + ) + + def build_weights_provider_headers( + self, + countinference: Optional[bool] = None, + service_secret: Optional[str] = None, + ) -> Optional[Dict[str, str]]: + return roboflow_api.get_extra_weights_provider_headers( + countinference=countinference, service_secret=service_secret + ) + + def wrap_url(self, url: str) -> str: + return url_utils.wrap_url(url) + + +class ServerWorkspaceResolver: + """Swallows `WorkspaceLoadError` into `None`, exactly what both former call + sites in `block_scaffolding.py` did with their own `try/except`.""" + + def resolve_workspace(self, api_key: Optional[str]) -> Optional[str]: + try: + return roboflow_api.get_roboflow_workspace(api_key) + except WorkspaceLoadError: + return None + + +def default_inner_workflow_spec_resolver( + workspace_id: str, + workflow_id: str, + workflow_version_id: Optional[str], + init_parameters: Dict[str, Any], +) -> Dict[str, Any]: + """Fetch an inner workflow's definition from the Roboflow API. + + Moved out of `execution_engine/v1/inner_workflow/reference_resolution.py` + so that module stops importing `roboflow_api`. Installed at every server + composition root, like `resolve_step_error_handler()`. + """ + api_key = init_parameters.get("workflows_core.api_key") + if workspace_id != "local" and not api_key: + raise WorkflowDefinitionError( + public_message=( + "Resolving an `inner_workflow` step by workflow id requires a Roboflow API key. " + "Set `workflows_core.api_key` in workflow init_parameters, inject " + "`workflows_core.inner_workflow_spec_resolver`, or use " + '`workflow_workspace_id` `"local"` with a matching on-disk workflow ' + "definition." + ), + context="workflow_compilation | inner_workflow_spec_resolution", + ) + return roboflow_api.get_workflow_specification( + api_key=api_key, + workspace_id=workspace_id, + workflow_id=workflow_id, + workflow_version_id=workflow_version_id, + ) + + +# Module-level singletons: one client and one resolver per process. +SERVER_PLATFORM_CLIENT = ServerRoboflowPlatformClient() +SERVER_WORKSPACE_RESOLVER = ServerWorkspaceResolver() + + +def workflows_platform_bindings() -> Dict[str, Any]: + """The `workflows_core.*` init parameters every composition root installs.""" + return { + "workflows_core.cache": server_cache, + "workflows_core.platform_client": SERVER_PLATFORM_CLIENT, + "workflows_core.workspace_resolver": SERVER_WORKSPACE_RESOLVER, + "workflows_core.inner_workflow_spec_resolver": default_inner_workflow_spec_resolver, + } + + +def install_workflows_platform_bindings( + init_parameters: Dict[str, Any], +) -> Dict[str, Any]: + """Install the server defaults WITHOUT replacing explicit values. + + `setdefault`, not `update`: `InferencePipeline.init_with_workflow` forwards + a caller's `workflow_init_parameters` dict, and + `workflows_core.inner_workflow_spec_resolver` overriding the default is an + existing contract (`reference_resolution.get_inner_workflow_spec_resolver`). + + ADDITIVE by construction: it only fills in keys that are absent, so a phase + that adds its own `workflows_core.*` binding at the same roots (Phase 6's + observer, Phase 11's models adapter) composes with this instead of + replacing it. + """ + for key, value in workflows_platform_bindings().items(): + init_parameters.setdefault(key, value) + return init_parameters diff --git a/inference/core/interfaces/stream/inference_pipeline.py b/inference/core/interfaces/stream/inference_pipeline.py index 0fc10e29a5..c1fc96eb31 100644 --- a/inference/core/interfaces/stream/inference_pipeline.py +++ b/inference/core/interfaces/stream/inference_pipeline.py @@ -46,6 +46,9 @@ BufferFillingStrategy, VideoSource, ) +from inference.core.interfaces.roboflow_platform_client import ( + install_workflows_platform_bindings, +) from inference.core.interfaces.stream.entities import ( AnyPrediction, InferenceHandler, @@ -748,6 +751,9 @@ def init_with_workflow( thread_pool_executor ) workflow_init_parameters["workflows_core.disable_sinks"] = disable_sinks + # setdefault semantics: a caller's workflow_init_parameters may + # already carry an explicit inner_workflow_spec_resolver. + install_workflows_platform_bindings(workflow_init_parameters) execution_engine = ExecutionEngine.init( workflow_definition=workflow_specification, init_parameters=workflow_init_parameters, diff --git a/inference/core/workflows/core_steps/loader.py b/inference/core/workflows/core_steps/loader.py index ef9484e259..f04ee3c8ae 100644 --- a/inference/core/workflows/core_steps/loader.py +++ b/inference/core/workflows/core_steps/loader.py @@ -1544,6 +1544,7 @@ Kind, ) from inference.core.workflows.prototypes.block import WorkflowBlock +from inference.core.workflows.prototypes.platform_client import OFFLINE_PLATFORM_CLIENT REGISTERED_INITIALIZERS = { "api_key": API_KEY, @@ -1555,6 +1556,11 @@ "inner_workflow_remote_target": WORKFLOWS_INNER_WORKFLOW_REMOTE_TARGET, "inner_workflow_dispatch_depth": 0, "disable_sinks": False, + # Standalone default. The server overrides it with + # `workflows_core.platform_client` at every composition root. An object, + # not a function: call_if_callable() would invoke a function registered + # here with no arguments. + "platform_client": OFFLINE_PLATFORM_CLIENT, "update_attributes_offloader": None, "allow_access_to_file_system": ALLOW_WORKFLOW_BLOCKS_ACCESSING_LOCAL_STORAGE, "allowed_write_directory": WORKFLOW_BLOCKS_WRITE_DIRECTORY, diff --git a/inference/core/workflows/prototypes/platform_client.py b/inference/core/workflows/prototypes/platform_client.py new file mode 100644 index 0000000000..ba5f427302 --- /dev/null +++ b/inference/core/workflows/prototypes/platform_client.py @@ -0,0 +1,97 @@ +"""The port through which Workflow blocks reach the Roboflow platform. + +Implemented in the server by +`inference.core.interfaces.roboflow_platform_client.ServerRoboflowPlatformClient`, +which forwards to `inference.core.roboflow_api` and +`inference.core.utils.url_utils`. Declared here so that a model block proxying +a VLM call through Roboflow does not import the platform's HTTP client. + +`wrap_url` is a member on purpose: it is the secure-gateway proxy wrapper, a +security control that must be injected rather than reimplemented, and every URL +it guards in Workflows is produced next to a `build_api_headers` call. +""" + +from typing import Callable, Dict, List, Optional, Protocol, Tuple, Union + +from inference.core.workflows.errors import WorkflowEnvironmentConfigurationError + +HttpErrorHandlers = Dict[int, Callable[[Exception], None]] + + +class RoboflowPlatformClient(Protocol): + """Deliberately NOT `runtime_checkable`: nothing isinstance-checks it.""" + + def post( + self, + endpoint: str, + api_key: Optional[str], + payload: Optional[dict] = None, + params: Optional[List[Tuple[str, str]]] = None, + http_errors_handlers: Optional[HttpErrorHandlers] = None, + ) -> dict: ... + + def build_api_headers( + self, explicit_headers: Optional[Dict[str, Union[str, List[str]]]] = None + ) -> Dict[str, Union[str, List[str]]]: ... + + def build_weights_provider_headers( + self, + countinference: Optional[bool] = None, + service_secret: Optional[str] = None, + ) -> Optional[Dict[str, str]]: ... + + def wrap_url(self, url: str) -> str: ... + + +class OfflineRoboflowPlatformClient: + """Standalone default: no platform, and it says so. + + Registered as `REGISTERED_INITIALIZERS["platform_client"]`; the server + overrides it at every composition root. Header builders return empty/None + rather than raising, so a block can still assemble a request for a + non-Roboflow endpoint; only `post` - which can only target Roboflow - + refuses. `wrap_url` is the identity, which is what the real `wrap_url` does + when `SECURE_GATEWAY` is unset. + """ + + def post( + self, + endpoint: str, + api_key: Optional[str], + payload: Optional[dict] = None, + params: Optional[List[Tuple[str, str]]] = None, + http_errors_handlers: Optional[HttpErrorHandlers] = None, + ) -> dict: + raise WorkflowEnvironmentConfigurationError( + public_message=( + "This step routes its request through the Roboflow API, which is " + "not available in this installation of `workflows`. Provide a " + "`workflows_core.platform_client` init parameter, or configure the " + "step to call its provider directly with your own API key." + ), + context="workflow_execution | step_execution | roboflow_platform_access", + ) + + def build_api_headers( + self, explicit_headers: Optional[Dict[str, Union[str, List[str]]]] = None + ) -> Dict[str, Union[str, List[str]]]: + return dict(explicit_headers or {}) + + def build_weights_provider_headers( + self, + countinference: Optional[bool] = None, + service_secret: Optional[str] = None, + ) -> Optional[Dict[str, str]]: + return None + + def wrap_url(self, url: str) -> str: + return url + + +# One shared instance. Stateless; it is what +# `REGISTERED_INITIALIZERS["platform_client"]` binds and the `__init__` default +# of every block that takes the port, so a block constructed directly (as 69 +# existing unit-test call sites do: 47 for the Task 9.4 classes, 22 for the +# Task 9.5 ones) still works while the engine always passes +# the resolved value explicitly. +OFFLINE_PLATFORM_CLIENT = OfflineRoboflowPlatformClient() diff --git a/inference/core/workflows/prototypes/workspace_resolver.py b/inference/core/workflows/prototypes/workspace_resolver.py new file mode 100644 index 0000000000..4f8d6ae7d0 --- /dev/null +++ b/inference/core/workflows/prototypes/workspace_resolver.py @@ -0,0 +1,26 @@ +"""Resolve the Roboflow workspace id for the configured API key. + +Used by the dynamic-block scaffolding to name the Modal sandbox that runs +custom Python. `None` means "unknown", and the caller falls back to the +anonymous workspace - which is also what happened when +`get_roboflow_workspace` raised `WorkspaceLoadError`. + +A METHOD, not a bare callable: `steps_initialiser.call_if_callable` invokes any +callable registered in `REGISTERED_INITIALIZERS`. +""" + +from typing import Optional, Protocol + + +class WorkspaceResolver(Protocol): + def resolve_workspace(self, api_key: Optional[str]) -> Optional[str]: ... + + +class NullWorkspaceResolver: + """Standalone default: no platform lookup, so no workspace.""" + + def resolve_workspace(self, api_key: Optional[str]) -> Optional[str]: + return None + + +NULL_WORKSPACE_RESOLVER = NullWorkspaceResolver() diff --git a/inference_cli/lib/workflows/local_image_adapter.py b/inference_cli/lib/workflows/local_image_adapter.py index 117b58e088..c4ba3f902c 100644 --- a/inference_cli/lib/workflows/local_image_adapter.py +++ b/inference_cli/lib/workflows/local_image_adapter.py @@ -9,6 +9,9 @@ from inference.core.cache import cache from inference.core.env import API_KEY, MAX_ACTIVE_MODELS +from inference.core.interfaces.roboflow_platform_client import ( + install_workflows_platform_bindings, +) from inference.core.interfaces.workflows_step_error_handlers import ( resolve_step_error_handler, ) @@ -453,6 +456,7 @@ def _run_workflow_for_single_image_with_inference( } if workflows_execution_engine_init_params: workflow_init_parameters.update(workflows_execution_engine_init_params) + install_workflows_platform_bindings(workflow_init_parameters) execution_engine = ExecutionEngine.init( workflow_definition=workflow_specification, init_parameters=workflow_init_parameters, diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_platform_bindings.py b/tests/inference/unit_tests/core/interfaces/test_workflows_platform_bindings.py new file mode 100644 index 0000000000..7e341d3827 --- /dev/null +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_platform_bindings.py @@ -0,0 +1,229 @@ +"""Every composition root must hand the platform bindings to ExecutionEngine.init. + +A root that forgets one leaves blocks on the standalone default, which refuses +to talk to the Roboflow API - and there is no failing test at the block level +for that, because the default only raises when the step runs. So the values are +observed where they actually arrive. +""" + +import ast +import pathlib +from unittest.mock import AsyncMock, MagicMock + +import pytest +from starlette.testclient import TestClient + +from inference.core.cache import cache as server_cache +from inference.core.interfaces.roboflow_platform_client import ( + SERVER_PLATFORM_CLIENT, + SERVER_WORKSPACE_RESOLVER, + default_inner_workflow_spec_resolver, +) + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[5] + +REQUIRED = { + "workflows_core.cache": server_cache, + "workflows_core.platform_client": SERVER_PLATFORM_CLIENT, + "workflows_core.workspace_resolver": SERVER_WORKSPACE_RESOLVER, + "workflows_core.inner_workflow_spec_resolver": default_inner_workflow_spec_resolver, +} + +# One entry per file: the number of ExecutionEngine.init call sites the runtime +# cases below cover. A new root shows up here as a mismatch. +ROOT_SITES = { + "inference/core/interfaces/http/http_api.py": 2, + "inference/core/interfaces/stream/inference_pipeline.py": 1, + "inference_cli/lib/workflows/local_image_adapter.py": 1, +} + +TRIVIAL_WORKFLOW = {"version": "1.0", "inputs": [], "steps": [], "outputs": []} + + +class _Captured(Exception): + def __init__(self, init_parameters): + super().__init__("captured") + self.init_parameters = init_parameters + + +def _capturing_init(**kwargs): + raise _Captured(kwargs.get("init_parameters")) + + +def _assert_bindings(init_parameters: dict, site: str) -> None: + assert init_parameters is not None, f"{site} passed no init_parameters" + for key, expected in REQUIRED.items(): + assert key in init_parameters, f"{site} did not pass {key}" + assert ( + init_parameters[key] is expected + ), f"{site} passed a different object for {key}" + + +def test_ast_site_count_matches_the_runtime_cases() -> None: + for relative_path, expected_sites in ROOT_SITES.items(): + tree = ast.parse((REPO_ROOT / relative_path).read_text(encoding="utf-8")) + sites = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "init" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "ExecutionEngine" + ] + assert len(sites) == expected_sites, ( + f"{relative_path}: {len(sites)} ExecutionEngine.init call sites, " + f"expected {expected_sites} - add a runtime case for the new root" + ) + + +class _DummyInstrumentator: + def __init__(self, app, model_manager, endpoint="/metrics"): + self.app = app + self.model_manager = model_manager + self.endpoint = endpoint + + def set_stream_manager_client(self, stream_manager_client) -> None: + self.stream_manager_client = stream_manager_client + + +def _build_test_client(monkeypatch) -> TestClient: + import inference.core.interfaces.http.http_api as http_api + + monkeypatch.setattr(http_api, "InferenceInstrumentator", _DummyInstrumentator) + monkeypatch.setattr( + http_api.usage_collector, "async_push_usage_payloads", AsyncMock() + ) + model_manager = MagicMock() + model_manager.pingback = None + model_manager.num_errors = 0 + return TestClient(http_api.HttpInterface(model_manager=model_manager).app) + + +def _http_case(monkeypatch, path, json_body, site): + import inference.core.interfaces.http.http_api as http_api + + captured = {} + + class _Engine: + @staticmethod + def init(**kwargs): + captured["init_parameters"] = kwargs.get("init_parameters") + raise _Captured(kwargs.get("init_parameters")) + + monkeypatch.setattr(http_api, "ExecutionEngine", _Engine) + _build_test_client(monkeypatch).post(path, json=json_body) + _assert_bindings(captured.get("init_parameters"), site) + + +def test_http_workflow_run_root_passes_the_bindings(monkeypatch) -> None: + _http_case( + monkeypatch, + "/workflows/run", + {"specification": TRIVIAL_WORKFLOW, "inputs": {}}, + "http_api /workflows/run", + ) + + +def test_http_workflow_validate_root_passes_the_bindings(monkeypatch) -> None: + _http_case( + monkeypatch, + "/workflows/validate", + TRIVIAL_WORKFLOW, + "http_api /workflows/validate", + ) + + +def test_cli_root_passes_the_bindings(monkeypatch) -> None: + from concurrent.futures import ThreadPoolExecutor + + import inference_cli.lib.workflows.local_image_adapter as adapter + + monkeypatch.setattr(adapter.ExecutionEngine, "init", staticmethod(_capturing_init)) + with ThreadPoolExecutor(max_workers=1) as pool, pytest.raises(_Captured) as error: + adapter._run_workflow_for_single_image_with_inference( + model_manager=MagicMock(), + image_path="unused.jpg", + workflow_specification=TRIVIAL_WORKFLOW, + workflow_id=None, + image_input_name="image", + workflow_parameters=None, + api_key="k", + thread_pool_executor=pool, + max_concurrent_workflows_steps=1, + ) + _assert_bindings(error.value.init_parameters, "inference_cli local_image_adapter") + + +def test_pipeline_root_passes_the_bindings(monkeypatch) -> None: + import inference.core.workflows.execution_engine.core as engine_module + from inference.core.interfaces.stream.inference_pipeline import InferencePipeline + + monkeypatch.setattr( + engine_module.ExecutionEngine, "init", staticmethod(_capturing_init) + ) + with pytest.raises(_Captured) as error: + InferencePipeline.init_with_workflow( + video_reference="unused.mp4", + workflow_specification=TRIVIAL_WORKFLOW, + api_key="k", + model_manager=MagicMock(), + ) + _assert_bindings( + error.value.init_parameters, "InferencePipeline.init_with_workflow" + ) + + +def test_pipeline_does_not_overwrite_a_caller_supplied_resolver(monkeypatch) -> None: + import inference.core.workflows.execution_engine.core as engine_module + from inference.core.interfaces.stream.inference_pipeline import InferencePipeline + + def caller_resolver(*args, **kwargs): + return {} + + monkeypatch.setattr( + engine_module.ExecutionEngine, "init", staticmethod(_capturing_init) + ) + with pytest.raises(_Captured) as error: + InferencePipeline.init_with_workflow( + video_reference="unused.mp4", + workflow_specification=TRIVIAL_WORKFLOW, + api_key="k", + model_manager=MagicMock(), + workflow_init_parameters={ + "workflows_core.inner_workflow_spec_resolver": caller_resolver + }, + ) + parameters = error.value.init_parameters + assert parameters["workflows_core.inner_workflow_spec_resolver"] is caller_resolver + assert parameters["workflows_core.platform_client"] is SERVER_PLATFORM_CLIENT + + +def test_cli_does_not_overwrite_caller_supplied_engine_init_params(monkeypatch) -> None: + from concurrent.futures import ThreadPoolExecutor + + import inference_cli.lib.workflows.local_image_adapter as adapter + + def caller_resolver(*args, **kwargs): + return {} + + monkeypatch.setattr(adapter.ExecutionEngine, "init", staticmethod(_capturing_init)) + with ThreadPoolExecutor(max_workers=1) as pool, pytest.raises(_Captured) as error: + adapter._run_workflow_for_single_image_with_inference( + model_manager=MagicMock(), + image_path="unused.jpg", + workflow_specification=TRIVIAL_WORKFLOW, + workflow_id=None, + image_input_name="image", + workflow_parameters=None, + api_key="k", + thread_pool_executor=pool, + max_concurrent_workflows_steps=1, + workflows_execution_engine_init_params={ + "workflows_core.inner_workflow_spec_resolver": caller_resolver + }, + ) + assert ( + error.value.init_parameters["workflows_core.inner_workflow_spec_resolver"] + is caller_resolver + ) diff --git a/tests/workflows/unit_tests/prototypes/platform_client_double.py b/tests/workflows/unit_tests/prototypes/platform_client_double.py new file mode 100644 index 0000000000..453207e6b2 --- /dev/null +++ b/tests/workflows/unit_tests/prototypes/platform_client_double.py @@ -0,0 +1,92 @@ +"""One `RoboflowPlatformClient` test double for every Phase 9 test. + +Records what the block asked for so a migrated test can assert on `posts`, +`headers_calls`, `weights_calls` and `wrapped` instead of patching module-level +functions that no longer exist. + +`post` is backed by a `unittest.mock.Mock` (`post_mock`) so a test migrated +from `mock.patch(... "post_to_roboflow_api")` keeps its `mock_post.*` +assertions verbatim - `return_value`, `side_effect` sequences (an error +followed by success), `call_count`, `call_args_list[i].kwargs["payload"]` all +work unchanged (round-3 defect 3). +""" + +from typing import Any, Callable, Dict, List, Optional, Tuple +from unittest import mock + + +class RecordingPlatformClient: + def __init__( + self, + post_response: Optional[dict] = None, + wrap_prefix: str = "", + weights_headers: Optional[Dict[str, str]] = None, + ): + self.post_mock = mock.Mock( + name="platform_client.post", + return_value=post_response if post_response is not None else {}, + ) + self.posts: List[Dict[str, Any]] = [] + self.headers_calls: List[Optional[dict]] = [] + self.weights_calls: List[Tuple[Optional[bool], Optional[str]]] = [] + self.wrapped: List[str] = [] + self._wrap_prefix = wrap_prefix + self._weights_headers = ( + weights_headers if weights_headers is not None else {"X-Test-Weights": "1"} + ) + + def post( + self, + endpoint: str, + api_key: Optional[str], + payload: Optional[dict] = None, + params: Optional[List[Tuple[str, str]]] = None, + http_errors_handlers: Optional[Dict[int, Callable[[Exception], None]]] = None, + ) -> dict: + self.posts.append( + { + "endpoint": endpoint, + "api_key": api_key, + "payload": payload, + "params": params, + "http_errors_handlers": http_errors_handlers, + } + ) + return self.post_mock( + endpoint=endpoint, + api_key=api_key, + payload=payload, + params=params, + http_errors_handlers=http_errors_handlers, + ) + + def build_api_headers(self, explicit_headers: Optional[dict] = None) -> dict: + self.headers_calls.append(explicit_headers) + return {"X-Test": "1", **(explicit_headers or {})} + + def build_weights_provider_headers( + self, + countinference: Optional[bool] = None, + service_secret: Optional[str] = None, + ) -> Optional[dict]: + self.weights_calls.append((countinference, service_secret)) + return self._weights_headers + + def wrap_url(self, url: str) -> str: + self.wrapped.append(url) + return self._wrap_prefix + url + + def reset(self) -> None: + """Called by the autouse fixture the test-side codemod installs.""" + self.posts.clear() + self.headers_calls.clear() + self.weights_calls.clear() + self.wrapped.clear() + self.post_mock.reset_mock(return_value=True, side_effect=True) + + def set_post_response(self, value: Any) -> None: + """An exception instance is raised by `post`; anything else is returned.""" + if isinstance(value, BaseException): + self.post_mock.side_effect = value + else: + self.post_mock.return_value = value diff --git a/tests/workflows/unit_tests/prototypes/test_platform_client.py b/tests/workflows/unit_tests/prototypes/test_platform_client.py new file mode 100644 index 0000000000..429940edca --- /dev/null +++ b/tests/workflows/unit_tests/prototypes/test_platform_client.py @@ -0,0 +1,191 @@ +"""The Roboflow-platform port, its standalone default and the server adapter. + +The adapter assertions are behavioural, with a configured gateway, a controlled +transport and the real header-policy flags - identity checks would not prove +the secure-gateway wrapping, the api-key redaction or the credit/serverless +header policy survived the port. +""" + +import inspect + +import pytest +import requests + +from inference.core.workflows.errors import WorkflowEnvironmentConfigurationError +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + OfflineRoboflowPlatformClient, + RoboflowPlatformClient, +) + + +def test_the_shared_offline_instance_is_the_offline_client() -> None: + assert isinstance(OFFLINE_PLATFORM_CLIENT, OfflineRoboflowPlatformClient) + + +def test_port_declares_the_four_members_blocks_use() -> None: + for name in ( + "post", + "build_api_headers", + "build_weights_provider_headers", + "wrap_url", + ): + assert hasattr(RoboflowPlatformClient, name) + + +def test_offline_default_is_not_callable() -> None: + # steps_initialiser.call_if_callable() invokes any callable registered in + # REGISTERED_INITIALIZERS with no arguments. + assert not callable(OFFLINE_PLATFORM_CLIENT) + + +def test_offline_wrap_url_is_the_identity() -> None: + url = "https://example.com/a?b=c" + assert OFFLINE_PLATFORM_CLIENT.wrap_url(url) == url + + +def test_offline_headers_are_empty() -> None: + assert OFFLINE_PLATFORM_CLIENT.build_api_headers() == {} + assert OFFLINE_PLATFORM_CLIENT.build_api_headers(explicit_headers={"X": "1"}) == { + "X": "1" + } + assert OFFLINE_PLATFORM_CLIENT.build_weights_provider_headers() is None + + +def test_offline_post_raises_an_actionable_error() -> None: + with pytest.raises(WorkflowEnvironmentConfigurationError) as error: + OFFLINE_PLATFORM_CLIENT.post( + endpoint="apiproxy/openai", api_key="k", payload={} + ) + assert "workflows_core.platform_client" in str(error.value) + + +def test_server_adapter_satisfies_the_port_signatures() -> None: + from inference.core.interfaces.roboflow_platform_client import ( + ServerRoboflowPlatformClient, + ) + + for name in ( + "post", + "build_api_headers", + "build_weights_provider_headers", + "wrap_url", + ): + port_params = list( + inspect.signature(getattr(RoboflowPlatformClient, name)).parameters + ) + real_params = list( + inspect.signature(getattr(ServerRoboflowPlatformClient, name)).parameters + ) + assert port_params == real_params, f"{name}: {port_params} != {real_params}" + + +def test_server_adapter_wrap_url_really_proxies_through_the_secure_gateway(monkeypatch): + import inference.core.utils.url_utils as url_utils + from inference.core.interfaces.roboflow_platform_client import ( + ServerRoboflowPlatformClient, + ) + + monkeypatch.setattr(url_utils, "SECURE_GATEWAY", "gateway.local") + adapter = ServerRoboflowPlatformClient() + raw = "https://api.roboflow.com/x?api_key=abcd1234&a=1" + wrapped = adapter.wrap_url(raw) + + # Measured: urllib.parse.quote(..., safe="~()*!'") encodes the slashes too. + assert wrapped == ( + "http://gateway.local/proxy?url=" + "https%3A%2F%2Fapi.roboflow.com%2Fx%3Fapi_key%3Dabcd1234%26a%3D1" + ) + assert wrapped == url_utils.wrap_url(raw) + assert adapter.wrap_url(wrapped) == wrapped # idempotent + + +def test_server_adapter_post_redacts_the_api_key(monkeypatch) -> None: + """`post_to_roboflow_api` ends in `api_key_safe_raise_for_status`. + + The redaction lands on the INNER `requests.HTTPError` (its message quotes + the sanitised `response.url`); the outer + `RoboflowAPIUnsuccessfulRequestError` message is generic by construction + (`roboflow_api.py:274`). Assert both, so neither can regress. + """ + import inference.core.roboflow_api as roboflow_api + from inference.core.interfaces.roboflow_platform_client import ( + ServerRoboflowPlatformClient, + ) + + response = requests.Response() + response.status_code = 500 + response.url = "https://api.roboflow.com/x?api_key=SECRETKEY123&nocache=true" + monkeypatch.setattr(roboflow_api, "OFFLINE_MODE", False) + monkeypatch.setattr(roboflow_api.requests, "post", lambda **kwargs: response) + + with pytest.raises(Exception) as error: + ServerRoboflowPlatformClient().post(endpoint="x", api_key="SECRETKEY123") + + cause = error.value.__cause__ + assert cause is not None, "the sanitised HTTPError must be chained as the cause" + assert "SECRETKEY123" not in str(cause) + assert "api_key=SE***23" in str(cause) + assert "SECRETKEY123" not in str(error.value) + assert "SECRETKEY123" not in response.url + + +def test_server_adapter_header_policy_matches_the_server(monkeypatch) -> None: + """Credit verification, serverless artefacts, the internal service secret, + the version header and ROBOFLOW_API_EXTRA_HEADERS merging all have to + survive the port. Values measured on the unchanged tree. + """ + import inference.core.roboflow_api as roboflow_api + from inference.core.interfaces.roboflow_platform_client import ( + ServerRoboflowPlatformClient, + ) + from inference.core.version import __version__ + + adapter = ServerRoboflowPlatformClient() + + monkeypatch.setattr(roboflow_api, "ROBOFLOW_API_EXTRA_HEADERS", None) + monkeypatch.setattr(roboflow_api, "GCP_SERVERLESS", False) + monkeypatch.setattr(roboflow_api, "ENFORCE_CREDITS_VERIFICATION", False) + monkeypatch.setattr(roboflow_api, "ROBOFLOW_INTERNAL_SERVICE_SECRET", None) + assert adapter.build_api_headers() == { + "X-Roboflow-Inference-Version": __version__, + "X-Allow-Chunked": "true", + } + assert adapter.build_weights_provider_headers() == { + "X-Roboflow-Inference-Version": __version__, + "X-Allow-Chunked": "true", + } + + monkeypatch.setattr(roboflow_api, "GCP_SERVERLESS", True) + monkeypatch.setattr(roboflow_api, "ENFORCE_CREDITS_VERIFICATION", True) + monkeypatch.setattr(roboflow_api, "ROBOFLOW_INTERNAL_SERVICE_SECRET", "s3cr3t") + assert adapter.build_weights_provider_headers() == { + "x-enforce-internal-artefacts-urls": "true", + "x-enforce-credits-verification": "true", + "X-Roboflow-Internal-Service-Secret": "s3cr3t", + "X-Roboflow-Inference-Version": __version__, + "X-Allow-Chunked": "true", + } + + monkeypatch.setattr(roboflow_api, "ROBOFLOW_API_EXTRA_HEADERS", '{"X-Extra": "1"}') + assert adapter.build_api_headers(explicit_headers={"X-Own": "2"}) == { + "X-Extra": "1", + "X-Own": "2", + "X-Roboflow-Inference-Version": __version__, + "X-Allow-Chunked": "true", + } + + +def test_the_two_api_key_redaction_implementations_agree() -> None: + # D3: the workflows caller of `api_key_safe_raise_for_status` moves to the + # plugin, so no copy is made - pin the equivalence that rests on. + from inference.core.utils.requests import deduct_api_key_from_string as server_impl + from inference_sdk.http.utils.requests import deduct_api_key_from_string as sdk_impl + + for case in ( + "https://x/y?api_key=abcdefghij&z=1", + "https://x/y?api_key=ab", + "https://x/y?service_secret=topsecret&api_key=abcdefghij", + "no credentials here", + ): + assert server_impl(case) == sdk_impl(value=case) From 2710b863d8b08b9bac5732eee2504f2a1ee41fe7 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:17:01 +0200 Subject: [PATCH 08/86] refactor(workflows): route Roboflow-proxied requests through the platform port platform_client is threaded alongside roboflow_api_key through all 49 helper signatures and 49 call sites, the three Qwen subclasses forward it to the OpenRouter base, post_to_roboflow_api is gone from workflows, and the 50 affected test calls and 46 block constructions carry the recording double. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../workflows/core_steps/common/openrouter.py | 15 +- .../models/foundation/anthropic_claude/v3.py | 19 +- .../models/foundation/anthropic_claude/v4.py | 19 +- .../models/foundation/google_gemini/v3.py | 19 +- .../models/foundation/google_gemini/v4.py | 19 +- .../models/foundation/google_gemini/v5.py | 19 +- .../models/foundation/google_vision_ocr/v1.py | 13 +- .../foundation/google_vision_ocr/v1_tensor.py | 13 +- .../core_steps/models/foundation/openai/v3.py | 19 +- .../core_steps/models/foundation/openai/v4.py | 19 +- .../core_steps/models/foundation/openai/v5.py | 19 +- .../core_steps/models/foundation/openai/v6.py | 19 +- .../models/foundation/qwen_vlm/v1.py | 13 +- .../models/foundation/qwen_vlm/v2.py | 13 +- .../models/foundation/qwen_vlm/v3.py | 13 +- .../models/foundation/spacexai/v1.py | 19 +- .../models/foundation/spacexai/v2.py | 19 +- .../core_steps/sinks/email_notification/v2.py | 19 +- .../core_steps/sinks/twilio/sms/v2.py | 19 +- scripts/phase9_check_test_references.py | 29 ++ scripts/phase9_platform_client.py | 266 ++++++++++++++ scripts/phase9_platform_client_tests.py | 196 +++++++++++ .../core_steps/common/test_openrouter.py | 62 ++-- .../common/test_platform_client_injection.py | 137 ++++++++ .../foundation/test_anthropic_claude.py | 130 +++---- .../foundation/test_anthropic_claude_v4.py | 21 +- .../test_anthropic_claude_v4_detection.py | 11 + .../foundation/test_google_gemini_v5.py | 21 +- .../models/foundation/test_openai_v4.py | 22 +- .../models/foundation/test_openai_v5.py | 30 +- .../models/foundation/test_openai_v6.py | 21 +- .../models/foundation/test_qwen_vlm.py | 18 +- .../models/foundation/test_qwen_vlm_v2.py | 21 ++ .../models/foundation/test_qwen_vlm_v3.py | 13 + .../models/foundation/test_spacexai.py | 12 + .../models/foundation/test_spacexai_v2.py | 23 +- .../core_steps/sinks/test_email_v2.py | 116 ++++--- .../sinks/test_email_v2_inline_images.py | 15 + .../core_steps/sinks/test_twilio_sms_v2.py | 326 ++++++++++-------- .../unit_tests/decontamination_baseline.txt | 18 +- .../prototypes/platform_client_double.py | 1 + .../prototypes/test_platform_client.py | 15 + 42 files changed, 1464 insertions(+), 387 deletions(-) create mode 100644 scripts/phase9_check_test_references.py create mode 100644 scripts/phase9_platform_client.py create mode 100644 scripts/phase9_platform_client_tests.py create mode 100644 tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py diff --git a/inference/core/workflows/core_steps/common/openrouter.py b/inference/core/workflows/core_steps/common/openrouter.py index 6d2fa35f2f..b874cbc82b 100644 --- a/inference/core/workflows/core_steps/common/openrouter.py +++ b/inference/core/workflows/core_steps/common/openrouter.py @@ -35,7 +35,6 @@ RoboflowAPIForbiddenError, RoboflowAPIUnsuccessfulRequestError, ) -from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.token_usage import ( parse_chat_completion_usage, @@ -54,6 +53,10 @@ WorkflowBlockManifest, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) logger = logging.getLogger(__name__) @@ -259,13 +262,15 @@ def __init__( self, model_manager: ModelsProvider, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._model_manager = model_manager self._roboflow_api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["model_manager", "api_key", "platform_client"] def execute_openrouter_batch( self, @@ -345,6 +350,7 @@ def execute_openrouter_batch_with_usage( single = partial( _execute_proxied_openrouter_request, roboflow_api_key=self._roboflow_api_key, + platform_client=self._platform_client, openrouter_api_key=openrouter_api_key, model=model, privacy_level=privacy_level, @@ -462,6 +468,7 @@ def _is_unsupported_reasoning_error(error: Exception) -> bool: def _execute_proxied_openrouter_request( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, openrouter_api_key: str, model: str, messages: List[dict], @@ -485,7 +492,7 @@ def _execute_proxied_openrouter_request( if quantizations is not None: payload["quantizations"] = list(quantizations) try: - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint="apiproxy/openrouter", api_key=roboflow_api_key, payload=payload, @@ -503,7 +510,7 @@ def _execute_proxied_openrouter_request( error, ) retry_payload = {k: v for k, v in payload.items() if k != "reasoning"} - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint="apiproxy/openrouter", api_key=roboflow_api_key, payload=retry_payload, diff --git a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py index 6790fa98df..3ede0743bc 100644 --- a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py @@ -9,7 +9,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA @@ -43,6 +42,10 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference.core.workflows.utils.images import downscale_image_keeping_aspect_ratio CLAUDE_MODELS = [ @@ -392,12 +395,14 @@ class AnthropicClaudeBlockV3(WorkflowBlock): def __init__( self, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -426,6 +431,7 @@ def run( inference_images = [i.to_inference_format() for i in images] raw_outputs = run_claude_prompting( roboflow_api_key=self._api_key, + platform_client=self._platform_client, images=inference_images, task_type=task_type, prompt=prompt, @@ -447,6 +453,7 @@ def run( def run_claude_prompting( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, images: List[Dict[str, Any]], task_type: TaskType, prompt: Optional[str], @@ -481,6 +488,7 @@ def run_claude_prompting( prompts.append(generated_prompt) return execute_claude_requests( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, anthropic_api_key=anthropic_api_key, prompts=prompts, model_version=model_version, @@ -494,6 +502,7 @@ def run_claude_prompting( def execute_claude_requests( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, anthropic_api_key: str, prompts: List[Tuple[Optional[str], List[dict]]], model_version: str, @@ -507,6 +516,7 @@ def execute_claude_requests( partial( execute_claude_request, roboflow_api_key=roboflow_api_key, + platform_client=platform_client, anthropic_api_key=anthropic_api_key, system_prompt=prompt[0], messages=prompt[1], @@ -530,6 +540,7 @@ def execute_claude_requests( def execute_claude_request( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, anthropic_api_key: str, system_prompt: Optional[str], messages: List[dict], @@ -543,6 +554,7 @@ def execute_claude_request( if anthropic_api_key.startswith(("rf_key:account", "rf_key:user:")): return _execute_proxied_claude_request( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, anthropic_api_key=anthropic_api_key, system_prompt=system_prompt, messages=messages, @@ -567,6 +579,7 @@ def execute_claude_request( def _execute_proxied_claude_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, anthropic_api_key: str, system_prompt: Optional[str], messages: List[dict], @@ -610,7 +623,7 @@ def _execute_proxied_claude_request( endpoint = "apiproxy/anthropic" try: - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint=endpoint, api_key=roboflow_api_key, payload=payload, diff --git a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py index c416bd9f87..9159d124f5 100644 --- a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py +++ b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py @@ -23,7 +23,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.token_usage import ( TOKEN_OUTPUT_DEFINITIONS, @@ -64,6 +63,10 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference.core.workflows.utils.images import downscale_image_keeping_aspect_ratio CLAUDE_MODELS = [ @@ -473,12 +476,14 @@ class AnthropicClaudeBlockV4(WorkflowBlock): def __init__( self, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -507,6 +512,7 @@ def run( inference_images = [i.to_inference_format() for i in images] raw_outputs = run_claude_prompting( roboflow_api_key=self._api_key, + platform_client=self._platform_client, images=inference_images, task_type=task_type, prompt=prompt, @@ -534,6 +540,7 @@ def run( def run_claude_prompting( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, images: List[Dict[str, Any]], task_type: TaskType, prompt: Optional[str], @@ -568,6 +575,7 @@ def run_claude_prompting( prompts.append(generated_prompt) return execute_claude_requests( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, anthropic_api_key=anthropic_api_key, prompts=prompts, model_version=model_version, @@ -648,6 +656,7 @@ def _encode_image_to_jpeg_bytes_with_quality( def execute_claude_requests( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, anthropic_api_key: str, prompts: List[Tuple[Optional[str], List[dict]]], model_version: str, @@ -661,6 +670,7 @@ def execute_claude_requests( partial( execute_claude_request, roboflow_api_key=roboflow_api_key, + platform_client=platform_client, anthropic_api_key=anthropic_api_key, system_prompt=prompt[0], messages=prompt[1], @@ -684,6 +694,7 @@ def execute_claude_requests( def execute_claude_request( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, anthropic_api_key: str, system_prompt: Optional[str], messages: List[dict], @@ -697,6 +708,7 @@ def execute_claude_request( if anthropic_api_key.startswith(("rf_key:account", "rf_key:user:")): return _execute_proxied_claude_request( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, anthropic_api_key=anthropic_api_key, system_prompt=system_prompt, messages=messages, @@ -721,6 +733,7 @@ def execute_claude_request( def _execute_proxied_claude_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, anthropic_api_key: str, system_prompt: Optional[str], messages: List[dict], @@ -764,7 +777,7 @@ def _execute_proxied_claude_request( endpoint = "apiproxy/anthropic" try: - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint=endpoint, api_key=roboflow_api_key, payload=payload, diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py index 6829a76413..2c7b005e66 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py @@ -9,7 +9,6 @@ from requests import Response from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA @@ -38,6 +37,10 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) GOOGLE_API_KEY_PATTERN = re.compile(r"key=(.[^&]*)") GOOGLE_API_KEY_VALUE_GROUP = 1 @@ -394,12 +397,14 @@ class GoogleGeminiBlockV3(WorkflowBlock): def __init__( self, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -427,6 +432,7 @@ def run( inference_images = [i.to_inference_format() for i in images] raw_outputs = run_gemini_prompting( roboflow_api_key=self._api_key, + platform_client=self._platform_client, images=inference_images, task_type=task_type, prompt=prompt, @@ -447,6 +453,7 @@ def run( def run_gemini_prompting( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, images: List[Dict[str, Any]], task_type: TaskType, prompt: Optional[str], @@ -488,6 +495,7 @@ def run_gemini_prompting( gemini_prompts.append(generated_prompt) return execute_gemini_requests( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, google_api_key=google_api_key, gemini_prompts=gemini_prompts, model_version=model_version, @@ -497,6 +505,7 @@ def run_gemini_prompting( def execute_gemini_requests( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, google_api_key: str, gemini_prompts: List[dict], model_version: str, @@ -506,6 +515,7 @@ def execute_gemini_requests( partial( execute_gemini_request, roboflow_api_key=roboflow_api_key, + platform_client=platform_client, google_api_key=google_api_key, prompt=prompt, model_version=model_version, @@ -524,6 +534,7 @@ def execute_gemini_requests( def execute_gemini_request( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, google_api_key: str, prompt: dict, model_version: str, @@ -532,6 +543,7 @@ def execute_gemini_request( if google_api_key.startswith(("rf_key:account", "rf_key:user:")): return _execute_proxied_gemini_request( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, google_api_key=google_api_key, prompt=prompt, model_version=model_version, @@ -546,6 +558,7 @@ def execute_gemini_request( def _execute_proxied_gemini_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, google_api_key: str, prompt: dict, model_version: str, @@ -560,7 +573,7 @@ def _execute_proxied_gemini_request( endpoint = "apiproxy/gemini" try: - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint=endpoint, api_key=roboflow_api_key, payload=payload, diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py index 602beec8b2..b514f1a36f 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py @@ -9,7 +9,6 @@ from requests import Response from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA @@ -38,6 +37,10 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) GOOGLE_API_KEY_PATTERN = re.compile(r"key=(.[^&]*)") GOOGLE_API_KEY_VALUE_GROUP = 1 @@ -402,12 +405,14 @@ class GoogleGeminiBlockV4(WorkflowBlock): def __init__( self, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -435,6 +440,7 @@ def run( inference_images = [i.to_inference_format() for i in images] raw_outputs = run_gemini_prompting( roboflow_api_key=self._api_key, + platform_client=self._platform_client, images=inference_images, task_type=task_type, prompt=prompt, @@ -455,6 +461,7 @@ def run( def run_gemini_prompting( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, images: List[Dict[str, Any]], task_type: TaskType, prompt: Optional[str], @@ -496,6 +503,7 @@ def run_gemini_prompting( gemini_prompts.append(generated_prompt) return execute_gemini_requests( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, google_api_key=google_api_key, gemini_prompts=gemini_prompts, model_version=model_version, @@ -505,6 +513,7 @@ def run_gemini_prompting( def execute_gemini_requests( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, google_api_key: str, gemini_prompts: List[dict], model_version: str, @@ -514,6 +523,7 @@ def execute_gemini_requests( partial( execute_gemini_request, roboflow_api_key=roboflow_api_key, + platform_client=platform_client, google_api_key=google_api_key, prompt=prompt, model_version=model_version, @@ -532,6 +542,7 @@ def execute_gemini_requests( def execute_gemini_request( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, google_api_key: str, prompt: dict, model_version: str, @@ -540,6 +551,7 @@ def execute_gemini_request( if google_api_key.startswith(("rf_key:account", "rf_key:user:")): return _execute_proxied_gemini_request( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, google_api_key=google_api_key, prompt=prompt, model_version=model_version, @@ -554,6 +566,7 @@ def execute_gemini_request( def _execute_proxied_gemini_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, google_api_key: str, prompt: dict, model_version: str, @@ -568,7 +581,7 @@ def _execute_proxied_gemini_request( endpoint = "apiproxy/gemini" try: - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint=endpoint, api_key=roboflow_api_key, payload=payload, diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py index 33ca3fc9a1..eaa85732a6 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py @@ -9,7 +9,6 @@ from requests import Response from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.reasoning import ( attach_reasoning_levels, @@ -47,6 +46,10 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) GOOGLE_API_KEY_PATTERN = re.compile(r"key=(.[^&]*)") GOOGLE_API_KEY_VALUE_GROUP = 1 @@ -446,12 +449,14 @@ class GoogleGeminiBlockV5(WorkflowBlock): def __init__( self, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -479,6 +484,7 @@ def run( inference_images = [i.to_inference_format() for i in images] raw_outputs = run_gemini_prompting( roboflow_api_key=self._api_key, + platform_client=self._platform_client, images=inference_images, task_type=task_type, prompt=prompt, @@ -505,6 +511,7 @@ def run( def run_gemini_prompting( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, images: List[Dict[str, Any]], task_type: TaskType, prompt: Optional[str], @@ -546,6 +553,7 @@ def run_gemini_prompting( gemini_prompts.append(generated_prompt) return execute_gemini_requests( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, google_api_key=google_api_key, gemini_prompts=gemini_prompts, model_version=model_version, @@ -555,6 +563,7 @@ def run_gemini_prompting( def execute_gemini_requests( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, google_api_key: str, gemini_prompts: List[dict], model_version: str, @@ -564,6 +573,7 @@ def execute_gemini_requests( partial( execute_gemini_request, roboflow_api_key=roboflow_api_key, + platform_client=platform_client, google_api_key=google_api_key, prompt=prompt, model_version=model_version, @@ -582,6 +592,7 @@ def execute_gemini_requests( def execute_gemini_request( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, google_api_key: str, prompt: dict, model_version: str, @@ -590,6 +601,7 @@ def execute_gemini_request( if google_api_key.startswith(("rf_key:account", "rf_key:user:")): return _execute_proxied_gemini_request( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, google_api_key=google_api_key, prompt=prompt, model_version=model_version, @@ -604,6 +616,7 @@ def execute_gemini_request( def _execute_proxied_gemini_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, google_api_key: str, prompt: dict, model_version: str, @@ -618,7 +631,7 @@ def _execute_proxied_gemini_request( endpoint = "apiproxy/gemini" try: - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint=endpoint, api_key=roboflow_api_key, payload=payload, diff --git a/inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1.py index 1edbb2a5b1..188b96b9c9 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1.py @@ -7,7 +7,6 @@ from pydantic import ConfigDict, Field from supervision.config import CLASS_NAME_DATA_FIELD -from inference.core.roboflow_api import post_to_roboflow_api from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_sv_detections, ) @@ -34,6 +33,10 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) LONG_DESCRIPTION = """ Detect text in images using Google Vision OCR. @@ -123,12 +126,14 @@ class GoogleVisionOCRBlockV1(WorkflowBlock): def __init__( self, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._roboflow_api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -159,6 +164,7 @@ def run( if api_key.startswith(("rf_key:account", "rf_key:user:")): result = _execute_proxied_google_vision_request( roboflow_api_key=self._roboflow_api_key, + platform_client=self._platform_client, google_vision_api_key=api_key, request_json=request_json, ) @@ -189,6 +195,7 @@ def _build_request_json( def _execute_proxied_google_vision_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, google_vision_api_key: str, request_json: dict, ) -> dict: @@ -198,7 +205,7 @@ def _execute_proxied_google_vision_request( } try: - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint="apiproxy/google_vision_ocr", api_key=roboflow_api_key, payload=payload, diff --git a/inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1_tensor.py index d0d5126662..ee3ddd28cf 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1_tensor.py @@ -8,7 +8,6 @@ from pydantic import ConfigDict, Field from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE -from inference.core.roboflow_api import post_to_roboflow_api from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, ) @@ -36,6 +35,10 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference_models.models.base.object_detection import Detections # The recognized text per block is carried per box in @@ -134,12 +137,14 @@ class GoogleVisionOCRBlockV1(WorkflowBlock): def __init__( self, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._roboflow_api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -170,6 +175,7 @@ def run( if api_key.startswith(("rf_key:account", "rf_key:user:")): result = _execute_proxied_google_vision_request( roboflow_api_key=self._roboflow_api_key, + platform_client=self._platform_client, google_vision_api_key=api_key, request_json=request_json, ) @@ -200,6 +206,7 @@ def _build_request_json( def _execute_proxied_google_vision_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, google_vision_api_key: str, request_json: dict, ) -> dict: @@ -209,7 +216,7 @@ def _execute_proxied_google_vision_request( } try: - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint="apiproxy/google_vision_ocr", api_key=roboflow_api_key, payload=payload, diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v3.py b/inference/core/workflows/core_steps/models/foundation/openai/v3.py index 7ba1e00ac7..e1d3ead1e1 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v3.py @@ -9,7 +9,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA @@ -37,6 +36,10 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) SUPPORTED_TASK_TYPES_LIST = [ "unconstrained", @@ -265,12 +268,14 @@ class OpenAIBlockV3(WorkflowBlock): def __init__( self, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -297,6 +302,7 @@ def run( inference_images = [i.to_inference_format() for i in images] raw_outputs = run_gpt_4v_llm_prompting( roboflow_api_key=self._api_key, + platform_client=self._platform_client, images=inference_images, task_type=task_type, prompt=prompt, @@ -321,6 +327,7 @@ def run_gpt_4v_llm_prompting( output_structure: Optional[Dict[str, str]], classes: Optional[List[str]], roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, openai_api_key: Optional[str], gpt_model_version: str, gpt_image_detail: Literal["auto", "high", "low"], @@ -346,6 +353,7 @@ def run_gpt_4v_llm_prompting( gpt4_prompts.append(generated_prompt) return execute_gpt_4v_requests( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, openai_api_key=openai_api_key, gpt4_prompts=gpt4_prompts, gpt_model_version=gpt_model_version, @@ -357,6 +365,7 @@ def run_gpt_4v_llm_prompting( def execute_gpt_4v_requests( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, openai_api_key: str, gpt4_prompts: List[List[dict]], gpt_model_version: str, @@ -368,6 +377,7 @@ def execute_gpt_4v_requests( partial( execute_gpt_4v_request, roboflow_api_key=roboflow_api_key, + platform_client=platform_client, openai_api_key=openai_api_key, prompt=prompt, gpt_model_version=gpt_model_version, @@ -388,6 +398,7 @@ def execute_gpt_4v_requests( def _execute_proxied_openai_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, openai_api_key: str, prompt: List[dict], gpt_model_version: str, @@ -409,7 +420,7 @@ def _execute_proxied_openai_request( try: # Use the Roboflow API post function (this enures proper auth headers used based on invocation context) - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint=endpoint, api_key=roboflow_api_key, payload=payload, @@ -455,6 +466,7 @@ def _execute_openai_request( def execute_gpt_4v_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, openai_api_key: str, prompt: List[dict], gpt_model_version: str, @@ -465,6 +477,7 @@ def execute_gpt_4v_request( if openai_api_key.startswith(("rf_key:account", "rf_key:user:")): return _execute_proxied_openai_request( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, openai_api_key=openai_api_key, prompt=prompt, gpt_model_version=gpt_model_version, diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v4.py b/inference/core/workflows/core_steps/models/foundation/openai/v4.py index f5a30a8a5b..26e3259434 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v4.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v4.py @@ -8,7 +8,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA @@ -36,6 +35,10 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) OPENAI_MODELS = [ { @@ -382,12 +385,14 @@ class OpenAIBlockV4(WorkflowBlock): def __init__( self, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -415,6 +420,7 @@ def run( inference_images = [i.to_inference_format() for i in images] raw_outputs = run_openai_prompting( roboflow_api_key=self._api_key, + platform_client=self._platform_client, images=inference_images, task_type=task_type, prompt=prompt, @@ -435,6 +441,7 @@ def run( def run_openai_prompting( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, images: List[Dict[str, Any]], task_type: TaskType, prompt: Optional[str], @@ -466,6 +473,7 @@ def run_openai_prompting( openai_prompts.append(generated_prompt) return execute_openai_requests( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, openai_api_key=openai_api_key, openai_prompts=openai_prompts, model_version=model_version, @@ -478,6 +486,7 @@ def run_openai_prompting( def execute_openai_requests( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, openai_api_key: str, openai_prompts: List[dict], model_version: str, @@ -490,6 +499,7 @@ def execute_openai_requests( partial( execute_openai_request, roboflow_api_key=roboflow_api_key, + platform_client=platform_client, openai_api_key=openai_api_key, instructions=prompt.get("instructions"), input_content=prompt["input"], @@ -512,6 +522,7 @@ def execute_openai_requests( def _execute_proxied_openai_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, openai_api_key: str, instructions: Optional[str], input_content: List[dict], @@ -551,7 +562,7 @@ def _execute_proxied_openai_request( try: # Use the Roboflow API post function (this ensures proper auth headers used based on invocation context) - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint=endpoint, api_key=roboflow_api_key, payload=payload, @@ -684,6 +695,7 @@ def _execute_direct_openai_request( def execute_openai_request( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, openai_api_key: str, instructions: Optional[str], input_content: List[dict], @@ -695,6 +707,7 @@ def execute_openai_request( if openai_api_key.startswith(("rf_key:account", "rf_key:user:")): return _execute_proxied_openai_request( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, openai_api_key=openai_api_key, instructions=instructions, input_content=input_content, diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v5.py b/inference/core/workflows/core_steps/models/foundation/openai/v5.py index 6d085f579d..a95543858a 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v5.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v5.py @@ -10,7 +10,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import ( DETECTION_MAX_EDGE_PIXELS, @@ -42,6 +41,10 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) # Detection prompt styles (selected per model based on a 17-model x 10-format # x 100-image benchmark; see the object-detection prompt builders below): @@ -526,12 +529,14 @@ class OpenAIBlockV5(WorkflowBlock): def __init__( self, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -559,6 +564,7 @@ def run( inference_images = [i.to_inference_format() for i in images] raw_outputs = run_openai_prompting( roboflow_api_key=self._api_key, + platform_client=self._platform_client, images=inference_images, task_type=task_type, prompt=prompt, @@ -579,6 +585,7 @@ def run( def run_openai_prompting( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, images: List[Dict[str, Any]], task_type: TaskType, prompt: Optional[str], @@ -642,6 +649,7 @@ def run_openai_prompting( openai_prompts.append(generated_prompt) return execute_openai_requests( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, openai_api_key=openai_api_key, openai_prompts=openai_prompts, model_version=model_version, @@ -705,6 +713,7 @@ def _encode_image_to_png_bytes(image: np.ndarray) -> bytes: def execute_openai_requests( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, openai_api_key: str, openai_prompts: List[dict], model_version: str, @@ -734,6 +743,7 @@ def execute_openai_requests( partial( execute_openai_request, roboflow_api_key=roboflow_api_key, + platform_client=platform_client, openai_api_key=openai_api_key, instructions=prompt.get("instructions"), input_content=prompt["input"], @@ -757,6 +767,7 @@ def execute_openai_requests( def _execute_proxied_openai_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, openai_api_key: str, instructions: Optional[str], input_content: List[dict], @@ -800,7 +811,7 @@ def _execute_proxied_openai_request( try: # Use the Roboflow API post function (this ensures proper auth headers used based on invocation context) - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint=endpoint, api_key=roboflow_api_key, payload=payload, @@ -937,6 +948,7 @@ def _execute_direct_openai_request( def execute_openai_request( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, openai_api_key: str, instructions: Optional[str], input_content: List[dict], @@ -974,6 +986,7 @@ def execute_openai_request( return _execute_proxied_openai_request( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, openai_api_key=openai_api_key, instructions=instructions, input_content=input_content, diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v6.py b/inference/core/workflows/core_steps/models/foundation/openai/v6.py index 3f8be1a4fa..2ea97a1c97 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v6.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v6.py @@ -10,7 +10,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.reasoning import ( attach_reasoning_levels, @@ -51,6 +50,10 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) # Detection prompt styles (selected per model based on a 17-model x 10-format # x 100-image benchmark; see the object-detection prompt builders below): @@ -555,12 +558,14 @@ class OpenAIBlockV6(WorkflowBlock): def __init__( self, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -588,6 +593,7 @@ def run( inference_images = [i.to_inference_format() for i in images] raw_outputs = run_openai_prompting( roboflow_api_key=self._api_key, + platform_client=self._platform_client, images=inference_images, task_type=task_type, prompt=prompt, @@ -614,6 +620,7 @@ def run( def run_openai_prompting( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, images: List[Dict[str, Any]], task_type: TaskType, prompt: Optional[str], @@ -678,6 +685,7 @@ def run_openai_prompting( openai_prompts.append(generated_prompt) return execute_openai_requests( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, openai_api_key=openai_api_key, openai_prompts=openai_prompts, model_version=model_version, @@ -741,6 +749,7 @@ def _encode_image_to_png_bytes(image: np.ndarray) -> bytes: def execute_openai_requests( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, openai_api_key: str, openai_prompts: List[dict], model_version: str, @@ -771,6 +780,7 @@ def execute_openai_requests( partial( execute_openai_request, roboflow_api_key=roboflow_api_key, + platform_client=platform_client, openai_api_key=openai_api_key, instructions=prompt.get("instructions"), input_content=prompt["input"], @@ -794,6 +804,7 @@ def execute_openai_requests( def _execute_proxied_openai_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, openai_api_key: str, instructions: Optional[str], input_content: List[dict], @@ -837,7 +848,7 @@ def _execute_proxied_openai_request( try: # Use the Roboflow API post function (this ensures proper auth headers used based on invocation context) - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint=endpoint, api_key=roboflow_api_key, payload=payload, @@ -981,6 +992,7 @@ def _execute_direct_openai_request( def execute_openai_request( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, openai_api_key: str, instructions: Optional[str], input_content: List[dict], @@ -1019,6 +1031,7 @@ def execute_openai_request( return _execute_proxied_openai_request( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, openai_api_key=openai_api_key, instructions=instructions, input_content=input_content, diff --git a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py index 91135b13b1..92fe879131 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py @@ -74,6 +74,10 @@ third_party_model, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference_sdk import InferenceConfiguration, InferenceHTTPClient # --------------------------------------------------------------------------- @@ -783,13 +787,18 @@ def __init__( model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): - super().__init__(model_manager=model_manager, api_key=api_key) + super().__init__( + model_manager=model_manager, + api_key=api_key, + platform_client=platform_client, + ) self._step_execution_mode = step_execution_mode @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["model_manager", "api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py index 6814a2034d..63f5065ba2 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py @@ -81,6 +81,10 @@ third_party_model, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference_sdk import InferenceHTTPClient # --------------------------------------------------------------------------- @@ -1170,13 +1174,18 @@ def __init__( model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): - super().__init__(model_manager=model_manager, api_key=api_key) + super().__init__( + model_manager=model_manager, + api_key=api_key, + platform_client=platform_client, + ) self._step_execution_mode = step_execution_mode @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["model_manager", "api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py index 1e040e37da..1a17b72a06 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py @@ -91,6 +91,10 @@ third_party_model, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference_sdk import InferenceHTTPClient # --------------------------------------------------------------------------- @@ -1210,13 +1214,18 @@ def __init__( model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): - super().__init__(model_manager=model_manager, api_key=api_key) + super().__init__( + model_manager=model_manager, + api_key=api_key, + platform_client=platform_client, + ) self._step_execution_mode = step_execution_mode @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["model_manager", "api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/core_steps/models/foundation/spacexai/v1.py b/inference/core/workflows/core_steps/models/foundation/spacexai/v1.py index dfc0b28902..964b663dd0 100644 --- a/inference/core/workflows/core_steps/models/foundation/spacexai/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/spacexai/v1.py @@ -18,7 +18,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA @@ -46,6 +45,10 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) XAI_BASE_URL = "https://api.x.ai/v1" @@ -329,12 +332,14 @@ class SpaceXAIBlockV1(WorkflowBlock): def __init__( self, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -361,6 +366,7 @@ def run( inference_images = [i.to_inference_format() for i in images] raw_outputs = run_spacexai_prompting( roboflow_api_key=self._api_key, + platform_client=self._platform_client, images=inference_images, task_type=task_type, prompt=prompt, @@ -380,6 +386,7 @@ def run( def run_spacexai_prompting( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, images: List[Dict[str, Any]], task_type: TaskType, prompt: Optional[str], @@ -433,6 +440,7 @@ def run_spacexai_prompting( spacexai_prompts.append(generated_prompt) return execute_spacexai_requests( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, xai_api_key=xai_api_key, spacexai_prompts=spacexai_prompts, model_version=model_version, @@ -479,6 +487,7 @@ def _encode_image_to_png_bytes(image: np.ndarray) -> bytes: def execute_spacexai_requests( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, xai_api_key: str, spacexai_prompts: List[dict], model_version: str, @@ -507,6 +516,7 @@ def execute_spacexai_requests( partial( execute_spacexai_request, roboflow_api_key=roboflow_api_key, + platform_client=platform_client, xai_api_key=xai_api_key, instructions=prompt.get("instructions"), input_content=prompt["input"], @@ -529,6 +539,7 @@ def execute_spacexai_requests( def execute_spacexai_request( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, xai_api_key: str, instructions: Optional[str], input_content: List[dict], @@ -560,6 +571,7 @@ def execute_spacexai_request( return _execute_proxied_spacexai_request( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, xai_api_key=xai_api_key, instructions=instructions, input_content=input_content, @@ -582,6 +594,7 @@ def execute_spacexai_request( def _execute_proxied_spacexai_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, xai_api_key: str, instructions: Optional[str], input_content: List[dict], @@ -611,7 +624,7 @@ def _execute_proxied_spacexai_request( payload["reasoning"] = {"effort": reasoning_effort} try: - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint="apiproxy/xai", api_key=roboflow_api_key, payload=payload, diff --git a/inference/core/workflows/core_steps/models/foundation/spacexai/v2.py b/inference/core/workflows/core_steps/models/foundation/spacexai/v2.py index 413ed41d42..ee4bc82b85 100644 --- a/inference/core/workflows/core_steps/models/foundation/spacexai/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/spacexai/v2.py @@ -18,7 +18,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.reasoning import ( attach_reasoning_levels, @@ -54,6 +53,10 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) XAI_BASE_URL = "https://api.x.ai/v1" @@ -358,12 +361,14 @@ class SpaceXAIBlockV2(WorkflowBlock): def __init__( self, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -390,6 +395,7 @@ def run( inference_images = [i.to_inference_format() for i in images] raw_outputs = run_spacexai_prompting( roboflow_api_key=self._api_key, + platform_client=self._platform_client, images=inference_images, task_type=task_type, prompt=prompt, @@ -415,6 +421,7 @@ def run( def run_spacexai_prompting( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, images: List[Dict[str, Any]], task_type: TaskType, prompt: Optional[str], @@ -468,6 +475,7 @@ def run_spacexai_prompting( spacexai_prompts.append(generated_prompt) return execute_spacexai_requests( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, xai_api_key=xai_api_key, spacexai_prompts=spacexai_prompts, model_version=model_version, @@ -514,6 +522,7 @@ def _encode_image_to_png_bytes(image: np.ndarray) -> bytes: def execute_spacexai_requests( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, xai_api_key: str, spacexai_prompts: List[dict], model_version: str, @@ -542,6 +551,7 @@ def execute_spacexai_requests( partial( execute_spacexai_request, roboflow_api_key=roboflow_api_key, + platform_client=platform_client, xai_api_key=xai_api_key, instructions=prompt.get("instructions"), input_content=prompt["input"], @@ -564,6 +574,7 @@ def execute_spacexai_requests( def execute_spacexai_request( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, xai_api_key: str, instructions: Optional[str], input_content: List[dict], @@ -595,6 +606,7 @@ def execute_spacexai_request( return _execute_proxied_spacexai_request( roboflow_api_key=roboflow_api_key, + platform_client=platform_client, xai_api_key=xai_api_key, instructions=instructions, input_content=input_content, @@ -617,6 +629,7 @@ def execute_spacexai_request( def _execute_proxied_spacexai_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, xai_api_key: str, instructions: Optional[str], input_content: List[dict], @@ -651,7 +664,7 @@ def _execute_proxied_spacexai_request( payload["reasoning"] = {"effort": reasoning_effort} try: - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint="apiproxy/xai", api_key=roboflow_api_key, payload=payload, diff --git a/inference/core/workflows/core_steps/sinks/email_notification/v2.py b/inference/core/workflows/core_steps/sinks/email_notification/v2.py index a8e91e1aea..d4a0d52c0c 100644 --- a/inference/core/workflows/core_steps/sinks/email_notification/v2.py +++ b/inference/core/workflows/core_steps/sinks/email_notification/v2.py @@ -8,7 +8,6 @@ from pydantic import ConfigDict, Field, field_validator -from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.query_language.entities.operations import ( AllOperationsType, @@ -43,6 +42,10 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) LONG_DESCRIPTION = """ The **Email Notification** block allows users to send email notifications as part of a workflow. @@ -441,16 +444,24 @@ def __init__( thread_pool_executor: Optional[ThreadPoolExecutor], api_key: Optional[str], disable_sinks: bool = False, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._background_tasks = background_tasks self._thread_pool_executor = thread_pool_executor self._api_key = api_key self._disable_sinks = disable_sinks self._last_notification_fired: Optional[datetime] = None + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["background_tasks", "thread_pool_executor", "api_key", "disable_sinks"] + return [ + "background_tasks", + "thread_pool_executor", + "api_key", + "disable_sinks", + "platform_client", + ] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -519,6 +530,7 @@ def run( send_email_handler = partial( send_email_via_roboflow_proxy, roboflow_api_key=self._api_key, + platform_client=self._platform_client, receiver_email=receiver_email, cc_receiver_email=cc_receiver_email, bcc_receiver_email=bcc_receiver_email, @@ -790,6 +802,7 @@ def process_attachments(attachments: Dict[str, Any]) -> Dict[str, bytes]: def send_email_via_roboflow_proxy( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, receiver_email: List[str], cc_receiver_email: Optional[List[str]], bcc_receiver_email: Optional[List[str]], @@ -893,7 +906,7 @@ def handle_email_proxy_error(status_code: int, http_error: Exception) -> None: endpoint = "apiproxy/email" - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint=endpoint, api_key=roboflow_api_key, payload=payload, diff --git a/inference/core/workflows/core_steps/sinks/twilio/sms/v2.py b/inference/core/workflows/core_steps/sinks/twilio/sms/v2.py index 6c2702c263..ec6c915d11 100644 --- a/inference/core/workflows/core_steps/sinks/twilio/sms/v2.py +++ b/inference/core/workflows/core_steps/sinks/twilio/sms/v2.py @@ -16,7 +16,6 @@ RoboflowAPIForbiddenError, RoboflowAPIUnsuccessfulRequestError, ) -from inference.core.roboflow_api import post_to_roboflow_api from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.query_language.entities.operations import ( AllOperationsType, @@ -47,6 +46,10 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) LONG_DESCRIPTION = """ The **Twilio SMS/MMS Notification** block allows users to send text and multimedia messages as part of a workflow. @@ -341,6 +344,7 @@ def __init__( thread_pool_executor: Optional[ThreadPoolExecutor], api_key: Optional[str], disable_sinks: bool = False, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._background_tasks = background_tasks self._thread_pool_executor = thread_pool_executor @@ -348,10 +352,17 @@ def __init__( self._disable_sinks = disable_sinks self._last_notification_fired: Optional[datetime] = None self._clients: Dict[str, Client] = {} + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["background_tasks", "thread_pool_executor", "api_key", "disable_sinks"] + return [ + "background_tasks", + "thread_pool_executor", + "api_key", + "disable_sinks", + "platform_client", + ] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -404,6 +415,7 @@ def run( send_sms_handler = partial( send_sms_via_roboflow_proxy, roboflow_api_key=self._api_key, + platform_client=self._platform_client, receiver_number=receiver_number, message=message, message_parameters=message_parameters, @@ -662,6 +674,7 @@ def serialize_media_for_api( def send_sms_via_roboflow_proxy( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, receiver_number: str, message: str, message_parameters: Dict[str, Any], @@ -724,7 +737,7 @@ def handle_sms_proxy_error(status_code: int, http_error: Exception) -> None: endpoint = "apiproxy/twilio" - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint=endpoint, api_key=roboflow_api_key, payload=payload, diff --git a/scripts/phase9_check_test_references.py b/scripts/phase9_check_test_references.py new file mode 100644 index 0000000000..0b952e615b --- /dev/null +++ b/scripts/phase9_check_test_references.py @@ -0,0 +1,29 @@ +"""AST check: mock targets (string, f-string, patch.object) and `from` imports naming +the given symbols under a test root. Prints file:line per hit and a TOTAL; exits 1 on any hit +outside --allow files.""" +import argparse, ast, pathlib, sys +p = argparse.ArgumentParser() +p.add_argument("root"); p.add_argument("--symbols", required=True); p.add_argument("--allow", default="") +a = p.parse_args(); symbols = set(a.symbols.split(",")); allow = {x for x in a.allow.split(",") if x} +hits = [] +for path in sorted(pathlib.Path(a.root).rglob("*.py")): + try: tree = ast.parse(path.read_text(encoding="utf-8")) + except SyntaxError: continue + for n in ast.walk(tree): + if isinstance(n, ast.ImportFrom) and n.module in {"inference.core.roboflow_api", "inference.core.utils.url_utils"}: + for al in n.names: + if al.name in symbols: hits.append((str(path), n.lineno, f"import {al.name}")) + if isinstance(n, ast.Call): + f = n.func + is_patch = (isinstance(f, ast.Name) and f.id == "patch") or (isinstance(f, ast.Attribute) and f.attr in {"patch", "object", "setattr"}) + if not is_patch: continue + for arg in list(n.args) + [k.value for k in n.keywords]: + text = None + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): text = arg.value + elif isinstance(arg, ast.JoinedStr): text = "".join(v.value for v in arg.values if isinstance(v, ast.Constant)) + if text and (text in symbols or text.rsplit(".", 1)[-1] in symbols): + hits.append((str(path), n.lineno, f"mock target {text.rsplit('.', 1)[-1]}")); break +bad = [h for h in hits if h[0] not in allow] +for h in hits: print(("allowed " if h[0] in allow else "") + f"{h[0]}:{h[1]} {h[2]}") +print(f"TOTAL {len(hits)} references, {len(bad)} outside the allow-list") +sys.exit(1 if bad else 0) diff --git a/scripts/phase9_platform_client.py b/scripts/phase9_platform_client.py new file mode 100644 index 0000000000..f3d8974782 --- /dev/null +++ b/scripts/phase9_platform_client.py @@ -0,0 +1,266 @@ +"""Phase 9: give a Workflow block the `platform_client` port, in one pass. + +Three transformations computed from ONE parse and applied as (line, col) spans +bottom-up, so they cannot reject each other's output: + + A every top-level `def` with a `roboflow_api_key` parameter gains + `platform_client: RoboflowPlatformClient` right after it, and every call + passing `roboflow_api_key=` gains `platform_client=`; + B the named block class gains the `platform_client` init parameter, the + `self._platform_client` assignment (or `platform_client=platform_client` + forwarded to `super().__init__`), and `"platform_client"` in + `get_init_parameters()`; + C `from inference.core.roboflow_api import post_to_roboflow_api` is dropped, + `post_to_roboflow_api(` becomes `platform_client.post(`, and the port + import is added. + +Guards are SPECIFIC - a def's argument list, a call's keyword set, the +`get_init_parameters` literal, the constructor's argument list - never +file-wide or class-wide substring presence. That makes the script idempotent +and order-independent, and it is why Task 9.5 can reuse it on files that still +import OTHER names from `inference.core.roboflow_api`: the verifier rejects a +surviving `post_to_roboflow_api` import, not the module. + +The constructor parameter is appended AFTER the last default value when the +signature has one (`arg.end_col_offset` stops after the annotation, before +` = False`), which is what `email_notification/v2.py` needs. +""" + +import argparse +import ast +import pathlib +import sys + +PARAM_ANNOTATION = "platform_client: RoboflowPlatformClient" +CTOR_PARAM = "platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT" +MAPPING = { + "roboflow_api_key": "platform_client", + "self._api_key": "self._platform_client", + "self._roboflow_api_key": "self._platform_client", +} +IMPORT_BLOCK = ( + "from inference.core.workflows.prototypes.platform_client import (\n" + " OFFLINE_PLATFORM_CLIENT,\n" + " RoboflowPlatformClient,\n" + ")" +) +OLD_IMPORT_MODULE = "inference.core.roboflow_api" +PROXY_HELPER = "post_to_roboflow_api" + + +def _apply(lines, edits): + """edits: (lineno, col, end_lineno, end_col, text), 1-based lines.""" + for lineno, col, end_lineno, end_col, text in sorted( + edits, key=lambda e: (e[0], e[1]), reverse=True + ): + first, last = lines[lineno - 1], lines[end_lineno - 1] + lines[lineno - 1 : end_lineno] = [first[:col] + text + last[end_col:]] + return lines + + +def transform(source: str, class_name: str, path: str): + newline = "\r\n" if "\r\n" in source else "\n" + lines = source.split(newline) + tree = ast.parse(source) + edits = [] + stats = {"defs": 0, "calls": 0, "ctor": 0, "gip": 0, "super": 0, + "post_calls": 0, "old_imports": 0} + + for node in tree.body: # A: defs + if not isinstance(node, ast.FunctionDef): + continue + names = [a.arg for a in node.args.args] + if "roboflow_api_key" not in names or "platform_client" in names: + continue + if (node.args.vararg or node.args.kwarg or node.args.kwonlyargs + or node.args.posonlyargs): + raise SystemExit(f"{path}: unsupported signature {node.name}") + arg = node.args.args[names.index("roboflow_api_key")] + edits.append((arg.end_lineno, arg.end_col_offset, arg.end_lineno, + arg.end_col_offset, f", {PARAM_ANNOTATION}")) + stats["defs"] += 1 + + for node in ast.walk(tree): # A: calls + if not isinstance(node, ast.Call): + continue + keys = {k.arg for k in node.keywords} + if "roboflow_api_key" not in keys or "platform_client" in keys: + continue + kw = next(k for k in node.keywords if k.arg == "roboflow_api_key") + expr = ast.unparse(kw.value) + if expr not in MAPPING: + raise SystemExit( + f"{path}: unexpected roboflow_api_key expression {expr!r} " + f"at line {kw.value.lineno}" + ) + edits.append((kw.value.end_lineno, kw.value.end_col_offset, + kw.value.end_lineno, kw.value.end_col_offset, + f", platform_client={MAPPING[expr]}")) + stats["calls"] += 1 + + target = next((n for n in ast.walk(tree) # B: the class + if isinstance(n, ast.ClassDef) and n.name == class_name), None) + if target is None: + raise SystemExit(f"{path}: no class {class_name}") + init = next((n for n in target.body + if isinstance(n, ast.FunctionDef) and n.name == "__init__"), None) + gip = next((n for n in target.body + if isinstance(n, ast.FunctionDef) and n.name == "get_init_parameters"), None) + if init is None or gip is None: + raise SystemExit(f"{path}: {class_name} needs __init__ and get_init_parameters") + + ret = next((n for n in ast.walk(gip) if isinstance(n, ast.Return)), None) + if ret is None or not isinstance(ret.value, ast.List): + raise SystemExit(f"{path}: {class_name}.get_init_parameters must return a list") + declared = [e.value for e in ret.value.elts + if isinstance(e, ast.Constant) and isinstance(e.value, str)] + if len(declared) != len(ret.value.elts): + raise SystemExit(f"{path}: non-literal in get_init_parameters") + if "platform_client" not in declared: + rendered = " return [" + ", ".join( + f'"{n}"' for n in declared + ["platform_client"]) + "]" + edits.append((ret.lineno, 0, ret.end_lineno, + len(lines[ret.end_lineno - 1]), rendered)) + stats["gip"] += 1 + + if "platform_client" not in [a.arg for a in init.args.args]: + last_arg = init.args.args[-1] + if last_arg.arg == "self": + raise SystemExit(f"{path}: {class_name}.__init__ takes no parameters") + anchor = init.args.defaults[-1] if init.args.defaults else last_arg + edits.append((anchor.end_lineno, anchor.end_col_offset, anchor.end_lineno, + anchor.end_col_offset, f", {CTOR_PARAM}")) + stats["ctor"] += 1 + super_call = None + for node in ast.walk(init): + if (isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "__init__" + and isinstance(node.func.value, ast.Call) + and isinstance(node.func.value.func, ast.Name) + and node.func.value.func.id == "super"): + super_call = node + if super_call is not None: + last_kw = super_call.keywords[-1] + edits.append((last_kw.value.end_lineno, last_kw.value.end_col_offset, + last_kw.value.end_lineno, last_kw.value.end_col_offset, + ", platform_client=platform_client")) + stats["super"] += 1 + else: + end = init.body[-1].end_lineno + edits.append((end, len(lines[end - 1]), end, len(lines[end - 1]), + newline + " self._platform_client = platform_client")) + + for node in ast.walk(tree): # C: the proxy call + if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id == PROXY_HELPER): + edits.append((node.func.lineno, node.func.col_offset, + node.func.end_lineno, node.func.end_col_offset, + "platform_client.post")) + stats["post_calls"] += 1 + old_import_span = None + for node in tree.body: + if (isinstance(node, ast.ImportFrom) and node.module == OLD_IMPORT_MODULE + and [a.name for a in node.names] == [PROXY_HELPER]): + old_import_span = (node.lineno, node.end_lineno) + stats["old_imports"] += 1 + + updated = newline.join(_apply(lines, edits)) + if old_import_span is not None: + out = updated.split(newline) + start, end = old_import_span + del out[start - 1 : end] + updated = newline.join(out) + if "prototypes.platform_client" not in updated: + out = updated.split(newline) + anchor = None + for node in ast.parse(updated).body: + if isinstance(node, (ast.Import, ast.ImportFrom)): + anchor = node + if anchor is None: + raise SystemExit(f"{path}: no import anchor") + out.insert(anchor.end_lineno, IMPORT_BLOCK) + updated = newline.join(out) + ast.parse(updated) + return updated, stats + + +def verify(source: str, class_name: str, path: str): + """Post-state, SPECIFIC to what this script removes: the proxy helper. + + Other `inference.core.roboflow_api` names (header builders in the Task 9.5 + inputs) legitimately survive this script; `scripts/phase9_platform_headers.py` + removes and verifies those. + """ + tree = ast.parse(source) + problems = [] + for node in tree.body: + if isinstance(node, ast.FunctionDef): + names = [a.arg for a in node.args.args] + if "roboflow_api_key" in names and "platform_client" not in names: + problems.append(f"def {node.name} lacks platform_client") + for node in ast.walk(tree): + if isinstance(node, ast.Call): + keys = {k.arg for k in node.keywords} + if "roboflow_api_key" in keys and "platform_client" not in keys: + problems.append(f"call at line {node.lineno} lacks platform_client") + if isinstance(node, ast.Name) and node.id == PROXY_HELPER: + problems.append(f"{PROXY_HELPER} referenced at line {node.lineno}") + if (isinstance(node, ast.ImportFrom) and node.module == OLD_IMPORT_MODULE + and any(a.name == PROXY_HELPER for a in node.names)): + problems.append(f"{PROXY_HELPER} import survives at line {node.lineno}") + cls = next(n for n in ast.walk(tree) + if isinstance(n, ast.ClassDef) and n.name == class_name) + gip = next(n for n in cls.body + if isinstance(n, ast.FunctionDef) and n.name == "get_init_parameters") + ret = next(n for n in ast.walk(gip) if isinstance(n, ast.Return)) + if "platform_client" not in [e.value for e in ret.value.elts]: + problems.append("get_init_parameters lacks platform_client") + init = next(n for n in cls.body + if isinstance(n, ast.FunctionDef) and n.name == "__init__") + if "platform_client" not in [a.arg for a in init.args.args]: + problems.append("__init__ lacks platform_client") + return problems + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("pairs", nargs="+", help="path=ClassName") + parser.add_argument("--expected-defs", type=int, required=True) + parser.add_argument("--expected-calls", type=int, required=True) + parser.add_argument("--expected-post-calls", type=int, required=True) + parser.add_argument("--expected-ctor", type=int, required=True) + args = parser.parse_args() + totals = dict(defs=0, calls=0, ctor=0, gip=0, super=0, post_calls=0, old_imports=0) + outputs = {} + for pair in args.pairs: + path_text, _, class_name = pair.partition("=") + path = pathlib.Path(path_text) + updated, stats = transform(path.read_text(encoding="utf-8"), class_name, path_text) + problems = verify(updated, class_name, path_text) + if problems: + print(f"FAIL {path_text}: {problems}", file=sys.stderr) + return 2 + again, _ = transform(updated, class_name, path_text) + if again != updated: + print(f"FAIL {path_text}: not idempotent", file=sys.stderr) + return 2 + for key in totals: + totals[key] += stats[key] + outputs[path_text] = updated + print(f" {path_text}: {stats}") + print("TOTALS", totals) + for name, expected in (("defs", args.expected_defs), ("calls", args.expected_calls), + ("post_calls", args.expected_post_calls), + ("ctor", args.expected_ctor)): + if totals[name] != expected: + print(f"FAIL: {name} {totals[name]} != {expected}", file=sys.stderr) + return 1 + for path_text, updated in outputs.items(): + pathlib.Path(path_text).write_text(updated, encoding="utf-8") + print("POST-STATE verified for every file; idempotent; written") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/phase9_platform_client_tests.py b/scripts/phase9_platform_client_tests.py new file mode 100644 index 0000000000..3570c80dee --- /dev/null +++ b/scripts/phase9_platform_client_tests.py @@ -0,0 +1,196 @@ +"""Add `platform_client=` where a test reaches a signature Task 9.4 changed. + +Two edits, both origin-resolved so a same-named helper in an untouched module +(`anthropic_claude/v2`, `google_gemini/v2`, `lmm/v1`, `openai/v1`) is left alone: + + 1. a call whose callee resolves - through `from X import name [as alias]`, + through `module.name` where `module` was imported from a touched package, + or through a `pytest.mark.parametrize` argument whose values are such + imported names - to one of the changed modules gains + `platform_client=platform_client`; + 2. a keyword-form construction of one of the named block classes gains the + same keyword (positional constructions are refused: none exist). + +The file gains, once, after its last top-level import: + + from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, + ) + + platform_client = RecordingPlatformClient() + + + @pytest.fixture(autouse=True) + def _reset_platform_client(): + platform_client.reset() + +so no test signature or decorator order changes and no return value leaks +between tests. Tests that seed a response or assert on the call use +`platform_client.post_mock` (a `unittest.mock.Mock`), which keeps every +existing `mock_post.*` assertion verbatim - see Task 9.4 Step 8. +""" + +import argparse +import ast +import collections +import pathlib +import sys + +CHAIN = { + "run_gpt_4v_llm_prompting", "execute_gpt_4v_requests", "execute_gpt_4v_request", + "_execute_proxied_openai_request", "run_openai_prompting", "execute_openai_requests", + "execute_openai_request", "run_gemini_prompting", "execute_gemini_requests", + "execute_gemini_request", "_execute_proxied_gemini_request", "run_claude_prompting", + "execute_claude_requests", "execute_claude_request", "_execute_proxied_claude_request", + "run_spacexai_prompting", "execute_spacexai_requests", "execute_spacexai_request", + "_execute_proxied_spacexai_request", "_execute_proxied_google_vision_request", + "send_email_via_roboflow_proxy", "send_sms_via_roboflow_proxy", + "_execute_proxied_openrouter_request", +} +PREAMBLE = ( + "from tests.workflows.unit_tests.prototypes.platform_client_double import (\n" + " RecordingPlatformClient,\n" + ")\n" + "\n" + "platform_client = RecordingPlatformClient()\n" + "\n" + "\n" + "@pytest.fixture(autouse=True)\n" + "def _reset_platform_client():\n" + " platform_client.reset()" +) + + +def _touched_modules(list_file: str): + return { + line.strip().replace("/", ".")[:-3] + for line in open(list_file, encoding="utf-8") + if line.strip() + } + + +def _affected_calls(tree, touched, classes): + origin, alias = {}, {} + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + for a in node.names: + origin[a.asname or a.name] = (node.module, a.name) + alias[a.asname or a.name] = f"{node.module}.{a.name}" + functions = [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)] + param_origins = collections.defaultdict(set) + for fn in functions: + for dec in fn.decorator_list: + if not (isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute) + and dec.func.attr == "parametrize"): + continue + if len(dec.args) < 2 or not isinstance(dec.args[0], ast.Constant): + continue + names = [s.strip() for s in dec.args[0].value.split(",")] + if not isinstance(dec.args[1], (ast.List, ast.Tuple)): + continue + for elt in dec.args[1].elts: + items = elt.elts if isinstance(elt, (ast.Tuple, ast.List)) else [elt] + for pname, item in zip(names, items): + if isinstance(item, ast.Name) and item.id in origin: + param_origins[(fn.name, pname)].add(origin[item.id]) + helper_calls, constructions = {}, {} + for fn in functions: + for node in ast.walk(fn): + if not isinstance(node, ast.Call): + continue + f = node.func + mods = set() + if isinstance(f, ast.Name): + if f.id in CHAIN and f.id in origin: + mods.add(origin[f.id][0]) + elif (fn.name, f.id) in param_origins: + for mod, name in param_origins[(fn.name, f.id)]: + if name in CHAIN: + mods.add(mod) + elif f.id in origin and origin[f.id][1] in classes: + if node.args: + raise SystemExit(f"positional construction at line {node.lineno}") + constructions[(node.lineno, node.col_offset)] = node + continue + elif isinstance(f, ast.Attribute) and f.attr in CHAIN and isinstance(f.value, ast.Name): + if f.value.id in alias: + mods.add(alias[f.value.id]) + if mods and any(m in touched for m in mods): + helper_calls[(node.lineno, node.col_offset)] = node + return list(helper_calls.values()), list(constructions.values()) + + +def patch(path: pathlib.Path, touched, classes): + source = path.read_text(encoding="utf-8") + newline = "\r\n" if "\r\n" in source else "\n" + lines = source.split(newline) + tree = ast.parse(source) + calls, ctors = _affected_calls(tree, touched, classes) + edits = [] + edited_calls = edited_ctors = 0 + for node in calls + ctors: + keys = {k.arg for k in node.keywords} + if "platform_client" in keys: + continue + if node in calls: + edited_calls += 1 + else: + edited_ctors += 1 + if "roboflow_api_key" in keys: + kw = next(k for k in node.keywords if k.arg == "roboflow_api_key") + edits.append((kw.value.end_lineno, kw.value.end_col_offset)) + elif node.keywords: + last = node.keywords[-1].value + edits.append((last.end_lineno, last.end_col_offset)) + else: + raise SystemExit(f"{path}:{node.lineno}: call without keywords") + for lineno, col in sorted(set(edits), reverse=True): + line = lines[lineno - 1] + lines[lineno - 1] = line[:col] + ", platform_client=platform_client" + line[col:] + updated = newline.join(lines) + if edits and "RecordingPlatformClient" not in updated: + out = updated.split(newline) + new_tree = ast.parse(updated) + anchor = None + has_pytest = False + for node in new_tree.body: + if isinstance(node, (ast.Import, ast.ImportFrom)): + anchor = node + if isinstance(node, ast.Import) and any(a.name == "pytest" for a in node.names): + has_pytest = True + if anchor is None: + raise SystemExit(f"{path}: no import anchor") + preamble = PREAMBLE if has_pytest else "import pytest\n\n" + PREAMBLE + out.insert(anchor.end_lineno, preamble) + updated = newline.join(out) + ast.parse(updated) + if edits: + path.write_text(updated, encoding="utf-8") + return edited_calls, edited_ctors + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("files", nargs="+") + parser.add_argument("--touched", required=True, help="file listing the changed modules") + parser.add_argument("--classes", required=True, help="comma-separated block class names") + parser.add_argument("--expected-calls", type=int, required=True) + parser.add_argument("--expected-constructions", type=int, required=True) + args = parser.parse_args() + touched = _touched_modules(args.touched) + classes = set(args.classes.split(",")) + total_calls = total_ctors = 0 + for name in args.files: + calls, ctors = patch(pathlib.Path(name), touched, classes) + total_calls += calls + total_ctors += ctors + print(f"{calls:3d} calls {ctors:3d} constructions edited {name}") + print(f"TOTAL {total_calls} helper calls, {total_ctors} block constructions edited") + if total_calls != args.expected_calls or total_ctors != args.expected_constructions: + print("FAIL: counts do not match", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/workflows/unit_tests/core_steps/common/test_openrouter.py b/tests/workflows/unit_tests/core_steps/common/test_openrouter.py index 946cb24a14..224dc1af85 100644 --- a/tests/workflows/unit_tests/core_steps/common/test_openrouter.py +++ b/tests/workflows/unit_tests/core_steps/common/test_openrouter.py @@ -23,6 +23,17 @@ get_native_quantizations, validate_task_type_required_fields, ) +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +platform_client = RecordingPlatformClient() + + +@pytest.fixture(autouse=True) +def _reset_platform_client(): + platform_client.reset() + # Real error strings captured live from OpenRouter (2026-08-19): # qwen/qwen3.8-max rejecting `reasoning: {"enabled": false}`: @@ -271,12 +282,13 @@ def test_execute_openrouter_batch_attaches_native_quantizations( # --------------------------------------------------------------------------- -@patch("inference.core.workflows.core_steps.common.openrouter.post_to_roboflow_api") -def test_proxied_request_sends_expected_payload_to_roboflow(mock_post): +def test_proxied_request_sends_expected_payload_to_roboflow(): + mock_post = platform_client.post_mock mock_post.return_value = {"choices": [{"message": {"content": "hello world"}}]} out = _execute_proxied_openrouter_request( roboflow_api_key="ws-key-xyz", + platform_client=platform_client, openrouter_api_key="rf_key:account", model="moonshotai/kimi-k2.6", messages=[{"role": "user", "content": "hi"}], @@ -303,13 +315,14 @@ def test_proxied_request_sends_expected_payload_to_roboflow(mock_post): assert "provider" not in payload -@patch("inference.core.workflows.core_steps.common.openrouter.post_to_roboflow_api") -def test_proxied_request_raises_when_choices_empty(mock_post): +def test_proxied_request_raises_when_choices_empty(): + mock_post = platform_client.post_mock mock_post.return_value = {"choices": [], "error": {"message": "providers down"}} with pytest.raises(RuntimeError, match="providers down"): _execute_proxied_openrouter_request( roboflow_api_key="k", + platform_client=platform_client, openrouter_api_key="rf_key:account", model="m", messages=[], @@ -324,14 +337,15 @@ def test_proxied_request_raises_when_choices_empty(mock_post): # --------------------------------------------------------------------------- -@patch("inference.core.workflows.core_steps.common.openrouter.post_to_roboflow_api") -def test_proxied_request_populates_reasoning_trace(mock_post): +def test_proxied_request_populates_reasoning_trace(): + mock_post = platform_client.post_mock mock_post.return_value = { "choices": [{"message": {"content": "answer", "reasoning": "trace"}}] } out = _execute_proxied_openrouter_request( roboflow_api_key="k", + platform_client=platform_client, openrouter_api_key="rf_key:account", model="qwen/qwen3.7-plus", messages=[{"role": "user", "content": "hi"}], @@ -367,11 +381,13 @@ def test_direct_request_returns_empty_trace_when_reasoning_missing(mock_openai_c assert out == OpenRouterResult(content="answer") -@patch("inference.core.workflows.core_steps.common.openrouter.post_to_roboflow_api") -def test_proxied_request_returns_usage_and_none_when_omitted(mock_post): +def test_proxied_request_returns_usage_and_none_when_omitted(): + mock_post = platform_client.post_mock + def call(): return _execute_proxied_openrouter_request( roboflow_api_key="k", + platform_client=platform_client, openrouter_api_key="rf_key:account", model="qwen/qwen3.7-plus", messages=[{"role": "user", "content": "hi"}], @@ -544,12 +560,13 @@ def test_direct_request_sends_quantizations_even_when_privacy_allows(mock_openai } -@patch("inference.core.workflows.core_steps.common.openrouter.post_to_roboflow_api") -def test_proxied_request_forwards_quantizations_in_payload(mock_post): +def test_proxied_request_forwards_quantizations_in_payload(): + mock_post = platform_client.post_mock mock_post.return_value = {"choices": [{"message": {"content": "ok"}}]} _execute_proxied_openrouter_request( roboflow_api_key="ws-key", + platform_client=platform_client, openrouter_api_key="rf_key:account", model="z-ai/glm-5.3-flash", messages=[{"role": "user", "content": "hi"}], @@ -656,12 +673,13 @@ def test_reasoning_error_ignores_unrelated_client_errors(): # --------------------------------------------------------------------------- -@patch("inference.core.workflows.core_steps.common.openrouter.post_to_roboflow_api") -def test_proxied_request_includes_reasoning_and_omits_none_temperature(mock_post): +def test_proxied_request_includes_reasoning_and_omits_none_temperature(): + mock_post = platform_client.post_mock mock_post.return_value = {"choices": [{"message": {"content": "ok"}}]} _execute_proxied_openrouter_request( roboflow_api_key="ws-key", + platform_client=platform_client, openrouter_api_key="rf_key:account", model="qwen/qwen3.7-flash", messages=[{"role": "user", "content": "hi"}], @@ -703,8 +721,8 @@ def test_direct_request_includes_reasoning_and_omits_none_temperature(mock_opena @patch("inference.core.workflows.core_steps.common.openrouter.logger") -@patch("inference.core.workflows.core_steps.common.openrouter.post_to_roboflow_api") -def test_proxied_request_retries_without_reasoning_on_rejection(mock_post, mock_logger): +def test_proxied_request_retries_without_reasoning_on_rejection(mock_logger): + mock_post = platform_client.post_mock mock_post.side_effect = [ _proxy_error(MANDATORY_REASONING_ERROR, status_code=400), {"choices": [{"message": {"content": "ok"}}]}, @@ -712,6 +730,7 @@ def test_proxied_request_retries_without_reasoning_on_rejection(mock_post, mock_ out = _execute_proxied_openrouter_request( roboflow_api_key="ws-key", + platform_client=platform_client, openrouter_api_key="rf_key:account", model="qwen/qwen3.8-max", messages=[{"role": "user", "content": "hi"}], @@ -729,9 +748,9 @@ def test_proxied_request_retries_without_reasoning_on_rejection(mock_post, mock_ @patch("inference.core.workflows.core_steps.common.openrouter.logger") -@patch("inference.core.workflows.core_steps.common.openrouter.post_to_roboflow_api") -def test_proxied_retry_without_reasoning_keeps_quantizations(mock_post, mock_logger): +def test_proxied_retry_without_reasoning_keeps_quantizations(mock_logger): """Dropping a rejected reasoning config must not drop the precision filter.""" + mock_post = platform_client.post_mock mock_post.side_effect = [ _proxy_error(MANDATORY_REASONING_ERROR, status_code=400), {"choices": [{"message": {"content": "ok"}}]}, @@ -739,6 +758,7 @@ def test_proxied_retry_without_reasoning_keeps_quantizations(mock_post, mock_log _execute_proxied_openrouter_request( roboflow_api_key="ws-key", + platform_client=platform_client, openrouter_api_key="rf_key:account", model="z-ai/glm-5.3-flash", messages=[{"role": "user", "content": "hi"}], @@ -754,8 +774,8 @@ def test_proxied_retry_without_reasoning_keeps_quantizations(mock_post, mock_log assert retry_payload["quantizations"] == ["fp8", "bf16", "fp32"] -@patch("inference.core.workflows.core_steps.common.openrouter.post_to_roboflow_api") -def test_proxied_request_does_not_retry_on_relayed_502(mock_post): +def test_proxied_request_does_not_retry_on_relayed_502(): + mock_post = platform_client.post_mock # Regression: the proxy relays upstream 5xx with the provider message # preserved; a reasoning-flavored 502 must not fire a duplicate request. mock_post.side_effect = _proxy_error(MANDATORY_REASONING_ERROR, status_code=502) @@ -763,6 +783,7 @@ def test_proxied_request_does_not_retry_on_relayed_502(mock_post): with pytest.raises(RoboflowAPIUnsuccessfulRequestError): _execute_proxied_openrouter_request( roboflow_api_key="ws-key", + platform_client=platform_client, openrouter_api_key="rf_key:account", model="qwen/qwen3.8-max", messages=[], @@ -775,13 +796,14 @@ def test_proxied_request_does_not_retry_on_relayed_502(mock_post): assert mock_post.call_count == 1 -@patch("inference.core.workflows.core_steps.common.openrouter.post_to_roboflow_api") -def test_proxied_request_does_not_retry_when_no_reasoning_sent(mock_post): +def test_proxied_request_does_not_retry_when_no_reasoning_sent(): + mock_post = platform_client.post_mock mock_post.side_effect = _proxy_error(MANDATORY_REASONING_ERROR, status_code=400) with pytest.raises(RoboflowAPIUnsuccessfulRequestError): _execute_proxied_openrouter_request( roboflow_api_key="ws-key", + platform_client=platform_client, openrouter_api_key="rf_key:account", model="qwen/qwen3.8-max", messages=[], diff --git a/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py b/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py new file mode 100644 index 0000000000..3c2a9c6405 --- /dev/null +++ b/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py @@ -0,0 +1,137 @@ +"""Every block that reaches the Roboflow proxy asks for - and uses - the port. + +`get_init_parameters()` is the block's dependency declaration: if a block drops +`platform_client`, `steps_initialiser` never passes one and the block silently +falls back to the offline default that refuses every call. The Qwen blocks are +here because they override both the constructor and the declaration of the +OpenRouter base. +""" + +import ast +import importlib +import pathlib +from functools import partial + +import pytest + +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +PREFIX = "inference.core.workflows.core_steps" +PROXY_BLOCKS = [ + (".models.foundation.openai.v3", "OpenAIBlockV3"), + (".models.foundation.openai.v4", "OpenAIBlockV4"), + (".models.foundation.openai.v5", "OpenAIBlockV5"), + (".models.foundation.openai.v6", "OpenAIBlockV6"), + (".models.foundation.google_gemini.v3", "GoogleGeminiBlockV3"), + (".models.foundation.google_gemini.v4", "GoogleGeminiBlockV4"), + (".models.foundation.google_gemini.v5", "GoogleGeminiBlockV5"), + (".models.foundation.anthropic_claude.v3", "AnthropicClaudeBlockV3"), + (".models.foundation.anthropic_claude.v4", "AnthropicClaudeBlockV4"), + (".models.foundation.spacexai.v1", "SpaceXAIBlockV1"), + (".models.foundation.spacexai.v2", "SpaceXAIBlockV2"), + (".models.foundation.google_vision_ocr.v1", "GoogleVisionOCRBlockV1"), + (".models.foundation.google_vision_ocr.v1_tensor", "GoogleVisionOCRBlockV1"), + (".sinks.email_notification.v2", "EmailNotificationBlockV2"), + (".sinks.twilio.sms.v2", "TwilioSMSNotificationBlockV2"), + (".common.openrouter", "OpenRouterWorkflowBlockBase"), + (".models.foundation.qwen_vlm.v1", "QwenVlmBlockV1"), + (".models.foundation.qwen_vlm.v2", "QwenVlmBlockV2"), + (".models.foundation.qwen_vlm.v3", "QwenVlmBlockV3"), +] +WORKFLOWS_ROOT = ( + pathlib.Path(__file__).resolve().parents[5] / "inference" / "core" / "workflows" +) + + +def _load(module_suffix: str, class_name: str): + return getattr(importlib.import_module(PREFIX + module_suffix), class_name) + + +@pytest.mark.parametrize("module_suffix,class_name", PROXY_BLOCKS) +def test_block_declares_the_platform_client_init_parameter(module_suffix, class_name): + assert "platform_client" in _load(module_suffix, class_name).get_init_parameters() + + +@pytest.mark.parametrize("module_suffix,class_name", PROXY_BLOCKS) +def test_block_stores_the_injected_client(module_suffix, class_name): + sentinel = RecordingPlatformClient() + block_class = _load(module_suffix, class_name) + kwargs = {name: None for name in block_class.get_init_parameters()} + kwargs["platform_client"] = sentinel + assert block_class(**kwargs)._platform_client is sentinel + + +def test_no_workflows_module_imports_the_roboflow_proxy_helper() -> None: + """Symbol-specific: 16 files still legitimately import other names from + `roboflow_api` until Tasks 9.5-9.6 (the 14 header users and the two engine + files). Only `post_to_roboflow_api` is gone. + """ + offenders = [] + for path in WORKFLOWS_ROOT.rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if ( + isinstance(node, ast.ImportFrom) + and node.module == "inference.core.roboflow_api" + ): + if any(a.name == "post_to_roboflow_api" for a in node.names): + offenders.append(f"{path}:{node.lineno}") + assert not offenders, offenders + + +def test_concurrent_blocks_keep_their_own_clients() -> None: + """The client travels inside the `partial`, not in shared state: + `common/utils.run_in_parallel` hands the partials to a thread pool.""" + from inference.core.workflows.core_steps.common.utils import run_in_parallel + + def helper(roboflow_api_key, platform_client): + return platform_client + + a, b = RecordingPlatformClient(), RecordingPlatformClient() + results = run_in_parallel( + tasks=[ + partial(helper, roboflow_api_key="a", platform_client=a), + partial(helper, roboflow_api_key="b", platform_client=b), + ], + max_workers=2, + ) + assert results == [a, b] + + +def test_openai_managed_key_path_uses_the_injected_client() -> None: + """The whole chain: block -> partial -> thread pool -> + `_execute_proxied_openai_request` -> `platform_client.post`.""" + from inference.core.workflows.core_steps.models.foundation.openai import v3 + + client = RecordingPlatformClient( + post_response={"choices": [{"message": {"content": "ok"}}]} + ) + result = v3.execute_gpt_4v_requests( + roboflow_api_key="rf-key", + platform_client=client, + openai_api_key="rf_key:account:abc", + gpt4_prompts=[[{"role": "user", "content": "hi"}]], + gpt_model_version="gpt-4o", + max_tokens=10, + temperature=None, + max_concurrent_requests=1, + ) + assert client.posts, "the managed-key path never reached the injected client" + assert result + + +def test_qwen_forwards_the_client_to_the_openrouter_base() -> None: + from inference.core.workflows.core_steps.models.foundation.qwen_vlm.v1 import ( + QwenVlmBlockV1, + ) + + sentinel = RecordingPlatformClient() + block = QwenVlmBlockV1( + model_manager=None, + api_key=None, + step_execution_mode=None, + platform_client=sentinel, + ) + assert block._platform_client is sentinel diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_anthropic_claude.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_anthropic_claude.py index 4d52181729..507747bcb7 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_anthropic_claude.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_anthropic_claude.py @@ -36,6 +36,16 @@ from inference.core.workflows.core_steps.models.foundation.anthropic_claude.v4 import ( execute_claude_request as execute_claude_request_v4, ) +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +platform_client = RecordingPlatformClient() + + +@pytest.fixture(autouse=True) +def _reset_platform_client(): + platform_client.reset() def test_claude_step_validation_when_input_is_valid() -> None: @@ -863,6 +873,7 @@ def test_direct_request_keeps_legacy_controls_for_legacy_model( # when - thinking off, temperature on execute_request( roboflow_api_key=None, + platform_client=platform_client, anthropic_api_key="sk-ant-test", system_prompt=None, messages=[{"role": "user", "content": "Hello"}], @@ -877,6 +888,7 @@ def test_direct_request_keeps_legacy_controls_for_legacy_model( # when - thinking on with an explicit budget execute_request( roboflow_api_key=None, + platform_client=platform_client, anthropic_api_key="sk-ant-test", system_prompt=None, messages=[{"role": "user", "content": "Think"}], @@ -914,6 +926,7 @@ def test_direct_request_translates_controls_for_new_generation_model( # when - temperature configured, thinking off execute_request( roboflow_api_key=None, + platform_client=platform_client, anthropic_api_key="sk-ant-test", system_prompt=None, messages=[{"role": "user", "content": "Hello"}], @@ -928,6 +941,7 @@ def test_direct_request_translates_controls_for_new_generation_model( # when - thinking on with a budget the model cannot take execute_request( roboflow_api_key=None, + platform_client=platform_client, anthropic_api_key="sk-ant-test", system_prompt=None, messages=[{"role": "user", "content": "Think"}], @@ -964,36 +978,36 @@ def test_direct_request_translates_controls_for_new_generation_model( def test_proxied_request_keeps_legacy_controls_for_legacy_model( execute_request: Any, module: str ) -> None: - with patch( - f"inference.core.workflows.core_steps.models.foundation.anthropic_claude.{module}.post_to_roboflow_api", - return_value=PROXY_RESPONSE, - ) as post_mock: - # when - execute_request( - roboflow_api_key="rf-key", - anthropic_api_key="rf_key:account", - system_prompt="sys", - messages=[{"role": "user", "content": "Hello"}], - model_version=LEGACY_MODEL, - max_tokens=100, - temperature=0.4, - extended_thinking=None, - thinking_budget_tokens=None, - ) - plain_payload = post_mock.call_args.kwargs["payload"] + post_mock = platform_client.post_mock + post_mock.return_value = PROXY_RESPONSE + # when + execute_request( + roboflow_api_key="rf-key", + platform_client=platform_client, + anthropic_api_key="rf_key:account", + system_prompt="sys", + messages=[{"role": "user", "content": "Hello"}], + model_version=LEGACY_MODEL, + max_tokens=100, + temperature=0.4, + extended_thinking=None, + thinking_budget_tokens=None, + ) + plain_payload = post_mock.call_args.kwargs["payload"] - execute_request( - roboflow_api_key="rf-key", - anthropic_api_key="rf_key:account", - system_prompt=None, - messages=[{"role": "user", "content": "Think"}], - model_version=LEGACY_MODEL, - max_tokens=None, - temperature=0.4, - extended_thinking=True, - thinking_budget_tokens=None, - ) - thinking_payload = post_mock.call_args.kwargs["payload"] + execute_request( + roboflow_api_key="rf-key", + platform_client=platform_client, + anthropic_api_key="rf_key:account", + system_prompt=None, + messages=[{"role": "user", "content": "Think"}], + model_version=LEGACY_MODEL, + max_tokens=None, + temperature=0.4, + extended_thinking=True, + thinking_budget_tokens=None, + ) + thinking_payload = post_mock.call_args.kwargs["payload"] # then assert plain_payload["model"] == LEGACY_MODEL @@ -1015,36 +1029,36 @@ def test_proxied_request_keeps_legacy_controls_for_legacy_model( def test_proxied_request_translates_controls_for_new_generation_model( execute_request: Any, module: str ) -> None: - with patch( - f"inference.core.workflows.core_steps.models.foundation.anthropic_claude.{module}.post_to_roboflow_api", - return_value=PROXY_RESPONSE, - ) as post_mock: - # when - execute_request( - roboflow_api_key="rf-key", - anthropic_api_key="rf_key:account", - system_prompt=None, - messages=[{"role": "user", "content": "Hello"}], - model_version=NEW_GENERATION_MODEL, - max_tokens=100, - temperature=0.4, - extended_thinking=None, - thinking_budget_tokens=None, - ) - plain_payload = post_mock.call_args.kwargs["payload"] + post_mock = platform_client.post_mock + post_mock.return_value = PROXY_RESPONSE + # when + execute_request( + roboflow_api_key="rf-key", + platform_client=platform_client, + anthropic_api_key="rf_key:account", + system_prompt=None, + messages=[{"role": "user", "content": "Hello"}], + model_version=NEW_GENERATION_MODEL, + max_tokens=100, + temperature=0.4, + extended_thinking=None, + thinking_budget_tokens=None, + ) + plain_payload = post_mock.call_args.kwargs["payload"] - execute_request( - roboflow_api_key="rf-key", - anthropic_api_key="rf_key:account", - system_prompt=None, - messages=[{"role": "user", "content": "Think"}], - model_version=NEW_GENERATION_MODEL, - max_tokens=None, - temperature=None, - extended_thinking=True, - thinking_budget_tokens=5000, - ) - thinking_payload = post_mock.call_args.kwargs["payload"] + execute_request( + roboflow_api_key="rf-key", + platform_client=platform_client, + anthropic_api_key="rf_key:account", + system_prompt=None, + messages=[{"role": "user", "content": "Think"}], + model_version=NEW_GENERATION_MODEL, + max_tokens=None, + temperature=None, + extended_thinking=True, + thinking_budget_tokens=5000, + ) + thinking_payload = post_mock.call_args.kwargs["payload"] # then assert plain_payload["model"] == NEW_GENERATION_MODEL diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_anthropic_claude_v4.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_anthropic_claude_v4.py index ec465601b5..e816cd08e1 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_anthropic_claude_v4.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_anthropic_claude_v4.py @@ -7,10 +7,23 @@ from unittest.mock import MagicMock, Mock, patch +import pytest + from inference.core.workflows.core_steps.models.foundation.anthropic_claude.v4 import ( _execute_direct_claude_request, _execute_proxied_claude_request, ) +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +platform_client = RecordingPlatformClient() + + +@pytest.fixture(autouse=True) +def _reset_platform_client(): + platform_client.reset() + _CLAUDE_OK = { "stop_reason": "end_turn", @@ -18,13 +31,13 @@ } -@patch( - "inference.core.workflows.core_steps.models.foundation.anthropic_claude.v4.post_to_roboflow_api" -) -def test_proxied_request_returns_usage_and_none_when_omitted(mock_post: Mock) -> None: +def test_proxied_request_returns_usage_and_none_when_omitted() -> None: + mock_post = platform_client.post_mock + def call(): return _execute_proxied_claude_request( roboflow_api_key="rf_api_key", + platform_client=platform_client, anthropic_api_key="rf_key:account", system_prompt=None, messages=[], diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_anthropic_claude_v4_detection.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_anthropic_claude_v4_detection.py index f0a497982f..48734652ee 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_anthropic_claude_v4_detection.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_anthropic_claude_v4_detection.py @@ -20,6 +20,16 @@ prepare_object_detection_prompt, run_claude_prompting, ) +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +platform_client = RecordingPlatformClient() + + +@pytest.fixture(autouse=True) +def _reset_platform_client(): + platform_client.reset() def test_manifest_parsing_for_object_detection_task() -> None: @@ -166,6 +176,7 @@ def test_run_claude_prompting_states_uploaded_dimensions_in_detection_prompt( # when result = run_claude_prompting( roboflow_api_key="rf-key", + platform_client=platform_client, images=images, task_type="object-detection", prompt=None, diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_google_gemini_v5.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_google_gemini_v5.py index 92dcef0c01..54beafc630 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_google_gemini_v5.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_google_gemini_v5.py @@ -8,10 +8,23 @@ from unittest.mock import Mock, patch +import pytest + from inference.core.workflows.core_steps.models.foundation.google_gemini.v5 import ( _execute_direct_gemini_request, _execute_proxied_gemini_request, ) +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +platform_client = RecordingPlatformClient() + + +@pytest.fixture(autouse=True) +def _reset_platform_client(): + platform_client.reset() + _GEMINI_OK = { "candidates": [ @@ -23,13 +36,13 @@ } -@patch( - "inference.core.workflows.core_steps.models.foundation.google_gemini.v5.post_to_roboflow_api" -) -def test_proxied_request_returns_usage_and_none_when_omitted(mock_post: Mock) -> None: +def test_proxied_request_returns_usage_and_none_when_omitted() -> None: + mock_post = platform_client.post_mock + def call(): return _execute_proxied_gemini_request( roboflow_api_key="rf_api_key", + platform_client=platform_client, google_api_key="rf_key:account", prompt={"contents": {"parts": [{"text": "test"}]}}, model_version="gemini-2.5-pro", diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_openai_v4.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_openai_v4.py index 40d0ee12d1..5d87206a7e 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_openai_v4.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_openai_v4.py @@ -21,6 +21,16 @@ prepare_unconstrained_prompt, prepare_vqa_prompt, ) +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +platform_client = RecordingPlatformClient() + + +@pytest.fixture(autouse=True) +def _reset_platform_client(): + platform_client.reset() def test_openai_step_validation_when_input_is_valid() -> None: @@ -486,6 +496,7 @@ def test_execute_openai_request_routes_to_proxy_for_rf_key_account() -> None: # when result = execute_openai_request( roboflow_api_key="rf_api_key", + platform_client=platform_client, openai_api_key="rf_key:account", instructions="test", input_content=[], @@ -510,6 +521,7 @@ def test_execute_openai_request_routes_to_proxy_for_rf_key_user() -> None: # when result = execute_openai_request( roboflow_api_key="rf_api_key", + platform_client=platform_client, openai_api_key="rf_key:user:12345", instructions="test", input_content=[], @@ -534,6 +546,7 @@ def test_execute_openai_request_routes_to_direct_for_regular_api_key() -> None: # when result = execute_openai_request( roboflow_api_key="rf_api_key", + platform_client=platform_client, openai_api_key="sk-test-key", instructions="test", input_content=[], @@ -604,16 +617,13 @@ def test_direct_request_with_invalid_reasoning_effort_for_gpt_5_1_raises_error( assert 'does not support reasoning effort "minimal"' in str(exc_info.value) -@patch( - "inference.core.workflows.core_steps.models.foundation.openai.v4.post_to_roboflow_api" -) -def test_proxied_request_with_invalid_reasoning_effort_for_gpt_5_raises_error( - mock_post: Mock, -) -> None: +def test_proxied_request_with_invalid_reasoning_effort_for_gpt_5_raises_error() -> None: + mock_post = platform_client.post_mock # when/then with pytest.raises(ValueError) as exc_info: _execute_proxied_openai_request( roboflow_api_key="rf_api_key", + platform_client=platform_client, openai_api_key="rf_key:account", instructions="test", input_content=[{"role": "user", "content": []}], diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_openai_v5.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_openai_v5.py index b5baf57daf..ea51855b05 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_openai_v5.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_openai_v5.py @@ -30,6 +30,17 @@ prepare_vqa_prompt, ) from inference.core.workflows.prototypes.block import third_party_model +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +platform_client = RecordingPlatformClient() + + +@pytest.fixture(autouse=True) +def _reset_platform_client(): + platform_client.reset() + PNG_MAGIC_BYTES = b"\x89PNG\r\n\x1a\n" JPEG_MAGIC_BYTES = b"\xff\xd8\xff" @@ -393,6 +404,7 @@ def test_execute_openai_request_routes_to_proxy_for_rf_key_account() -> None: # when result = execute_openai_request( roboflow_api_key="rf_api_key", + platform_client=platform_client, openai_api_key="rf_key:account", instructions="test", input_content=[], @@ -414,6 +426,7 @@ def test_execute_openai_request_rejects_managed_key_without_roboflow_key() -> No with pytest.raises(ValueError, match="Roboflow API key is required"): execute_openai_request( roboflow_api_key=None, + platform_client=platform_client, openai_api_key="rf_key:account", instructions="test", input_content=[], @@ -436,6 +449,7 @@ def test_execute_openai_request_routes_to_direct_for_regular_api_key() -> None: # when result = execute_openai_request( roboflow_api_key="rf_api_key", + platform_client=platform_client, openai_api_key="sk-test-key", instructions="test", input_content=[], @@ -506,16 +520,13 @@ def test_direct_request_with_invalid_reasoning_effort_for_gpt_5_1_raises_error( assert 'does not support reasoning effort "minimal"' in str(exc_info.value) -@patch( - "inference.core.workflows.core_steps.models.foundation.openai.v5.post_to_roboflow_api" -) -def test_proxied_request_with_invalid_reasoning_effort_for_gpt_5_raises_error( - mock_post: Mock, -) -> None: +def test_proxied_request_with_invalid_reasoning_effort_for_gpt_5_raises_error() -> None: + mock_post = platform_client.post_mock # when/then with pytest.raises(ValueError) as exc_info: _execute_proxied_openai_request( roboflow_api_key="rf_api_key", + platform_client=platform_client, openai_api_key="rf_key:account", instructions="test", input_content=[{"role": "user", "content": []}], @@ -587,11 +598,9 @@ def test_direct_request_omits_text_format_when_not_provided( assert "text" not in call_kwargs -@patch( - "inference.core.workflows.core_steps.models.foundation.openai.v5.post_to_roboflow_api" -) -def test_proxied_request_forwards_text_format(mock_post: Mock) -> None: +def test_proxied_request_forwards_text_format() -> None: # given + mock_post = platform_client.post_mock mock_post.return_value = { "status": "completed", "output": [ @@ -605,6 +614,7 @@ def test_proxied_request_forwards_text_format(mock_post: Mock) -> None: # when _execute_proxied_openai_request( roboflow_api_key="rf_api_key", + platform_client=platform_client, openai_api_key="rf_key:account", instructions=None, input_content=[{"role": "user", "content": []}], diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_openai_v6.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_openai_v6.py index d749057245..9851e6b503 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_openai_v6.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_openai_v6.py @@ -7,10 +7,23 @@ from unittest.mock import MagicMock, Mock, patch +import pytest + from inference.core.workflows.core_steps.models.foundation.openai.v6 import ( _execute_direct_openai_request, _execute_proxied_openai_request, ) +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +platform_client = RecordingPlatformClient() + + +@pytest.fixture(autouse=True) +def _reset_platform_client(): + platform_client.reset() + _OPENAI_OK = { "status": "completed", @@ -23,13 +36,13 @@ } -@patch( - "inference.core.workflows.core_steps.models.foundation.openai.v6.post_to_roboflow_api" -) -def test_proxied_request_returns_usage_and_none_when_omitted(mock_post: Mock) -> None: +def test_proxied_request_returns_usage_and_none_when_omitted() -> None: + mock_post = platform_client.post_mock + def call(): return _execute_proxied_openai_request( roboflow_api_key="rf_api_key", + platform_client=platform_client, openai_api_key="rf_key:account", instructions="test", input_content=[], diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm.py index 6d25a83d21..cdba7d4dcb 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm.py @@ -17,6 +17,16 @@ _coerce_native_response, ) from inference.core.workflows.execution_engine.entities.base import WorkflowImageData +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +platform_client = RecordingPlatformClient() + + +@pytest.fixture(autouse=True) +def _reset_platform_client(): + platform_client.reset() def _stub_image() -> WorkflowImageData: @@ -259,7 +269,8 @@ def test_run_dispatches_to_openrouter_for_openrouter_backend(mock_or): block = QwenVlmBlockV1( model_manager=MagicMock(), api_key="ws-key", - step_execution_mode=StepExecutionMode.LOCAL, # ignored for openrouter path + step_execution_mode=StepExecutionMode.LOCAL, + platform_client=platform_client, # ignored for openrouter path ) result = block.run( @@ -286,6 +297,7 @@ def test_run_dispatches_to_local_native_when_step_mode_local(): model_manager=model_manager, api_key="ws-key", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=platform_client, ) result = block.run(**_base_run_kwargs()) assert result == [ @@ -311,6 +323,7 @@ def test_run_local_native_with_enable_thinking_splits_response(): model_manager=model_manager, api_key="ws-key", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=platform_client, ) result = block.run( **_base_run_kwargs( @@ -337,6 +350,7 @@ def test_run_local_native_enable_thinking_silently_ignored_on_unsupported_model( model_manager=model_manager, api_key="ws-key", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=platform_client, ) block.run( **_base_run_kwargs( @@ -362,6 +376,7 @@ def test_run_dispatches_to_remote_native_when_step_mode_remote(mock_client_cls): model_manager=MagicMock(), api_key="ws-key", step_execution_mode=StepExecutionMode.REMOTE, + platform_client=platform_client, ) result = block.run( **_base_run_kwargs( @@ -390,6 +405,7 @@ def test_run_dispatches_to_local_native_with_fine_tuned_model_id(): model_manager=model_manager, api_key="ws-key", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=platform_client, ) result = block.run( **_base_run_kwargs( diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v2.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v2.py index beb886396c..b1019bd0fe 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v2.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v2.py @@ -21,6 +21,17 @@ encode_image_for_qwen_openrouter, ) from inference.core.workflows.execution_engine.entities.base import WorkflowImageData +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +platform_client = RecordingPlatformClient() + + +@pytest.fixture(autouse=True) +def _reset_platform_client(): + platform_client.reset() + # Copied literally from vlm-exam's `_NORMALIZED_XYXY_PROMPT_TEMPLATE` # (the benchmarked Qwen detection contract) so any accidental edit to the @@ -302,6 +313,7 @@ def test_run_openrouter_passes_slug_reasoning_and_temperature(mock_or): model_manager=MagicMock(), api_key="ws-key", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=platform_client, ) result = block.run( @@ -329,6 +341,7 @@ def test_run_openrouter_populates_thinking_from_reasoning_trace(mock_or): model_manager=MagicMock(), api_key="ws-key", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=platform_client, ) result = block.run( @@ -351,6 +364,7 @@ def test_run_openrouter_reasoning_required_model_falls_back_to_low_effort(mock_o model_manager=MagicMock(), api_key="ws-key", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=platform_client, ) block.run( @@ -375,6 +389,7 @@ def test_run_openrouter_explicit_max_tokens_overrides_default(mock_or): model_manager=MagicMock(), api_key="ws-key", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=platform_client, ) block.run( @@ -398,6 +413,7 @@ def test_run_native_default_max_tokens_is_forwarded(): model_manager=model_manager, api_key="ws-key", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=platform_client, ) block.run(**_base_run_kwargs()) @@ -415,6 +431,7 @@ def test_run_native_explicit_max_tokens_is_forwarded_as_max_new_tokens(): model_manager=model_manager, api_key="ws-key", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=platform_client, ) block.run(**_base_run_kwargs(max_tokens=1024)) @@ -432,6 +449,7 @@ def test_run_dispatches_to_local_native_when_step_mode_local(): model_manager=model_manager, api_key="ws-key", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=platform_client, ) result = block.run(**_base_run_kwargs()) assert result == [ @@ -452,6 +470,7 @@ def test_run_local_native_with_enable_thinking_splits_response(): model_manager=model_manager, api_key="ws-key", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=platform_client, ) result = block.run( **_base_run_kwargs( @@ -477,6 +496,7 @@ def test_run_dispatches_to_remote_native_when_step_mode_remote(mock_client_cls): model_manager=MagicMock(), api_key="ws-key", step_execution_mode=StepExecutionMode.REMOTE, + platform_client=platform_client, ) result = block.run( **_base_run_kwargs( @@ -498,6 +518,7 @@ def test_run_dispatches_to_local_native_with_fine_tuned_model_id(): model_manager=model_manager, api_key="ws-key", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=platform_client, ) result = block.run( **_base_run_kwargs( diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v3.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v3.py index 7040c94475..d494365773 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v3.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v3.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch import numpy as np +import pytest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.openrouter import OpenRouterResult @@ -16,6 +17,16 @@ QwenVlmBlockV3, ) from inference.core.workflows.execution_engine.entities.base import WorkflowImageData +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +platform_client = RecordingPlatformClient() + + +@pytest.fixture(autouse=True) +def _reset_platform_client(): + platform_client.reset() def _stub_image() -> WorkflowImageData: @@ -61,6 +72,7 @@ def test_run_openrouter_surfaces_token_usage(mock_or): model_manager=MagicMock(), api_key="ws-key", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=platform_client, ) result = block.run(**_base_run_kwargs(backend="openrouter")) @@ -86,6 +98,7 @@ def test_run_native_reports_none_token_usage(): model_manager=model_manager, api_key="ws-key", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=platform_client, ) result = block.run(**_base_run_kwargs()) diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_spacexai.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_spacexai.py index 94736c0ae8..25f0884dea 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_spacexai.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_spacexai.py @@ -14,6 +14,17 @@ prepare_object_detection_prompt, ) from inference.core.workflows.prototypes.block import third_party_model +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +platform_client = RecordingPlatformClient() + + +@pytest.fixture(autouse=True) +def _reset_platform_client(): + platform_client.reset() + PNG_MAGIC_BYTES = b"\x89PNG\r\n\x1a\n" JPEG_MAGIC_BYTES = b"\xff\xd8\xff" @@ -187,6 +198,7 @@ def test_execute_spacexai_request_routes_direct_key( direct_mock.return_value = "direct" result = execute_spacexai_request( roboflow_api_key="rf_abc", + platform_client=platform_client, xai_api_key="xai-secret", instructions=None, input_content=[{"role": "user", "content": []}], diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_spacexai_v2.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_spacexai_v2.py index e26dc5f5ac..0a65d38491 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_spacexai_v2.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_spacexai_v2.py @@ -7,10 +7,23 @@ from unittest.mock import MagicMock, patch +import pytest + from inference.core.workflows.core_steps.models.foundation.spacexai.v2 import ( _execute_direct_spacexai_request, _execute_proxied_spacexai_request, ) +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +platform_client = RecordingPlatformClient() + + +@pytest.fixture(autouse=True) +def _reset_platform_client(): + platform_client.reset() + _XAI_OK = { "status": "completed", @@ -23,15 +36,13 @@ } -@patch( - "inference.core.workflows.core_steps.models.foundation.spacexai.v2.post_to_roboflow_api" -) -def test_proxied_request_returns_usage_and_none_when_omitted( - mock_post: MagicMock, -) -> None: +def test_proxied_request_returns_usage_and_none_when_omitted() -> None: + mock_post = platform_client.post_mock + def call(): return _execute_proxied_spacexai_request( roboflow_api_key="rf_abc", + platform_client=platform_client, xai_api_key="rf_key:account", instructions=None, input_content=[], diff --git a/tests/workflows/unit_tests/core_steps/sinks/test_email_v2.py b/tests/workflows/unit_tests/core_steps/sinks/test_email_v2.py index a70e9b6a5c..994fd9bd87 100644 --- a/tests/workflows/unit_tests/core_steps/sinks/test_email_v2.py +++ b/tests/workflows/unit_tests/core_steps/sinks/test_email_v2.py @@ -33,6 +33,16 @@ ImageParentMetadata, WorkflowImageData, ) +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +platform_client = RecordingPlatformClient() + + +@pytest.fixture(autouse=True) +def _reset_platform_client(): + platform_client.reset() @pytest.mark.parametrize( @@ -340,16 +350,15 @@ def test_apply_operations_to_message_parameters_to_number() -> None: assert result["ratio"] == 0.5 -@mock.patch.object(v2, "post_to_roboflow_api") -def test_v2_send_email_via_roboflow_proxy_success( - post_to_roboflow_api_mock: MagicMock, -) -> None: +def test_v2_send_email_via_roboflow_proxy_success() -> None: # given + post_to_roboflow_api_mock = platform_client.post_mock post_to_roboflow_api_mock.return_value = {"status": "success"} # when result = send_email_via_roboflow_proxy( roboflow_api_key="test_api_key", + platform_client=platform_client, receiver_email=["receiver@gmail.com"], cc_receiver_email=None, bcc_receiver_email=None, @@ -373,15 +382,16 @@ def test_v2_send_email_via_roboflow_proxy_success( assert payload["message_parameters"] == {"var": "value"} -@mock.patch.object(v2, "post_to_roboflow_api") -def test_v2_send_email_via_roboflow_proxy_applies_message_parameters_operations( - post_to_roboflow_api_mock: MagicMock, -) -> None: +def test_v2_send_email_via_roboflow_proxy_applies_message_parameters_operations() -> ( + None +): """When message_parameters_operations are provided, transformed values are sent in payload.""" + post_to_roboflow_api_mock = platform_client.post_mock post_to_roboflow_api_mock.return_value = {"status": "success"} result = send_email_via_roboflow_proxy( roboflow_api_key="test_api_key", + platform_client=platform_client, receiver_email=["receiver@gmail.com"], cc_receiver_email=None, bcc_receiver_email=None, @@ -399,16 +409,15 @@ def test_v2_send_email_via_roboflow_proxy_applies_message_parameters_operations( assert payload["message_parameters"]["var"] == "LOWERCASE" -@mock.patch.object(v2, "post_to_roboflow_api") -def test_v2_send_email_via_roboflow_proxy_with_cc_bcc( - post_to_roboflow_api_mock: MagicMock, -) -> None: +def test_v2_send_email_via_roboflow_proxy_with_cc_bcc() -> None: # given + post_to_roboflow_api_mock = platform_client.post_mock post_to_roboflow_api_mock.return_value = {"status": "success"} # when result = send_email_via_roboflow_proxy( roboflow_api_key="test_api_key", + platform_client=platform_client, receiver_email=["receiver@gmail.com"], cc_receiver_email=["cc@gmail.com"], bcc_receiver_email=["bcc@gmail.com"], @@ -432,16 +441,15 @@ def test_v2_send_email_via_roboflow_proxy_with_cc_bcc( assert decoded_csv == "csv_content" -@mock.patch.object(v2, "post_to_roboflow_api") -def test_v2_send_email_via_roboflow_proxy_failure( - post_to_roboflow_api_mock: MagicMock, -) -> None: +def test_v2_send_email_via_roboflow_proxy_failure() -> None: # given + post_to_roboflow_api_mock = platform_client.post_mock post_to_roboflow_api_mock.side_effect = Exception("API Error") # when result = send_email_via_roboflow_proxy( roboflow_api_key="test_api_key", + platform_client=platform_client, receiver_email=["receiver@gmail.com"], cc_receiver_email=None, bcc_receiver_email=None, @@ -464,6 +472,7 @@ def test_v2_roboflow_managed_mode_sends_via_proxy() -> None: background_tasks=None, thread_pool_executor=thread_pool_executor, api_key="test_roboflow_key", + platform_client=platform_client, ) with mock.patch.object(v2, "send_email_via_roboflow_proxy") as proxy_mock: @@ -509,6 +518,7 @@ def test_v2_custom_smtp_mode_sends_via_smtp( background_tasks=None, thread_pool_executor=thread_pool_executor, api_key="test_roboflow_key", + platform_client=platform_client, ) # when @@ -546,6 +556,7 @@ def test_v2_custom_smtp_validates_required_fields() -> None: background_tasks=None, thread_pool_executor=None, api_key="test_roboflow_key", + platform_client=platform_client, ) # when - missing sender_email @@ -583,6 +594,7 @@ def test_v2_cooldown_functionality() -> None: background_tasks=None, thread_pool_executor=thread_pool_executor, api_key="test_roboflow_key", + platform_client=platform_client, ) # when @@ -620,6 +632,7 @@ def test_v2_cooldown_recovery() -> None: background_tasks=None, thread_pool_executor=thread_pool_executor, api_key="test_roboflow_key", + platform_client=platform_client, ) # when @@ -657,6 +670,7 @@ def test_v2_disable_sink() -> None: background_tasks=None, thread_pool_executor=None, api_key="test_roboflow_key", + platform_client=platform_client, ) # when @@ -697,6 +711,7 @@ def test_v2_synchronous_execution_with_roboflow_managed( background_tasks=None, thread_pool_executor=None, api_key="test_roboflow_key", + platform_client=platform_client, ) # when @@ -735,6 +750,7 @@ def test_v2_asynchronous_execution_with_background_tasks() -> None: background_tasks=background_tasks, thread_pool_executor=None, api_key="test_roboflow_key", + platform_client=platform_client, ) # when @@ -773,6 +789,7 @@ def test_v2_message_parameters_not_flattened_in_roboflow_mode() -> None: background_tasks=None, thread_pool_executor=thread_pool_executor, api_key="test_roboflow_key", + platform_client=platform_client, ) with mock.patch.object(v2, "send_email_via_roboflow_proxy") as proxy_mock: @@ -951,11 +968,9 @@ def test_v2_serialize_image_data_with_nested_structures() -> None: assert result["images"][1] == "/9j/nested" -@mock.patch.object(v2, "post_to_roboflow_api") -def test_v2_send_email_via_roboflow_proxy_serializes_images( - post_to_roboflow_api_mock: MagicMock, -) -> None: +def test_v2_send_email_via_roboflow_proxy_serializes_images() -> None: # given + post_to_roboflow_api_mock = platform_client.post_mock post_to_roboflow_api_mock.return_value = {"status": "success"} parent_metadata = ImageParentMetadata(parent_id="test") image_data = WorkflowImageData( @@ -966,6 +981,7 @@ def test_v2_send_email_via_roboflow_proxy_serializes_images( # when result = send_email_via_roboflow_proxy( roboflow_api_key="test_api_key", + platform_client=platform_client, receiver_email=["receiver@gmail.com"], cc_receiver_email=None, bcc_receiver_email=None, @@ -985,11 +1001,9 @@ def test_v2_send_email_via_roboflow_proxy_serializes_images( assert isinstance(payload["message_parameters"]["image"], str) -@mock.patch.object(v2, "post_to_roboflow_api") -def test_v2_send_email_via_roboflow_proxy_with_multiple_images( - post_to_roboflow_api_mock: MagicMock, -) -> None: +def test_v2_send_email_via_roboflow_proxy_with_multiple_images() -> None: # given + post_to_roboflow_api_mock = platform_client.post_mock post_to_roboflow_api_mock.return_value = {"status": "success"} parent_metadata = ImageParentMetadata(parent_id="test") image1 = WorkflowImageData( @@ -1004,6 +1018,7 @@ def test_v2_send_email_via_roboflow_proxy_with_multiple_images( # when result = send_email_via_roboflow_proxy( roboflow_api_key="test_api_key", + platform_client=platform_client, receiver_email=["receiver@gmail.com"], cc_receiver_email=None, bcc_receiver_email=None, @@ -1024,11 +1039,9 @@ def test_v2_send_email_via_roboflow_proxy_with_multiple_images( assert all(isinstance(img, str) for img in payload["message_parameters"]["images"]) -@mock.patch.object(v2, "post_to_roboflow_api") -def test_v2_send_email_with_image_attachment( - post_to_roboflow_api_mock: MagicMock, -) -> None: +def test_v2_send_email_with_image_attachment() -> None: # given + post_to_roboflow_api_mock = platform_client.post_mock post_to_roboflow_api_mock.return_value = {"status": "success"} parent_metadata = ImageParentMetadata(parent_id="test") numpy_array = np.zeros((100, 100, 3), dtype=np.uint8) @@ -1040,6 +1053,7 @@ def test_v2_send_email_with_image_attachment( # when result = send_email_via_roboflow_proxy( roboflow_api_key="test_api_key", + platform_client=platform_client, receiver_email=["receiver@gmail.com"], cc_receiver_email=None, bcc_receiver_email=None, @@ -1070,11 +1084,9 @@ def test_v2_send_email_with_image_attachment( assert valid_base64 -@mock.patch.object(v2, "post_to_roboflow_api") -def test_v2_send_email_with_image_attachment_existing_jpg_extension( - post_to_roboflow_api_mock: MagicMock, -) -> None: +def test_v2_send_email_with_image_attachment_existing_jpg_extension() -> None: # given + post_to_roboflow_api_mock = platform_client.post_mock post_to_roboflow_api_mock.return_value = {"status": "success"} parent_metadata = ImageParentMetadata(parent_id="test") numpy_array = np.zeros((50, 50, 3), dtype=np.uint8) @@ -1086,6 +1098,7 @@ def test_v2_send_email_with_image_attachment_existing_jpg_extension( # when result = send_email_via_roboflow_proxy( roboflow_api_key="test_api_key", + platform_client=platform_client, receiver_email=["receiver@gmail.com"], cc_receiver_email=None, bcc_receiver_email=None, @@ -1103,11 +1116,9 @@ def test_v2_send_email_with_image_attachment_existing_jpg_extension( assert "image.jpg.jpg" not in payload["attachments"] -@mock.patch.object(v2, "post_to_roboflow_api") -def test_v2_send_email_with_mixed_attachments( - post_to_roboflow_api_mock: MagicMock, -) -> None: +def test_v2_send_email_with_mixed_attachments() -> None: # given + post_to_roboflow_api_mock = platform_client.post_mock post_to_roboflow_api_mock.return_value = {"status": "success"} parent_metadata = ImageParentMetadata(parent_id="test") numpy_array = np.zeros((50, 50, 3), dtype=np.uint8) @@ -1119,6 +1130,7 @@ def test_v2_send_email_with_mixed_attachments( # when result = send_email_via_roboflow_proxy( roboflow_api_key="test_api_key", + platform_client=platform_client, receiver_email=["receiver@gmail.com"], cc_receiver_email=None, bcc_receiver_email=None, @@ -1159,6 +1171,7 @@ def test_v2_smtp_mode_with_image_attachment( background_tasks=None, thread_pool_executor=None, api_key="test_roboflow_key", + platform_client=platform_client, ) # when @@ -1209,6 +1222,7 @@ def test_v2_smtp_mode_with_mixed_attachments( background_tasks=None, thread_pool_executor=None, api_key="test_roboflow_key", + platform_client=platform_client, ) # when @@ -1248,11 +1262,9 @@ def test_v2_smtp_mode_with_mixed_attachments( assert "name,count" in csv_content -@mock.patch.object(v2, "post_to_roboflow_api") -def test_v2_send_email_with_multiple_image_attachments( - post_to_roboflow_api_mock: MagicMock, -) -> None: +def test_v2_send_email_with_multiple_image_attachments() -> None: # given + post_to_roboflow_api_mock = platform_client.post_mock post_to_roboflow_api_mock.return_value = {"status": "success"} parent_metadata = ImageParentMetadata(parent_id="test") @@ -1269,6 +1281,7 @@ def test_v2_send_email_with_multiple_image_attachments( # when result = send_email_via_roboflow_proxy( roboflow_api_key="test_api_key", + platform_client=platform_client, receiver_email=["receiver@gmail.com"], cc_receiver_email=None, bcc_receiver_email=None, @@ -1300,11 +1313,9 @@ def test_v2_send_email_with_multiple_image_attachments( pytest.fail(f"Attachment {key} is not valid base64") -@mock.patch.object(v2, "post_to_roboflow_api") -def test_v2_send_email_with_image_attachment_jpeg_extension( - post_to_roboflow_api_mock: MagicMock, -) -> None: +def test_v2_send_email_with_image_attachment_jpeg_extension() -> None: # given + post_to_roboflow_api_mock = platform_client.post_mock post_to_roboflow_api_mock.return_value = {"status": "success"} parent_metadata = ImageParentMetadata(parent_id="test") numpy_array = np.zeros((50, 50, 3), dtype=np.uint8) @@ -1316,6 +1327,7 @@ def test_v2_send_email_with_image_attachment_jpeg_extension( # when - filename already has .jpeg extension result = send_email_via_roboflow_proxy( roboflow_api_key="test_api_key", + platform_client=platform_client, receiver_email=["receiver@gmail.com"], cc_receiver_email=None, bcc_receiver_email=None, @@ -1333,17 +1345,16 @@ def test_v2_send_email_with_image_attachment_jpeg_extension( assert "image.jpeg.jpg" not in payload["attachments"] -@mock.patch.object(v2, "post_to_roboflow_api") -def test_v2_send_email_with_bytes_attachment_via_proxy( - post_to_roboflow_api_mock: MagicMock, -) -> None: +def test_v2_send_email_with_bytes_attachment_via_proxy() -> None: # given + post_to_roboflow_api_mock = platform_client.post_mock post_to_roboflow_api_mock.return_value = {"status": "success"} binary_data = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00" # when result = send_email_via_roboflow_proxy( roboflow_api_key="test_api_key", + platform_client=platform_client, receiver_email=["receiver@gmail.com"], cc_receiver_email=None, bcc_receiver_email=None, @@ -1376,6 +1387,7 @@ def test_v2_smtp_mode_with_bytes_attachment( background_tasks=None, thread_pool_executor=None, api_key="test_roboflow_key", + platform_client=platform_client, ) # when @@ -1427,6 +1439,7 @@ def test_v2_smtp_mode_with_multiple_image_attachments( background_tasks=None, thread_pool_executor=None, api_key="test_roboflow_key", + platform_client=platform_client, ) # when @@ -1462,11 +1475,9 @@ def test_v2_smtp_mode_with_multiple_image_attachments( assert call_kwargs["attachments"]["detection2.jpg"][:2] == b"\xff\xd8" -@mock.patch.object(v2, "post_to_roboflow_api") -def test_v2_send_email_with_all_attachment_types( - post_to_roboflow_api_mock: MagicMock, -) -> None: +def test_v2_send_email_with_all_attachment_types() -> None: # given + post_to_roboflow_api_mock = platform_client.post_mock post_to_roboflow_api_mock.return_value = {"status": "success"} parent_metadata = ImageParentMetadata(parent_id="test") @@ -1480,6 +1491,7 @@ def test_v2_send_email_with_all_attachment_types( # when result = send_email_via_roboflow_proxy( roboflow_api_key="test_api_key", + platform_client=platform_client, receiver_email=["receiver@gmail.com"], cc_receiver_email=None, bcc_receiver_email=None, diff --git a/tests/workflows/unit_tests/core_steps/sinks/test_email_v2_inline_images.py b/tests/workflows/unit_tests/core_steps/sinks/test_email_v2_inline_images.py index 84f88b98ee..daf4c0f220 100644 --- a/tests/workflows/unit_tests/core_steps/sinks/test_email_v2_inline_images.py +++ b/tests/workflows/unit_tests/core_steps/sinks/test_email_v2_inline_images.py @@ -15,6 +15,16 @@ ImageParentMetadata, WorkflowImageData, ) +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +platform_client = RecordingPlatformClient() + + +@pytest.fixture(autouse=True) +def _reset_platform_client(): + platform_client.reset() def test_format_email_message_html_with_single_inline_image() -> None: @@ -207,6 +217,7 @@ def test_v2_smtp_mode_with_inline_image( background_tasks=None, thread_pool_executor=None, api_key="test_roboflow_key", + platform_client=platform_client, ) # when @@ -263,6 +274,7 @@ def test_v2_smtp_mode_with_inline_and_attachment_images( background_tasks=None, thread_pool_executor=None, api_key="test_roboflow_key", + platform_client=platform_client, ) # when @@ -313,6 +325,7 @@ def test_v2_smtp_html_support_without_images( background_tasks=None, thread_pool_executor=None, api_key="test_roboflow_key", + platform_client=platform_client, ) # when @@ -371,6 +384,7 @@ def test_v2_smtp_mode_with_multiple_inline_images( background_tasks=None, thread_pool_executor=None, api_key="test_roboflow_key", + platform_client=platform_client, ) # when @@ -419,6 +433,7 @@ def test_v2_smtp_mode_preserves_html_formatting( background_tasks=None, thread_pool_executor=None, api_key="test_roboflow_key", + platform_client=platform_client, ) # when diff --git a/tests/workflows/unit_tests/core_steps/sinks/test_twilio_sms_v2.py b/tests/workflows/unit_tests/core_steps/sinks/test_twilio_sms_v2.py index 5719532d8f..b66721c656 100644 --- a/tests/workflows/unit_tests/core_steps/sinks/test_twilio_sms_v2.py +++ b/tests/workflows/unit_tests/core_steps/sinks/test_twilio_sms_v2.py @@ -21,6 +21,16 @@ ImageParentMetadata, WorkflowImageData, ) +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +platform_client = RecordingPlatformClient() + + +@pytest.fixture(autouse=True) +def _reset_platform_client(): + platform_client.reset() @pytest.mark.parametrize( @@ -217,14 +227,15 @@ def test_serialize_media_for_api_with_workflow_image() -> None: assert media_base64[0]["mimeType"] == "image/jpeg" -@mock.patch.object(v2, "post_to_roboflow_api") -def test_send_sms_via_roboflow_proxy_success(mock_post: MagicMock) -> None: +def test_send_sms_via_roboflow_proxy_success() -> None: # given + mock_post = platform_client.post_mock mock_post.return_value = {"success": True, "message_sid": "SM123"} # when error, message = send_sms_via_roboflow_proxy( roboflow_api_key="test_key", + platform_client=platform_client, receiver_number="+15551234567", message="Test {{ $parameters.count }}", message_parameters={"count": 5}, @@ -244,14 +255,15 @@ def test_send_sms_via_roboflow_proxy_success(mock_post: MagicMock) -> None: assert "Test" in payload["message"] -@mock.patch.object(v2, "post_to_roboflow_api") -def test_send_sms_via_roboflow_proxy_with_media(mock_post: MagicMock) -> None: +def test_send_sms_via_roboflow_proxy_with_media() -> None: # given + mock_post = platform_client.post_mock mock_post.return_value = {"success": True, "message_sid": "SM123"} # when error, message = send_sms_via_roboflow_proxy( roboflow_api_key="test_key", + platform_client=platform_client, receiver_number="+15551234567", message="Check this out", message_parameters={}, @@ -266,12 +278,11 @@ def test_send_sms_via_roboflow_proxy_with_media(mock_post: MagicMock) -> None: assert payload["media_urls"] == ["https://example.com/image.jpg"] -@mock.patch.object(v2, "post_to_roboflow_api") -def test_send_sms_via_roboflow_proxy_rate_limit_error(mock_post: MagicMock) -> None: +def test_send_sms_via_roboflow_proxy_rate_limit_error() -> None: # given from inference.core.exceptions import RoboflowAPIUnsuccessfulRequestError - def raise_rate_limit(endpoint, api_key, payload, http_errors_handlers): + def raise_rate_limit(endpoint, api_key, payload, http_errors_handlers, params=None): handler = http_errors_handlers[429] mock_response = MagicMock() mock_response.json.return_value = { @@ -282,11 +293,13 @@ def raise_rate_limit(endpoint, api_key, payload, http_errors_handlers): mock_error.response = mock_response handler(mock_error) + mock_post = platform_client.post_mock mock_post.side_effect = raise_rate_limit # when error, message = send_sms_via_roboflow_proxy( roboflow_api_key="test_key", + platform_client=platform_client, receiver_number="+15551234567", message="Test", message_parameters={}, @@ -299,12 +312,13 @@ def raise_rate_limit(endpoint, api_key, payload, http_errors_handlers): assert "rate limit" in message.lower() -@mock.patch.object(v2, "post_to_roboflow_api") -def test_send_sms_via_roboflow_proxy_credits_exceeded(mock_post: MagicMock) -> None: +def test_send_sms_via_roboflow_proxy_credits_exceeded() -> None: # given from inference.core.exceptions import RoboflowAPIUnsuccessfulRequestError - def raise_credits_error(endpoint, api_key, payload, http_errors_handlers): + def raise_credits_error( + endpoint, api_key, payload, http_errors_handlers, params=None + ): handler = http_errors_handlers.get(429, http_errors_handlers.get(403)) mock_response = MagicMock() mock_response.json.return_value = { @@ -315,11 +329,13 @@ def raise_credits_error(endpoint, api_key, payload, http_errors_handlers): mock_error.response = mock_response handler(mock_error) + mock_post = platform_client.post_mock mock_post.side_effect = raise_credits_error # when error, message = send_sms_via_roboflow_proxy( roboflow_api_key="test_key", + platform_client=platform_client, receiver_number="+15551234567", message="Test", message_parameters={}, @@ -338,31 +354,32 @@ def test_twilio_block_v2_roboflow_managed_success() -> None: background_tasks=None, thread_pool_executor=None, api_key="test_key", + platform_client=platform_client, ) - with mock.patch.object(v2, "post_to_roboflow_api") as mock_post: - mock_post.return_value = {"success": True} + mock_post = platform_client.post_mock + mock_post.return_value = {"success": True} - # when - result = block.run( - sms_provider="Roboflow Managed API Key", - receiver_number="+15551234567", - message="Test message", - message_parameters={}, - message_parameters_operations={}, - media_url=None, - twilio_account_sid=None, - twilio_auth_token=None, - sender_number=None, - fire_and_forget=False, - disable_sink=False, - cooldown_seconds=5, - ) + # when + result = block.run( + sms_provider="Roboflow Managed API Key", + receiver_number="+15551234567", + message="Test message", + message_parameters={}, + message_parameters_operations={}, + media_url=None, + twilio_account_sid=None, + twilio_auth_token=None, + sender_number=None, + fire_and_forget=False, + disable_sink=False, + cooldown_seconds=5, + ) - # then - assert result["error_status"] is False - assert result["throttling_status"] is False - assert "successfully" in result["message"].lower() + # then + assert result["error_status"] is False + assert result["throttling_status"] is False + assert "successfully" in result["message"].lower() def test_twilio_block_v2_custom_twilio_success() -> None: @@ -371,6 +388,7 @@ def test_twilio_block_v2_custom_twilio_success() -> None: background_tasks=None, thread_pool_executor=None, api_key="test_key", + platform_client=platform_client, ) mock_client = MagicMock() @@ -409,6 +427,7 @@ def test_twilio_block_v2_custom_twilio_missing_credentials() -> None: background_tasks=None, thread_pool_executor=None, api_key="test_key", + platform_client=platform_client, ) # when @@ -438,6 +457,7 @@ def test_twilio_block_v2_disable_sink() -> None: background_tasks=None, thread_pool_executor=None, api_key="test_key", + platform_client=platform_client, ) # when @@ -467,6 +487,7 @@ def test_twilio_block_v2_custom_twilio_with_mms_list() -> None: background_tasks=None, thread_pool_executor=None, api_key="test_key", + platform_client=platform_client, ) mock_client = MagicMock() @@ -511,6 +532,7 @@ def test_twilio_block_v2_custom_twilio_with_workflow_image() -> None: background_tasks=None, thread_pool_executor=None, api_key="test_key", + platform_client=platform_client, ) image = np.zeros((100, 100, 3), dtype=np.uint8) @@ -563,51 +585,52 @@ def test_twilio_block_v2_cooldown_behavior() -> None: background_tasks=None, thread_pool_executor=None, api_key="test_key", + platform_client=platform_client, ) - with mock.patch.object(v2, "post_to_roboflow_api") as mock_post: - mock_post.return_value = {"success": True} + mock_post = platform_client.post_mock + mock_post.return_value = {"success": True} - # First call - should succeed - result1 = block.run( - sms_provider="Roboflow Managed API Key", - receiver_number="+15551234567", - message="Test message 1", - message_parameters={}, - message_parameters_operations={}, - media_url=None, - twilio_account_sid=None, - twilio_auth_token=None, - sender_number=None, - fire_and_forget=False, - disable_sink=False, - cooldown_seconds=5, - ) + # First call - should succeed + result1 = block.run( + sms_provider="Roboflow Managed API Key", + receiver_number="+15551234567", + message="Test message 1", + message_parameters={}, + message_parameters_operations={}, + media_url=None, + twilio_account_sid=None, + twilio_auth_token=None, + sender_number=None, + fire_and_forget=False, + disable_sink=False, + cooldown_seconds=5, + ) - # then - assert result1["error_status"] is False - assert result1["throttling_status"] is False + # then + assert result1["error_status"] is False + assert result1["throttling_status"] is False - # Second call immediately - should be throttled - result2 = block.run( - sms_provider="Roboflow Managed API Key", - receiver_number="+15551234567", - message="Test message 2", - message_parameters={}, - message_parameters_operations={}, - media_url=None, - twilio_account_sid=None, - twilio_auth_token=None, - sender_number=None, - fire_and_forget=False, - disable_sink=False, - cooldown_seconds=5, - ) + # Second call immediately - should be throttled + result2 = block.run( + sms_provider="Roboflow Managed API Key", + receiver_number="+15551234567", + message="Test message 2", + message_parameters={}, + message_parameters_operations={}, + media_url=None, + twilio_account_sid=None, + twilio_auth_token=None, + sender_number=None, + fire_and_forget=False, + disable_sink=False, + cooldown_seconds=5, + ) - # then - assert result2["error_status"] is False - assert result2["throttling_status"] is True - assert "cooldown" in result2["message"].lower() + # then + assert result2["error_status"] is False + assert result2["throttling_status"] is True + assert "cooldown" in result2["message"].lower() def test_twilio_block_v2_cooldown_expires() -> None: @@ -616,51 +639,52 @@ def test_twilio_block_v2_cooldown_expires() -> None: background_tasks=None, thread_pool_executor=None, api_key="test_key", + platform_client=platform_client, ) - with mock.patch.object(v2, "post_to_roboflow_api") as mock_post: - mock_post.return_value = {"success": True} + mock_post = platform_client.post_mock + mock_post.return_value = {"success": True} - # First call - result1 = block.run( - sms_provider="Roboflow Managed API Key", - receiver_number="+15551234567", - message="Test message 1", - message_parameters={}, - message_parameters_operations={}, - media_url=None, - twilio_account_sid=None, - twilio_auth_token=None, - sender_number=None, - fire_and_forget=False, - disable_sink=False, - cooldown_seconds=1, # 1 second cooldown - ) + # First call + result1 = block.run( + sms_provider="Roboflow Managed API Key", + receiver_number="+15551234567", + message="Test message 1", + message_parameters={}, + message_parameters_operations={}, + media_url=None, + twilio_account_sid=None, + twilio_auth_token=None, + sender_number=None, + fire_and_forget=False, + disable_sink=False, + cooldown_seconds=1, # 1 second cooldown + ) - assert result1["throttling_status"] is False + assert result1["throttling_status"] is False - # Wait for cooldown to expire - time.sleep(1.1) + # Wait for cooldown to expire + time.sleep(1.1) - # Second call after cooldown - should succeed - result2 = block.run( - sms_provider="Roboflow Managed API Key", - receiver_number="+15551234567", - message="Test message 2", - message_parameters={}, - message_parameters_operations={}, - media_url=None, - twilio_account_sid=None, - twilio_auth_token=None, - sender_number=None, - fire_and_forget=False, - disable_sink=False, - cooldown_seconds=1, - ) + # Second call after cooldown - should succeed + result2 = block.run( + sms_provider="Roboflow Managed API Key", + receiver_number="+15551234567", + message="Test message 2", + message_parameters={}, + message_parameters_operations={}, + media_url=None, + twilio_account_sid=None, + twilio_auth_token=None, + sender_number=None, + fire_and_forget=False, + disable_sink=False, + cooldown_seconds=1, + ) - # then - assert result2["error_status"] is False - assert result2["throttling_status"] is False + # then + assert result2["error_status"] is False + assert result2["throttling_status"] is False def test_twilio_block_v2_fire_and_forget_with_thread_pool() -> None: @@ -672,31 +696,32 @@ def test_twilio_block_v2_fire_and_forget_with_thread_pool() -> None: background_tasks=None, thread_pool_executor=executor, api_key="test_key", + platform_client=platform_client, ) - with mock.patch.object(v2, "post_to_roboflow_api") as mock_post: - mock_post.return_value = {"success": True} + mock_post = platform_client.post_mock + mock_post.return_value = {"success": True} - # when - result = block.run( - sms_provider="Roboflow Managed API Key", - receiver_number="+15551234567", - message="Test message", - message_parameters={}, - message_parameters_operations={}, - media_url=None, - twilio_account_sid=None, - twilio_auth_token=None, - sender_number=None, - fire_and_forget=True, - disable_sink=False, - cooldown_seconds=5, - ) + # when + result = block.run( + sms_provider="Roboflow Managed API Key", + receiver_number="+15551234567", + message="Test message", + message_parameters={}, + message_parameters_operations={}, + media_url=None, + twilio_account_sid=None, + twilio_auth_token=None, + sender_number=None, + fire_and_forget=True, + disable_sink=False, + cooldown_seconds=5, + ) - # then - should return immediately without error - assert result["error_status"] is False - assert result["throttling_status"] is False - assert "background" in result["message"].lower() + # then - should return immediately without error + assert result["error_status"] is False + assert result["throttling_status"] is False + assert "background" in result["message"].lower() executor.shutdown(wait=True) @@ -710,28 +735,29 @@ def test_twilio_block_v2_fire_and_forget_with_background_tasks() -> None: background_tasks=background_tasks, thread_pool_executor=None, api_key="test_key", + platform_client=platform_client, ) - with mock.patch.object(v2, "post_to_roboflow_api") as mock_post: - mock_post.return_value = {"success": True} + mock_post = platform_client.post_mock + mock_post.return_value = {"success": True} - # when - result = block.run( - sms_provider="Roboflow Managed API Key", - receiver_number="+15551234567", - message="Test message", - message_parameters={}, - message_parameters_operations={}, - media_url=None, - twilio_account_sid=None, - twilio_auth_token=None, - sender_number=None, - fire_and_forget=True, - disable_sink=False, - cooldown_seconds=5, - ) + # when + result = block.run( + sms_provider="Roboflow Managed API Key", + receiver_number="+15551234567", + message="Test message", + message_parameters={}, + message_parameters_operations={}, + media_url=None, + twilio_account_sid=None, + twilio_auth_token=None, + sender_number=None, + fire_and_forget=True, + disable_sink=False, + cooldown_seconds=5, + ) - # then - should return immediately without error - assert result["error_status"] is False - assert result["throttling_status"] is False - assert "background" in result["message"].lower() + # then - should return immediately without error + assert result["error_status"] is False + assert result["throttling_status"] is False + assert "background" in result["message"].lower() diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 4e80d530d4..2acd71c865 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 318 +# Count: 302 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env @@ -8,7 +8,6 @@ inference/core/workflows/core_steps/common/deserializers.py inference.core.utils inference/core/workflows/core_steps/common/deserializers_tensor.py inference.core.env inference/core/workflows/core_steps/common/openrouter.py inference.core.env inference/core/workflows/core_steps/common/openrouter.py inference.core.exceptions -inference/core/workflows/core_steps/common/openrouter.py inference.core.roboflow_api inference/core/workflows/core_steps/common/openrouter.py inference.core.utils.image_utils inference/core/workflows/core_steps/common/query_language/operations/classification_results/base.py inference.core.env inference/core/workflows/core_steps/common/query_language/operations/detection/base.py inference.core.env @@ -32,10 +31,8 @@ inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py inf inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py inference.core.env inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py inference.core.env -inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py inference.core.env -inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.env @@ -68,19 +65,14 @@ inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py infere inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py inference.core.env inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py inference.core.utils.image_utils -inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1_tensor.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py inference.core.env @@ -101,16 +93,12 @@ inference/core/workflows/core_steps/models/foundation/openai/v1.py inference.cor inference/core/workflows/core_steps/models/foundation/openai/v2.py inference.core.env inference/core/workflows/core_steps/models/foundation/openai/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/openai/v3.py inference.core.env -inference/core/workflows/core_steps/models/foundation/openai/v3.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/openai/v3.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/openai/v4.py inference.core.env -inference/core/workflows/core_steps/models/foundation/openai/v4.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/openai/v4.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/openai/v5.py inference.core.env -inference/core/workflows/core_steps/models/foundation/openai/v5.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/openai/v5.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/openai/v6.py inference.core.env -inference/core/workflows/core_steps/models/foundation/openai/v6.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/openai/v6.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/openai_compatible/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py inference.core.entities.requests.perception_encoder @@ -212,10 +200,8 @@ inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py inference.co inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/smolvlm/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/spacexai/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/spacexai/v1.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/spacexai/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/spacexai/v2.py inference.core.env -inference/core/workflows/core_steps/models/foundation/spacexai/v2.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/spacexai/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.entities.requests.yolo_world inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.env @@ -282,11 +268,9 @@ inference/core/workflows/core_steps/models/third_party/barcode_detection/v1_tens inference/core/workflows/core_steps/models/third_party/qr_code_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/secrets_providers/environment_secrets_store/v1.py inference.core.env inference/core/workflows/core_steps/sinks/email_notification/v2.py inference.core.exceptions -inference/core/workflows/core_steps/sinks/email_notification/v2.py inference.core.roboflow_api inference/core/workflows/core_steps/sinks/email_notification/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/sinks/local_file/v1.py inference.core.env inference/core/workflows/core_steps/sinks/twilio/sms/v2.py inference.core.exceptions -inference/core/workflows/core_steps/sinks/twilio/sms/v2.py inference.core.roboflow_api inference/core/workflows/core_steps/sinks/twilio/sms/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/transformations/detections_combine/v1_tensor.py inference.core.env inference/core/workflows/core_steps/transformations/detections_merge/v1_tensor.py inference.core.env diff --git a/tests/workflows/unit_tests/prototypes/platform_client_double.py b/tests/workflows/unit_tests/prototypes/platform_client_double.py index 453207e6b2..2cfb925606 100644 --- a/tests/workflows/unit_tests/prototypes/platform_client_double.py +++ b/tests/workflows/unit_tests/prototypes/platform_client_double.py @@ -89,4 +89,5 @@ def set_post_response(self, value: Any) -> None: if isinstance(value, BaseException): self.post_mock.side_effect = value else: + self.post_mock.side_effect = None self.post_mock.return_value = value diff --git a/tests/workflows/unit_tests/prototypes/test_platform_client.py b/tests/workflows/unit_tests/prototypes/test_platform_client.py index 429940edca..b7ff4d768f 100644 --- a/tests/workflows/unit_tests/prototypes/test_platform_client.py +++ b/tests/workflows/unit_tests/prototypes/test_platform_client.py @@ -17,6 +17,9 @@ OfflineRoboflowPlatformClient, RoboflowPlatformClient, ) +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) def test_the_shared_offline_instance_is_the_offline_client() -> None: @@ -176,6 +179,18 @@ def test_server_adapter_header_policy_matches_the_server(monkeypatch) -> None: } +def test_set_post_response_clears_a_previously_seeded_exception() -> None: + # R9-H: seeding an error and then a success must not keep raising. + client = RecordingPlatformClient() + + client.set_post_response(RuntimeError("boom")) + with pytest.raises(RuntimeError, match="boom"): + client.post(endpoint="apiproxy/openai", api_key="k") + + client.set_post_response({"ok": True}) + assert client.post(endpoint="apiproxy/openai", api_key="k") == {"ok": True} + + def test_the_two_api_key_redaction_implementations_agree() -> None: # D3: the workflows caller of `api_key_safe_raise_for_status` moves to the # plugin, so no copy is made - pin the equivalence that rests on. From cdad099a7cfd65c97b5ac7e337d9a15d9a804398 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:33:45 +0200 Subject: [PATCH 09/86] fix(workflows): address Task 9.4 review findings in the platform-client codemods Skip the abstract OpenRouterWorkflowBlockBase construction case instead of failing it; fix CRLF round-tripping in both phase9 codemods (read_text() silently normalized \r\n, and the inserted blocks hardcoded \n); resolve aliased chain-helper calls and untouched-module same-named classes correctly in the test-side codemod; stop silently swallowing unparsable test files in the AST reference checker. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- scripts/phase9_check_test_references.py | 54 +++-- scripts/phase9_platform_client.py | 215 +++++++++++++----- scripts/phase9_platform_client_tests.py | 78 +++++-- .../common/test_platform_client_injection.py | 7 +- .../workflows/unit_tests/scripts/__init__.py | 0 .../test_phase9_codemods_preserve_crlf.py | 99 ++++++++ 6 files changed, 360 insertions(+), 93 deletions(-) create mode 100644 tests/workflows/unit_tests/scripts/__init__.py create mode 100644 tests/workflows/unit_tests/scripts/test_phase9_codemods_preserve_crlf.py diff --git a/scripts/phase9_check_test_references.py b/scripts/phase9_check_test_references.py index 0b952e615b..997fc148e0 100644 --- a/scripts/phase9_check_test_references.py +++ b/scripts/phase9_check_test_references.py @@ -1,29 +1,57 @@ """AST check: mock targets (string, f-string, patch.object) and `from` imports naming the given symbols under a test root. Prints file:line per hit and a TOTAL; exits 1 on any hit outside --allow files.""" -import argparse, ast, pathlib, sys + +import argparse +import ast +import pathlib +import sys + p = argparse.ArgumentParser() -p.add_argument("root"); p.add_argument("--symbols", required=True); p.add_argument("--allow", default="") -a = p.parse_args(); symbols = set(a.symbols.split(",")); allow = {x for x in a.allow.split(",") if x} +p.add_argument("root") +p.add_argument("--symbols", required=True) +p.add_argument("--allow", default="") +a = p.parse_args() +symbols = set(a.symbols.split(",")) +allow = {x for x in a.allow.split(",") if x} hits = [] for path in sorted(pathlib.Path(a.root).rglob("*.py")): - try: tree = ast.parse(path.read_text(encoding="utf-8")) - except SyntaxError: continue + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except SyntaxError as e: + print(f"FAIL: {path}: {e}", file=sys.stderr) + sys.exit(2) for n in ast.walk(tree): - if isinstance(n, ast.ImportFrom) and n.module in {"inference.core.roboflow_api", "inference.core.utils.url_utils"}: + if isinstance(n, ast.ImportFrom) and n.module in { + "inference.core.roboflow_api", + "inference.core.utils.url_utils", + }: for al in n.names: - if al.name in symbols: hits.append((str(path), n.lineno, f"import {al.name}")) + if al.name in symbols: + hits.append((str(path), n.lineno, f"import {al.name}")) if isinstance(n, ast.Call): f = n.func - is_patch = (isinstance(f, ast.Name) and f.id == "patch") or (isinstance(f, ast.Attribute) and f.attr in {"patch", "object", "setattr"}) - if not is_patch: continue + is_patch = (isinstance(f, ast.Name) and f.id == "patch") or ( + isinstance(f, ast.Attribute) + and f.attr in {"patch", "object", "setattr"} + ) + if not is_patch: + continue for arg in list(n.args) + [k.value for k in n.keywords]: text = None - if isinstance(arg, ast.Constant) and isinstance(arg.value, str): text = arg.value - elif isinstance(arg, ast.JoinedStr): text = "".join(v.value for v in arg.values if isinstance(v, ast.Constant)) + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + text = arg.value + elif isinstance(arg, ast.JoinedStr): + text = "".join( + v.value for v in arg.values if isinstance(v, ast.Constant) + ) if text and (text in symbols or text.rsplit(".", 1)[-1] in symbols): - hits.append((str(path), n.lineno, f"mock target {text.rsplit('.', 1)[-1]}")); break + hits.append( + (str(path), n.lineno, f"mock target {text.rsplit('.', 1)[-1]}") + ) + break bad = [h for h in hits if h[0] not in allow] -for h in hits: print(("allowed " if h[0] in allow else "") + f"{h[0]}:{h[1]} {h[2]}") +for h in hits: + print(("allowed " if h[0] in allow else "") + f"{h[0]}:{h[1]} {h[2]}") print(f"TOTAL {len(hits)} references, {len(bad)} outside the allow-list") sys.exit(1 if bad else 0) diff --git a/scripts/phase9_platform_client.py b/scripts/phase9_platform_client.py index f3d8974782..842c8f6bcb 100644 --- a/scripts/phase9_platform_client.py +++ b/scripts/phase9_platform_client.py @@ -63,24 +63,42 @@ def transform(source: str, class_name: str, path: str): lines = source.split(newline) tree = ast.parse(source) edits = [] - stats = {"defs": 0, "calls": 0, "ctor": 0, "gip": 0, "super": 0, - "post_calls": 0, "old_imports": 0} + stats = { + "defs": 0, + "calls": 0, + "ctor": 0, + "gip": 0, + "super": 0, + "post_calls": 0, + "old_imports": 0, + } - for node in tree.body: # A: defs + for node in tree.body: # A: defs if not isinstance(node, ast.FunctionDef): continue names = [a.arg for a in node.args.args] if "roboflow_api_key" not in names or "platform_client" in names: continue - if (node.args.vararg or node.args.kwarg or node.args.kwonlyargs - or node.args.posonlyargs): + if ( + node.args.vararg + or node.args.kwarg + or node.args.kwonlyargs + or node.args.posonlyargs + ): raise SystemExit(f"{path}: unsupported signature {node.name}") arg = node.args.args[names.index("roboflow_api_key")] - edits.append((arg.end_lineno, arg.end_col_offset, arg.end_lineno, - arg.end_col_offset, f", {PARAM_ANNOTATION}")) + edits.append( + ( + arg.end_lineno, + arg.end_col_offset, + arg.end_lineno, + arg.end_col_offset, + f", {PARAM_ANNOTATION}", + ) + ) stats["defs"] += 1 - for node in ast.walk(tree): # A: calls + for node in ast.walk(tree): # A: calls if not isinstance(node, ast.Call): continue keys = {k.arg for k in node.keywords} @@ -93,34 +111,65 @@ def transform(source: str, class_name: str, path: str): f"{path}: unexpected roboflow_api_key expression {expr!r} " f"at line {kw.value.lineno}" ) - edits.append((kw.value.end_lineno, kw.value.end_col_offset, - kw.value.end_lineno, kw.value.end_col_offset, - f", platform_client={MAPPING[expr]}")) + edits.append( + ( + kw.value.end_lineno, + kw.value.end_col_offset, + kw.value.end_lineno, + kw.value.end_col_offset, + f", platform_client={MAPPING[expr]}", + ) + ) stats["calls"] += 1 - target = next((n for n in ast.walk(tree) # B: the class - if isinstance(n, ast.ClassDef) and n.name == class_name), None) + target = next( + ( + n + for n in ast.walk(tree) # B: the class + if isinstance(n, ast.ClassDef) and n.name == class_name + ), + None, + ) if target is None: raise SystemExit(f"{path}: no class {class_name}") - init = next((n for n in target.body - if isinstance(n, ast.FunctionDef) and n.name == "__init__"), None) - gip = next((n for n in target.body - if isinstance(n, ast.FunctionDef) and n.name == "get_init_parameters"), None) + init = next( + ( + n + for n in target.body + if isinstance(n, ast.FunctionDef) and n.name == "__init__" + ), + None, + ) + gip = next( + ( + n + for n in target.body + if isinstance(n, ast.FunctionDef) and n.name == "get_init_parameters" + ), + None, + ) if init is None or gip is None: raise SystemExit(f"{path}: {class_name} needs __init__ and get_init_parameters") ret = next((n for n in ast.walk(gip) if isinstance(n, ast.Return)), None) if ret is None or not isinstance(ret.value, ast.List): raise SystemExit(f"{path}: {class_name}.get_init_parameters must return a list") - declared = [e.value for e in ret.value.elts - if isinstance(e, ast.Constant) and isinstance(e.value, str)] + declared = [ + e.value + for e in ret.value.elts + if isinstance(e, ast.Constant) and isinstance(e.value, str) + ] if len(declared) != len(ret.value.elts): raise SystemExit(f"{path}: non-literal in get_init_parameters") if "platform_client" not in declared: - rendered = " return [" + ", ".join( - f'"{n}"' for n in declared + ["platform_client"]) + "]" - edits.append((ret.lineno, 0, ret.end_lineno, - len(lines[ret.end_lineno - 1]), rendered)) + rendered = ( + " return [" + + ", ".join(f'"{n}"' for n in declared + ["platform_client"]) + + "]" + ) + edits.append( + (ret.lineno, 0, ret.end_lineno, len(lines[ret.end_lineno - 1]), rendered) + ) stats["gip"] += 1 if "platform_client" not in [a.arg for a in init.args.args]: @@ -128,40 +177,74 @@ def transform(source: str, class_name: str, path: str): if last_arg.arg == "self": raise SystemExit(f"{path}: {class_name}.__init__ takes no parameters") anchor = init.args.defaults[-1] if init.args.defaults else last_arg - edits.append((anchor.end_lineno, anchor.end_col_offset, anchor.end_lineno, - anchor.end_col_offset, f", {CTOR_PARAM}")) + edits.append( + ( + anchor.end_lineno, + anchor.end_col_offset, + anchor.end_lineno, + anchor.end_col_offset, + f", {CTOR_PARAM}", + ) + ) stats["ctor"] += 1 super_call = None for node in ast.walk(init): - if (isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "__init__" - and isinstance(node.func.value, ast.Call) - and isinstance(node.func.value.func, ast.Name) - and node.func.value.func.id == "super"): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "__init__" + and isinstance(node.func.value, ast.Call) + and isinstance(node.func.value.func, ast.Name) + and node.func.value.func.id == "super" + ): super_call = node if super_call is not None: last_kw = super_call.keywords[-1] - edits.append((last_kw.value.end_lineno, last_kw.value.end_col_offset, - last_kw.value.end_lineno, last_kw.value.end_col_offset, - ", platform_client=platform_client")) + edits.append( + ( + last_kw.value.end_lineno, + last_kw.value.end_col_offset, + last_kw.value.end_lineno, + last_kw.value.end_col_offset, + ", platform_client=platform_client", + ) + ) stats["super"] += 1 else: end = init.body[-1].end_lineno - edits.append((end, len(lines[end - 1]), end, len(lines[end - 1]), - newline + " self._platform_client = platform_client")) + edits.append( + ( + end, + len(lines[end - 1]), + end, + len(lines[end - 1]), + newline + " self._platform_client = platform_client", + ) + ) - for node in ast.walk(tree): # C: the proxy call - if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) - and node.func.id == PROXY_HELPER): - edits.append((node.func.lineno, node.func.col_offset, - node.func.end_lineno, node.func.end_col_offset, - "platform_client.post")) + for node in ast.walk(tree): # C: the proxy call + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == PROXY_HELPER + ): + edits.append( + ( + node.func.lineno, + node.func.col_offset, + node.func.end_lineno, + node.func.end_col_offset, + "platform_client.post", + ) + ) stats["post_calls"] += 1 old_import_span = None for node in tree.body: - if (isinstance(node, ast.ImportFrom) and node.module == OLD_IMPORT_MODULE - and [a.name for a in node.names] == [PROXY_HELPER]): + if ( + isinstance(node, ast.ImportFrom) + and node.module == OLD_IMPORT_MODULE + and [a.name for a in node.names] == [PROXY_HELPER] + ): old_import_span = (node.lineno, node.end_lineno) stats["old_imports"] += 1 @@ -179,7 +262,7 @@ def transform(source: str, class_name: str, path: str): anchor = node if anchor is None: raise SystemExit(f"{path}: no import anchor") - out.insert(anchor.end_lineno, IMPORT_BLOCK) + out.insert(anchor.end_lineno, IMPORT_BLOCK.replace("\n", newline)) updated = newline.join(out) ast.parse(updated) return updated, stats @@ -206,18 +289,28 @@ def verify(source: str, class_name: str, path: str): problems.append(f"call at line {node.lineno} lacks platform_client") if isinstance(node, ast.Name) and node.id == PROXY_HELPER: problems.append(f"{PROXY_HELPER} referenced at line {node.lineno}") - if (isinstance(node, ast.ImportFrom) and node.module == OLD_IMPORT_MODULE - and any(a.name == PROXY_HELPER for a in node.names)): + if ( + isinstance(node, ast.ImportFrom) + and node.module == OLD_IMPORT_MODULE + and any(a.name == PROXY_HELPER for a in node.names) + ): problems.append(f"{PROXY_HELPER} import survives at line {node.lineno}") - cls = next(n for n in ast.walk(tree) - if isinstance(n, ast.ClassDef) and n.name == class_name) - gip = next(n for n in cls.body - if isinstance(n, ast.FunctionDef) and n.name == "get_init_parameters") + cls = next( + n + for n in ast.walk(tree) + if isinstance(n, ast.ClassDef) and n.name == class_name + ) + gip = next( + n + for n in cls.body + if isinstance(n, ast.FunctionDef) and n.name == "get_init_parameters" + ) ret = next(n for n in ast.walk(gip) if isinstance(n, ast.Return)) if "platform_client" not in [e.value for e in ret.value.elts]: problems.append("get_init_parameters lacks platform_client") - init = next(n for n in cls.body - if isinstance(n, ast.FunctionDef) and n.name == "__init__") + init = next( + n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == "__init__" + ) if "platform_client" not in [a.arg for a in init.args.args]: problems.append("__init__ lacks platform_client") return problems @@ -236,7 +329,9 @@ def main() -> int: for pair in args.pairs: path_text, _, class_name = pair.partition("=") path = pathlib.Path(path_text) - updated, stats = transform(path.read_text(encoding="utf-8"), class_name, path_text) + updated, stats = transform( + path.read_bytes().decode("utf-8"), class_name, path_text + ) problems = verify(updated, class_name, path_text) if problems: print(f"FAIL {path_text}: {problems}", file=sys.stderr) @@ -250,14 +345,18 @@ def main() -> int: outputs[path_text] = updated print(f" {path_text}: {stats}") print("TOTALS", totals) - for name, expected in (("defs", args.expected_defs), ("calls", args.expected_calls), - ("post_calls", args.expected_post_calls), - ("ctor", args.expected_ctor)): + for name, expected in ( + ("defs", args.expected_defs), + ("calls", args.expected_calls), + ("post_calls", args.expected_post_calls), + ("ctor", args.expected_ctor), + ): if totals[name] != expected: print(f"FAIL: {name} {totals[name]} != {expected}", file=sys.stderr) return 1 for path_text, updated in outputs.items(): - pathlib.Path(path_text).write_text(updated, encoding="utf-8") + with open(path_text, "w", encoding="utf-8", newline="") as f: + f.write(updated) print("POST-STATE verified for every file; idempotent; written") return 0 diff --git a/scripts/phase9_platform_client_tests.py b/scripts/phase9_platform_client_tests.py index 3570c80dee..50115a4dbd 100644 --- a/scripts/phase9_platform_client_tests.py +++ b/scripts/phase9_platform_client_tests.py @@ -37,14 +37,28 @@ def _reset_platform_client(): import sys CHAIN = { - "run_gpt_4v_llm_prompting", "execute_gpt_4v_requests", "execute_gpt_4v_request", - "_execute_proxied_openai_request", "run_openai_prompting", "execute_openai_requests", - "execute_openai_request", "run_gemini_prompting", "execute_gemini_requests", - "execute_gemini_request", "_execute_proxied_gemini_request", "run_claude_prompting", - "execute_claude_requests", "execute_claude_request", "_execute_proxied_claude_request", - "run_spacexai_prompting", "execute_spacexai_requests", "execute_spacexai_request", - "_execute_proxied_spacexai_request", "_execute_proxied_google_vision_request", - "send_email_via_roboflow_proxy", "send_sms_via_roboflow_proxy", + "run_gpt_4v_llm_prompting", + "execute_gpt_4v_requests", + "execute_gpt_4v_request", + "_execute_proxied_openai_request", + "run_openai_prompting", + "execute_openai_requests", + "execute_openai_request", + "run_gemini_prompting", + "execute_gemini_requests", + "execute_gemini_request", + "_execute_proxied_gemini_request", + "run_claude_prompting", + "execute_claude_requests", + "execute_claude_request", + "_execute_proxied_claude_request", + "run_spacexai_prompting", + "execute_spacexai_requests", + "execute_spacexai_request", + "_execute_proxied_spacexai_request", + "_execute_proxied_google_vision_request", + "send_email_via_roboflow_proxy", + "send_sms_via_roboflow_proxy", "_execute_proxied_openrouter_request", } PREAMBLE = ( @@ -80,8 +94,11 @@ def _affected_calls(tree, touched, classes): param_origins = collections.defaultdict(set) for fn in functions: for dec in fn.decorator_list: - if not (isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute) - and dec.func.attr == "parametrize"): + if not ( + isinstance(dec, ast.Call) + and isinstance(dec.func, ast.Attribute) + and dec.func.attr == "parametrize" + ): continue if len(dec.args) < 2 or not isinstance(dec.args[0], ast.Constant): continue @@ -101,18 +118,28 @@ def _affected_calls(tree, touched, classes): f = node.func mods = set() if isinstance(f, ast.Name): - if f.id in CHAIN and f.id in origin: + if f.id in origin and origin[f.id][1] in CHAIN: mods.add(origin[f.id][0]) elif (fn.name, f.id) in param_origins: for mod, name in param_origins[(fn.name, f.id)]: if name in CHAIN: mods.add(mod) - elif f.id in origin and origin[f.id][1] in classes: + elif ( + f.id in origin + and origin[f.id][1] in classes + and origin[f.id][0] in touched + ): if node.args: - raise SystemExit(f"positional construction at line {node.lineno}") + raise SystemExit( + f"positional construction at line {node.lineno}" + ) constructions[(node.lineno, node.col_offset)] = node continue - elif isinstance(f, ast.Attribute) and f.attr in CHAIN and isinstance(f.value, ast.Name): + elif ( + isinstance(f, ast.Attribute) + and f.attr in CHAIN + and isinstance(f.value, ast.Name) + ): if f.value.id in alias: mods.add(alias[f.value.id]) if mods and any(m in touched for m in mods): @@ -121,7 +148,7 @@ def _affected_calls(tree, touched, classes): def patch(path: pathlib.Path, touched, classes): - source = path.read_text(encoding="utf-8") + source = path.read_bytes().decode("utf-8") newline = "\r\n" if "\r\n" in source else "\n" lines = source.split(newline) tree = ast.parse(source) @@ -146,7 +173,9 @@ def patch(path: pathlib.Path, touched, classes): raise SystemExit(f"{path}:{node.lineno}: call without keywords") for lineno, col in sorted(set(edits), reverse=True): line = lines[lineno - 1] - lines[lineno - 1] = line[:col] + ", platform_client=platform_client" + line[col:] + lines[lineno - 1] = ( + line[:col] + ", platform_client=platform_client" + line[col:] + ) updated = newline.join(lines) if edits and "RecordingPlatformClient" not in updated: out = updated.split(newline) @@ -156,24 +185,31 @@ def patch(path: pathlib.Path, touched, classes): for node in new_tree.body: if isinstance(node, (ast.Import, ast.ImportFrom)): anchor = node - if isinstance(node, ast.Import) and any(a.name == "pytest" for a in node.names): + if isinstance(node, ast.Import) and any( + a.name == "pytest" for a in node.names + ): has_pytest = True if anchor is None: raise SystemExit(f"{path}: no import anchor") preamble = PREAMBLE if has_pytest else "import pytest\n\n" + PREAMBLE - out.insert(anchor.end_lineno, preamble) + out.insert(anchor.end_lineno, preamble.replace("\n", newline)) updated = newline.join(out) ast.parse(updated) if edits: - path.write_text(updated, encoding="utf-8") + with open(path, "w", encoding="utf-8", newline="") as f: + f.write(updated) return edited_calls, edited_ctors def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("files", nargs="+") - parser.add_argument("--touched", required=True, help="file listing the changed modules") - parser.add_argument("--classes", required=True, help="comma-separated block class names") + parser.add_argument( + "--touched", required=True, help="file listing the changed modules" + ) + parser.add_argument( + "--classes", required=True, help="comma-separated block class names" + ) parser.add_argument("--expected-calls", type=int, required=True) parser.add_argument("--expected-constructions", type=int, required=True) args = parser.parse_args() diff --git a/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py b/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py index 3c2a9c6405..95f0ee11c8 100644 --- a/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py +++ b/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py @@ -9,6 +9,7 @@ import ast import importlib +import inspect import pathlib from functools import partial @@ -56,8 +57,12 @@ def test_block_declares_the_platform_client_init_parameter(module_suffix, class_ @pytest.mark.parametrize("module_suffix,class_name", PROXY_BLOCKS) def test_block_stores_the_injected_client(module_suffix, class_name): - sentinel = RecordingPlatformClient() block_class = _load(module_suffix, class_name) + if inspect.isabstract(block_class): + pytest.skip( + f"{class_name} is abstract; storage is proven through QwenVlmBlockV1" + ) + sentinel = RecordingPlatformClient() kwargs = {name: None for name in block_class.get_init_parameters()} kwargs["platform_client"] = sentinel assert block_class(**kwargs)._platform_client is sentinel diff --git a/tests/workflows/unit_tests/scripts/__init__.py b/tests/workflows/unit_tests/scripts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/workflows/unit_tests/scripts/test_phase9_codemods_preserve_crlf.py b/tests/workflows/unit_tests/scripts/test_phase9_codemods_preserve_crlf.py new file mode 100644 index 0000000000..cf546c1b1f --- /dev/null +++ b/tests/workflows/unit_tests/scripts/test_phase9_codemods_preserve_crlf.py @@ -0,0 +1,99 @@ +"""Fix round 1, F2/F3: both Phase 9 codemods must round-trip CRLF files verbatim +and resolve call/construction origins correctly (aliased imports; untouched-module +same-named classes). + +`Path.read_text()` performs universal-newline translation, so the scripts' own +`"\r\n" in source` detection was always False even on a genuinely CRLF file - the +file would silently come back out as LF. These tests write real CRLF fixtures to +disk, run the codemods exactly as `main()` does (`read_bytes().decode("utf-8")` +in, `open(..., newline="")` out), and assert every line ending survives. +""" + +import ast + +import scripts.phase9_platform_client as platform_client_codemod +import scripts.phase9_platform_client_tests as test_codemod + + +def _crlf(text: str) -> str: + return text.replace("\n", "\r\n") + + +def _assert_all_crlf(text: str) -> None: + assert "\r\n" in text, "fixture lost its CRLF endings entirely" + assert text.count("\n") == text.count("\r\n"), "a bare \\n survived the codemod" + + +MODULE_SOURCE = _crlf( + "from typing import Optional\n" + "\n" + "\n" + "def helper(roboflow_api_key: Optional[str]) -> None:\n" + " inner(roboflow_api_key=roboflow_api_key)\n" + "\n" + "\n" + "def inner(roboflow_api_key: Optional[str]) -> None:\n" + " pass\n" + "\n" + "\n" + "class Block:\n" + " def __init__(self, api_key: Optional[str]):\n" + " self._roboflow_api_key = api_key\n" + "\n" + " @classmethod\n" + " def get_init_parameters(cls):\n" + ' return ["api_key"]\n' +) + + +def test_platform_client_transform_preserves_crlf_line_endings(tmp_path): + path = tmp_path / "module.py" + path.write_bytes(MODULE_SOURCE.encode("utf-8")) + source = path.read_bytes().decode("utf-8") + + updated, stats = platform_client_codemod.transform(source, "Block", str(path)) + + assert stats["defs"] == 2 + assert stats["calls"] == 1 + assert stats["ctor"] == 1 + assert stats["gip"] == 1 + _assert_all_crlf(updated) + ast.parse(updated) # still parses + + +TEST_SOURCE = _crlf( + "import pytest\n" + "\n" + "from fake.touched.module import execute_claude_request as aliased_call\n" + "from fake.untouched.module import SomeBlock\n" + "\n" + "\n" + "def test_aliased_call_gets_client():\n" + ' aliased_call(roboflow_api_key="y")\n' + "\n" + "\n" + "def test_construction_from_untouched_module_is_left_alone():\n" + ' SomeBlock(some_kwarg="z")\n' +) + + +def test_test_codemod_preserves_crlf_and_resolves_origins_correctly(tmp_path): + path = tmp_path / "test_fixture.py" + path.write_bytes(TEST_SOURCE.encode("utf-8")) + + calls, ctors = test_codemod.patch( + path, touched={"fake.touched.module"}, classes={"SomeBlock"} + ) + + # F3(a): the alias's ORIGINAL imported name ("execute_claude_request") is in + # CHAIN, not the local alias ("aliased_call") - the call must still be edited. + assert calls == 1 + # F3(b): SomeBlock matches by name but its origin module ("fake.untouched.module") + # is not in `touched` - the construction must be left alone. + assert ctors == 0 + + updated = path.read_bytes().decode("utf-8") + assert "platform_client=platform_client" in updated + assert 'SomeBlock(some_kwarg="z")' in updated # untouched: byte-for-byte unchanged + _assert_all_crlf(updated) + ast.parse(updated) # still parses From ea0a77028f4aa9ceed3c4a7cd09b081a2f138112 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:51:23 +0200 Subject: [PATCH 10/86] refactor(workflows): inject Roboflow API headers and wrap_url into SAM/seg blocks build_roboflow_api_headers, get_extra_weights_provider_headers and the secure-gateway wrap_url now arrive through the platform port; wrap_url is injected, never reimplemented. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../models/foundation/seg_preview/v1.py | 16 ++- .../foundation/seg_preview/v1_tensor.py | 16 ++- .../foundation/segment_anything2_video/v1.py | 13 +- .../segment_anything2_video/v1_tensor.py | 13 +- .../models/foundation/segment_anything3/v1.py | 16 ++- .../foundation/segment_anything3/v1_tensor.py | 16 ++- .../models/foundation/segment_anything3/v2.py | 16 ++- .../foundation/segment_anything3/v2_tensor.py | 16 ++- .../models/foundation/segment_anything3/v3.py | 16 ++- .../foundation/segment_anything3/v3_tensor.py | 16 ++- .../segment_anything3_interactive/v1.py | 18 ++- .../v1_tensor.py | 18 ++- .../foundation/segment_anything3_video/v1.py | 13 +- .../segment_anything3_video/v1_tensor.py | 13 +- scripts/phase9_platform_headers.py | 89 ++++++++++++ .../test_platform_client_headers.py | 136 ++++++++++++++++++ .../test_segment_anything2_video.py | 12 +- .../test_segment_anything3_video.py | 19 +-- .../unit_tests/decontamination_baseline.txt | 26 +--- 19 files changed, 398 insertions(+), 100 deletions(-) create mode 100644 scripts/phase9_platform_headers.py create mode 100644 tests/workflows/unit_tests/core_steps/models/foundation/test_platform_client_headers.py diff --git a/inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py b/inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py index b7b22d792c..50819e892a 100644 --- a/inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py @@ -16,8 +16,6 @@ ROBOFLOW_INTERNAL_SERVICE_NAME, ROBOFLOW_INTERNAL_SERVICE_SECRET, ) -from inference.core.roboflow_api import build_roboflow_api_headers -from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -48,6 +46,10 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) DETECTIONS_CLASS_NAME_FIELD = "class_name" DETECTION_ID_FIELD = "detection_id" @@ -142,13 +144,15 @@ def __init__( self, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._api_key = api_key self._step_execution_mode = step_execution_mode + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key", "step_execution_mode"] + return ["api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -220,10 +224,12 @@ def run_via_request( ROBOFLOW_INTERNAL_SERVICE_SECRET ) - headers = build_roboflow_api_headers(explicit_headers=headers) + headers = self._platform_client.build_api_headers( + explicit_headers=headers + ) response = requests.post( - wrap_url(f"{endpoint}?api_key={api_key}"), + self._platform_client.wrap_url(f"{endpoint}?api_key={api_key}"), json=payload, headers=headers, timeout=60, diff --git a/inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py index 78394cf168..0e03aff4ac 100644 --- a/inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py @@ -30,8 +30,6 @@ ROBOFLOW_INTERNAL_SERVICE_SECRET, WORKFLOWS_IMAGE_TENSOR_DEVICE, ) -from inference.core.roboflow_api import build_roboflow_api_headers -from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, @@ -66,6 +64,10 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.types import InstancesRLEMasks @@ -161,13 +163,15 @@ def __init__( self, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._api_key = api_key self._step_execution_mode = step_execution_mode + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key", "step_execution_mode"] + return ["api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -235,10 +239,12 @@ def run_via_request( ROBOFLOW_INTERNAL_SERVICE_SECRET ) - headers = build_roboflow_api_headers(explicit_headers=headers) + headers = self._platform_client.build_api_headers( + explicit_headers=headers + ) response = requests.post( - wrap_url(f"{endpoint}?api_key={api_key}"), + self._platform_client.wrap_url(f"{endpoint}?api_key={api_key}"), json=payload, headers=headers, timeout=60, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py index dcfbe937c2..3c97034aa9 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py @@ -26,7 +26,6 @@ import supervision as sv from pydantic import ConfigDict, Field -from inference.core.roboflow_api import get_extra_weights_provider_headers from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -67,6 +66,10 @@ WorkflowBlockManifest, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference.usage_tracking.collector import usage_collector PromptMode = Literal["first_frame", "every_n_frames", "every_frame"] @@ -244,6 +247,7 @@ def __init__( model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._model_manager = model_manager self._api_key = api_key @@ -251,10 +255,11 @@ def __init__( self._model = None # lazily loaded self._current_model_id: Optional[str] = None self._sessions: Dict[str, VideoSessionBookkeeping] = {} + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["model_manager", "api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -264,7 +269,9 @@ def _get_model(self, model_id: str): if self._model is None or self._current_model_id != model_id: from inference_models import AutoModel - extra_weights_provider_headers = get_extra_weights_provider_headers() + extra_weights_provider_headers = ( + self._platform_client.build_weights_provider_headers() + ) self._model = AutoModel.from_pretrained( model_id_or_path=model_id, api_key=self._api_key, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py index e37167ba7e..d1db5649ce 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py @@ -32,7 +32,6 @@ from pydantic import ConfigDict, Field from inference.core.env import GCP_SERVERLESS, WORKFLOWS_SAM_VIDEO_MASK_REPRESENTATION -from inference.core.roboflow_api import get_extra_weights_provider_headers from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.models.foundation.segment_anything_common.streaming_video import ( VideoSessionBookkeeping, @@ -72,6 +71,10 @@ WorkflowBlockManifest, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) PromptMode = Literal["first_frame", "every_n_frames", "every_frame"] @@ -233,6 +236,7 @@ def __init__( model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._model_manager = model_manager self._api_key = api_key @@ -240,10 +244,11 @@ def __init__( self._model = None # lazily loaded self._current_model_id: Optional[str] = None self._sessions: Dict[str, VideoSessionBookkeeping] = {} + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["model_manager", "api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -253,7 +258,9 @@ def _get_model(self, model_id: str): if self._model is None or self._current_model_id != model_id: from inference_models import AutoModel - extra_weights_provider_headers = get_extra_weights_provider_headers() + extra_weights_provider_headers = ( + self._platform_client.build_weights_provider_headers() + ) self._model = AutoModel.from_pretrained( model_id_or_path=model_id, api_key=self._api_key, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py index ab775be9a3..b59c2978a5 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py @@ -26,8 +26,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.roboflow_api import build_roboflow_api_headers -from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -66,6 +64,10 @@ roboflow_platform_model, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference_sdk import InferenceConfiguration, InferenceHTTPClient logger = logging.getLogger(__name__) @@ -195,14 +197,16 @@ def __init__( model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._model_manager = model_manager self._api_key = api_key self._step_execution_mode = step_execution_mode + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["model_manager", "api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -451,10 +455,12 @@ def run_via_request( ROBOFLOW_INTERNAL_SERVICE_SECRET ) - headers = build_roboflow_api_headers(explicit_headers=headers) + headers = self._platform_client.build_api_headers( + explicit_headers=headers + ) response = requests.post( - wrap_url(f"{endpoint}?api_key={api_key}"), + self._platform_client.wrap_url(f"{endpoint}?api_key={api_key}"), json=payload, headers=headers, timeout=60, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py index 6680a1d428..9cb36ef825 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py @@ -40,8 +40,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.roboflow_api import build_roboflow_api_headers -from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, @@ -79,6 +77,10 @@ roboflow_platform_model, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.types import InstancesRLEMasks from inference_models.models.common.rle_utils import torch_mask_to_coco_rle @@ -201,14 +203,16 @@ def __init__( model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._model_manager = model_manager self._api_key = api_key self._step_execution_mode = step_execution_mode + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["model_manager", "api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -363,10 +367,12 @@ def run_via_request( headers["X-Roboflow-Internal-Service-Secret"] = ( ROBOFLOW_INTERNAL_SERVICE_SECRET ) - headers = build_roboflow_api_headers(explicit_headers=headers) + headers = self._platform_client.build_api_headers(explicit_headers=headers) try: response = requests.post( - wrap_url(f"{endpoint}?api_key={self._api_key}"), + self._platform_client.wrap_url( + f"{endpoint}?api_key={self._api_key}" + ), json=payload, headers=headers, timeout=60, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py index 5db38188a2..8673031895 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py @@ -26,8 +26,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.roboflow_api import build_roboflow_api_headers -from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -66,6 +64,10 @@ roboflow_platform_model, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference_sdk import InferenceConfiguration, InferenceHTTPClient logger = logging.getLogger(__name__) @@ -246,14 +248,16 @@ def __init__( model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._model_manager = model_manager self._api_key = api_key self._step_execution_mode = step_execution_mode + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["model_manager", "api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -540,10 +544,12 @@ def run_via_request( ROBOFLOW_INTERNAL_SERVICE_SECRET ) - headers = build_roboflow_api_headers(explicit_headers=headers) + headers = self._platform_client.build_api_headers( + explicit_headers=headers + ) response = requests.post( - wrap_url(f"{endpoint}?api_key={api_key}"), + self._platform_client.wrap_url(f"{endpoint}?api_key={api_key}"), json=payload, headers=headers, timeout=60, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py index 5ac6575163..51a5238ea3 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py @@ -41,8 +41,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.roboflow_api import build_roboflow_api_headers -from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode # Reuse the v1_tensor SAM3 conversion machinery verbatim. @@ -87,6 +85,10 @@ roboflow_platform_model, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.types import InstancesRLEMasks from inference_sdk import InferenceConfiguration, InferenceHTTPClient @@ -262,14 +264,16 @@ def __init__( model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._model_manager = model_manager self._api_key = api_key self._step_execution_mode = step_execution_mode + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["model_manager", "api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -464,10 +468,12 @@ def run_via_request( headers["X-Roboflow-Internal-Service-Secret"] = ( ROBOFLOW_INTERNAL_SERVICE_SECRET ) - headers = build_roboflow_api_headers(explicit_headers=headers) + headers = self._platform_client.build_api_headers(explicit_headers=headers) try: response = requests.post( - wrap_url(f"{endpoint}?api_key={self._api_key}"), + self._platform_client.wrap_url( + f"{endpoint}?api_key={self._api_key}" + ), json=payload, headers=headers, timeout=60, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py index df6c91e48a..9d37c81f92 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py @@ -26,8 +26,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.roboflow_api import build_roboflow_api_headers -from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -69,6 +67,10 @@ roboflow_platform_model, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference_sdk import InferenceConfiguration, InferenceHTTPClient logger = logging.getLogger(__name__) @@ -273,14 +275,16 @@ def __init__( model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._model_manager = model_manager self._api_key = api_key self._step_execution_mode = step_execution_mode + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["model_manager", "api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -583,10 +587,12 @@ def run_via_request( ROBOFLOW_INTERNAL_SERVICE_SECRET ) - headers = build_roboflow_api_headers(explicit_headers=headers) + headers = self._platform_client.build_api_headers( + explicit_headers=headers + ) response = requests.post( - wrap_url(f"{endpoint}?api_key={api_key}"), + self._platform_client.wrap_url(f"{endpoint}?api_key={api_key}"), json=payload, headers=headers, timeout=60, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py index d683641e8b..a17e8dcb89 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py @@ -56,8 +56,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.roboflow_api import build_roboflow_api_headers -from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode # Reuse the v1_tensor conversion machinery + the v2_tensor per-class/NMS collector. @@ -105,6 +103,10 @@ roboflow_platform_model, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference_sdk import InferenceConfiguration, InferenceHTTPClient logger = logging.getLogger(__name__) @@ -301,14 +303,16 @@ def __init__( model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._model_manager = model_manager self._api_key = api_key self._step_execution_mode = step_execution_mode + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["model_manager", "api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -518,10 +522,12 @@ def run_via_request( headers["X-Roboflow-Internal-Service-Secret"] = ( ROBOFLOW_INTERNAL_SERVICE_SECRET ) - headers = build_roboflow_api_headers(explicit_headers=headers) + headers = self._platform_client.build_api_headers(explicit_headers=headers) try: response = requests.post( - wrap_url(f"{endpoint}?api_key={self._api_key}"), + self._platform_client.wrap_url( + f"{endpoint}?api_key={self._api_key}" + ), json=payload, headers=headers, timeout=60, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py index 2221b30089..cef5a4f2a8 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py @@ -25,8 +25,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.roboflow_api import build_roboflow_api_headers -from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -69,6 +67,10 @@ roboflow_platform_model, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference_sdk import InferenceConfiguration, InferenceHTTPClient logger = logging.getLogger(__name__) @@ -249,14 +251,16 @@ def __init__( model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._model_manager = model_manager self._api_key = api_key self._step_execution_mode = step_execution_mode + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["model_manager", "api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -477,9 +481,13 @@ def run_via_request( headers["X-Roboflow-Internal-Service-Secret"] = ( ROBOFLOW_INTERNAL_SERVICE_SECRET ) - headers = build_roboflow_api_headers(explicit_headers=headers) + headers = self._platform_client.build_api_headers( + explicit_headers=headers + ) response = requests.post( - wrap_url(f"{endpoint}?api_key={self._api_key}"), + self._platform_client.wrap_url( + f"{endpoint}?api_key={self._api_key}" + ), json=payload, headers=headers, timeout=60, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py index c2a07d085d..9c8d4ad4e9 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py @@ -50,8 +50,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.roboflow_api import build_roboflow_api_headers -from inference.core.utils.url_utils import wrap_url from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, @@ -97,6 +95,10 @@ roboflow_platform_model, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.types import InstancesRLEMasks from inference_models.models.common.rle_utils import ( @@ -290,14 +292,16 @@ def __init__( model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._model_manager = model_manager self._api_key = api_key self._step_execution_mode = step_execution_mode + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["model_manager", "api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -531,9 +535,13 @@ def run_via_request( headers["X-Roboflow-Internal-Service-Secret"] = ( ROBOFLOW_INTERNAL_SERVICE_SECRET ) - headers = build_roboflow_api_headers(explicit_headers=headers) + headers = self._platform_client.build_api_headers( + explicit_headers=headers + ) response = requests.post( - wrap_url(f"{endpoint}?api_key={self._api_key}"), + self._platform_client.wrap_url( + f"{endpoint}?api_key={self._api_key}" + ), json=payload, headers=headers, timeout=60, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py index dbfc23a275..abedebfa25 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py @@ -24,7 +24,6 @@ import supervision as sv from pydantic import ConfigDict, Field, model_validator -from inference.core.roboflow_api import get_extra_weights_provider_headers from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -76,6 +75,10 @@ WorkflowBlockManifest, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference.usage_tracking.collector import usage_collector PromptMode = Literal["first_frame", "every_n_frames", "every_frame"] @@ -339,6 +342,7 @@ def __init__( model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._model_manager = model_manager self._api_key = api_key @@ -347,10 +351,11 @@ def __init__( self._current_model_id: Optional[str] = None self._concept_sessions: Dict[str, _ConceptSessionBookkeeping] = {} self._visual_sessions: Dict[str, VideoSessionBookkeeping] = {} + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["model_manager", "api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -360,7 +365,9 @@ def _get_model(self, model_id: str): if self._model is None or self._current_model_id != model_id: from inference_models import AutoModel - extra_weights_provider_headers = get_extra_weights_provider_headers() + extra_weights_provider_headers = ( + self._platform_client.build_weights_provider_headers() + ) self._model = AutoModel.from_pretrained( model_id_or_path=model_id, api_key=self._api_key, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py index 5459261e57..a4b44c959e 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py @@ -42,7 +42,6 @@ WORKFLOWS_IMAGE_TENSOR_DEVICE, WORKFLOWS_SAM_VIDEO_MASK_REPRESENTATION, ) -from inference.core.roboflow_api import get_extra_weights_provider_headers from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, @@ -101,6 +100,10 @@ WorkflowBlockManifest, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) from inference_models.models.base.instance_segmentation import InstanceDetections from inference_models.models.base.types import InstancesRLEMasks from inference_models.models.common.rle_utils import torch_mask_to_coco_rle @@ -379,6 +382,7 @@ def __init__( model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): self._model_manager = model_manager self._api_key = api_key @@ -387,10 +391,11 @@ def __init__( self._current_model_id: Optional[str] = None self._concept_sessions: Dict[str, _ConceptSessionBookkeeping] = {} self._visual_sessions: Dict[str, VideoSessionBookkeeping] = {} + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["model_manager", "api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -400,7 +405,9 @@ def _get_model(self, model_id: str): if self._model is None or self._current_model_id != model_id: from inference_models import AutoModel - extra_weights_provider_headers = get_extra_weights_provider_headers() + extra_weights_provider_headers = ( + self._platform_client.build_weights_provider_headers() + ) self._model = AutoModel.from_pretrained( model_id_or_path=model_id, api_key=self._api_key, diff --git a/scripts/phase9_platform_headers.py b/scripts/phase9_platform_headers.py new file mode 100644 index 0000000000..060aa69814 --- /dev/null +++ b/scripts/phase9_platform_headers.py @@ -0,0 +1,89 @@ +"""Repoint header building and `wrap_url` at the injected platform client. + +All 24 call sites are inside methods, so `self._platform_client` is in scope. +Replacements are made on AST-located `Name` nodes in call position, so a +comment or a docstring mentioning the name is untouched. Every file is +transformed, re-parsed and post-state-verified BEFORE any file is written. +""" + +import argparse +import ast +import pathlib +import re +import sys + +RENAMES = { + "build_roboflow_api_headers": "self._platform_client.build_api_headers", + "get_extra_weights_provider_headers": "self._platform_client.build_weights_provider_headers", + "wrap_url": "self._platform_client.wrap_url", +} +IMPORTS = re.compile( + r"^from inference\.core\.(?:roboflow_api import (?:build_roboflow_api_headers" + r"|get_extra_weights_provider_headers)|utils\.url_utils import wrap_url)[ \t]*\r?\n", + re.MULTILINE, +) + + +def _call_sites(tree): + for node in ast.walk(tree): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + if node.func.id in RENAMES: + yield node.func + + +def transform(source: str, path: str): + newline = "\r\n" if "\r\n" in source else "\n" + lines = source.split(newline) + sites = sorted(_call_sites(ast.parse(source)), + key=lambda n: (n.lineno, n.col_offset), reverse=True) + for func in sites: + index = func.lineno - 1 + line = lines[index] + assert line[func.col_offset : func.end_col_offset] == func.id, (path, func.lineno) + lines[index] = line[: func.col_offset] + RENAMES[func.id] + line[func.end_col_offset :] + updated = newline.join(lines) + removed = len(IMPORTS.findall(updated)) + updated = IMPORTS.sub("", updated) + tree = ast.parse(updated) + leftovers = [f.id for f in _call_sites(tree)] + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module in { + "inference.core.roboflow_api", "inference.core.utils.url_utils"}: + leftovers.extend(a.name for a in node.names if a.name in RENAMES) + if leftovers: + print(f"FAIL: {path} still references {leftovers}", file=sys.stderr) + raise SystemExit(2) + return updated, len(sites), removed + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("files", nargs="+") + parser.add_argument("--expected-calls", type=int, required=True) + parser.add_argument("--expected-imports", type=int, required=True) + args = parser.parse_args() + total_c = total_i = 0 + outputs = {} + for name in args.files: + path = pathlib.Path(name) + updated, calls, imports = transform(path.read_text(encoding="utf-8"), name) + again, _, _ = transform(updated, name) + if again != updated: + print(f"FAIL: {name} not idempotent", file=sys.stderr) + return 2 + outputs[path] = updated + total_c += calls + total_i += imports + print(f"{calls:3d} calls {imports:3d} imports {name}") + print(f"TOTAL {total_c} calls, {total_i} imports") + if total_c != args.expected_calls or total_i != args.expected_imports: + print("FAIL: counts do not match", file=sys.stderr) + return 1 + for path, updated in outputs.items(): + path.write_text(updated, encoding="utf-8") + print("POST-STATE verified: no bare header/wrap_url call or import remains; idempotent; written") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_platform_client_headers.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_platform_client_headers.py new file mode 100644 index 0000000000..032b0406f0 --- /dev/null +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_platform_client_headers.py @@ -0,0 +1,136 @@ +"""Header building and secure-gateway URL wrapping are injected, not local. + +`wrap_url` is a security control: it routes outbound calls through the +configured SECURE_GATEWAY proxy. Reimplementing it inside a block would +silently bypass the gateway. +""" + +import ast +import importlib +import pathlib +from unittest import mock + +import numpy as np +import pytest + +from inference.core.workflows.execution_engine.entities.base import ( + Batch, + ImageParentMetadata, + WorkflowImageData, +) +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) + +PREFIX = "inference.core.workflows.core_steps.models.foundation." +BLOCKS = [ + ("seg_preview.v1", "SegPreviewBlockV1"), + ("seg_preview.v1_tensor", "SegPreviewBlockV1"), + ("segment_anything3.v1", "SegmentAnything3BlockV1"), + ("segment_anything3.v1_tensor", "SegmentAnything3BlockV1"), + ("segment_anything3.v2", "SegmentAnything3BlockV2"), + ("segment_anything3.v2_tensor", "SegmentAnything3BlockV2"), + ("segment_anything3.v3", "SegmentAnything3BlockV3"), + ("segment_anything3.v3_tensor", "SegmentAnything3BlockV3"), + ("segment_anything3_interactive.v1", "SegmentAnything3InteractiveBlockV1"), + ("segment_anything3_interactive.v1_tensor", "SegmentAnything3InteractiveBlockV1"), + ("segment_anything2_video.v1", "SegmentAnything2VideoBlockV1"), + ("segment_anything2_video.v1_tensor", "SegmentAnything2VideoBlockV1"), + ("segment_anything3_video.v1", "SegmentAnything3VideoBlockV1"), + ("segment_anything3_video.v1_tensor", "SegmentAnything3VideoBlockV1"), +] +WORKFLOWS_ROOT = ( + pathlib.Path(__file__).resolve().parents[6] / "inference" / "core" / "workflows" +) +BANNED_SYMBOLS = { + "build_roboflow_api_headers", + "get_extra_weights_provider_headers", + "wrap_url", +} + + +@pytest.mark.parametrize("module_suffix,class_name", BLOCKS) +def test_block_declares_the_platform_client_init_parameter(module_suffix, class_name): + module = importlib.import_module(PREFIX + module_suffix) + assert "platform_client" in getattr(module, class_name).get_init_parameters() + + +@pytest.mark.parametrize("module_suffix,class_name", BLOCKS) +def test_block_stores_the_injected_client(module_suffix, class_name): + sentinel = RecordingPlatformClient() + block_class = getattr(importlib.import_module(PREFIX + module_suffix), class_name) + kwargs = {name: None for name in block_class.get_init_parameters()} + kwargs["platform_client"] = sentinel + assert block_class(**kwargs)._platform_client is sentinel + + +def test_no_workflows_module_imports_the_header_or_url_helpers() -> None: + """Symbol-specific: `inference.core.roboflow_api` still has legitimate + importers until Task 9.6 (`block_scaffolding`, `reference_resolution`), so + only these three names are banned here. Task 9.6 raises the ban to the + whole module. + """ + offenders = [] + for path in WORKFLOWS_ROOT.rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module in { + "inference.core.roboflow_api", + "inference.core.utils.url_utils", + }: + for alias in node.names: + if alias.name in BANNED_SYMBOLS: + offenders.append(f"{path}:{node.lineno} {alias.name}") + assert not offenders, offenders + + +def test_headers_and_url_go_through_the_injected_client(monkeypatch) -> None: + """Behaviour, not declaration: `run_via_request` must call the port. + + `run_via_request(images, class_names, threshold)` (segment_anything3/v1.py:408) + base64-encodes each image, builds `{"Content-Type": ...}` (+ the two + internal-service headers when the env sets them - pinned empty here), + passes them through `build_roboflow_api_headers`, POSTs to + `wrap_url(f"{API_BASE_URL}/inferenceproxy/seg-preview?api_key={api_key}")` + and converts `prompt_results`. An 8x8 black image and an empty result + list are enough to drive the whole path. Executed GREEN against the + codemod output while writing this plan (evidence E17). + """ + from inference.core.workflows.core_steps.models.foundation.segment_anything3 import ( + v1, + ) + + monkeypatch.setattr(v1, "ROBOFLOW_INTERNAL_SERVICE_NAME", None) + monkeypatch.setattr(v1, "ROBOFLOW_INTERNAL_SERVICE_SECRET", None) + client = RecordingPlatformClient(wrap_prefix="https://gateway.local/proxy?url=") + kwargs = {name: None for name in v1.SegmentAnything3BlockV1.get_init_parameters()} + kwargs["api_key"] = "rf-key" + kwargs["platform_client"] = client + block = v1.SegmentAnything3BlockV1(**kwargs) + image = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="p"), + numpy_image=np.zeros((8, 8, 3), dtype=np.uint8), + ) + + response = mock.MagicMock() + response.json.return_value = {"prompt_results": []} + response.raise_for_status.return_value = None + with mock.patch.object(v1, "requests") as requests_mock: + requests_mock.post.return_value = response + result = block.run_via_request( + images=Batch.init(content=[image], indices=[(0,)]), + class_names=["cat"], + threshold=0.5, + ) + + raw_url = f"{v1.API_BASE_URL}/inferenceproxy/seg-preview?api_key=rf-key" + assert client.headers_calls == [{"Content-Type": "application/json"}] + assert client.wrapped == [raw_url] + assert requests_mock.post.call_args.args[0] == ( + "https://gateway.local/proxy?url=" + raw_url + ) + assert requests_mock.post.call_args.kwargs["headers"] == { + "X-Test": "1", + "Content-Type": "application/json", + } + assert len(result) == 1 and len(result[0]["predictions"]) == 0 diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything2_video.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything2_video.py index 1f5869f793..d6f4865308 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything2_video.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything2_video.py @@ -30,6 +30,9 @@ VideoMetadata, WorkflowImageData, ) +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) # --------------------------------------------------------------------------- # Helpers @@ -231,15 +234,12 @@ def test_model_loader_forwards_inference_owned_dependencies( "inference_models", SimpleNamespace(AutoModel=SimpleNamespace(from_pretrained=from_pretrained)), ) - monkeypatch.setattr( - module, - "get_extra_weights_provider_headers", - MagicMock(return_value=headers), - ) + client = RecordingPlatformClient(weights_headers=headers) block = block_class( model_manager=model_manager, api_key="rf-test", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=client, ) block._get_model(model_id="sam2video/small") @@ -250,6 +250,8 @@ def test_model_loader_forwards_inference_owned_dependencies( weights_provider_extra_headers=headers, content_addressed_artifact_cache=artifact_cache, ) + # `_get_model` calls the port with no arguments (sam2_video/v1.py:267). + assert client.weights_calls == [(None, None)] # --------------------------------------------------------------------------- diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_video.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_video.py index 387ec9dae0..3d3a5f1ca5 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_video.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_video.py @@ -36,6 +36,9 @@ WorkflowImageData, ) from inference_models.models.base.object_detection import Detections +from tests.workflows.unit_tests.prototypes.platform_client_double import ( + RecordingPlatformClient, +) # --------------------------------------------------------------------------- # Helpers @@ -457,15 +460,12 @@ def test_model_loader_forwards_inference_owned_dependencies( "inference_models", SimpleNamespace(AutoModel=SimpleNamespace(from_pretrained=from_pretrained)), ) - monkeypatch.setattr( - module, - "get_extra_weights_provider_headers", - MagicMock(return_value=headers), - ) + client = RecordingPlatformClient(weights_headers=headers) block = block_class( model_manager=model_manager, api_key="rf-test", step_execution_mode=StepExecutionMode.LOCAL, + platform_client=client, ) block._get_model(model_id="sam3video") @@ -476,6 +476,8 @@ def test_model_loader_forwards_inference_owned_dependencies( weights_provider_extra_headers=headers, content_addressed_artifact_cache=artifact_cache, ) + # `_get_model` calls the port with no arguments (sam2_video/v1.py:267). + assert client.weights_calls == [(None, None)] @pytest.mark.parametrize( @@ -773,9 +775,10 @@ def test_visual_stream_restart_reprompts(block_factory): ) assert [call[0] for call in fake.calls] == ["prompt", "track", "prompt"] - assert [ - call[1]["frame_idx"] for call in fake.calls if call[0] == "prompt" - ] == [5, 0] + assert [call[1]["frame_idx"] for call in fake.calls if call[0] == "prompt"] == [ + 5, + 0, + ] assert [ call[1]["had_prior_state"] for call in fake.calls if call[0] == "prompt" ] == [False, False] diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 2acd71c865..628c48aad3 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 302 +# Count: 278 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env @@ -134,48 +134,30 @@ inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py inference.c inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py inference.core.roboflow_api -inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py inference.core.utils.url_utils inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py inference.core.roboflow_api -inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py inference.core.utils.url_utils inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.responses.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py inference.usage_tracking.collector inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.responses.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.roboflow_api -inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.utils.url_utils inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py inference.core.roboflow_api -inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py inference.core.utils.url_utils inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.responses.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.roboflow_api -inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.utils.url_utils inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py inference.core.roboflow_api -inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py inference.core.utils.url_utils inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.roboflow_api -inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.utils.url_utils inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py inference.core.roboflow_api -inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py inference.core.utils.url_utils inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py inference.core.entities.requests.sam3_3d inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py inference.core.entities.responses.sam3_3d inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py inference.core.env @@ -185,16 +167,10 @@ inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_te inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.entities.responses.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.roboflow_api -inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.utils.url_utils inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.roboflow_api -inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.utils.url_utils -inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py inference.usage_tracking.collector inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py inference.core.roboflow_api inference/core/workflows/core_steps/models/foundation/segment_anything_common/streaming_video_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py inference.core.env From 354ba91d2a5e75499267241efa9b5a586ea4fc37 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:02:57 +0200 Subject: [PATCH 11/86] fix(workflows): revert unrelated black reformat in sam3 video test The Step 6 whole-file black run also reformatted a pre-existing frame_idx assertion unrelated to the platform_client migration; revert that hunk to its pre-task text. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../models/foundation/test_segment_anything3_video.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_video.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_video.py index 3d3a5f1ca5..558f5e725d 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_video.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_video.py @@ -775,10 +775,9 @@ def test_visual_stream_restart_reprompts(block_factory): ) assert [call[0] for call in fake.calls] == ["prompt", "track", "prompt"] - assert [call[1]["frame_idx"] for call in fake.calls if call[0] == "prompt"] == [ - 5, - 0, - ] + assert [ + call[1]["frame_idx"] for call in fake.calls if call[0] == "prompt" + ] == [5, 0] assert [ call[1]["had_prior_state"] for call in fake.calls if call[0] == "prompt" ] == [False, False] From 0e3022facb56f44cdfeeed9ab83998d48acc990f Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:20:58 +0200 Subject: [PATCH 12/86] refactor(workflows): inject the workspace and inner-workflow spec resolvers block_scaffolding asks a WorkspaceResolver, mirrored into the dynamic_workflows_blocks namespace beside api_key, and compilation reads the same effective value the generated block will use. Resolver-dependent compilations bypass COMPILATION_CACHE, so an injected resolver cannot be skipped on a warm cache. The authenticated /workflows/blocks/describe handler binds the server resolver explicitly. Release note: Modal validation of a dynamic block now resolves the workspace with the api key the block executes with (dynamic_workflows_blocks.api_key); callers that never override that key are unaffected. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../interfaces/http/handlers/workflows.py | 2 + .../execution_engine/v1/compiler/core.py | 70 +++- .../workflows/execution_engine/v1/core.py | 43 ++- .../v1/dynamic_blocks/block_assembler.py | 8 + .../v1/dynamic_blocks/block_scaffolding.py | 28 +- .../v1/inner_workflow/reference_resolution.py | 40 +-- .../test_workflow_blocks_describe_resolver.py | 118 +++++++ .../common/test_platform_client_injection.py | 19 ++ .../unit_tests/decontamination_baseline.txt | 5 +- .../dynamic_blocs/test_block_scaffolding.py | 29 +- .../test_block_usage_metering.py | 32 +- .../dynamic_blocs/test_workspace_resolver.py | 310 ++++++++++++++++++ .../test_spec_resolver_default.py | 179 ++++++++++ 13 files changed, 814 insertions(+), 69 deletions(-) create mode 100644 tests/inference/unit_tests/core/interfaces/http/test_workflow_blocks_describe_resolver.py create mode 100644 tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_workspace_resolver.py create mode 100644 tests/workflows/unit_tests/execution_engine/inner_workflow/test_spec_resolver_default.py diff --git a/inference/core/interfaces/http/handlers/workflows.py b/inference/core/interfaces/http/handlers/workflows.py index d174bbf835..5161c2f2e6 100644 --- a/inference/core/interfaces/http/handlers/workflows.py +++ b/inference/core/interfaces/http/handlers/workflows.py @@ -13,6 +13,7 @@ WorkflowsBlocksDescription, ) from inference.core.env import ENABLE_BUILDER +from inference.core.interfaces.roboflow_platform_client import SERVER_WORKSPACE_RESOLVER from inference.core.workflows.core_steps.common.query_language.introspection.core import ( prepare_operations_descriptions, prepare_operators_descriptions, @@ -57,6 +58,7 @@ def handle_describe_workflows_blocks_request( dynamic_blocks = compile_dynamic_blocks( dynamic_blocks_definitions=dynamic_blocks_definitions, api_key=api_key, + workspace_resolver=SERVER_WORKSPACE_RESOLVER, ) blocks_description = describe_available_blocks( dynamic_blocks=dynamic_blocks, diff --git a/inference/core/workflows/execution_engine/v1/compiler/core.py b/inference/core/workflows/execution_engine/v1/compiler/core.py index cb2066ead9..1a5e2b2666 100644 --- a/inference/core/workflows/execution_engine/v1/compiler/core.py +++ b/inference/core/workflows/execution_engine/v1/compiler/core.py @@ -58,8 +58,12 @@ ) from inference.core.workflows.execution_engine.v1.inner_workflow.reference_resolution import ( normalize_inner_workflow_references_in_definition, + workflow_definition_contains_unresolved_inner_workflow_reference, ) from inference.core.workflows.prototypes.block import WorkflowBlockManifest +from inference.core.workflows.prototypes.workspace_resolver import ( + NULL_WORKSPACE_RESOLVER, +) COMPILATION_CACHE = BasicWorkflowsCache[GraphCompilationResult]( cache_size=256, @@ -73,6 +77,58 @@ ) +def _effective_workspace_resolver( + init_parameters: Dict[str, Union[Any, Callable[[None], Any]]], +): + """The resolver the GENERATED BLOCK will use, so compilation-time Modal + validation and runtime execution cannot disagree. + + `ExecutionEngineV1.init` mirrors the effective value into + `dynamic_workflows_blocks.workspace_resolver`, preserving an explicit + override in that namespace; reading `workflows_core.*` here would consult a + different object (round-2 defect 5). + """ + return init_parameters.get( + "dynamic_workflows_blocks.workspace_resolver", + init_parameters.get( + "workflows_core.workspace_resolver", NULL_WORKSPACE_RESOLVER + ), + ) + + +def _effective_dynamic_api_key( + init_parameters: Dict[str, Union[Any, Callable[[None], Any]]], +) -> Optional[str]: + """The api key the GENERATED BLOCK will hold (`steps_initialiser` prefers + `dynamic_workflows_blocks.api_key`, which the engine mirrors from + `workflows_core.api_key` unless a caller set it explicitly). Compile-time + Modal validation resolves the workspace with this key, so it names the + same sandbox the block later executes in (round-5 defect 2).""" + return init_parameters.get( + "dynamic_workflows_blocks.api_key", + init_parameters.get("workflows_core.api_key"), + ) + + +def _is_resolver_dependent( + workflow_definition: dict, dynamic_blocks_definitions: List[dict] +) -> bool: + """True when compiling this definition consults an injected resolver. + + Compilation resolves inner-workflow references and compiles dynamic blocks; + both consult resolvers, and `COMPILATION_CACHE` keys on neither them nor the + api key. Rather than invent a lifetime-safe context, such definitions simply + are not cached - they are the rare case, and the cache exists for the plain + ones. This also removes the authentication-context hazard for exactly the + definitions where it mattered. + """ + if dynamic_blocks_definitions: + return True + return workflow_definition_contains_unresolved_inner_workflow_reference( + workflow_definition=workflow_definition + ) + + @execution_phase( name="workflow_compilation", categories=["execution_engine_operation"], @@ -121,11 +177,17 @@ def compile_workflow_graph( ) -> GraphCompilationResult: if init_parameters is None: init_parameters = {} + cacheable = not _is_resolver_dependent( + workflow_definition=workflow_definition, + dynamic_blocks_definitions=collect_dynamic_blocks_definitions_from_workflow_definition( + workflow_definition=workflow_definition + ), + ) key = COMPILATION_CACHE.get_hash_key( workflow_definition=workflow_definition, execution_engine_version=execution_engine_version, ) - cached_value = COMPILATION_CACHE.get(key=key) + cached_value = COMPILATION_CACHE.get(key=key) if cacheable else None if cached_value is not None: dynamic_blocks_definitions = ( collect_dynamic_blocks_definitions_from_workflow_definition( @@ -158,7 +220,8 @@ def compile_workflow_graph( dynamic_blocks = compile_dynamic_blocks( dynamic_blocks_definitions=dynamic_blocks_definitions, profiler=profiler, - api_key=init_parameters.get("workflows_core.api_key", None), + api_key=_effective_dynamic_api_key(init_parameters), + workspace_resolver=_effective_workspace_resolver(init_parameters), ) available_blocks = statically_defined_blocks + dynamic_blocks validate_inner_workflow_composition_from_raw_workflow_definition( @@ -190,7 +253,8 @@ def compile_workflow_graph( kinds_serializers=kinds_serializers, kinds_deserializers=kinds_deserializers, ) - COMPILATION_CACHE.cache(key=key, value=result) + if cacheable: + COMPILATION_CACHE.cache(key=key, value=result) return result diff --git a/inference/core/workflows/execution_engine/v1/core.py b/inference/core/workflows/execution_engine/v1/core.py index b719ffe627..9b8ed14297 100644 --- a/inference/core/workflows/execution_engine/v1/core.py +++ b/inference/core/workflows/execution_engine/v1/core.py @@ -48,6 +48,9 @@ is_workflow_selector, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.workspace_resolver import ( + NULL_WORKSPACE_RESOLVER, +) logger = logging.getLogger(__name__) @@ -259,6 +262,34 @@ def _resolve_and_pre_load_runtime_dependencies( ) +def _mirror_dynamic_block_parameters( + init_parameters: Dict[str, Union[Any, Callable[[None], Any]]], +) -> None: + """Copy the init parameters dynamic blocks need into their own namespace. + + Generated blocks carry `block_source = "dynamic_workflows_blocks"` + (`dynamic_blocks/entities.BLOCK_SOURCE`), and + `retrieve_init_parameter_values` does NOT fall back from a plugin namespace + to `workflows_core.*` - `load_core_blocks_initializers` registers the core + defaults only under `workflows_core.`. Anything a dynamic block declares in + `get_init_parameters()` has to be mirrored here, which is why `api_key` + already was. Operates on the engine's PRIVATE copy of init_parameters (see + `init`), never on a caller's dictionary. + """ + init_parameters["dynamic_workflows_blocks.api_key"] = init_parameters.get( + "dynamic_workflows_blocks.api_key", + init_parameters.get("workflows_core.api_key"), + ) + init_parameters["dynamic_workflows_blocks.workspace_resolver"] = ( + init_parameters.get( + "dynamic_workflows_blocks.workspace_resolver", + init_parameters.get( + "workflows_core.workspace_resolver", NULL_WORKSPACE_RESOLVER + ), + ) + ) + + class ExecutionEngineV1(BaseExecutionEngine): @classmethod @@ -276,8 +307,11 @@ def init( ] = DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER, dependencies_pre_init: Optional[List[str]] = None, ) -> "ExecutionEngineV1": - if init_parameters is None: - init_parameters = {} + # The engine mutates this dict (dynamic-block mirrors below) and the + # compiled workflow retains it. Work on a private copy so a caller that + # reuses its dictionary across engines never sees, or re-supplies, a + # value this engine derived. + init_parameters = dict(init_parameters or {}) if isinstance(step_error_handler, str): if step_error_handler not in REGISTERED_STEP_ERROR_HANDLERS: raise WorkflowEnvironmentConfigurationError( @@ -287,10 +321,7 @@ def init( context="workflow_compilation | engine_initialisation", ) step_error_handler = REGISTERED_STEP_ERROR_HANDLERS[step_error_handler] - init_parameters["dynamic_workflows_blocks.api_key"] = init_parameters.get( - "dynamic_workflows_blocks.api_key", - init_parameters.get("workflows_core.api_key"), - ) + _mirror_dynamic_block_parameters(init_parameters) if profiler is None: profiler = NullWorkflowsProfiler.init() diff --git a/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_assembler.py b/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_assembler.py index 6afd02854f..92a99d2ed9 100644 --- a/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_assembler.py +++ b/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_assembler.py @@ -51,6 +51,10 @@ ValueType, ) from inference.core.workflows.prototypes.block import WorkflowBlockManifest +from inference.core.workflows.prototypes.workspace_resolver import ( + NULL_WORKSPACE_RESOLVER, + WorkspaceResolver, +) @execution_phase( @@ -61,6 +65,7 @@ def compile_dynamic_blocks( dynamic_blocks_definitions: List[dict], profiler: Optional[WorkflowsProfiler] = None, api_key: Optional[str] = None, + workspace_resolver: WorkspaceResolver = NULL_WORKSPACE_RESOLVER, skip_class_eval: Optional[bool] = False, ) -> List[BlockSpecification]: if not dynamic_blocks_definitions: @@ -78,6 +83,7 @@ def compile_dynamic_blocks( dynamic_block_definition=dynamic_block, kinds_lookup=kinds_lookup, api_key=api_key, + workspace_resolver=workspace_resolver, skip_class_eval=skip_class_eval, ) compiled_blocks.append(block_specification) @@ -114,6 +120,7 @@ def create_dynamic_block_specification( dynamic_block_definition: DynamicBlockDefinition, kinds_lookup: Dict[str, Kind], api_key: Optional[str] = None, + workspace_resolver: WorkspaceResolver = NULL_WORKSPACE_RESOLVER, skip_class_eval: Optional[bool] = False, ) -> BlockSpecification: ensure_tensor_compatibility_supported( @@ -131,6 +138,7 @@ def create_dynamic_block_specification( manifest=block_manifest, python_code=dynamic_block_definition.code, api_key=api_key, + workspace_resolver=workspace_resolver, skip_class_eval=skip_class_eval, manifest_description=dynamic_block_definition.manifest, ) diff --git a/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py b/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py index fd8ff9f098..160b409a44 100644 --- a/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py +++ b/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py @@ -16,8 +16,6 @@ WEBEXEC_MODAL_EXECUTOR_IDLE_TTL_SECONDS, WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE, ) -from inference.core.exceptions import WorkspaceLoadError -from inference.core.roboflow_api import get_roboflow_workspace from inference.core.workflows.errors import ( DynamicBlockCodeError, DynamicBlockError, @@ -46,6 +44,10 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.workspace_resolver import ( + NULL_WORKSPACE_RESOLVER, + WorkspaceResolver, +) from inference.usage_tracking.block_execution import ( BLOCK_DURATION_SOURCE_CLIENT_WALL_CLOCK, BLOCK_DURATION_SOURCE_LOCAL_RUNTIME, @@ -305,6 +307,7 @@ def assembly_custom_python_block( manifest: Type[WorkflowBlockManifest], python_code: PythonCode, api_key: Optional[str] = None, + workspace_resolver: WorkspaceResolver = NULL_WORKSPACE_RESOLVER, skip_class_eval: Optional[bool] = False, manifest_description: Optional[ManifestDescription] = None, ) -> Type[WorkflowBlock]: @@ -314,6 +317,7 @@ def assembly_custom_python_block( python_code=python_code, module_name=f"dynamic_module_{unique_identifier}", api_key=api_key, + workspace_resolver=workspace_resolver, skip_class_eval=skip_class_eval, ) @@ -358,10 +362,7 @@ def run_dynamic_block(self, *args, **kwargs) -> BlockResult: declared_input_kinds=declared_input_kinds, ) - try: # Get workspace_id from context if available - workspace_id = get_roboflow_workspace(self._api_key) - except WorkspaceLoadError: - workspace_id = None + workspace_id = self._workspace_resolver.resolve_workspace(self._api_key) if not workspace_id: workspace_id = MODAL_ANONYMOUS_WORKSPACE_NAME @@ -457,9 +458,14 @@ def run_dynamic_block(self, *args, **kwargs) -> BlockResult: init_function = getattr(code_module, python_code.init_function_name, dict) - def constructor(self, api_key: Optional[str] = None): + def constructor( + self, + api_key: Optional[str] = None, + workspace_resolver: WorkspaceResolver = NULL_WORKSPACE_RESOLVER, + ): self._init_results = init_function() self._api_key = api_key + self._workspace_resolver = workspace_resolver def get_workflow_context(self) -> Dict[str, Any]: return { @@ -471,7 +477,7 @@ def get_workflow_context(self) -> Dict[str, Any]: @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key"] + return ["api_key", "workspace_resolver"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -512,6 +518,7 @@ def create_dynamic_module( python_code: PythonCode, module_name: str, api_key: Optional[str] = None, + workspace_resolver: WorkspaceResolver = NULL_WORKSPACE_RESOLVER, skip_class_eval: Optional[bool] = False, ) -> types.ModuleType: @@ -539,10 +546,7 @@ def create_dynamic_module( validate_code_in_modal, ) - try: # Get workspace_id from context if available - validation_workspace = get_roboflow_workspace(api_key) - except WorkspaceLoadError: - validation_workspace = None + validation_workspace = workspace_resolver.resolve_workspace(api_key) # Fall back to "anonymous" for non-authenticated users if not validation_workspace: diff --git a/inference/core/workflows/execution_engine/v1/inner_workflow/reference_resolution.py b/inference/core/workflows/execution_engine/v1/inner_workflow/reference_resolution.py index f22bf80aed..0898ac75e7 100644 --- a/inference/core/workflows/execution_engine/v1/inner_workflow/reference_resolution.py +++ b/inference/core/workflows/execution_engine/v1/inner_workflow/reference_resolution.py @@ -9,7 +9,10 @@ import copy from typing import Any, Callable, Dict, Optional, Tuple -from inference.core.workflows.errors import WorkflowDefinitionError +from inference.core.workflows.errors import ( + WorkflowDefinitionError, + WorkflowEnvironmentConfigurationError, +) from inference.core.workflows.execution_engine.v1.inner_workflow.constants import ( INNER_WORKFLOW_EXECUTION_MODE_REMOTE_DISPATCH, USE_INNER_WORKFLOW_BLOCK_TYPE, @@ -31,25 +34,22 @@ def default_inner_workflow_spec_resolver( workflow_version_id: Optional[str], init_parameters: Dict[str, Any], ) -> Dict[str, Any]: - from inference.core.roboflow_api import get_workflow_specification - - api_key = init_parameters.get("workflows_core.api_key") - if workspace_id != "local" and not api_key: - raise WorkflowDefinitionError( - public_message=( - "Resolving an `inner_workflow` step by workflow id requires a Roboflow API key. " - "Set `workflows_core.api_key` in workflow init_parameters, inject " - "`workflows_core.inner_workflow_spec_resolver`, or use " - '`workflow_workspace_id` `"local"` with a matching on-disk workflow ' - "definition." - ), - context="workflow_compilation | inner_workflow_spec_resolution", - ) - return get_workflow_specification( - api_key=api_key, - workspace_id=workspace_id, - workflow_id=workflow_id, - workflow_version_id=workflow_version_id, + """Standalone default: no platform, so no remote workflow definitions. + + The Roboflow-backed implementation moved to + `inference.core.interfaces.roboflow_platform_client.default_inner_workflow_spec_resolver` + and is installed by every server composition root - the shape Task 7.2 used + for the step error handler. + """ + raise WorkflowEnvironmentConfigurationError( + public_message=( + "Resolving an `inner_workflow` step by workflow id requires access to the " + "Roboflow API, which is not available in this installation of `workflows`. " + f"Inject `{WORKFLOWS_CORE_INNER_WORKFLOW_SPEC_RESOLVER}` in workflow " + 'init_parameters, or use `workflow_workspace_id` `"local"` with a matching ' + "on-disk workflow definition." + ), + context="workflow_compilation | inner_workflow_spec_resolution", ) diff --git a/tests/inference/unit_tests/core/interfaces/http/test_workflow_blocks_describe_resolver.py b/tests/inference/unit_tests/core/interfaces/http/test_workflow_blocks_describe_resolver.py new file mode 100644 index 0000000000..d1fc7ca57a --- /dev/null +++ b/tests/inference/unit_tests/core/interfaces/http/test_workflow_blocks_describe_resolver.py @@ -0,0 +1,118 @@ +"""`/workflows/blocks/describe` compiles the request's dynamic blocks with the +authenticated api key; after Phase 9 it must also hand the SERVER workspace +resolver to that compilation, or Modal validation silently runs as `anonymous` +for an authenticated caller (round-5 defect 1).""" + +from unittest.mock import AsyncMock, MagicMock + +from starlette.testclient import TestClient + +import inference.core.roboflow_api as roboflow_api +from inference.core.interfaces.roboflow_platform_client import SERVER_WORKSPACE_RESOLVER +from inference.core.workflows.execution_engine.v1.dynamic_blocks import ( + block_scaffolding, + modal_executor, +) + +DYNAMIC_BLOCK = { + "type": "DynamicBlockDefinition", + "manifest": { + "type": "ManifestDescription", + "block_type": "DescribeProbe", + "inputs": { + "a": { + "type": "DynamicInputDefinition", + "selector_types": ["input_parameter"], + } + }, + "outputs": {"out": {"type": "DynamicOutputDefinition"}}, + }, + "code": { + "type": "PythonCode", + "run_function_code": 'def run(self, a):\n return {"out": a}\n', + }, +} + + +class _DummyInstrumentator: + def __init__(self, app, model_manager, endpoint="/metrics"): + self.app = app + self.model_manager = model_manager + self.endpoint = endpoint + + def set_stream_manager_client(self, stream_manager_client) -> None: + self.stream_manager_client = stream_manager_client + + +def _client(monkeypatch) -> TestClient: + import inference.core.interfaces.http.http_api as http_api + + monkeypatch.setattr(http_api, "InferenceInstrumentator", _DummyInstrumentator) + monkeypatch.setattr( + http_api.usage_collector, "async_push_usage_payloads", AsyncMock() + ) + model_manager = MagicMock() + model_manager.pingback = None + model_manager.num_errors = 0 + return TestClient(http_api.HttpInterface(model_manager=model_manager).app) + + +def test_describe_route_hands_the_server_resolver_to_dynamic_compilation( + monkeypatch, +) -> None: + import inference.core.interfaces.http.handlers.workflows as handler + + captured = {} + + def recording_compile(**kwargs): + captured.update(kwargs) + return [] + + monkeypatch.setattr(handler, "compile_dynamic_blocks", recording_compile) + response = _client(monkeypatch).post( + "/workflows/blocks/describe", + json={ + "dynamic_blocks_definitions": [DYNAMIC_BLOCK], + "api_key": "authenticated-key", + }, + ) + assert response.status_code == 200, response.text + assert captured["api_key"] == "authenticated-key" + assert captured["workspace_resolver"] is SERVER_WORKSPACE_RESOLVER + + +def test_describe_route_validates_in_the_authenticated_workspace(monkeypatch) -> None: + """Behaviour: the workspace Modal validation receives is the one the + authenticated key resolves to - not `anonymous`. Green before AND after + the port: the lookup is stubbed wherever it lives (the pre-port module + attribute, while it still exists, and the server function the adapter + reads through).""" + + def lookup(api_key): + return f"ws-of-{api_key}" + + monkeypatch.setattr(roboflow_api, "get_roboflow_workspace", lookup) + if hasattr(block_scaffolding, "get_roboflow_workspace"): + monkeypatch.setattr(block_scaffolding, "get_roboflow_workspace", lookup) + validated = [] + monkeypatch.setattr( + block_scaffolding, "WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE", "modal" + ) + monkeypatch.setattr( + modal_executor, + "validate_code_in_modal", + lambda python_code, workspace_id=None: validated.append(workspace_id) or True, + ) + response = _client(monkeypatch).post( + "/workflows/blocks/describe", + json={ + "dynamic_blocks_definitions": [DYNAMIC_BLOCK], + "api_key": "authenticated-key", + }, + ) + assert response.status_code == 200, response.text + assert validated == ["ws-of-authenticated-key"] + assert any( + b["manifest_type_identifier"] == "DescribeProbe" + for b in response.json()["blocks"] + ) diff --git a/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py b/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py index 95f0ee11c8..b6c9f4245e 100644 --- a/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py +++ b/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py @@ -140,3 +140,22 @@ def test_qwen_forwards_the_client_to_the_openrouter_base() -> None: platform_client=sentinel, ) assert block._platform_client is sentinel + + +def test_no_workflows_module_imports_the_roboflow_api_client_at_all() -> None: + """True from Task 9.6 onward: the last two importers were + `block_scaffolding.py` and `reference_resolution.py`.""" + offenders = [] + for path in WORKFLOWS_ROOT.rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if ( + isinstance(node, ast.ImportFrom) + and node.module == "inference.core.roboflow_api" + ): + offenders.append(f"{path}:{node.lineno}") + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("inference.core.roboflow_api"): + offenders.append(f"{path}:{node.lineno}") + assert not offenders, offenders diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 628c48aad3..7c04e19ed0 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 278 +# Count: 275 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env @@ -262,8 +262,6 @@ inference/core/workflows/execution_engine/v1/core.py inference.core.env inference/core/workflows/execution_engine/v1/dynamic_blocks/block_assembler.py inference.core.env inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.env inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.env (exec'd string) -inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.exceptions -inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.roboflow_api inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.usage_tracking.block_execution inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.usage_tracking.collector inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py inference.core.env @@ -277,5 +275,4 @@ inference/core/workflows/execution_engine/v1/executor/core.py inference.usage_tr inference/core/workflows/execution_engine/v1/executor/output_constructor.py inference.core.env inference/core/workflows/execution_engine/v1/executor/utils.py inference.core.env inference/core/workflows/execution_engine/v1/inner_workflow/compiler_bridge.py inference.core.env -inference/core/workflows/execution_engine/v1/inner_workflow/reference_resolution.py inference.core.roboflow_api inference/core/workflows/offline.py inference.core.env diff --git a/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_scaffolding.py b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_scaffolding.py index c1532e679a..ad53acf2df 100644 --- a/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_scaffolding.py +++ b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_scaffolding.py @@ -21,6 +21,9 @@ from inference.core.workflows.execution_engine.v1.dynamic_blocks.entities import ( PythonCode, ) +from tests.workflows.unit_tests.execution_engine.dynamic_blocs.test_workspace_resolver import ( + StubResolver, +) class _FakeModalExecutor: @@ -135,9 +138,9 @@ def run_function(self, a, b) -> BlockResult: execution_result = workflow_block_instance.run(a=3, b=5) # then - assert workflow_block_class.get_init_parameters() == [ - "api_key" - ], "Expected api_key parameter defined" + assert {"api_key", "workspace_resolver"} <= set( + workflow_block_class.get_init_parameters() + ), "Expected api_key and workspace_resolver parameters defined" assert ( workflow_block_class.get_manifest() == BlockManifest ), "Expected manifest to be returned" @@ -171,9 +174,9 @@ def run_function(self, a, b) -> BlockResult: execution_result = workflow_block_instance.run(a=3, b=5) # then - assert workflow_block_class.get_init_parameters() == [ - "api_key" - ], "Expected api_key parameters defined" + assert {"api_key", "workspace_resolver"} <= set( + workflow_block_class.get_init_parameters() + ), "Expected api_key and workspace_resolver parameters defined" assert ( workflow_block_class.get_manifest() == BlockManifest ), "Expected manifest to be returned" @@ -650,12 +653,14 @@ def test_run_wrapper_modal_arm_converts_kwargs_and_remote_result( representation_boundary, "_TENSOR_REPRESENTATION_ACTIVE", True ), mock.patch.object( block_scaffolding, "WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE", "modal" - ), mock.patch.object( - block_scaffolding, "get_roboflow_workspace", return_value="test-workspace" ), mock.patch.object( modal_executor, "ModalExecutor", return_value=executor_instance ): - result = block_class().run(predictions=_native_od_fixture()) + stub = StubResolver("test-workspace") + result = block_class(workspace_resolver=stub).run( + predictions=_native_od_fixture() + ) + assert stub.calls == [None] # then - inputs leg: executor received sv (the `_type='sv_detections'` arm # territory), not native objects @@ -704,12 +709,12 @@ def test_run_wrapper_modal_arm_is_passthrough_when_flag_off( representation_boundary, "_TENSOR_REPRESENTATION_ACTIVE", False ), mock.patch.object( block_scaffolding, "WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE", "modal" - ), mock.patch.object( - block_scaffolding, "get_roboflow_workspace", return_value="test-workspace" ), mock.patch.object( modal_executor, "ModalExecutor", return_value=executor_instance ): - result = block_class().run(predictions=legacy_input) + stub = StubResolver("test-workspace") + result = block_class(workspace_resolver=stub).run(predictions=legacy_input) + assert stub.calls == [None] # then - flag-off byte-parity: both legs are is-identity sent_inputs = executor_instance.execute_remote.call_args.kwargs["inputs"] diff --git a/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_usage_metering.py b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_usage_metering.py index 305e0294a6..3cfb78f357 100644 --- a/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_usage_metering.py +++ b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_usage_metering.py @@ -40,6 +40,14 @@ from inference.usage_tracking.collector import usage_collector +class _StubWorkspaceResolver: + """Phase 9 replaced the module-level workspace lookup with an injected + `WorkspaceResolver`; the modal tests name their sandbox through it.""" + + def resolve_workspace(self, api_key): + return "test-workspace" + + @pytest.fixture(autouse=True) def cleared_block_execution(): clear_measured_block_execution() @@ -195,12 +203,12 @@ def _run_modal_block(execute_remote, unique_identifier): block_class, _ = _assemble_block(_PASSTHROUGH_BLOCK, unique_identifier) executor_instance = mock.MagicMock() executor_instance.execute_remote.side_effect = execute_remote - block = block_class(api_key="workflow-api-key") + block = block_class( + api_key="workflow-api-key", workspace_resolver=_StubWorkspaceResolver() + ) with mock.patch.object( block_scaffolding, "WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE", "modal" - ), mock.patch.object( - block_scaffolding, "get_roboflow_workspace", return_value="test-workspace" ), mock.patch.object( modal_executor, "ModalExecutor", return_value=executor_instance ), mock.patch.object( @@ -288,12 +296,12 @@ def _run_modal_block_expecting_error(execute_remote, unique_identifier, expected block_class, _ = _assemble_block(_PASSTHROUGH_BLOCK, unique_identifier) executor_instance = mock.MagicMock() executor_instance.execute_remote.side_effect = execute_remote - block = block_class(api_key="workflow-api-key") + block = block_class( + api_key="workflow-api-key", workspace_resolver=_StubWorkspaceResolver() + ) with mock.patch.object( block_scaffolding, "WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE", "modal" - ), mock.patch.object( - block_scaffolding, "get_roboflow_workspace", return_value="test-workspace" ), mock.patch.object( modal_executor, "ModalExecutor", return_value=executor_instance ), mock.patch.object( @@ -356,12 +364,12 @@ def slow_acquire(workspace_id): yield mock.MagicMock(execute_remote=execute_remote) block_class, _ = _assemble_block(_PASSTHROUGH_BLOCK, "metered-modal-acquire") - block = block_class(api_key="workflow-api-key") + block = block_class( + api_key="workflow-api-key", workspace_resolver=_StubWorkspaceResolver() + ) with mock.patch.object( block_scaffolding, "WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE", "modal" - ), mock.patch.object( - block_scaffolding, "get_roboflow_workspace", return_value="test-workspace" ), mock.patch.object( block_scaffolding, "_acquire_modal_executor", slow_acquire ), mock.patch.object( @@ -438,13 +446,13 @@ def execute_remote(**kwargs): block_class, _ = _assemble_block(_PASSTHROUGH_BLOCK, "metered-modal-user-error") executor_instance = mock.MagicMock() executor_instance.execute_remote.side_effect = execute_remote - block = block_class(api_key="workflow-api-key") + block = block_class( + api_key="workflow-api-key", workspace_resolver=_StubWorkspaceResolver() + ) # when with mock.patch.object( block_scaffolding, "WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE", "modal" - ), mock.patch.object( - block_scaffolding, "get_roboflow_workspace", return_value="test-workspace" ), mock.patch.object( modal_executor, "ModalExecutor", return_value=executor_instance ), mock.patch.object( diff --git a/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_workspace_resolver.py b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_workspace_resolver.py new file mode 100644 index 0000000000..2ec2e2db70 --- /dev/null +++ b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_workspace_resolver.py @@ -0,0 +1,310 @@ +"""Modal sandbox naming asks a resolver, not the Roboflow API client, and +compilation and execution ask the SAME resolver.""" + +import ast +import pathlib +from unittest import mock + +from inference.core.workflows.execution_engine.core import ExecutionEngine +from inference.core.workflows.prototypes.workspace_resolver import ( + NULL_WORKSPACE_RESOLVER, + NullWorkspaceResolver, +) + + +class StubResolver: + def __init__(self, workspace=None): + self._workspace = workspace + self.calls = [] + + def resolve_workspace(self, api_key): + self.calls.append(api_key) + return self._workspace + + +# Compiles and runs locally at HEAD (`[{'out': 7}]`, evidence E22). +# Selector value from dynamic_blocks/entities.SelectorType.INPUT_PARAMETER. +DYNAMIC_WORKFLOW = { + "version": "1.0", + "inputs": [{"type": "WorkflowParameter", "name": "a", "default_value": 1}], + "dynamic_blocks_definitions": [ + { + "type": "DynamicBlockDefinition", + "manifest": { + "type": "ManifestDescription", + "block_type": "ResolverProbe", + "inputs": { + "a": { + "type": "DynamicInputDefinition", + "selector_types": ["input_parameter"], + } + }, + "outputs": {"out": {"type": "DynamicOutputDefinition"}}, + }, + "code": { + "type": "PythonCode", + "run_function_code": 'def run(self, a):\n return {"out": a}\n', + }, + } + ], + "steps": [{"type": "ResolverProbe", "name": "custom_block", "a": "$inputs.a"}], + "outputs": [ + {"type": "JsonField", "name": "out", "selector": "$steps.custom_block.out"} + ], +} + + +def test_null_resolver_returns_none() -> None: + assert NullWorkspaceResolver().resolve_workspace("any-key") is None + + +def test_block_scaffolding_does_not_import_the_server() -> None: + # parents[5] == repo root from + # tests/workflows/unit_tests/execution_engine/dynamic_blocs/ + path = ( + pathlib.Path(__file__).resolve().parents[5] + / "inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py" + ) + tree = ast.parse(path.read_text(encoding="utf-8")) + modules = { + n.module for n in ast.walk(tree) if isinstance(n, ast.ImportFrom) and n.module + } + assert "inference.core.roboflow_api" not in modules + assert "inference.core.exceptions" not in modules + + +def test_server_resolver_swallows_workspace_load_error(monkeypatch) -> None: + import inference.core.roboflow_api as roboflow_api + from inference.core.exceptions import WorkspaceLoadError + from inference.core.interfaces.roboflow_platform_client import ( + ServerWorkspaceResolver, + ) + + def boom(api_key): + raise WorkspaceLoadError("no workspace") + + monkeypatch.setattr(roboflow_api, "get_roboflow_workspace", boom) + assert ServerWorkspaceResolver().resolve_workspace("k") is None + + +def test_server_resolver_returns_the_workspace(monkeypatch) -> None: + import inference.core.roboflow_api as roboflow_api + from inference.core.interfaces.roboflow_platform_client import ( + ServerWorkspaceResolver, + ) + + monkeypatch.setattr(roboflow_api, "get_roboflow_workspace", lambda api_key: "ws-1") + assert ServerWorkspaceResolver().resolve_workspace("k") == "ws-1" + + +def test_engine_mirrors_the_resolver_into_the_dynamic_namespace() -> None: + """Generated blocks are `dynamic_workflows_blocks`, and + `retrieve_init_parameter_values` does NOT fall back to `workflows_core.*` + (`steps_initialiser.py:124`, `blocks_loader.load_core_blocks_initializers`). + The engine mirrors `api_key` for exactly this reason.""" + from inference.core.workflows.execution_engine.v1.core import ( + _mirror_dynamic_block_parameters, + ) + + resolver = StubResolver() + init_parameters = { + "workflows_core.api_key": "k", + "workflows_core.workspace_resolver": resolver, + } + _mirror_dynamic_block_parameters(init_parameters) + assert init_parameters["dynamic_workflows_blocks.api_key"] == "k" + assert init_parameters["dynamic_workflows_blocks.workspace_resolver"] is resolver + + +def test_engine_mirror_defaults_to_the_null_resolver() -> None: + from inference.core.workflows.execution_engine.v1.core import ( + _mirror_dynamic_block_parameters, + ) + + init_parameters = {} + _mirror_dynamic_block_parameters(init_parameters) + assert ( + init_parameters["dynamic_workflows_blocks.workspace_resolver"] + is NULL_WORKSPACE_RESOLVER + ) + + +def test_engine_mirror_respects_an_explicit_dynamic_namespace_value() -> None: + from inference.core.workflows.execution_engine.v1.core import ( + _mirror_dynamic_block_parameters, + ) + + explicit = StubResolver() + init_parameters = {"dynamic_workflows_blocks.workspace_resolver": explicit} + _mirror_dynamic_block_parameters(init_parameters) + assert init_parameters["dynamic_workflows_blocks.workspace_resolver"] is explicit + + +def test_compilation_and_execution_use_the_same_effective_resolver() -> None: + """Round-2 defect 5: `create_dynamic_module` (Modal validation, compile + time) and the generated block's `run` (execution) are two separate lookups. + An explicit dynamic-namespace override must win in BOTH.""" + from inference.core.workflows.execution_engine.v1.compiler.core import ( + _effective_workspace_resolver, + ) + + core_resolver, dynamic_resolver = StubResolver("core"), StubResolver("dynamic") + assert ( + _effective_workspace_resolver( + { + "workflows_core.workspace_resolver": core_resolver, + "dynamic_workflows_blocks.workspace_resolver": dynamic_resolver, + } + ) + is dynamic_resolver + ) + assert ( + _effective_workspace_resolver( + {"workflows_core.workspace_resolver": core_resolver} + ) + is core_resolver + ) + assert _effective_workspace_resolver({}) is NULL_WORKSPACE_RESOLVER + + +def test_generated_block_declares_a_superset_of_the_two_parameters() -> None: + # Superset, not equality: Phase 6 appends `execution_observer` to the same + # list (fix-round-3-crossphase-9.md), and either order must pass. + engine = ExecutionEngine.init( + workflow_definition=DYNAMIC_WORKFLOW, + init_parameters={"workflows_core.api_key": "k"}, + ) + step = engine._engine._compiled_workflow.steps["custom_block"].step + assert {"api_key", "workspace_resolver"} <= set(type(step).get_init_parameters()) + assert engine.run(runtime_parameters={"a": 7}) == [{"out": 7}] + + +def test_effective_dynamic_api_key_prefers_an_explicit_dynamic_override() -> None: + """Round-5 defect 2: compile-time validation must resolve the workspace + with the SAME key the generated block executes with - the + `dynamic_workflows_blocks.api_key` the engine mirrors (which a caller may + override explicitly), not unconditionally `workflows_core.api_key`.""" + from inference.core.workflows.execution_engine.v1.compiler.core import ( + _effective_dynamic_api_key, + ) + + assert _effective_dynamic_api_key({"workflows_core.api_key": "core"}) == "core" + assert ( + _effective_dynamic_api_key( + { + "workflows_core.api_key": "core", + "dynamic_workflows_blocks.api_key": "dyn", + } + ) + == "dyn" + ) + assert _effective_dynamic_api_key({}) is None + + +def test_dynamic_block_compilation_and_execution_share_the_effective_resolver_and_key( + monkeypatch, +) -> None: + """Round-3 defect 6 and round-5 defect 2, end to end: compile a REAL + dynamic block in modal mode with different core / dynamic resolvers AND + different core / dynamic api keys, then run it. Compile-time validation and + run-time execution must ask the SAME resolver with the SAME key - the ones + the generated block holds. `validate_code_in_modal` and `ModalExecutor` + are stubbed on their module: block_scaffolding imports both lazily at call + time, which is how the existing modal tests patch them too.""" + from inference.core.workflows.execution_engine.v1.dynamic_blocks import ( + block_scaffolding, + modal_executor, + ) + + # Deterministic RED before Step 4 lands: the pre-port lookup (removed by + # Step 4) must not reach the network. No-op once the attribute is gone. + if hasattr(block_scaffolding, "get_roboflow_workspace"): + monkeypatch.setattr( + block_scaffolding, "get_roboflow_workspace", lambda api_key: None + ) + with block_scaffolding._MODAL_EXECUTOR_CACHE_LOCK: + block_scaffolding._MODAL_EXECUTOR_CACHE.clear() + + validated = [] + core, dynamic = StubResolver("core-ws"), StubResolver("dynamic-ws") + executor = mock.MagicMock() + executor.execute_remote.return_value = {"out": 1} + monkeypatch.setattr( + block_scaffolding, "WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE", "modal" + ) + monkeypatch.setattr( + modal_executor, + "validate_code_in_modal", + lambda python_code, workspace_id=None: validated.append(workspace_id) or True, + ) + monkeypatch.setattr( + modal_executor, "ModalExecutor", mock.MagicMock(return_value=executor) + ) + engine = ExecutionEngine.init( + # A distinct definition (different version string) so the plain + # local compilation above can never be served for this one. + workflow_definition={**DYNAMIC_WORKFLOW, "version": "1.0.1"}, + init_parameters={ + "workflows_core.api_key": "key-A", + "dynamic_workflows_blocks.api_key": "key-B", + "workflows_core.workspace_resolver": core, + "dynamic_workflows_blocks.workspace_resolver": dynamic, + }, + ) + assert validated == ["dynamic-ws"] + step = engine._engine._compiled_workflow.steps["custom_block"].step + assert step._workspace_resolver is dynamic + assert step._api_key == "key-B" + + engine.run(runtime_parameters={"a": 1}) + + # validation, then execution - the same resolver, the same key + assert dynamic.calls == ["key-B", "key-B"] + assert core.calls == [] + assert executor.execute_remote.call_args.kwargs["workspace_id"] == "dynamic-ws" + with block_scaffolding._MODAL_EXECUTOR_CACHE_LOCK: + block_scaffolding._MODAL_EXECUTOR_CACHE.clear() + + +def test_engine_works_on_a_private_copy_so_a_reused_caller_dictionary_is_not_contaminated() -> ( + None +): + """Round-4 defect 1. `init` used to write the dynamic mirror into the + caller's dictionary (HEAD leaks `dynamic_workflows_blocks.api_key` - E32). + A caller that reuses one dict and swaps the core resolver must see the NEW + resolver reach the next engine, its dict must stay clean, and a genuine + explicit dynamic-namespace override must still win (E31).""" + first_resolver, second_resolver = StubResolver("first"), StubResolver("second") + caller = { + "workflows_core.api_key": "k", + "workflows_core.workspace_resolver": first_resolver, + } + first = ExecutionEngine.init( + workflow_definition=DYNAMIC_WORKFLOW, init_parameters=caller + ) + caller["workflows_core.workspace_resolver"] = second_resolver + second = ExecutionEngine.init( + workflow_definition=DYNAMIC_WORKFLOW, init_parameters=caller + ) + + assert not [ + key for key in caller if key.startswith("dynamic_workflows_blocks.") + ], "the engine must work on a private copy of init_parameters" + assert ( + first._engine._compiled_workflow.steps["custom_block"].step._workspace_resolver + is first_resolver + ) + assert ( + second._engine._compiled_workflow.steps["custom_block"].step._workspace_resolver + is second_resolver + ) + + explicit = StubResolver("explicit") + caller["dynamic_workflows_blocks.workspace_resolver"] = explicit + third = ExecutionEngine.init( + workflow_definition=DYNAMIC_WORKFLOW, init_parameters=caller + ) + assert ( + third._engine._compiled_workflow.steps["custom_block"].step._workspace_resolver + is explicit + ) diff --git a/tests/workflows/unit_tests/execution_engine/inner_workflow/test_spec_resolver_default.py b/tests/workflows/unit_tests/execution_engine/inner_workflow/test_spec_resolver_default.py new file mode 100644 index 0000000000..af742718a6 --- /dev/null +++ b/tests/workflows/unit_tests/execution_engine/inner_workflow/test_spec_resolver_default.py @@ -0,0 +1,179 @@ +"""Standalone Workflows cannot fetch an inner workflow from the Roboflow API, +and a resolver-dependent compilation is never served from the cache - while a +plain definition still is.""" + +import ast +import pathlib +from typing import Any, Dict, Optional + +import pytest + +from inference.core.workflows.errors import WorkflowEnvironmentConfigurationError +from inference.core.workflows.execution_engine.core import ExecutionEngine +from inference.core.workflows.execution_engine.v1.compiler.core import ( + compile_workflow_graph, +) +from inference.core.workflows.execution_engine.v1.inner_workflow.reference_resolution import ( + WORKFLOWS_CORE_INNER_WORKFLOW_SPEC_RESOLVER, + get_inner_workflow_spec_resolver, +) + + +def _echo_spec() -> Dict[str, Any]: + # The same valid child `test_reference_resolution.py:18` uses; + # `inner_workflow/inline.py:477` rejects an empty `steps` list. + return { + "version": "1.0", + "inputs": [ + {"type": "WorkflowParameter", "name": "child_msg", "default_value": "x"}, + ], + "steps": [ + { + "type": "roboflow_core/first_non_empty_or_default@v1", + "name": "pick", + "data": ["$inputs.child_msg"], + "default": "fallback-inner", + }, + ], + "outputs": [ + {"type": "JsonField", "name": "echo", "selector": "$steps.pick.output"}, + ], + } + + +# Field names from core_steps/flow_control/inner_workflow/v1.py:56-80. +REFERENCING_DEFINITION = { + "version": "1.0", + "inputs": [{"type": "WorkflowParameter", "name": "p", "default_value": "hello"}], + "steps": [ + { + "type": "roboflow_core/inner_workflow@v1", + "name": "nested", + "workflow_workspace_id": "my-ws", + "workflow_id": "wf-1", + "parameter_bindings": {"child_msg": "$inputs.p"}, + } + ], + "outputs": [{"type": "JsonField", "name": "out", "selector": "$steps.nested.echo"}], +} + +PLAIN_DEFINITION = { + "version": "1.0", + "inputs": [{"type": "WorkflowParameter", "name": "p", "default_value": "x"}], + "steps": [ + { + "type": "roboflow_core/first_non_empty_or_default@v1", + "name": "pick", + "data": ["$inputs.p"], + "default": "d", + } + ], + "outputs": [{"type": "JsonField", "name": "o", "selector": "$steps.pick.output"}], +} + + +def test_default_resolver_refuses_and_names_the_init_parameter() -> None: + resolver = get_inner_workflow_spec_resolver(init_parameters={}) + with pytest.raises(WorkflowEnvironmentConfigurationError) as error: + resolver("some-workspace", "some-workflow", None, {}) + assert WORKFLOWS_CORE_INNER_WORKFLOW_SPEC_RESOLVER in str(error.value) + + +def test_injected_resolver_wins() -> None: + sentinel = {"version": "1.0"} + resolver = get_inner_workflow_spec_resolver( + init_parameters={ + WORKFLOWS_CORE_INNER_WORKFLOW_SPEC_RESOLVER: lambda *a, **k: sentinel + } + ) + assert resolver("w", "id", None, {}) is sentinel + + +def test_reference_resolution_does_not_import_the_server() -> None: + # parents[5] == repo root from + # tests/workflows/unit_tests/execution_engine/inner_workflow/ + path = ( + pathlib.Path(__file__).resolve().parents[5] + / "inference/core/workflows/execution_engine/v1/inner_workflow/reference_resolution.py" + ) + tree = ast.parse(path.read_text(encoding="utf-8")) + modules = { + n.module for n in ast.walk(tree) if isinstance(n, ast.ImportFrom) and n.module + } + assert "inference.core.roboflow_api" not in modules + + +def test_the_server_resolver_preserves_the_api_key_error_message() -> None: + from inference.core.interfaces.roboflow_platform_client import ( + default_inner_workflow_spec_resolver, + ) + from inference.core.workflows.errors import WorkflowDefinitionError + + with pytest.raises(WorkflowDefinitionError) as error: + default_inner_workflow_spec_resolver("workspace", "wf", None, {}) + assert "requires a Roboflow API key" in str(error.value) + + +def test_a_resolver_dependent_definition_is_not_cached() -> None: + """Round-2 defect 4: the cache returns before reference resolution, so a + second engine's resolver would be bypassed. Resolver-dependent definitions + therefore bypass the cache entirely.""" + from inference.core.workflows.execution_engine.v1.compiler.core import ( + _is_resolver_dependent, + ) + + assert _is_resolver_dependent(PLAIN_DEFINITION, []) is False + assert ( + _is_resolver_dependent(PLAIN_DEFINITION, [{"type": "DynamicBlockDefinition"}]) + is True + ) + assert _is_resolver_dependent(REFERENCING_DEFINITION, []) is True + + +def test_two_engines_with_different_resolvers_and_api_keys_do_not_share_a_compilation() -> ( + None +): + """The end-to-end property the bypass exists for (round-3 defect 6): the + SAME definition compiled twice with distinct resolvers AND distinct + authentication contexts must consult both, and both engines must run.""" + seen = [] + + def resolver_factory(tag): + def resolver( + workspace_id: str, + workflow_id: str, + workflow_version_id: Optional[str], + init_parameters: Dict[str, Any], + ) -> Dict[str, Any]: + seen.append((tag, init_parameters.get("workflows_core.api_key"))) + return _echo_spec() + + return resolver + + results = [] + for tag, api_key in (("a", "key-a"), ("b", "key-b")): + engine = ExecutionEngine.init( + workflow_definition=REFERENCING_DEFINITION, + init_parameters={ + "workflows_core.api_key": api_key, + WORKFLOWS_CORE_INNER_WORKFLOW_SPEC_RESOLVER: resolver_factory(tag), + }, + ) + results.append(engine.run(runtime_parameters={"p": "hello"})) + + assert seen == [ + ("a", "key-a"), + ("b", "key-b"), + ], "the second engine's resolver was bypassed by the cache" + assert results == [[{"out": "hello"}], [{"out": "hello"}]] + + +def test_plain_definitions_still_reuse_the_compilation() -> None: + """The cache exists for these; the bypass must not widen to them.""" + first = compile_workflow_graph( + workflow_definition=PLAIN_DEFINITION, init_parameters={} + ) + second = compile_workflow_graph( + workflow_definition=PLAIN_DEFINITION, init_parameters={} + ) + assert first is second From ea26764c42369ab894f64f8de6a44e8a7733ae28 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:28:28 +0200 Subject: [PATCH 13/86] fix(workflows): collect dynamic block definitions once per compile compile_workflow_graph called collect_dynamic_blocks_definitions_from_workflow_definition up to three times (cacheable check, cache-hit path, post-normalisation) - the collector warns on duplicate block types, so it warned repeatedly for the same compile. Collect once into a local and reuse it for the cacheable check and the cache-hit path's ensure_dynamic_blocks_allowed; the post-normalisation collection stays, since inlined inner workflows can contribute additional dynamic blocks. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../execution_engine/v1/compiler/core.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/inference/core/workflows/execution_engine/v1/compiler/core.py b/inference/core/workflows/execution_engine/v1/compiler/core.py index 1a5e2b2666..f381797af8 100644 --- a/inference/core/workflows/execution_engine/v1/compiler/core.py +++ b/inference/core/workflows/execution_engine/v1/compiler/core.py @@ -177,11 +177,14 @@ def compile_workflow_graph( ) -> GraphCompilationResult: if init_parameters is None: init_parameters = {} + pre_resolution_dynamic_blocks_definitions = ( + collect_dynamic_blocks_definitions_from_workflow_definition( + workflow_definition=workflow_definition + ) + ) cacheable = not _is_resolver_dependent( workflow_definition=workflow_definition, - dynamic_blocks_definitions=collect_dynamic_blocks_definitions_from_workflow_definition( - workflow_definition=workflow_definition - ), + dynamic_blocks_definitions=pre_resolution_dynamic_blocks_definitions, ) key = COMPILATION_CACHE.get_hash_key( workflow_definition=workflow_definition, @@ -189,13 +192,8 @@ def compile_workflow_graph( ) cached_value = COMPILATION_CACHE.get(key=key) if cacheable else None if cached_value is not None: - dynamic_blocks_definitions = ( - collect_dynamic_blocks_definitions_from_workflow_definition( - workflow_definition=workflow_definition, - ) - ) ensure_dynamic_blocks_allowed( - dynamic_blocks_definitions=dynamic_blocks_definitions + dynamic_blocks_definitions=pre_resolution_dynamic_blocks_definitions ) return cached_value From 58508a99a8c7f461b2f900787af8ffef4224f896 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:46:46 +0200 Subject: [PATCH 14/86] refactor(workflows): relocate the Roboflow API exception classes into workflows RoboflowAPIRequestError, RoboflowAPIUnsuccessfulRequestError, RoboflowAPIForbiddenError and FeatureDeprecatedError move to workflows/prototypes/platform_errors.py and inference/core/exceptions.py imports them back. One class object per name, so class names, constructors, MROs, isinstance checks, handler arms and serialized error_type payloads are all unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/exceptions.py | 64 +------ .../workflows/core_steps/common/openrouter.py | 8 +- .../models/foundation/cog_vlm/v1.py | 2 +- .../core_steps/models/foundation/gaze/v1.py | 2 +- .../models/foundation/yolo_world/v1_tensor.py | 2 +- .../core_steps/sinks/email_notification/v2.py | 2 +- .../core_steps/sinks/twilio/sms/v2.py | 8 +- .../workflows/prototypes/platform_errors.py | 74 ++++++++ scripts/phase9_platform_errors_imports.py | 74 ++++++++ .../unit_tests/decontamination_baseline.txt | 8 +- .../prototypes/test_platform_errors.py | 166 ++++++++++++++++++ 11 files changed, 334 insertions(+), 76 deletions(-) create mode 100644 inference/core/workflows/prototypes/platform_errors.py create mode 100644 scripts/phase9_platform_errors_imports.py create mode 100644 tests/workflows/unit_tests/prototypes/test_platform_errors.py diff --git a/inference/core/exceptions.py b/inference/core/exceptions.py index e796423721..3af148ee75 100644 --- a/inference/core/exceptions.py +++ b/inference/core/exceptions.py @@ -1,5 +1,12 @@ from typing import Optional +from inference.core.workflows.prototypes.platform_errors import ( + FeatureDeprecatedError, + RoboflowAPIForbiddenError, + RoboflowAPIRequestError, + RoboflowAPIUnsuccessfulRequestError, +) + class ContentTypeInvalid(Exception): """Raised when the content type is invalid. @@ -161,18 +168,10 @@ class ModelNotRecognisedError(ServiceConfigurationError): pass -class RoboflowAPIRequestError(Exception): - pass - - class ModelManagerLockAcquisitionError(RoboflowAPIRequestError): pass -class RoboflowAPIUnsuccessfulRequestError(RoboflowAPIRequestError): - pass - - class RoboflowAPINotAuthorizedError(RoboflowAPIUnsuccessfulRequestError): pass @@ -181,10 +180,6 @@ class PaymentRequiredError(RoboflowAPIUnsuccessfulRequestError): pass -class RoboflowAPIForbiddenError(RoboflowAPIUnsuccessfulRequestError): - pass - - class RoboflowAPIUsagePausedError(RoboflowAPIUnsuccessfulRequestError): pass @@ -277,48 +272,3 @@ class WorkspaceStreamQuotaError(Exception): """ pass - - -class FeatureDeprecatedError(Exception): - """Raised when a removed/deprecated feature is invoked. - - Maps to HTTP 410 Gone via the standard error handler; surfaces as - error_type="FeatureDeprecatedError" in InferencePipeline StatusUpdate - payloads and as the inner_error of - ClientCausedStepExecutionError(status_code=410) when raised from a - workflow block. - """ - - def __init__( - self, - feature: str, - *, - removal_release: Optional[str] = None, - replacement: Optional[str] = None, - reason: Optional[str] = None, - ): - self.feature = feature - self.removal_release = removal_release - self.replacement = replacement - self.reason = reason - public = f"Feature '{feature}' has been removed from inference." - if reason: - public += f" Reason: {reason}." - if removal_release: - public += f" Removed in {removal_release}." - public += ( - " No drop-in replacement is provided; contact Roboflow if you " - "require this capability." - ) - if replacement: - public += f" Closest replacement: {replacement}." - self._public_message = public - super().__init__(public) - - def get_structured_public_error_details(self) -> dict: - return { - "feature": self.feature, - "removal_release": self.removal_release, - "replacement": self.replacement, - "reason": self.reason, - } diff --git a/inference/core/workflows/core_steps/common/openrouter.py b/inference/core/workflows/core_steps/common/openrouter.py index b874cbc82b..8314f7eaca 100644 --- a/inference/core/workflows/core_steps/common/openrouter.py +++ b/inference/core/workflows/core_steps/common/openrouter.py @@ -31,10 +31,6 @@ from pydantic import ConfigDict, Field from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.exceptions import ( - RoboflowAPIForbiddenError, - RoboflowAPIUnsuccessfulRequestError, -) from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.token_usage import ( parse_chat_completion_usage, @@ -57,6 +53,10 @@ OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) +from inference.core.workflows.prototypes.platform_errors import ( + RoboflowAPIForbiddenError, + RoboflowAPIUnsuccessfulRequestError, +) logger = logging.getLogger(__name__) diff --git a/inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py b/inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py index ebc839f043..777f0aa5b0 100644 --- a/inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py @@ -4,7 +4,6 @@ from pydantic import ConfigDict, Field from inference.core.env import LMM_ENABLED -from inference.core.exceptions import FeatureDeprecatedError from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, @@ -30,6 +29,7 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.platform_errors import FeatureDeprecatedError NOT_DETECTED_VALUE = "not_detected" diff --git a/inference/core/workflows/core_steps/models/foundation/gaze/v1.py b/inference/core/workflows/core_steps/models/foundation/gaze/v1.py index 5c90e5e75d..1dc0ed991d 100644 --- a/inference/core/workflows/core_steps/models/foundation/gaze/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/gaze/v1.py @@ -3,7 +3,6 @@ from pydantic import ConfigDict, Field from inference.core.env import CORE_MODEL_GAZE_ENABLED -from inference.core.exceptions import FeatureDeprecatedError from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, @@ -26,6 +25,7 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.platform_errors import FeatureDeprecatedError LONG_DESCRIPTION = """ **DEPRECATED.** L2CS Gaze detection has been removed from inference along diff --git a/inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py index d3095e4b17..723d71ca19 100644 --- a/inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py @@ -12,7 +12,6 @@ from pydantic import ConfigDict, Field -from inference.core.exceptions import FeatureDeprecatedError from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, @@ -38,6 +37,7 @@ roboflow_platform_model, ) from inference.core.workflows.prototypes.models_provider import CORE_MODEL_ENDPOINT_TYPE +from inference.core.workflows.prototypes.platform_errors import FeatureDeprecatedError LONG_DESCRIPTION = """ **DEPRECATED.** YOLO-World is deprecated in the tensor-native Workflows pipeline. diff --git a/inference/core/workflows/core_steps/sinks/email_notification/v2.py b/inference/core/workflows/core_steps/sinks/email_notification/v2.py index d4a0d52c0c..edf02627f9 100644 --- a/inference/core/workflows/core_steps/sinks/email_notification/v2.py +++ b/inference/core/workflows/core_steps/sinks/email_notification/v2.py @@ -813,7 +813,7 @@ def send_email_via_roboflow_proxy( attachments: Dict[str, Any], ) -> Tuple[bool, str]: """Send email through Roboflow's proxy service.""" - from inference.core.exceptions import ( + from inference.core.workflows.prototypes.platform_errors import ( RoboflowAPIForbiddenError, RoboflowAPIUnsuccessfulRequestError, ) diff --git a/inference/core/workflows/core_steps/sinks/twilio/sms/v2.py b/inference/core/workflows/core_steps/sinks/twilio/sms/v2.py index ec6c915d11..3e16e872b2 100644 --- a/inference/core/workflows/core_steps/sinks/twilio/sms/v2.py +++ b/inference/core/workflows/core_steps/sinks/twilio/sms/v2.py @@ -12,10 +12,6 @@ from pydantic import ConfigDict, Field from twilio.rest import Client -from inference.core.exceptions import ( - RoboflowAPIForbiddenError, - RoboflowAPIUnsuccessfulRequestError, -) from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.query_language.entities.operations import ( AllOperationsType, @@ -50,6 +46,10 @@ OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) +from inference.core.workflows.prototypes.platform_errors import ( + RoboflowAPIForbiddenError, + RoboflowAPIUnsuccessfulRequestError, +) LONG_DESCRIPTION = """ The **Twilio SMS/MMS Notification** block allows users to send text and multimedia messages as part of a workflow. diff --git a/inference/core/workflows/prototypes/platform_errors.py b/inference/core/workflows/prototypes/platform_errors.py new file mode 100644 index 0000000000..be53441098 --- /dev/null +++ b/inference/core/workflows/prototypes/platform_errors.py @@ -0,0 +1,74 @@ +"""Exception classes Workflow blocks raise and catch around platform calls. + +RELOCATED, not duplicated: `inference/core/exceptions.py` imports these names +back, so there is exactly ONE class object per name. That keeps `__name__` +(serialized as `error_type` / `inner_error_type`), the constructors, the +subclass hierarchy and every `isinstance` check byte-identical, which a +parallel hierarchy could not. + +The names stay Roboflow-branded on purpose: the decontamination goal is import +independence, not vocabulary independence, and renaming them would change the +error payloads clients match on. + +Keep this module dependency-free apart from `typing` - `inference.core.exceptions` +imports it, and anything else would create a cycle. +""" + +from typing import Optional + + +class RoboflowAPIRequestError(Exception): + pass + + +class RoboflowAPIUnsuccessfulRequestError(RoboflowAPIRequestError): + pass + + +class RoboflowAPIForbiddenError(RoboflowAPIUnsuccessfulRequestError): + pass + + +class FeatureDeprecatedError(Exception): + """Raised when a removed/deprecated feature is invoked. + + Maps to HTTP 410 Gone via the standard error handler; surfaces as + error_type="FeatureDeprecatedError" in InferencePipeline StatusUpdate + payloads and as the inner_error of + ClientCausedStepExecutionError(status_code=410) when raised from a + workflow block. + """ + + def __init__( + self, + feature: str, + *, + removal_release: Optional[str] = None, + replacement: Optional[str] = None, + reason: Optional[str] = None, + ): + self.feature = feature + self.removal_release = removal_release + self.replacement = replacement + self.reason = reason + public = f"Feature '{feature}' has been removed from inference." + if reason: + public += f" Reason: {reason}." + if removal_release: + public += f" Removed in {removal_release}." + public += ( + " No drop-in replacement is provided; contact Roboflow if you " + "require this capability." + ) + if replacement: + public += f" Closest replacement: {replacement}." + self._public_message = public + super().__init__(public) + + def get_structured_public_error_details(self) -> dict: + return { + "feature": self.feature, + "removal_release": self.removal_release, + "replacement": self.replacement, + "reason": self.reason, + } diff --git a/scripts/phase9_platform_errors_imports.py b/scripts/phase9_platform_errors_imports.py new file mode 100644 index 0000000000..16f56a078f --- /dev/null +++ b/scripts/phase9_platform_errors_imports.py @@ -0,0 +1,74 @@ +"""Repoint `from inference.core.exceptions import …` at the relocated module. + +Names are unchanged, so this rewrites only the module part of the ImportFrom +node - which is why the function-local import in email_notification/v2.py keeps +its indentation. Refuses any name that did not move. +""" + +import argparse +import ast +import pathlib +import sys + +MOVED = { + "RoboflowAPIRequestError", + "RoboflowAPIUnsuccessfulRequestError", + "RoboflowAPIForbiddenError", + "FeatureDeprecatedError", +} +NEW_MODULE = "inference.core.workflows.prototypes.platform_errors" +OLD_MODULE = "inference.core.exceptions" + + +def patch(path: pathlib.Path) -> int: + source = path.read_text(encoding="utf-8") + newline = "\r\n" if "\r\n" in source else "\n" + lines = source.split(newline) + nodes = [ + node + for node in ast.walk(ast.parse(source)) + if isinstance(node, ast.ImportFrom) and node.module == OLD_MODULE + ] + if not nodes: + print(f"SKIP (already repointed): {path}") + return 0 + for node in nodes: + unknown = [a.name for a in node.names if a.name not in MOVED] + if unknown: + print(f"FAIL: {path}:{node.lineno} imports unmoved names {unknown}", file=sys.stderr) + raise SystemExit(2) + for node in sorted(nodes, key=lambda n: n.lineno, reverse=True): + # Replace only the module text; the `import ...` clause and the + # statement's indentation are preserved verbatim. + first = lines[node.lineno - 1] + assert OLD_MODULE in first, (path, node.lineno, first) + lines[node.lineno - 1] = first.replace(OLD_MODULE, NEW_MODULE, 1) + updated = newline.join(lines) + ast.parse(updated) + if OLD_MODULE in updated: + print(f"FAIL: {path} still imports {OLD_MODULE}", file=sys.stderr) + raise SystemExit(2) + path.write_text(updated, encoding="utf-8") + return len(nodes) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("files", nargs="+") + parser.add_argument("--expected", type=int, required=True) + args = parser.parse_args() + total = 0 + for name in args.files: + count = patch(pathlib.Path(name)) + total += count + print(f"{count:3d} {name}") + print(f"TOTAL {total} import statements repointed") + print(f"POST-STATE verified: no {OLD_MODULE} import remains in these files") + if total != args.expected: + print("FAIL: count does not match", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 7c04e19ed0..6fadd4fe28 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,13 +1,12 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 275 +# Count: 269 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env inference/core/workflows/core_steps/common/deserializers.py inference.core.utils.image_utils inference/core/workflows/core_steps/common/deserializers_tensor.py inference.core.env inference/core/workflows/core_steps/common/openrouter.py inference.core.env -inference/core/workflows/core_steps/common/openrouter.py inference.core.exceptions inference/core/workflows/core_steps/common/openrouter.py inference.core.utils.image_utils inference/core/workflows/core_steps/common/query_language/operations/classification_results/base.py inference.core.env inference/core/workflows/core_steps/common/query_language/operations/detection/base.py inference.core.env @@ -44,7 +43,6 @@ inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py infe inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py inference.core.env inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py inference.core.exceptions inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py inference.core.entities.requests.inference @@ -57,7 +55,6 @@ inference/core/workflows/core_steps/models/foundation/florence2/v1.py inference. inference/core/workflows/core_steps/models/foundation/florence2/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/florence2/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/gaze/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/gaze/v1.py inference.core.exceptions inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py inference.core.env @@ -181,7 +178,6 @@ inference/core/workflows/core_steps/models/foundation/spacexai/v2.py inference.c inference/core/workflows/core_steps/models/foundation/spacexai/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.entities.requests.yolo_world inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/yolo_world/v1_tensor.py inference.core.exceptions inference/core/workflows/core_steps/models/foundation/zai_vlm/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/roboflow/action_recognition/v1.py inference.core.models.action_recognition inference/core/workflows/core_steps/models/roboflow/action_recognition/v1.py inference.core.models.inference_models_adapters @@ -243,10 +239,8 @@ inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2_ten inference/core/workflows/core_steps/models/third_party/barcode_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/third_party/qr_code_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/secrets_providers/environment_secrets_store/v1.py inference.core.env -inference/core/workflows/core_steps/sinks/email_notification/v2.py inference.core.exceptions inference/core/workflows/core_steps/sinks/email_notification/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/sinks/local_file/v1.py inference.core.env -inference/core/workflows/core_steps/sinks/twilio/sms/v2.py inference.core.exceptions inference/core/workflows/core_steps/sinks/twilio/sms/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/transformations/detections_combine/v1_tensor.py inference.core.env inference/core/workflows/core_steps/transformations/detections_merge/v1_tensor.py inference.core.env diff --git a/tests/workflows/unit_tests/prototypes/test_platform_errors.py b/tests/workflows/unit_tests/prototypes/test_platform_errors.py new file mode 100644 index 0000000000..2b919f682e --- /dev/null +++ b/tests/workflows/unit_tests/prototypes/test_platform_errors.py @@ -0,0 +1,166 @@ +"""The four Roboflow-API exception classes live in workflows and the server +re-exports them - one class object, so nothing observable changes. + +`post_to_roboflow_api` raises these (401 -> RoboflowAPINotAuthorizedError, a +subclass of RoboflowAPIUnsuccessfulRequestError; 403 -> RoboflowAPIForbiddenError; +any other 4xx/5xx -> RoboflowAPIUnsuccessfulRequestError), and blocks catch +them - so identity, not just name, has to survive. +""" + +import ast +import pathlib + +import pytest + +from inference.core import exceptions as server_exceptions +from inference.core.interfaces.workflows_step_error_handlers import ( + extended_roboflow_errors_handler, + legacy_step_error_handler, +) +from inference.core.workflows.errors import ClientCausedStepExecutionError +from inference.core.workflows.prototypes import platform_errors + +RELOCATED = [ + "RoboflowAPIRequestError", + "RoboflowAPIUnsuccessfulRequestError", + "RoboflowAPIForbiddenError", + "FeatureDeprecatedError", +] + + +@pytest.mark.parametrize("name", RELOCATED) +def test_the_server_name_is_the_workflows_class(name) -> None: + assert getattr(server_exceptions, name) is getattr(platform_errors, name) + + +@pytest.mark.parametrize("name", RELOCATED) +def test_the_public_class_name_is_unchanged(name) -> None: + # `WorkflowError.inner_error_type` and `error_handlers.py` serialize + # `__class__.__name__`; a different name is a wire change. + assert getattr(platform_errors, name).__name__ == name + + +def test_the_subclass_hierarchy_is_unchanged() -> None: + E = server_exceptions + assert issubclass(E.RoboflowAPIUnsuccessfulRequestError, E.RoboflowAPIRequestError) + assert issubclass( + E.RoboflowAPINotAuthorizedError, E.RoboflowAPIUnsuccessfulRequestError + ) + assert issubclass( + E.RoboflowAPIForbiddenError, E.RoboflowAPIUnsuccessfulRequestError + ) + assert issubclass(E.PaymentRequiredError, E.RoboflowAPIUnsuccessfulRequestError) + assert issubclass( + E.RoboflowAPIUsagePausedError, E.RoboflowAPIUnsuccessfulRequestError + ) + assert issubclass( + E.RoboflowAPINotNotFoundError, E.RoboflowAPIUnsuccessfulRequestError + ) + assert issubclass(E.ModelManagerLockAcquisitionError, E.RoboflowAPIRequestError) + assert issubclass(E.RoboflowAPIConnectionError, E.RoboflowAPIRequestError) + assert issubclass(E.RoboflowAPITimeoutError, E.RoboflowAPIRequestError) + + +def test_the_deprecation_constructor_and_details_are_unchanged() -> None: + error = server_exceptions.FeatureDeprecatedError( + feature="roboflow_core/cog_vlm@v1", + reason="End-of-life due to CVE-2024-11393", + removal_release="0.54.0", + ) + assert error.get_structured_public_error_details() == { + "feature": "roboflow_core/cog_vlm@v1", + "removal_release": "0.54.0", + "replacement": None, + "reason": "End-of-life due to CVE-2024-11393", + } + assert "roboflow_core/cog_vlm@v1" in str(error) + assert "Reason: End-of-life due to CVE-2024-11393" in str(error) + + +def test_the_module_imports_only_typing() -> None: + path = ( + pathlib.Path(__file__).resolve().parents[4] + / "inference/core/workflows/prototypes/platform_errors.py" + ) + tree = ast.parse(path.read_text(encoding="utf-8")) + modules = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + modules.add(node.module) + if isinstance(node, ast.Import): + modules.update(a.name for a in node.names) + assert modules <= {"typing"}, ( + f"platform_errors.py must stay dependency-free ({modules}) - " + "exceptions.py imports it" + ) + + +@pytest.mark.parametrize( + "handler", [legacy_step_error_handler, extended_roboflow_errors_handler] +) +def test_deprecation_still_maps_to_410_from_the_three_real_raise_sites(handler) -> None: + for feature, reason in ( + ("roboflow_core/cog_vlm@v1", "End-of-life due to CVE-2024-11393"), + ("roboflow_core/gaze@v1", "MediaPipe dependency removed from inference"), + ("roboflow_core/yolo_world_model@v1", "YOLO-World is deprecated"), + ): + error = server_exceptions.FeatureDeprecatedError(feature=feature, reason=reason) + with pytest.raises(ClientCausedStepExecutionError) as raised: + handler("some_step", error) + assert raised.value.status_code == 410 + assert raised.value.inner_error_type == "FeatureDeprecatedError" + + +def test_a_proxy_403_still_maps_to_403_with_the_original_error_name() -> None: + """End to end through the block's own callback: the serialized + `inner_error_type` must stay `RoboflowAPIForbiddenError`.""" + import requests + + from inference.core.workflows.core_steps.common import openrouter + + response = requests.Response() + response.status_code = 403 + response._content = b'{"details": "not allowed"}' + http_error = requests.exceptions.HTTPError(response=response) + + with pytest.raises(server_exceptions.RoboflowAPIForbiddenError) as raised: + openrouter._PROXY_ERROR_HANDLERS[403](http_error) + assert raised.value.status_code == 403 + + with pytest.raises(ClientCausedStepExecutionError) as mapped: + extended_roboflow_errors_handler("some_step", raised.value) + assert mapped.value.status_code == 403 + assert mapped.value.inner_error_type == "RoboflowAPIForbiddenError" + + +def test_a_401_still_maps_to_401() -> None: + with pytest.raises(ClientCausedStepExecutionError) as error: + extended_roboflow_errors_handler( + "some_step", server_exceptions.RoboflowAPINotAuthorizedError("nope") + ) + assert error.value.status_code == 401 + + +def test_a_generic_unsuccessful_request_is_still_unmapped() -> None: + assert ( + extended_roboflow_errors_handler( + "some_step", server_exceptions.RoboflowAPIUnsuccessfulRequestError("boom") + ) + is None + ) + + +def test_no_workflows_module_imports_the_server_exceptions() -> None: + workflows_root = ( + pathlib.Path(__file__).resolve().parents[4] / "inference" / "core" / "workflows" + ) + offenders = [] + for path in workflows_root.rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if ( + isinstance(node, ast.ImportFrom) + and node.module == "inference.core.exceptions" + ): + offenders.append(f"{path}:{node.lineno}") + assert not offenders, offenders From 071af7f449284ebf7f96bbc40d61eadb27f20776 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:05:18 +0200 Subject: [PATCH 15/86] fix(workflows): remove orphaned typing.Optional import from exceptions.py The four relocated exception classes (Task 9.7) took their only Optional[...] usage with them into platform_errors.py, leaving the import dead in exceptions.py. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/exceptions.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/inference/core/exceptions.py b/inference/core/exceptions.py index 3af148ee75..2327d4c2c1 100644 --- a/inference/core/exceptions.py +++ b/inference/core/exceptions.py @@ -1,5 +1,3 @@ -from typing import Optional - from inference.core.workflows.prototypes.platform_errors import ( FeatureDeprecatedError, RoboflowAPIForbiddenError, From 44689051f4a6180325bed8cd421d82b15bfc07af Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:13:30 +0200 Subject: [PATCH 16/86] refactor(workflows): swap the cache default now that every root injects the shared one core_steps/loader.py defaults to a process-local InMemoryWorkflowsCache whose falsy-expire semantics match MemoryCache exactly. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/workflows/core_steps/loader.py | 9 ++- .../core/workflows/utils/in_memory_cache.py | 40 +++++++++++ .../unit_tests/decontamination_baseline.txt | 3 +- .../unit_tests/utils/test_in_memory_cache.py | 72 +++++++++++++++++++ 4 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 inference/core/workflows/utils/in_memory_cache.py create mode 100644 tests/workflows/unit_tests/utils/test_in_memory_cache.py diff --git a/inference/core/workflows/core_steps/loader.py b/inference/core/workflows/core_steps/loader.py index f04ee3c8ae..a0c910c7e4 100644 --- a/inference/core/workflows/core_steps/loader.py +++ b/inference/core/workflows/core_steps/loader.py @@ -1,6 +1,5 @@ from typing import List, Type -from inference.core.cache import cache from inference.core.env import ( ALLOW_WORKFLOW_BLOCKS_ACCESSING_ENVIRONMENTAL_VARIABLES, ALLOW_WORKFLOW_BLOCKS_ACCESSING_LOCAL_STORAGE, @@ -1545,11 +1544,15 @@ ) from inference.core.workflows.prototypes.block import WorkflowBlock from inference.core.workflows.prototypes.platform_client import OFFLINE_PLATFORM_CLIENT +from inference.core.workflows.utils.in_memory_cache import InMemoryWorkflowsCache REGISTERED_INITIALIZERS = { "api_key": API_KEY, - # Keep bound to the server cache until Phase 9 injects it at the composition roots - see DECONTAMINATION.PLAN.MD, Phase 4 preamble. - "cache": cache, + # Standalone default. Every server composition root overrides it with + # `workflows_core.cache` (the shared, Redis-backed singleton) through + # install_workflows_platform_bindings(); a per-process cache here would + # make sink cooldown and dedup state per-worker. + "cache": InMemoryWorkflowsCache(), "step_execution_mode": StepExecutionMode(WORKFLOWS_STEP_EXECUTION_MODE), "background_tasks": None, "thread_pool_executor": None, diff --git a/inference/core/workflows/utils/in_memory_cache.py b/inference/core/workflows/utils/in_memory_cache.py new file mode 100644 index 0000000000..6be192b24f --- /dev/null +++ b/inference/core/workflows/utils/in_memory_cache.py @@ -0,0 +1,40 @@ +"""Process-local `WorkflowsCache` used when the host injects nothing. + +The server always overrides it with `workflows_core.cache` - its Redis-backed +(or in-process) singleton - at every composition root, so this only ever runs +standalone. Deliberately small: no background thread, no size bound, no sorted +sets. Expiry is lazy; the server's `MemoryCache` also sweeps in a daemon thread, +which a library default must not start, but the observable `get` behaviour is +the same. + +`if expire:` matches `MemoryCache._set_unlocked` exactly - a falsy expire +(None, 0, 0.0) means "no expiry", not "expire immediately". +""" + +import time +from threading import Lock +from typing import Any, Dict, Optional + + +class InMemoryWorkflowsCache: + def __init__(self) -> None: + self._values: Dict[str, Any] = {} + self._deadlines: Dict[str, float] = {} + self._guard = Lock() + + def get(self, key: str) -> Any: + with self._guard: + deadline = self._deadlines.get(key) + if deadline is not None and deadline < time.time(): + self._values.pop(key, None) + self._deadlines.pop(key, None) + return None + return self._values.get(key) + + def set(self, key: str, value: Any, expire: Optional[float] = None) -> None: + with self._guard: + self._values[key] = value + if expire: + self._deadlines[key] = time.time() + expire + else: + self._deadlines.pop(key, None) diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 6fadd4fe28..d1100ce456 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 269 +# Count: 268 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env @@ -23,7 +23,6 @@ inference/core/workflows/core_steps/formatters/vlm_as_detector/v1_tensor.py infe inference/core/workflows/core_steps/formatters/vlm_as_detector/v2_tensor.py inference.core.env inference/core/workflows/core_steps/fusion/detections_consensus/v1_tensor.py inference.core.env inference/core/workflows/core_steps/fusion/image_stack/v1.py inference.core.utils.image_utils -inference/core/workflows/core_steps/loader.py inference.core.cache inference/core/workflows/core_steps/loader.py inference.core.env inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py inference.core.utils.image_utils diff --git a/tests/workflows/unit_tests/utils/test_in_memory_cache.py b/tests/workflows/unit_tests/utils/test_in_memory_cache.py new file mode 100644 index 0000000000..bcba720815 --- /dev/null +++ b/tests/workflows/unit_tests/utils/test_in_memory_cache.py @@ -0,0 +1,72 @@ +"""The standalone cache default. + +Deliberately not a copy of `inference.core.cache.memory.MemoryCache`: that one +starts a daemon expiry thread and reads MEMORY_CACHE_EXPIRE_INTERVAL from the +server env. Expiry here is lazy - checked on read. + +The server treats a FALSY `expire` as "no expiry" (`memory.py:84`, `if expire:`), +so `expire=0` must not expire the value. +""" + +import inspect +import time + +from inference.core.cache.memory import MemoryCache +from inference.core.workflows.prototypes.cache import WorkflowsCache +from inference.core.workflows.utils.in_memory_cache import InMemoryWorkflowsCache + + +def test_get_returns_none_for_a_missing_key() -> None: + assert InMemoryWorkflowsCache().get("nope") is None + + +def test_set_then_get_round_trips() -> None: + cache = InMemoryWorkflowsCache() + cache.set(key="k", value={"a": 1}) + assert cache.get("k") == {"a": 1} + + +def test_an_expired_value_is_gone() -> None: + cache = InMemoryWorkflowsCache() + cache.set(key="k", value="v", expire=0.01) + time.sleep(0.05) + assert cache.get("k") is None + + +def test_overwriting_clears_a_previous_deadline() -> None: + cache = InMemoryWorkflowsCache() + cache.set(key="k", value="old", expire=0.01) + cache.set(key="k", value="new") + time.sleep(0.05) + assert cache.get("k") == "new" + + +def test_it_satisfies_the_workflows_cache_port() -> None: + for method in ("get", "set"): + port = list(inspect.signature(getattr(WorkflowsCache, method)).parameters) + real = list( + inspect.signature(getattr(InMemoryWorkflowsCache, method)).parameters + ) + assert port == real, f"{method}: {port} != {real}" + + +def test_it_matches_the_server_cache_on_every_expire_shape() -> None: + reference = MemoryCache() + candidate = InMemoryWorkflowsCache() + for cache in (reference, candidate): + cache.set(key="none", value=1, expire=None) + cache.set(key="zero", value=2, expire=0) + cache.set(key="omitted", value=3) + cache.set(key="long", value=4, expire=30) + cache.set(key="short", value=5, expire=0.01) + time.sleep(0.05) + for key, expected in ( + ("none", 1), + ("zero", 2), + ("omitted", 3), + ("long", 4), + ("short", None), + ("missing", None), + ): + assert reference.get(key) == expected, f"reference disagrees on {key}" + assert candidate.get(key) == expected, f"candidate disagrees on {key}" From 631ce204efa5e2cad28ed08589f4d58b8d868e2e Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:35:28 +0200 Subject: [PATCH 17/86] refactor(workflows): schema DTO, merge_window_segments and the AR model loader get_workflow_schema returns a dict and the server wraps it (route name and OpenAPI operation id unchanged); merge_window_segments is copied in and pinned by a behavioural differential; load_action_recognition_model moves behind the ModelsProvider port. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/interfaces/http/http_api.py | 8 +- inference/core/managers/base.py | 19 ++ inference/core/managers/decorators/base.py | 7 + .../models/roboflow/action_recognition/v1.py | 25 ++- .../v1/compiler/syntactic_parser.py | 11 +- inference/core/workflows/prototypes/cache.py | 7 +- .../workflows/prototypes/models_provider.py | 4 + .../workflows/utils/action_recognition.py | 62 ++++++ .../http/test_workflow_schema_route.py | 53 +++++ .../roboflow/action_recognition/test_v1.py | 3 + .../unit_tests/decontamination_baseline.txt | 5 +- ...est_workflow_schema_detections_property.py | 7 +- .../utils/test_action_recognition_copy.py | 181 ++++++++++++++++++ 13 files changed, 366 insertions(+), 26 deletions(-) create mode 100644 inference/core/workflows/utils/action_recognition.py create mode 100644 tests/inference/unit_tests/core/interfaces/http/test_workflow_schema_route.py create mode 100644 tests/workflows/unit_tests/utils/test_action_recognition_copy.py diff --git a/inference/core/interfaces/http/http_api.py b/inference/core/interfaces/http/http_api.py index 57c5bbaf0f..ecde586f42 100644 --- a/inference/core/interfaces/http/http_api.py +++ b/inference/core/interfaces/http/http_api.py @@ -370,7 +370,9 @@ WorkflowsProfiler, ) from inference.core.workflows.execution_engine.v1.compiler.syntactic_parser import ( - get_workflow_schema_description, + get_workflow_schema as build_workflow_blocks_schema, +) +from inference.core.workflows.execution_engine.v1.compiler.syntactic_parser import ( parse_workflow_definition, ) from inference.core.workflows.execution_engine.v1.dynamic_blocks.debug_logs import ( @@ -2507,7 +2509,9 @@ def describe_workflows_blocks( def get_workflow_schema( request: Request, ) -> WorkflowsBlocksSchemaDescription: - result = get_workflow_schema_description() + result = WorkflowsBlocksSchemaDescription( + schema=build_workflow_blocks_schema() + ) return gzip_response_if_requested(request, response=result) @app.post( diff --git a/inference/core/managers/base.py b/inference/core/managers/base.py index fe65367071..bbc59ac59b 100644 --- a/inference/core/managers/base.py +++ b/inference/core/managers/base.py @@ -223,6 +223,25 @@ def add_model( self._dispose_model_lock(model_id=resolved_identifier) raise error + def load_action_recognition_model( + self, model_id: str, api_key: Optional[str] = None, **kwargs + ): + """Load an action-recognition model the way every entry point loads it. + + Forwarder so the Workflow block reaches the loader through the models + port instead of importing + `inference.core.models.inference_models_adapters` directly. The import + stays function-local for the same reason it was in the block: loading + the adapters module is expensive. + """ + from inference.core.models.inference_models_adapters import ( + load_action_recognition_model, + ) + + return load_action_recognition_model( + model_id=model_id, api_key=api_key, **kwargs + ) + def record_request_metadata( self, model_id: str, diff --git a/inference/core/managers/decorators/base.py b/inference/core/managers/decorators/base.py index da36382735..1996cd2308 100644 --- a/inference/core/managers/decorators/base.py +++ b/inference/core/managers/decorators/base.py @@ -90,6 +90,13 @@ def add_model( service_secret=service_secret, ) + def load_action_recognition_model( + self, model_id: str, api_key: Optional[str] = None, **kwargs + ): + return self.model_manager.load_action_recognition_model( + model_id=model_id, api_key=api_key, **kwargs + ) + def record_request_metadata( self, model_id: str, diff --git a/inference/core/workflows/core_steps/models/roboflow/action_recognition/v1.py b/inference/core/workflows/core_steps/models/roboflow/action_recognition/v1.py index e1c23224aa..3c300dbd25 100644 --- a/inference/core/workflows/core_steps/models/roboflow/action_recognition/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/action_recognition/v1.py @@ -10,11 +10,11 @@ import numpy as np from pydantic import ConfigDict, Field, model_validator -from inference.core.models.action_recognition import merge_window_segments from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.models.foundation.segment_anything_common.streaming_video import ( normalise_class_names, ) +from inference.core.workflows.errors import WorkflowEnvironmentConfigurationError from inference.core.workflows.execution_engine.entities.base import ( ActionRecognitionPrediction, Batch, @@ -43,6 +43,8 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.utils.action_recognition import merge_window_segments from inference_models.models.base.action_recognition import WHOLE_VIDEO_MODE from inference_models.models.base.action_recognition import ( ActionRecognitionPrediction as ModelActionRecognitionPrediction, @@ -230,9 +232,11 @@ def __init__( self, api_key: Optional[str], step_execution_mode: StepExecutionMode, + model_manager: Optional[ModelsProvider] = None, ): self._api_key = api_key self._step_execution_mode = step_execution_mode + self._model_manager = model_manager self._model = None self._current_model_id: Optional[str] = None self._video_bookkeeping: "OrderedDict[str, _ActionRecognitionBookkeeping]" = ( @@ -242,7 +246,7 @@ def __init__( @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key", "step_execution_mode"] + return ["api_key", "step_execution_mode", "model_manager"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -250,13 +254,16 @@ def get_manifest(cls) -> Type[WorkflowBlockManifest]: def _get_model(self, model_id: str): if self._model is None or self._current_model_id != model_id: - # Imported here so loading the block does not pull the adapters - # module, and so both surfaces load a model id identically. - from inference.core.models.inference_models_adapters import ( - load_action_recognition_model, - ) - - self._model = load_action_recognition_model( + if self._model_manager is None: + raise WorkflowEnvironmentConfigurationError( + public_message=( + "This block loads its model through the Workflows models " + "provider. Supply `workflows_core.model_manager` in the " + "workflow init parameters." + ), + context="workflow_execution | step_execution | model_loading", + ) + self._model = self._model_manager.load_action_recognition_model( model_id=model_id, api_key=self._api_key ) self._current_model_id = model_id diff --git a/inference/core/workflows/execution_engine/v1/compiler/syntactic_parser.py b/inference/core/workflows/execution_engine/v1/compiler/syntactic_parser.py index befde36074..eee8a5db16 100644 --- a/inference/core/workflows/execution_engine/v1/compiler/syntactic_parser.py +++ b/inference/core/workflows/execution_engine/v1/compiler/syntactic_parser.py @@ -5,7 +5,6 @@ from pydantic import BaseModel, Field, create_model from typing_extensions import Annotated -from inference.core.entities.responses.workflows import WorkflowsBlocksSchemaDescription from inference.core.workflows.errors import WorkflowBlockError, WorkflowSyntaxError from inference.core.workflows.execution_engine.entities.base import InputType, JsonField from inference.core.workflows.execution_engine.introspection.blocks_loader import ( @@ -149,7 +148,11 @@ def clear_cache() -> None: _cached_workflow_schema.cache_clear() -def get_workflow_schema_description() -> WorkflowsBlocksSchemaDescription: +def get_workflow_schema() -> dict: + """The JSON schema of every available block manifest. + + Returns the raw schema; wrapping it in the HTTP response model is the + server's job (`http_api.py`), which is why the DTO import is gone. + """ env_state = _get_env_configuration_state() - schema = _cached_workflow_schema(env_state=env_state) - return WorkflowsBlocksSchemaDescription(schema=schema) + return _cached_workflow_schema(env_state=env_state) diff --git a/inference/core/workflows/prototypes/cache.py b/inference/core/workflows/prototypes/cache.py index 014752841b..a0a9820c07 100644 --- a/inference/core/workflows/prototypes/cache.py +++ b/inference/core/workflows/prototypes/cache.py @@ -10,9 +10,10 @@ class WorkflowsCache(Protocol): first. The server's implementation is Redis-backed when ``REDIS_HOST`` is set and - honours ``expire``. There is deliberately NO default implementation here: - see the Phase 4 preamble - the initializer stays bound to the server's - cache until Phase 9 injects it at the composition roots. + honours ``expire``. The standalone default is + ``inference.core.workflows.utils.in_memory_cache.InMemoryWorkflowsCache``; + every server composition root overrides it with ``workflows_core.cache`` + (see ``inference/core/interfaces/roboflow_platform_client.py``). """ def get(self, key: str) -> Any: ... diff --git a/inference/core/workflows/prototypes/models_provider.py b/inference/core/workflows/prototypes/models_provider.py index 593e7ef2fd..a21df5baf8 100644 --- a/inference/core/workflows/prototypes/models_provider.py +++ b/inference/core/workflows/prototypes/models_provider.py @@ -38,6 +38,10 @@ def add_model( **kwargs: Any, ) -> None: ... + def load_action_recognition_model( + self, model_id: str, api_key: Optional[str] = None, **kwargs: Any + ) -> Any: ... + def infer_from_request_sync( self, model_id: str, request: Any, **kwargs: Any ) -> Any: ... diff --git a/inference/core/workflows/utils/action_recognition.py b/inference/core/workflows/utils/action_recognition.py new file mode 100644 index 0000000000..c824062814 --- /dev/null +++ b/inference/core/workflows/utils/action_recognition.py @@ -0,0 +1,62 @@ +"""Copied verbatim from ``inference.core.models.action_recognition``. + +Pure logic: it unions one sliding window's segments into a timeline. Its only +dependencies are ``ActionRecognitionPrediction`` (re-exported by +``execution_engine.entities.base``) and ``inference_models``' ``merge_segment`` +- an allowed dependency - so the copy carries no server coupling. + +Pinned to the original by +``tests/workflows/unit_tests/utils/test_action_recognition_copy.py``, which +drives BOTH implementations over the same inputs: if you change one, change +both. +""" + +from typing import Any, List, Optional, Sequence + +from inference.core.workflows.execution_engine.entities.base import ( + ActionRecognitionPrediction, +) +from inference_models.models.base.action_recognition import merge_segment + + +def merge_window_segments( + timeline: List[ActionRecognitionPrediction], + frame_numbers: Sequence[int], + segments: List[Any], + id_vocabulary: Optional[List[str]], + stride: float, + class_filter: Optional[List[str]] = None, +) -> None: + """Union one window's segments into ``timeline``, in place. + + ``frame_numbers`` holds the source frame each sampled index came from, in + the order the model saw them. ``class_filter`` drops classes the caller + did not ask for. Classes outside ``id_vocabulary`` report ``-1``, which is + what an open-vocabulary answer gets. + """ + sample_count = len(frame_numbers) + if sample_count == 0: + return + class_ids = ( + {class_name: index for index, class_name in enumerate(id_vocabulary)} + if id_vocabulary is not None + else {} + ) + for segment in segments: + class_name = segment.class_name + if class_filter is not None and class_name not in class_filter: + continue + start_index = min(sample_count - 1, max(0, int(segment.start_frame_idx))) + end_index = min(sample_count - 1, max(0, int(segment.end_frame_idx))) + if start_index > end_index: + start_index, end_index = end_index, start_index + merge_segment( + timeline=timeline, + segment=ActionRecognitionPrediction( + start_frame_idx=frame_numbers[start_index], + end_frame_idx=frame_numbers[end_index], + class_name=class_name, + class_id=class_ids.get(class_name, -1), + ), + stride=stride, + ) diff --git a/tests/inference/unit_tests/core/interfaces/http/test_workflow_schema_route.py b/tests/inference/unit_tests/core/interfaces/http/test_workflow_schema_route.py new file mode 100644 index 0000000000..c73d768c79 --- /dev/null +++ b/tests/inference/unit_tests/core/interfaces/http/test_workflow_schema_route.py @@ -0,0 +1,53 @@ +"""The `/workflows/definition/schema` route keeps its name and OpenAPI +operation id across Phase 9's DTO repoint (round-3 defect 8).""" + +from unittest.mock import AsyncMock, MagicMock + +from starlette.testclient import TestClient + + +class _DummyInstrumentator: + def __init__(self, app, model_manager, endpoint="/metrics"): + self.app = app + self.model_manager = model_manager + self.endpoint = endpoint + + def set_stream_manager_client(self, stream_manager_client) -> None: + self.stream_manager_client = stream_manager_client + + +def _build_app(monkeypatch): + import inference.core.interfaces.http.http_api as http_api + + monkeypatch.setattr(http_api, "InferenceInstrumentator", _DummyInstrumentator) + monkeypatch.setattr( + http_api.usage_collector, "async_push_usage_payloads", AsyncMock() + ) + model_manager = MagicMock() + model_manager.pingback = None + model_manager.num_errors = 0 + return http_api.HttpInterface(model_manager=model_manager).app + + +def test_schema_route_keeps_its_operation_id(monkeypatch) -> None: + app = _build_app(monkeypatch) + route = next( + r + for r in app.routes + if getattr(r, "path", None) == "/workflows/definition/schema" + ) + assert route.name == "get_workflow_schema" + operation = app.openapi()["paths"]["/workflows/definition/schema"]["get"] + assert ( + operation["operationId"] + == "get_workflow_schema_workflows_definition_schema_get" + ) + + +def test_schema_route_returns_the_wrapped_schema(monkeypatch) -> None: + client = TestClient(_build_app(monkeypatch)) + response = client.get("/workflows/definition/schema") + assert response.status_code == 200 + body = response.json() + assert set(body) == {"schema"} + assert "$defs" in body["schema"] or "definitions" in body["schema"] diff --git a/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_v1.py b/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_v1.py index c20a288167..d5547de59d 100644 --- a/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_v1.py +++ b/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_v1.py @@ -11,6 +11,7 @@ import torch import inference.core.env as core_env +from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.deserializers import ( deserialize_action_recognition_prediction_kind, ) @@ -223,6 +224,7 @@ def test_get_model_wraps_hosted_cosmos3_reasoner(monkeypatch): block = ActionRecognitionModelBlockV1( api_key=None, step_execution_mode=StepExecutionMode.LOCAL, + model_manager=ModelManager(model_registry=MagicMock()), ) loaded = block._get_model(model_id="cosmos-3-edge") @@ -242,6 +244,7 @@ def test_get_model_rejects_model_without_video_classification_support(monkeypatc block = ActionRecognitionModelBlockV1( api_key=None, step_execution_mode=StepExecutionMode.LOCAL, + model_manager=ModelManager(model_registry=MagicMock()), ) with pytest.raises( diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index d1100ce456..510bc7c627 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 268 +# Count: 265 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env @@ -178,8 +178,6 @@ inference/core/workflows/core_steps/models/foundation/spacexai/v2.py inference.c inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.entities.requests.yolo_world inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/zai_vlm/v1.py inference.core.utils.image_utils -inference/core/workflows/core_steps/models/roboflow/action_recognition/v1.py inference.core.models.action_recognition -inference/core/workflows/core_steps/models/roboflow/action_recognition/v1.py inference.core.models.inference_models_adapters inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py inference.core.env inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1_tensor.py inference.core.env @@ -250,7 +248,6 @@ inference/core/workflows/core_steps/visualizations/common/fonts/__init__.py infe inference/core/workflows/execution_engine/entities/base.py inference.core.entities.responses.action_recognition inference/core/workflows/execution_engine/entities/base.py inference.core.env inference/core/workflows/execution_engine/entities/base.py inference.core.utils.image_utils -inference/core/workflows/execution_engine/v1/compiler/syntactic_parser.py inference.core.entities.responses.workflows inference/core/workflows/execution_engine/v1/core.py inference.core.env inference/core/workflows/execution_engine/v1/dynamic_blocks/block_assembler.py inference.core.env inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.env diff --git a/tests/workflows/unit_tests/execution_engine/compiler/test_workflow_schema_detections_property.py b/tests/workflows/unit_tests/execution_engine/compiler/test_workflow_schema_detections_property.py index e05da3b847..8912975f11 100644 --- a/tests/workflows/unit_tests/execution_engine/compiler/test_workflow_schema_detections_property.py +++ b/tests/workflows/unit_tests/execution_engine/compiler/test_workflow_schema_detections_property.py @@ -1,6 +1,6 @@ """Regression tests for workflow schema containing DetectionsProperty enum. -Ensures the schema returned by get_workflow_schema_description() includes +Ensures the schema returned by get_workflow_schema() includes area_px and area_converted in the DetectionsProperty enum (used by DetectionsPropertyExtract and other UQL operations). """ @@ -21,11 +21,10 @@ def clear_schema_cache(): def test_workflow_schema_includes_area_px_and_area_converted_in_detections_property(): """DetectionsProperty enum in schema must include area_px and area_converted.""" from inference.core.workflows.execution_engine.v1.compiler.syntactic_parser import ( - get_workflow_schema_description, + get_workflow_schema, ) - desc = get_workflow_schema_description() - schema = desc.schema + schema = get_workflow_schema() defs = schema.get("$defs", schema.get("definitions", {})) detections_property_schema = defs.get("DetectionsProperty") diff --git a/tests/workflows/unit_tests/utils/test_action_recognition_copy.py b/tests/workflows/unit_tests/utils/test_action_recognition_copy.py new file mode 100644 index 0000000000..030304b975 --- /dev/null +++ b/tests/workflows/unit_tests/utils/test_action_recognition_copy.py @@ -0,0 +1,181 @@ +"""The copied `merge_window_segments` must BEHAVE identically to the server's. + +Copied rather than injected because it is pure logic over +`ActionRecognitionPrediction` (re-exported by execution_engine/entities/base.py) +and `inference_models`' `merge_segment` - an allowed dependency. Source +equality alone never calls either implementation, so both are driven over the +same inputs, including the in-place multi-window case. +""" + +import ast +import inspect +import pathlib +from types import SimpleNamespace + +import pytest + +from inference.core.models.action_recognition import ( + merge_window_segments as server_implementation, +) +from inference.core.workflows.utils.action_recognition import merge_window_segments + + +def test_signature_matches_the_server_implementation() -> None: + assert inspect.signature(merge_window_segments) == inspect.signature( + server_implementation + ) + + +def _segment(class_name, start, end, confidence=0.9): + return SimpleNamespace( + class_name=class_name, + start_frame_idx=start, + end_frame_idx=end, + confidence=confidence, + ) + + +CASES = [ + ([0, 1, 2, 3], [_segment("walk", 0, 2)], ["walk", "run"], 1.0, None), + ([0, 1, 2, 3], [_segment("run", 1, 3)], ["walk", "run"], 0.5, None), + ([0, 1, 2, 3], [_segment("fly", 0, 1)], ["walk", "run"], 1.0, None), + ([0, 1, 2, 3], [_segment("walk", 0, 2)], ["walk"], 1.0, ["run"]), + ([0, 1, 2, 3], [_segment("walk", 3, 0)], ["walk"], 1.0, None), + ([0, 1, 2, 3], [_segment("walk", -5, 99)], ["walk"], 1.0, None), + ([], [_segment("walk", 0, 1)], ["walk"], 1.0, None), + ([0, 1], [], ["walk"], 1.0, None), + ([0, 1, 2], [_segment("walk", 0, 1)], None, 1.0, None), +] + + +def _dump(timeline): + return [p.model_dump() for p in timeline] + + +@pytest.mark.parametrize("frame_numbers,segments,vocabulary,stride,class_filter", CASES) +def test_behaviour_matches_the_server_implementation( + frame_numbers, segments, vocabulary, stride, class_filter +) -> None: + ours, theirs = [], [] + for function, timeline in ( + (merge_window_segments, ours), + (server_implementation, theirs), + ): + function( + timeline=timeline, + frame_numbers=frame_numbers, + segments=segments, + id_vocabulary=vocabulary, + stride=stride, + class_filter=class_filter, + ) + assert _dump(ours) == _dump(theirs) + + +def test_repeated_calls_union_into_the_same_timeline_identically() -> None: + # The function mutates `timeline` in place across windows; a copy that + # merges differently on the second window would pass a single-call test. + ours, theirs = [], [] + for window in ([_segment("walk", 0, 1)], [_segment("walk", 1, 2)]): + for function, timeline in ( + (merge_window_segments, ours), + (server_implementation, theirs), + ): + function( + timeline=timeline, + frame_numbers=[0, 1, 2], + segments=window, + id_vocabulary=["walk"], + stride=1.0, + ) + assert _dump(ours) == _dump(theirs) + + +def test_the_module_imports_only_allowed_packages() -> None: + path = ( + pathlib.Path(__file__).resolve().parents[4] + / "inference/core/workflows/utils/action_recognition.py" + ) + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + module = None + if isinstance(node, ast.ImportFrom): + module = node.module or "" + elif isinstance(node, ast.Import): + module = node.names[0].name + if module and module.startswith("inference."): + assert module.startswith("inference.core.workflows"), module + + +def test_syntactic_parser_returns_a_plain_dict() -> None: + from inference.core.workflows.execution_engine.v1.compiler.syntactic_parser import ( + get_workflow_schema, + ) + + schema = get_workflow_schema() + assert isinstance(schema, dict) + assert "$defs" in schema or "definitions" in schema + + +def test_syntactic_parser_does_not_import_server_entities() -> None: + path = ( + pathlib.Path(__file__).resolve().parents[4] + / "inference/core/workflows/execution_engine/v1/compiler/syntactic_parser.py" + ) + tree = ast.parse(path.read_text(encoding="utf-8")) + modules = { + n.module for n in ast.walk(tree) if isinstance(n, ast.ImportFrom) and n.module + } + assert not any(m.startswith("inference.core.entities") for m in modules) + + +def test_models_provider_declares_the_action_recognition_loader() -> None: + from inference.core.workflows.prototypes.models_provider import ModelsProvider + + assert hasattr(ModelsProvider, "load_action_recognition_model") + + +def test_model_manager_implements_it() -> None: + from inference.core.managers.base import ModelManager + + assert hasattr(ModelManager, "load_action_recognition_model") + + +def test_model_manager_decorator_forwards_it() -> None: + """`ModelManagerDecorator` has no `__getattr__` and overrides `_models` + to raise, so an inherited method would not reach the inner manager - the + forwarder must be explicit (Phase 11's D2 makes the same point).""" + from unittest.mock import MagicMock + + from inference.core.managers.decorators.base import ModelManagerDecorator + + inner = MagicMock() + decorator = ModelManagerDecorator(model_manager=inner) + decorator.load_action_recognition_model(model_id="ar/1", api_key="k") + inner.load_action_recognition_model.assert_called_once_with( + model_id="ar/1", api_key="k" + ) + + +def test_the_action_recognition_block_loads_through_the_port() -> None: + from inference.core.workflows.core_steps.common.entities import StepExecutionMode + from inference.core.workflows.core_steps.models.roboflow.action_recognition.v1 import ( + ActionRecognitionModelBlockV1, + ) + + class _Manager: + def __init__(self): + self.calls = [] + + def load_action_recognition_model(self, model_id, api_key=None, **kwargs): + self.calls.append((model_id, api_key)) + return object() + + manager = _Manager() + block = ActionRecognitionModelBlockV1( + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + model_manager=manager, + ) + assert block._get_model("cosmos-3-edge") is not None + assert manager.calls == [("cosmos-3-edge", "k")] From 818569f68c9d9ac4dde0a9b55536be448516aa93 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:04:02 +0200 Subject: [PATCH 18/86] test(workflows): cross-check the isolation probe against the lint baseline Parses the probe's blocked_import_attempts JSON for both tensor modes and compares each name against the baseline at ancestor granularity, failing when extraction finds nothing. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- scripts/phase9_probe_baseline_crosscheck.py | 69 +++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 scripts/phase9_probe_baseline_crosscheck.py diff --git a/scripts/phase9_probe_baseline_crosscheck.py b/scripts/phase9_probe_baseline_crosscheck.py new file mode 100644 index 0000000000..be3140adee --- /dev/null +++ b/scripts/phase9_probe_baseline_crosscheck.py @@ -0,0 +1,69 @@ +"""Cross-check the isolation probe's blocked list against the static baseline. + +The probe blocks at the SHALLOWEST non-allowed ancestor, so +`inference.core.entities` legitimately has no exact baseline row - its leaves +do. The comparison is therefore "exact name OR dotted descendant", with the +`(exec'd string)` suffix normalised away. + +Fails loudly if it cannot find a `blocked_import_attempts` block, or finds one +that parses to zero names: a silently empty extraction would make the whole +check vacuous. +""" + +import argparse +import pathlib +import re +import sys + +BLOCK = re.compile(r'"blocked_import_attempts":\s*\[(.*?)\]', flags=re.S) +NAME = re.compile(r'"([^"]+)"') + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("probe_output") + parser.add_argument("baseline") + parser.add_argument("--expected-blocks", type=int, default=2, + help="one blocked_import_attempts block per tensor mode") + args = parser.parse_args() + + text = pathlib.Path(args.probe_output).read_text(encoding="utf-8") + blocks = BLOCK.findall(text) + if len(blocks) != args.expected_blocks: + print( + f"FAIL: found {len(blocks)} blocked_import_attempts blocks, " + f"expected {args.expected_blocks} - the probe output shape changed", + file=sys.stderr, + ) + return 2 + blocked = set() + for raw in blocks: + names = NAME.findall(raw) + if not names: + print("FAIL: a blocked_import_attempts block parsed to zero names", + file=sys.stderr) + return 2 + blocked.update(names) + blocked = sorted(blocked) + + modules = set() + for row in pathlib.Path(args.baseline).read_text(encoding="utf-8").splitlines(): + if not row or row.startswith("#") or "\t" not in row: + continue + modules.add(row.split("\t", 1)[1].strip().split(" ")[0]) + + def covered(name: str) -> bool: + return any(m == name or m.startswith(name + ".") for m in modules) + + uncovered = [name for name in blocked if not covered(name)] + print("blocked names:", blocked) + print("uncovered by the static baseline:", uncovered) + if uncovered: + print("FAIL: the probe blocked modules the lint cannot see", file=sys.stderr) + return 1 + print("OK: every blocked module is covered by a baseline row or its descendants") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 0d9b9dd098b4f14ca5dd41c3291efc810448a3cc Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:57:00 +0200 Subject: [PATCH 19/86] fix(workflows): validate-before-write in the remaining Phase 9 codemods; single duplicate-block warning The endpoint-type, test-side and exception-import codemods now compute every rewrite in memory and write only after the aggregate counts validate, matching the move and platform-client codemods; regression tests prove a count mismatch writes nothing. The pre-resolution dynamic-block collection no longer repeats the duplicate-definition warning, and the standalone inner-workflow resolver no longer recommends a "local" workspace it refuses. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../execution_engine/v1/compiler/core.py | 3 +- .../dynamic_blocks_collection.py | 22 +- .../v1/inner_workflow/reference_resolution.py | 4 +- scripts/phase9_endpoint_type_constant.py | 34 ++- scripts/phase9_platform_client_tests.py | 31 +- scripts/phase9_platform_errors_imports.py | 34 ++- .../test_duplicate_dynamic_block_warning.py | 85 ++++++ .../test_phase9_codemods_preserve_crlf.py | 9 +- ...t_phase9_codemods_validate_before_write.py | 268 ++++++++++++++++++ 9 files changed, 458 insertions(+), 32 deletions(-) create mode 100644 tests/workflows/unit_tests/execution_engine/compiler/test_duplicate_dynamic_block_warning.py create mode 100644 tests/workflows/unit_tests/scripts/test_phase9_codemods_validate_before_write.py diff --git a/inference/core/workflows/execution_engine/v1/compiler/core.py b/inference/core/workflows/execution_engine/v1/compiler/core.py index f381797af8..cabfbbb87f 100644 --- a/inference/core/workflows/execution_engine/v1/compiler/core.py +++ b/inference/core/workflows/execution_engine/v1/compiler/core.py @@ -179,7 +179,8 @@ def compile_workflow_graph( init_parameters = {} pre_resolution_dynamic_blocks_definitions = ( collect_dynamic_blocks_definitions_from_workflow_definition( - workflow_definition=workflow_definition + workflow_definition=workflow_definition, + warn_on_duplicates=False, ) ) cacheable = not _is_resolver_dependent( diff --git a/inference/core/workflows/execution_engine/v1/inner_workflow/dynamic_blocks_collection.py b/inference/core/workflows/execution_engine/v1/inner_workflow/dynamic_blocks_collection.py index 7f05e4e495..5c430701a9 100644 --- a/inference/core/workflows/execution_engine/v1/inner_workflow/dynamic_blocks_collection.py +++ b/inference/core/workflows/execution_engine/v1/inner_workflow/dynamic_blocks_collection.py @@ -34,6 +34,7 @@ def _dynamic_block_type(definition: Dict[str, Any]) -> Optional[str]: def collect_dynamic_blocks_definitions_from_workflow_definition( workflow_definition: Dict[str, Any], + warn_on_duplicates: bool = True, ) -> List[Any]: """Collect dynamic block definitions from a workflow and embedded inner workflows. @@ -42,15 +43,19 @@ def collect_dynamic_blocks_definitions_from_workflow_definition( steps via ``workflow_definition``. Dispatched steps are skipped. When the same ``manifest.block_type`` appears more than once, the first occurrence - is kept (parent definitions win over nested children) and a warning is logged for - each skipped duplicate. Definitions without a ``block_type`` are still included and - are not deduplicated. + is kept (parent definitions win over nested children) and, when ``warn_on_duplicates`` + is ``True``, a warning is logged for each skipped duplicate. Definitions without a + ``block_type`` are still included and are not deduplicated. Malformed entries (non-list ``dynamic_blocks_definitions``, non-dict list items) are passed through as-is so :func:`compile_dynamic_blocks` can validate them. Args: workflow_definition: Raw workflow JSON (``steps``, optional nested definitions). + warn_on_duplicates: Whether to log a warning for each skipped duplicate. The + compiler collects definitions twice per cold compile (once before + normalisation, once after); the pre-resolution call passes ``False`` so + the warning is logged only once. Returns: Merged list of dynamic block definition dicts in discovery order. @@ -65,11 +70,12 @@ def append_definition(definition: Any) -> None: if block_type is not None: if block_type in seen_block_types: - logger.warning( - "Skipping duplicate dynamic block definition for block_type=%r; " - "using the first definition collected while compiling the workflow.", - block_type, - ) + if warn_on_duplicates: + logger.warning( + "Skipping duplicate dynamic block definition for block_type=%r; " + "using the first definition collected while compiling the workflow.", + block_type, + ) return seen_block_types.add(block_type) diff --git a/inference/core/workflows/execution_engine/v1/inner_workflow/reference_resolution.py b/inference/core/workflows/execution_engine/v1/inner_workflow/reference_resolution.py index 0898ac75e7..b7b5bdb513 100644 --- a/inference/core/workflows/execution_engine/v1/inner_workflow/reference_resolution.py +++ b/inference/core/workflows/execution_engine/v1/inner_workflow/reference_resolution.py @@ -46,8 +46,8 @@ def default_inner_workflow_spec_resolver( "Resolving an `inner_workflow` step by workflow id requires access to the " "Roboflow API, which is not available in this installation of `workflows`. " f"Inject `{WORKFLOWS_CORE_INNER_WORKFLOW_SPEC_RESOLVER}` in workflow " - 'init_parameters, or use `workflow_workspace_id` `"local"` with a matching ' - "on-disk workflow definition." + "init_parameters, or define the inner workflow inline instead of " + "referencing it by id." ), context="workflow_compilation | inner_workflow_spec_resolution", ) diff --git a/scripts/phase9_endpoint_type_constant.py b/scripts/phase9_endpoint_type_constant.py index 444f68e144..8dbf5881a5 100644 --- a/scripts/phase9_endpoint_type_constant.py +++ b/scripts/phase9_endpoint_type_constant.py @@ -4,12 +4,18 @@ the attribute accesses through `ast`, counts them, re-parses its own output and verifies the post-state (no `ModelEndpointType` reference left in the file). Idempotent: a file with no matches contributes zero and still passes. + +Every file's rewrite is computed fully in memory first (`patch()` never +writes); `main()` validates the aggregate `--expected*` counts against the +in-memory results and only then writes - a count mismatch never leaves any +file half-rewritten. """ import argparse import ast import pathlib import sys +from typing import Optional NEW_IMPORT = ( "from inference.core.workflows.prototypes.models_provider import (\n" @@ -18,7 +24,12 @@ ) -def patch(path: pathlib.Path) -> tuple[int, int]: +def patch(path: pathlib.Path) -> tuple[int, int, Optional[str]]: + """Compute the rewrite for `path` without writing anything. + + Returns `(imports, usages, updated)`. `updated` is `None` when the file is + already repointed (nothing to write). + """ source = path.read_text(encoding="utf-8") newline = "\r\n" if "\r\n" in source else "\n" lines = source.split(newline) @@ -43,12 +54,15 @@ def patch(path: pathlib.Path) -> tuple[int, int]: import_span = (node.lineno, node.end_lineno) if not usages and import_span is None: print(f"SKIP (already repointed): {path}") - return 0, 0 + return 0, 0, None for node in sorted(usages, key=lambda n: (n.lineno, n.col_offset), reverse=True): index = node.lineno - 1 line = lines[index] - assert line[node.col_offset : node.end_col_offset] == "ModelEndpointType.CORE_MODEL" + assert ( + line[node.col_offset : node.end_col_offset] + == "ModelEndpointType.CORE_MODEL" + ) lines[index] = ( line[: node.col_offset] + "CORE_MODEL_ENDPOINT_TYPE" @@ -62,8 +76,7 @@ def patch(path: pathlib.Path) -> tuple[int, int]: print(f"FAIL: {path} still mentions ModelEndpointType", file=sys.stderr) raise SystemExit(2) ast.parse(updated) - path.write_text(updated, encoding="utf-8") - return (1 if import_span else 0), len(usages) + return (1 if import_span else 0), len(usages), updated def main() -> int: @@ -73,16 +86,25 @@ def main() -> int: parser.add_argument("--expected-usages", type=int, required=True) args = parser.parse_args() total_i = total_u = 0 + pending_writes = [] for name in args.files: - i, u = patch(pathlib.Path(name)) + path = pathlib.Path(name) + i, u, updated = patch(path) total_i += i total_u += u print(f"{i:3d} imports {u:3d} usages {name}") + if updated is not None: + pending_writes.append((path, updated)) print(f"TOTAL {total_i} imports, {total_u} usages") print("POST-STATE verified: no ModelEndpointType reference remains") if total_i != args.expected_imports or total_u != args.expected_usages: print("FAIL: counts do not match", file=sys.stderr) return 1 + + # Validation above passed for the aggregate and every individual file - + # only now do we write. + for path, updated in pending_writes: + path.write_text(updated, encoding="utf-8") return 0 diff --git a/scripts/phase9_platform_client_tests.py b/scripts/phase9_platform_client_tests.py index 50115a4dbd..21c3d45f72 100644 --- a/scripts/phase9_platform_client_tests.py +++ b/scripts/phase9_platform_client_tests.py @@ -28,6 +28,11 @@ def _reset_platform_client(): between tests. Tests that seed a response or assert on the call use `platform_client.post_mock` (a `unittest.mock.Mock`), which keeps every existing `mock_post.*` assertion verbatim - see Task 9.4 Step 8. + +Every file's rewrite is computed fully in memory first (`patch()` never +writes); `main()` validates the aggregate `--expected-calls` / +`--expected-constructions` counts against the in-memory results and only +then writes - a count mismatch never leaves any file half-rewritten. """ import argparse @@ -35,6 +40,7 @@ def _reset_platform_client(): import collections import pathlib import sys +from typing import Optional CHAIN = { "run_gpt_4v_llm_prompting", @@ -147,7 +153,12 @@ def _affected_calls(tree, touched, classes): return list(helper_calls.values()), list(constructions.values()) -def patch(path: pathlib.Path, touched, classes): +def patch(path: pathlib.Path, touched, classes) -> tuple[int, int, Optional[str]]: + """Compute the rewrite for `path` without writing anything. + + Returns `(edited_calls, edited_ctors, updated)`. `updated` is `None` when + nothing needed editing (nothing to write). + """ source = path.read_bytes().decode("utf-8") newline = "\r\n" if "\r\n" in source else "\n" lines = source.split(newline) @@ -195,10 +206,9 @@ def patch(path: pathlib.Path, touched, classes): out.insert(anchor.end_lineno, preamble.replace("\n", newline)) updated = newline.join(out) ast.parse(updated) - if edits: - with open(path, "w", encoding="utf-8", newline="") as f: - f.write(updated) - return edited_calls, edited_ctors + if not edits: + return edited_calls, edited_ctors, None + return edited_calls, edited_ctors, updated def main() -> int: @@ -216,15 +226,24 @@ def main() -> int: touched = _touched_modules(args.touched) classes = set(args.classes.split(",")) total_calls = total_ctors = 0 + pending_writes = [] for name in args.files: - calls, ctors = patch(pathlib.Path(name), touched, classes) + path = pathlib.Path(name) + calls, ctors, updated = patch(path, touched, classes) total_calls += calls total_ctors += ctors print(f"{calls:3d} calls {ctors:3d} constructions edited {name}") + if updated is not None: + pending_writes.append((path, updated)) print(f"TOTAL {total_calls} helper calls, {total_ctors} block constructions edited") if total_calls != args.expected_calls or total_ctors != args.expected_constructions: print("FAIL: counts do not match", file=sys.stderr) return 1 + + # Validation above passed for the aggregate - only now do we write. + for path, updated in pending_writes: + with open(path, "w", encoding="utf-8", newline="") as f: + f.write(updated) return 0 diff --git a/scripts/phase9_platform_errors_imports.py b/scripts/phase9_platform_errors_imports.py index 16f56a078f..25825add30 100644 --- a/scripts/phase9_platform_errors_imports.py +++ b/scripts/phase9_platform_errors_imports.py @@ -3,12 +3,18 @@ Names are unchanged, so this rewrites only the module part of the ImportFrom node - which is why the function-local import in email_notification/v2.py keeps its indentation. Refuses any name that did not move. + +Every file's rewrite is computed fully in memory first (`patch()` never +writes); `main()` validates the aggregate `--expected` count against the +in-memory results and only then writes - a count mismatch never leaves any +file half-rewritten. """ import argparse import ast import pathlib import sys +from typing import Optional MOVED = { "RoboflowAPIRequestError", @@ -20,7 +26,12 @@ OLD_MODULE = "inference.core.exceptions" -def patch(path: pathlib.Path) -> int: +def patch(path: pathlib.Path) -> tuple[int, Optional[str]]: + """Compute the rewrite for `path` without writing anything. + + Returns `(count, updated)`. `updated` is `None` when the file is already + repointed (nothing to write). + """ source = path.read_text(encoding="utf-8") newline = "\r\n" if "\r\n" in source else "\n" lines = source.split(newline) @@ -31,11 +42,14 @@ def patch(path: pathlib.Path) -> int: ] if not nodes: print(f"SKIP (already repointed): {path}") - return 0 + return 0, None for node in nodes: unknown = [a.name for a in node.names if a.name not in MOVED] if unknown: - print(f"FAIL: {path}:{node.lineno} imports unmoved names {unknown}", file=sys.stderr) + print( + f"FAIL: {path}:{node.lineno} imports unmoved names {unknown}", + file=sys.stderr, + ) raise SystemExit(2) for node in sorted(nodes, key=lambda n: n.lineno, reverse=True): # Replace only the module text; the `import ...` clause and the @@ -48,8 +62,7 @@ def patch(path: pathlib.Path) -> int: if OLD_MODULE in updated: print(f"FAIL: {path} still imports {OLD_MODULE}", file=sys.stderr) raise SystemExit(2) - path.write_text(updated, encoding="utf-8") - return len(nodes) + return len(nodes), updated def main() -> int: @@ -58,15 +71,24 @@ def main() -> int: parser.add_argument("--expected", type=int, required=True) args = parser.parse_args() total = 0 + pending_writes = [] for name in args.files: - count = patch(pathlib.Path(name)) + path = pathlib.Path(name) + count, updated = patch(path) total += count print(f"{count:3d} {name}") + if updated is not None: + pending_writes.append((path, updated)) print(f"TOTAL {total} import statements repointed") print(f"POST-STATE verified: no {OLD_MODULE} import remains in these files") if total != args.expected: print("FAIL: count does not match", file=sys.stderr) return 1 + + # Validation above passed for the aggregate and every individual file - + # only now do we write. + for path, updated in pending_writes: + path.write_text(updated, encoding="utf-8") return 0 diff --git a/tests/workflows/unit_tests/execution_engine/compiler/test_duplicate_dynamic_block_warning.py b/tests/workflows/unit_tests/execution_engine/compiler/test_duplicate_dynamic_block_warning.py new file mode 100644 index 0000000000..d2ec6cc448 --- /dev/null +++ b/tests/workflows/unit_tests/execution_engine/compiler/test_duplicate_dynamic_block_warning.py @@ -0,0 +1,85 @@ +"""Phase 9 final fix wave, F2: `compile_workflow_graph` used to collect dynamic +block definitions twice on a cold compile - once before normalisation (to feed +the cache-bypass predicate and `ensure_dynamic_blocks_allowed` on the +cache-hit path), once after (`apply_collected_dynamic_blocks_definitions_to_workflow_root`) +- so a definition with a duplicate `block_type` logged the "skipping duplicate" +warning twice. The pre-resolution call now passes `warn_on_duplicates=False`, +so the warning is only ever logged once, by the post-normalisation collection. +""" + +from unittest import mock + +from inference.core.workflows.execution_engine.v1.compiler import core as compiler_core +from inference.core.workflows.execution_engine.v1.inner_workflow import ( + dynamic_blocks_collection, +) + + +def _dynamic_block_definition(block_type: str) -> dict: + return { + "type": "DynamicBlockDefinition", + "manifest": { + "type": "ManifestDescription", + "block_type": block_type, + "inputs": {}, + "outputs": {}, + }, + "code": { + "type": "PythonCode", + "run_function_code": "def run(self): return {}", + }, + } + + +@mock.patch.object(compiler_core, "compile_dynamic_blocks") +@mock.patch.object(compiler_core, "inline_inner_workflow_steps") +@mock.patch.object( + compiler_core, + "validate_inner_workflow_composition_from_raw_workflow_definition", +) +@mock.patch.object(compiler_core, "parse_workflow_definition") +@mock.patch.object(compiler_core, "prepare_execution_graph") +@mock.patch.object(compiler_core, "validate_workflow_specification") +@mock.patch.object(compiler_core, "load_kinds_deserializers") +@mock.patch.object(compiler_core, "load_kinds_serializers") +@mock.patch.object(compiler_core, "load_initializers") +@mock.patch.object(compiler_core, "load_workflow_blocks", return_value=[]) +def test_duplicate_dynamic_block_definition_warns_exactly_once_on_cold_compile( + _load_blocks, + _load_initializers, + _load_serializers, + _load_deserializers, + _validate_spec, + _prepare_graph, + parse_workflow_definition, + _validate_composition, + inline_inner_workflow_steps, + compile_dynamic_blocks, +) -> None: + duplicated_block_type = "DuplicatedBlock" + workflow_definition = { + "version": "1.0", + "inputs": [], + "dynamic_blocks_definitions": [ + _dynamic_block_definition(duplicated_block_type), + _dynamic_block_definition(duplicated_block_type), + ], + "steps": [], + "outputs": [], + } + + compile_dynamic_blocks.return_value = [] + parse_workflow_definition.return_value = mock.Mock( + steps=[], + inputs=[], + outputs=[], + ) + inline_inner_workflow_steps.side_effect = lambda definition, **_: definition + + with mock.patch.object(dynamic_blocks_collection, "logger") as mocked_logger: + compiler_core.compile_workflow_graph( + workflow_definition=workflow_definition, + init_parameters={}, + ) + + assert mocked_logger.warning.call_count == 1 diff --git a/tests/workflows/unit_tests/scripts/test_phase9_codemods_preserve_crlf.py b/tests/workflows/unit_tests/scripts/test_phase9_codemods_preserve_crlf.py index cf546c1b1f..90faf21e70 100644 --- a/tests/workflows/unit_tests/scripts/test_phase9_codemods_preserve_crlf.py +++ b/tests/workflows/unit_tests/scripts/test_phase9_codemods_preserve_crlf.py @@ -79,11 +79,15 @@ def test_platform_client_transform_preserves_crlf_line_endings(tmp_path): def test_test_codemod_preserves_crlf_and_resolves_origins_correctly(tmp_path): path = tmp_path / "test_fixture.py" - path.write_bytes(TEST_SOURCE.encode("utf-8")) + original = TEST_SOURCE.encode("utf-8") + path.write_bytes(original) - calls, ctors = test_codemod.patch( + # `patch()` only computes the rewrite in memory - it does not write - so + # the source file on disk must be untouched after the call. + calls, ctors, updated = test_codemod.patch( path, touched={"fake.touched.module"}, classes={"SomeBlock"} ) + assert path.read_bytes() == original # F3(a): the alias's ORIGINAL imported name ("execute_claude_request") is in # CHAIN, not the local alias ("aliased_call") - the call must still be edited. @@ -92,7 +96,6 @@ def test_test_codemod_preserves_crlf_and_resolves_origins_correctly(tmp_path): # is not in `touched` - the construction must be left alone. assert ctors == 0 - updated = path.read_bytes().decode("utf-8") assert "platform_client=platform_client" in updated assert 'SomeBlock(some_kwarg="z")' in updated # untouched: byte-for-byte unchanged _assert_all_crlf(updated) diff --git a/tests/workflows/unit_tests/scripts/test_phase9_codemods_validate_before_write.py b/tests/workflows/unit_tests/scripts/test_phase9_codemods_validate_before_write.py new file mode 100644 index 0000000000..26c4361d22 --- /dev/null +++ b/tests/workflows/unit_tests/scripts/test_phase9_codemods_validate_before_write.py @@ -0,0 +1,268 @@ +"""Phase 9 final fix wave, F1: `phase9_endpoint_type_constant.py`, +`phase9_platform_client_tests.py` and `phase9_platform_errors_imports.py` used +to write each file as soon as its own `patch()` ran, and only check the +aggregate `--expected*` totals in `main()` afterwards - a wrong count left +earlier files rewritten with a non-zero exit code. All three now compute every +file's rewrite in memory, validate the aggregate counts, and only then write - +matching `scripts/phase9_move_roboflow_plugin.py` and +`scripts/phase9_platform_client.py`. + +Each script gets a wrong-count case (asserts nothing is written) and a +happy-path case (asserts the file changed), driven through `main()` exactly +as the CLI is: `subprocess.run([sys.executable, script, ...])`. +""" + +import subprocess +import sys +from pathlib import Path + +import scripts.phase9_endpoint_type_constant as endpoint_codemod +import scripts.phase9_platform_client_tests as client_tests_codemod +import scripts.phase9_platform_errors_imports as errors_imports_codemod + + +def _run(script_path: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(script_path), *args], + capture_output=True, + text=True, + ) + + +# --- phase9_endpoint_type_constant.py ------------------------------------- + +ENDPOINT_SOURCE = ( + "from inference.core.roboflow_api import ModelEndpointType\n" + "\n" + "\n" + "def endpoint_type():\n" + " return ModelEndpointType.CORE_MODEL\n" +) + + +def test_endpoint_codemod_wrong_expected_count_writes_nothing(tmp_path): + target = tmp_path / "module.py" + target.write_text(ENDPOINT_SOURCE, encoding="utf-8") + before = target.read_bytes() + + result = _run( + Path(endpoint_codemod.__file__), + str(target), + "--expected-imports", + "0", # wrong: the fixture has 1 + "--expected-usages", + "1", + ) + + assert result.returncode != 0 + assert target.read_bytes() == before + + +def test_endpoint_codemod_happy_path_writes_and_exits_zero(tmp_path): + target = tmp_path / "module.py" + target.write_text(ENDPOINT_SOURCE, encoding="utf-8") + before = target.read_bytes() + + result = _run( + Path(endpoint_codemod.__file__), + str(target), + "--expected-imports", + "1", + "--expected-usages", + "1", + ) + + assert result.returncode == 0 + after = target.read_bytes() + assert after != before + assert b"ModelEndpointType" not in after + assert b"CORE_MODEL_ENDPOINT_TYPE" in after + + +def test_endpoint_codemod_second_run_is_a_no_op(tmp_path): + target = tmp_path / "module.py" + target.write_text(ENDPOINT_SOURCE, encoding="utf-8") + _run( + Path(endpoint_codemod.__file__), + str(target), + "--expected-imports", + "1", + "--expected-usages", + "1", + ) + migrated = target.read_bytes() + + result = _run( + Path(endpoint_codemod.__file__), + str(target), + "--expected-imports", + "0", + "--expected-usages", + "0", + ) + + assert result.returncode == 0 + assert "SKIP (already repointed)" in result.stdout + assert target.read_bytes() == migrated + + +# --- phase9_platform_errors_imports.py ------------------------------------- + +ERRORS_SOURCE = ( + "from inference.core.exceptions import RoboflowAPIRequestError\n" + "\n" + "\n" + "def use():\n" + " raise RoboflowAPIRequestError()\n" +) + + +def test_errors_imports_codemod_wrong_expected_count_writes_nothing(tmp_path): + target = tmp_path / "module.py" + target.write_text(ERRORS_SOURCE, encoding="utf-8") + before = target.read_bytes() + + result = _run( + Path(errors_imports_codemod.__file__), + str(target), + "--expected", + "0", # wrong: the fixture has 1 + ) + + assert result.returncode != 0 + assert target.read_bytes() == before + + +def test_errors_imports_codemod_happy_path_writes_and_exits_zero(tmp_path): + target = tmp_path / "module.py" + target.write_text(ERRORS_SOURCE, encoding="utf-8") + before = target.read_bytes() + + result = _run( + Path(errors_imports_codemod.__file__), + str(target), + "--expected", + "1", + ) + + assert result.returncode == 0 + after = target.read_bytes() + assert after != before + assert b"inference.core.exceptions" not in after + assert b"inference.core.workflows.prototypes.platform_errors" in after + + +def test_errors_imports_codemod_second_run_is_a_no_op(tmp_path): + target = tmp_path / "module.py" + target.write_text(ERRORS_SOURCE, encoding="utf-8") + _run(Path(errors_imports_codemod.__file__), str(target), "--expected", "1") + migrated = target.read_bytes() + + result = _run(Path(errors_imports_codemod.__file__), str(target), "--expected", "0") + + assert result.returncode == 0 + assert "SKIP (already repointed)" in result.stdout + assert target.read_bytes() == migrated + + +# --- phase9_platform_client_tests.py ---------------------------------------- + +CLIENT_TEST_SOURCE = ( + "import pytest\n" + "\n" + "from fake.touched.module import execute_claude_request as aliased_call\n" + "\n" + "\n" + "def test_aliased_call_gets_client():\n" + ' aliased_call(roboflow_api_key="y")\n' +) + + +def _touched_file(tmp_path: Path) -> Path: + touched = tmp_path / "touched.txt" + touched.write_text("fake/touched/module.py\n", encoding="utf-8") + return touched + + +def test_client_tests_codemod_wrong_expected_count_writes_nothing(tmp_path): + target = tmp_path / "test_fixture.py" + target.write_text(CLIENT_TEST_SOURCE, encoding="utf-8") + before = target.read_bytes() + touched = _touched_file(tmp_path) + + result = _run( + Path(client_tests_codemod.__file__), + str(target), + "--touched", + str(touched), + "--classes", + "SomeUnusedClass", + "--expected-calls", + "0", # wrong: the fixture has 1 + "--expected-constructions", + "0", + ) + + assert result.returncode != 0 + assert target.read_bytes() == before + + +def test_client_tests_codemod_happy_path_writes_and_exits_zero(tmp_path): + target = tmp_path / "test_fixture.py" + target.write_text(CLIENT_TEST_SOURCE, encoding="utf-8") + before = target.read_bytes() + touched = _touched_file(tmp_path) + + result = _run( + Path(client_tests_codemod.__file__), + str(target), + "--touched", + str(touched), + "--classes", + "SomeUnusedClass", + "--expected-calls", + "1", + "--expected-constructions", + "0", + ) + + assert result.returncode == 0 + after = target.read_bytes() + assert after != before + assert b"platform_client=platform_client" in after + + +def test_client_tests_codemod_second_run_is_a_no_op(tmp_path): + target = tmp_path / "test_fixture.py" + target.write_text(CLIENT_TEST_SOURCE, encoding="utf-8") + touched = _touched_file(tmp_path) + _run( + Path(client_tests_codemod.__file__), + str(target), + "--touched", + str(touched), + "--classes", + "SomeUnusedClass", + "--expected-calls", + "1", + "--expected-constructions", + "0", + ) + migrated = target.read_bytes() + + result = _run( + Path(client_tests_codemod.__file__), + str(target), + "--touched", + str(touched), + "--classes", + "SomeUnusedClass", + "--expected-calls", + "0", + "--expected-constructions", + "0", + ) + + assert result.returncode == 0 + assert "TOTAL 0 helper calls, 0 block constructions edited" in result.stdout + assert target.read_bytes() == migrated From 5e06273e42b4a5afc77f71587c348a774a9971c3 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:25:56 +0200 Subject: [PATCH 20/86] feat(workflows): add ExecutionObserver port with a null default Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../core/workflows/prototypes/observer.py | 155 ++++++++++++++++++ .../unit_tests/prototypes/test_observer.py | 101 ++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 inference/core/workflows/prototypes/observer.py create mode 100644 tests/workflows/unit_tests/prototypes/test_observer.py diff --git a/inference/core/workflows/prototypes/observer.py b/inference/core/workflows/prototypes/observer.py new file mode 100644 index 0000000000..80c2868c0b --- /dev/null +++ b/inference/core/workflows/prototypes/observer.py @@ -0,0 +1,155 @@ +"""Observation hooks the host may bind to watch workflow execution. + +Billing and tracing are host concerns: an inference server bills workflow runs +and parents its spans, a standalone workflows process does neither. The engine +therefore reports what it is doing through this port and never knows which of +those two it is talking to. + +Three hooks *wrap* the work rather than bracketing it. That is deliberate: a +host's usage accounting is typically a decorator that must call the function +itself - it times the call, records on both the success and the exception +path, and reads what it records off the call's own arguments. A context +manager cannot delegate the call, so bracketing hooks would force the host to +restructure its accounting rather than reuse it. + +Step context is split in two because the engine runs steps on a +``ThreadPoolExecutor``: ``capture_step_context()`` runs in the thread that +submits the work, ``step_scope()`` in the worker that executes it. The opaque +token in between is whatever the host needs to carry across that boundary. +Note this is the host's *second* layer: the engine already re-enters a +snapshot of the submitting thread's whole ``contextvars`` context per task +(``execution_engine/v1/executor/utils.py``). Hosts that need an explicit +attach/detach - OpenTelemetry does - use these hooks; hosts that do not can +return ``None`` and yield. +""" + +from contextlib import contextmanager +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ContextManager, + Dict, + Iterator, + Optional, + Protocol, + Tuple, + TypeVar, + runtime_checkable, +) + +if TYPE_CHECKING: # pragma: no cover - typing only, never imported at runtime + from inference.core.workflows.execution_engine.v1.compiler.entities import ( + CompiledWorkflow, + ) + +T = TypeVar("T") + + +@runtime_checkable +class ExecutionObserver(Protocol): + """What the execution engine reports, and the host may act on.""" + + def observe_workflow_run( + self, + *, + workflow: "CompiledWorkflow", + runtime_parameters: Dict[str, Any], + workflow_id: Optional[str], + fps: float, + is_preview: bool, + run: Callable[[], T], + ) -> T: + """Wrap one whole workflow run. + + The compiled workflow is passed whole rather than as extracted fields: + a host that bills runs identifies the workflow from its definition and + its init parameters, and re-deriving that here would move the host's + rules into the engine. + """ + ... + + def capture_step_context(self) -> Any: + """Snapshot, in the submitting thread, whatever the workers need.""" + ... + + def step_scope(self, *, context: Any, step_name: str) -> ContextManager[None]: + """Establish that snapshot inside a worker thread, for one step.""" + ... + + def observe_block_run( + self, + *, + block: Any, + block_args: Tuple[Any, ...], + block_kwargs: Dict[str, Any], + run: Callable[[], T], + ) -> T: + """Wrap one invocation of an assembled custom-Python block. + + ``block_kwargs`` stays a nested mapping: the block's parameter names + come from the workflow definition unvalidated, so spreading them would + let an input collide with a host-reserved argument name. + """ + ... + + def observe_model_run( + self, + *, + block: Any, + model_id: Optional[str], + images: Any, + run: Callable[[], T], + ) -> T: + """Wrap a block's own model call - blocks that load a model directly + rather than through the models provider.""" + ... + + +class NullExecutionObserver: + """Observes nothing. The default, so workflows runs with no host bound.""" + + def observe_workflow_run( + self, + *, + workflow: "CompiledWorkflow", + runtime_parameters: Dict[str, Any], + workflow_id: Optional[str], + fps: float, + is_preview: bool, + run: Callable[[], T], + ) -> T: + return run() + + def capture_step_context(self) -> Any: + return None + + @contextmanager + def step_scope(self, *, context: Any, step_name: str) -> Iterator[None]: + yield + + def observe_block_run( + self, + *, + block: Any, + block_args: Tuple[Any, ...], + block_kwargs: Dict[str, Any], + run: Callable[[], T], + ) -> T: + return run() + + def observe_model_run( + self, + *, + block: Any, + model_id: Optional[str], + images: Any, + run: Callable[[], T], + ) -> T: + return run() + + +# A shared instance rather than a class: the steps initialiser calls anything +# callable it finds in the initializer registry (`call_if_callable`), and an +# instance is not callable, so it is handed to blocks as-is. +NULL_EXECUTION_OBSERVER = NullExecutionObserver() diff --git a/tests/workflows/unit_tests/prototypes/test_observer.py b/tests/workflows/unit_tests/prototypes/test_observer.py new file mode 100644 index 0000000000..1e5834d68b --- /dev/null +++ b/tests/workflows/unit_tests/prototypes/test_observer.py @@ -0,0 +1,101 @@ +"""The workflows-local default observer: runs the work, records nothing. + +`NullExecutionObserver` is what the module gets when no host binds one, so +these tests pin the two things a standalone workflows run depends on: the +result of the wrapped call reaches the caller unchanged (including the +exception path), and no context is established that a later run could inherit. +""" + +import pytest + +from inference.core.workflows.prototypes.observer import ( + NULL_EXECUTION_OBSERVER, + ExecutionObserver, + NullExecutionObserver, +) + + +def test_null_observer_satisfies_the_protocol() -> None: + assert isinstance(NullExecutionObserver(), ExecutionObserver) + assert isinstance(NULL_EXECUTION_OBSERVER, NullExecutionObserver) + + +def test_observe_workflow_run_returns_the_wrapped_result() -> None: + # given + observer = NullExecutionObserver() + + # when + result = observer.observe_workflow_run( + workflow=object(), + runtime_parameters={"image": []}, + workflow_id="wf-1", + fps=30.0, + is_preview=True, + run=lambda: [{"a": 1}], + ) + + # then + assert result == [{"a": 1}] + + +def test_observe_workflow_run_propagates_the_error() -> None: + observer = NullExecutionObserver() + + def boom(): + raise RuntimeError("boom") + + with pytest.raises(RuntimeError, match="boom"): + observer.observe_workflow_run( + workflow=object(), + runtime_parameters={}, + workflow_id=None, + fps=0, + is_preview=False, + run=boom, + ) + + +def test_capture_step_context_establishes_nothing() -> None: + assert NullExecutionObserver().capture_step_context() is None + + +def test_step_scope_is_a_transparent_context_manager() -> None: + # given + observer = NullExecutionObserver() + entered = [] + + # when + with observer.step_scope(context=None, step_name="a_step"): + entered.append(True) + + # then + assert entered == [True] + + +def test_step_scope_does_not_swallow_errors() -> None: + observer = NullExecutionObserver() + with pytest.raises(ValueError): + with observer.step_scope(context=None, step_name="a_step"): + raise ValueError("from the step") + + +def test_observe_block_run_returns_the_wrapped_result() -> None: + observer = NullExecutionObserver() + result = observer.observe_block_run( + block=object(), + block_args=(), + block_kwargs={"a": 1}, + run=lambda: {"result": 2}, + ) + assert result == {"result": 2} + + +def test_observe_model_run_returns_the_wrapped_result() -> None: + observer = NullExecutionObserver() + result = observer.observe_model_run( + block=object(), + model_id="sam2video", + images=[], + run=lambda: [{"masks": None}], + ) + assert result == [{"masks": None}] From df477f59577219ecc25c885a30af2c35b1765f00 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:28:05 +0200 Subject: [PATCH 21/86] feat(workflows): add workflows-local block duration channel Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../v1/dynamic_blocks/block_duration.py | 89 +++++++++++++++++ .../dynamic_blocs/test_block_duration.py | 96 +++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 inference/core/workflows/execution_engine/v1/dynamic_blocks/block_duration.py create mode 100644 tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_duration.py diff --git a/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_duration.py b/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_duration.py new file mode 100644 index 0000000000..dd594ef7da --- /dev/null +++ b/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_duration.py @@ -0,0 +1,89 @@ +"""Measured execution duration for a single workflow block invocation. + +Whoever actually measured the block - the in-process scaffolding, or the Modal +sandbox that reported its own runtime - publishes the duration here. A host +that bills block execution reads it through `ExecutionObserver` and prefers it +over its own wall clock, which for a remote block also covers input +serialization and the network round trip. + +The duration travels through a `ContextVar` rather than an attribute on the +block: block instances are shared across the server's worker threads, so an +attribute would let one step overwrite the duration of another step that is +still running. + +Values that cannot be summed are dropped rather than stored, so a misreporting +executor degrades to the host's own measurement instead of corrupting a usage +row. `bool` is rejected explicitly - it is an `int`, and a flag billed as a +second is silent corruption. This validation and the four source names are a +verbatim copy of the host channel they are relayed into, and are pinned to it +by `tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_duration.py`. +""" + +from __future__ import annotations + +import math +from contextvars import ContextVar +from typing import Any, NamedTuple, Optional + +# Where the recorded duration came from, reported alongside it so a host can +# tell a measured runtime from a fallback estimate. +BLOCK_DURATION_SOURCE_REMOTE_RUNTIME = "remote_runtime" +BLOCK_DURATION_SOURCE_LOCAL_RUNTIME = "local_runtime" +BLOCK_DURATION_SOURCE_CLIENT_WALL_CLOCK = "client_wall_clock" +BLOCK_DURATION_SOURCE_UNAVAILABLE = "unavailable" + + +class MeasuredBlockDuration(NamedTuple): + duration: float + source: str + + +_measured_block_duration: ContextVar[Optional[MeasuredBlockDuration]] = ContextVar( + "workflows_measured_block_duration", + default=None, +) + + +def _as_duration(value: Any) -> Optional[float]: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + duration = float(value) + if not math.isfinite(duration) or duration < 0: + return None + return duration + + +def clear_block_duration() -> None: + _measured_block_duration.set(None) + + +def record_block_duration(*, duration: Any, source: str) -> None: + """Publish the duration to bill for the block invocation now running.""" + measured_duration = _as_duration(duration) + if measured_duration is None: + return + _measured_block_duration.set( + MeasuredBlockDuration(duration=measured_duration, source=source) + ) + + +def peek_block_duration() -> Optional[MeasuredBlockDuration]: + """Whether this invocation already published a duration, without taking it. + + Lets the remote arm decide it has nothing to add when the sandbox reported + its own runtime, while leaving the value for the host to consume. + """ + return _measured_block_duration.get() + + +def consume_block_duration() -> Optional[MeasuredBlockDuration]: + """Read and clear the duration published by the current invocation. + + Clearing on read keeps a stale duration from leaking into a later + invocation that did not publish one, which would bill it twice. + """ + measured = _measured_block_duration.get() + if measured is None: + return None + _measured_block_duration.set(None) + return measured diff --git a/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_duration.py b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_duration.py new file mode 100644 index 0000000000..c9085ee278 --- /dev/null +++ b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_duration.py @@ -0,0 +1,96 @@ +"""Pins the workflows-local block-duration channel to the server's original. + +`inference/usage_tracking/block_execution.py` is the host's channel; workflows +now owns a copy so the engine can publish a measurement without importing the +server. The two must agree exactly on which values are usable and on the four +source names, because the host relays one into the other - a divergence would +silently change what a block is billed for. +""" + +import pytest + +from inference.core.workflows.execution_engine.v1.dynamic_blocks import block_duration +from inference.usage_tracking import block_execution as server_channel + +SOURCE_NAMES = [ + "BLOCK_DURATION_SOURCE_REMOTE_RUNTIME", + "BLOCK_DURATION_SOURCE_LOCAL_RUNTIME", + "BLOCK_DURATION_SOURCE_CLIENT_WALL_CLOCK", + "BLOCK_DURATION_SOURCE_UNAVAILABLE", +] + +# Values the host must drop, and values it must keep. `True` is in the rejected +# list on purpose: `bool` is an `int`, and billing one second for a flag would +# be silent corruption. +UNUSABLE = [None, True, False, "0.25", -1.0, float("nan"), float("inf"), object()] +USABLE = [0, 0.0, 1, 0.25, 1e-6, 3600.0] + + +@pytest.fixture(autouse=True) +def _cleared(): + block_duration.clear_block_duration() + server_channel.clear_measured_block_execution() + yield + block_duration.clear_block_duration() + server_channel.clear_measured_block_execution() + + +@pytest.mark.parametrize("name", SOURCE_NAMES) +def test_source_names_match_the_server_channel(name: str) -> None: + assert getattr(block_duration, name) == getattr(server_channel, name) + + +@pytest.mark.parametrize("value", UNUSABLE) +def test_unusable_values_are_dropped_by_both_channels(value) -> None: + # given + block_duration.record_block_duration( + duration=value, source=block_duration.BLOCK_DURATION_SOURCE_LOCAL_RUNTIME + ) + server_channel.record_measured_block_execution( + duration=value, source=server_channel.BLOCK_DURATION_SOURCE_LOCAL_RUNTIME + ) + + # then + assert block_duration.peek_block_duration() is None + assert server_channel.peek_measured_block_execution() is None + + +@pytest.mark.parametrize("value", USABLE) +def test_usable_values_are_kept_identically_by_both_channels(value) -> None: + # given + block_duration.record_block_duration( + duration=value, source=block_duration.BLOCK_DURATION_SOURCE_REMOTE_RUNTIME + ) + server_channel.record_measured_block_execution( + duration=value, source=server_channel.BLOCK_DURATION_SOURCE_REMOTE_RUNTIME + ) + + # then + local = block_duration.peek_block_duration() + remote = server_channel.peek_measured_block_execution() + assert local is not None and remote is not None + assert local.duration == remote.duration == float(value) + assert local.source == remote.source + + +def test_consume_clears_so_a_later_invocation_is_not_billed_twice() -> None: + # given + block_duration.record_block_duration( + duration=0.25, source=block_duration.BLOCK_DURATION_SOURCE_LOCAL_RUNTIME + ) + + # when + first = block_duration.consume_block_duration() + second = block_duration.consume_block_duration() + + # then + assert first is not None and first.duration == 0.25 + assert second is None + + +def test_clear_removes_a_pending_measurement() -> None: + block_duration.record_block_duration( + duration=0.5, source=block_duration.BLOCK_DURATION_SOURCE_LOCAL_RUNTIME + ) + block_duration.clear_block_duration() + assert block_duration.peek_block_duration() is None From 8cbeed9b148c5a6c56e6f56059fbf20652c029de Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:48:26 +0200 Subject: [PATCH 22/86] feat(server): add UsageTrackingExecutionObserver for workflows Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../workflows_execution_observer.py | 175 +++++++++++++ .../test_workflows_execution_observer.py | 246 ++++++++++++++++++ .../test_workflows_observer_row_parity.py | 233 +++++++++++++++++ 3 files changed, 654 insertions(+) create mode 100644 inference/core/interfaces/workflows_execution_observer.py create mode 100644 tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer.py create mode 100644 tests/inference/unit_tests/core/interfaces/test_workflows_observer_row_parity.py diff --git a/inference/core/interfaces/workflows_execution_observer.py b/inference/core/interfaces/workflows_execution_observer.py new file mode 100644 index 0000000000..2172515244 --- /dev/null +++ b/inference/core/interfaces/workflows_execution_observer.py @@ -0,0 +1,175 @@ +"""The server's binding of the workflows `ExecutionObserver`. + +Workflows declares what it is doing; this module is where "what it is doing" +becomes a usage row and an OpenTelemetry span. Nothing here re-implements +either: the three billed hooks call `usage_collector` - the real decorator, +with the real extraction - and the tracing hooks call the real telemetry +helpers. `test_workflows_observer_row_parity.py` compares the rows these +produce against the rows the decorated entry points produced before the port, +under a frozen clock. + +The three decorated functions below exist for one reason: the collector reads +a row's fields off the *decorated function's own parameter names* +(`collect_func_params`). So each shim declares exactly the names its category's +extractor looks for, and calls the engine's continuation in its body. That is +also why they are module-level: the collector memoizes signatures keyed by +function object, and a per-call closure would pin one entry per workflow run. +""" + +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any, Callable, Dict, Iterator, Optional, Tuple, TypeVar + +from inference.core.telemetry import ( + attach_context, + capture_context, + detach_context, + start_span, +) +from inference.core.workflows.execution_engine.v1.dynamic_blocks.block_duration import ( + consume_block_duration, +) +from inference.usage_tracking.block_execution import ( + clear_measured_block_execution, + record_measured_block_execution, +) +from inference.usage_tracking.collector import usage_collector +from inference.usage_tracking.stream_session import stream_session_id + +T = TypeVar("T") + + +@usage_collector("workflows") +def _billed_workflow_run(workflow, runtime_parameters, run): + """One whole workflow run, billed and traced. + + `workflow` and `runtime_parameters` are declared so the collector can read + the workflow's api key, its step list and the run's image source off them - + exactly the names the engine's `run_workflow` used to expose. + """ + with start_span("workflow.run"): + return run() + + +@usage_collector("workflow_block") +def _billed_block_run(self, block_args, block_kwargs, run): + """One custom-Python block invocation, billed. + + `self` is the block; the collector reads its usage resource id, api key and + step metadata. `block_kwargs` stays nested so a workflow-authored input + cannot bind to one of the decorator's own keyword arguments, and so batch + inputs stay countable. + + The duration the engine measured is relayed in a `finally`, before the + decorator extracts the row, so a block that raised is still billed for the + time it actually ran. + """ + try: + return run() + finally: + _relay_measured_block_duration() + + +@usage_collector("model") +def _billed_model_run(self, model_id, images, run): + """A block's own model call, billed as a model row. + + `model_id` and `images` are the two names the model extractor reads; the + api key comes off the block. + """ + return run() + + +def _relay_measured_block_duration() -> None: + """Hand the engine's measurement to the collector's channel. + + The host channel is cleared first, unconditionally: a measurement left + behind by an invocation whose usage recording failed must not be billed to + this one. + """ + measured = consume_block_duration() + clear_measured_block_execution() + if measured is None: + return + record_measured_block_execution(duration=measured.duration, source=measured.source) + + +@dataclass(frozen=True) +class ServerStepContext: + """What a step worker thread needs from the thread that submitted it. + + The engine already re-enters a snapshot of the submitting thread's whole + `contextvars` context per task, so both fields normally arrive anyway; this + is the explicit second layer, and OpenTelemetry needs it because its + context must be attached and detached, not merely present. Both fields are + re-bound in the worker including when they are None: pool threads are + reused across requests. + """ + + otel_context: Any + stream_session_id: Optional[str] + + +class UsageTrackingExecutionObserver: + """Binds workflow execution to usage tracking and OpenTelemetry.""" + + def observe_workflow_run( + self, + *, + workflow: Any, + runtime_parameters: Dict[str, Any], + workflow_id: Optional[str], + fps: float, + is_preview: bool, + run: Callable[[], T], + ) -> T: + return _billed_workflow_run( + workflow=workflow, + runtime_parameters=runtime_parameters, + run=run, + usage_fps=fps, + usage_workflow_id=workflow_id or "", + usage_workflow_preview=is_preview, + ) + + def capture_step_context(self) -> ServerStepContext: + return ServerStepContext( + otel_context=capture_context(), + stream_session_id=stream_session_id.get(), + ) + + @contextmanager + def step_scope(self, *, context: Any, step_name: str) -> Iterator[None]: + stream_session_id.set( + context.stream_session_id if context is not None else None + ) + otel_token = attach_context( + context.otel_context if context is not None else None + ) + try: + with start_span("workflow.step", {"workflow.step": step_name}): + yield + finally: + detach_context(otel_token) + + def observe_block_run( + self, + *, + block: Any, + block_args: Tuple[Any, ...], + block_kwargs: Dict[str, Any], + run: Callable[[], T], + ) -> T: + return _billed_block_run( + self=block, block_args=block_args, block_kwargs=block_kwargs, run=run + ) + + def observe_model_run( + self, + *, + block: Any, + model_id: Optional[str], + images: Any, + run: Callable[[], T], + ) -> T: + return _billed_model_run(self=block, model_id=model_id, images=images, run=run) diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer.py b/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer.py new file mode 100644 index 0000000000..1efe1dabf9 --- /dev/null +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer.py @@ -0,0 +1,246 @@ +"""The server's ExecutionObserver: usage rows and OTel context for workflows. + +Workflows no longer imports the usage collector or the telemetry helpers; this +object is where both are reached. Each test names the field it protects rather +than asserting "a row was recorded". +""" + +from concurrent.futures import ThreadPoolExecutor +from types import SimpleNamespace +from unittest import mock + +import pytest + +from inference.core.interfaces.workflows_execution_observer import ( + ServerStepContext, + UsageTrackingExecutionObserver, +) +from inference.core.workflows.execution_engine.v1.dynamic_blocks import block_duration +from inference.core.workflows.prototypes.observer import ExecutionObserver +from inference.usage_tracking import block_execution as server_channel +from inference.usage_tracking.collector import usage_collector +from inference.usage_tracking.stream_session import stream_session_id + + +@pytest.fixture(autouse=True) +def _cleared_channels(): + block_duration.clear_block_duration() + server_channel.clear_measured_block_execution() + yield + block_duration.clear_block_duration() + server_channel.clear_measured_block_execution() + + +def _compiled_workflow(api_key: str = "observer-key") -> SimpleNamespace: + return SimpleNamespace( + init_parameters={"workflows_core.api_key": api_key}, + workflow_json={"steps": [{"type": "SomeBlock", "name": "a_step"}]}, + ) + + +def test_observer_satisfies_the_workflows_protocol() -> None: + assert isinstance(UsageTrackingExecutionObserver(), ExecutionObserver) + + +def test_workflow_run_records_a_workflows_row_with_every_field() -> None: + # given + observer = UsageTrackingExecutionObserver() + + # when + with mock.patch.object(usage_collector, "record_usage") as record_usage: + result = observer.observe_workflow_run( + workflow=_compiled_workflow(), + runtime_parameters={"image": [SimpleNamespace(_image_reference="s3://x")]}, + workflow_id="wf-internal-id", + fps=12.5, + is_preview=True, + run=lambda: [{"out": 1}], + ) + + # then + assert result == [{"out": 1}] + params = record_usage.call_args.kwargs + assert params["category"] == "workflows" + assert params["resource_id"] == "wf-internal-id" + assert params["api_key"] == "observer-key" + assert params["fps"] == 12.5 + assert params["source"] == "s3://x" + assert params["resource_details"]["is_preview"] is True + assert params["resource_details"]["billable"] is True + assert params["resource_details"]["steps"] == ["SomeBlock:a_step"] + + +def test_workflow_run_records_a_row_when_the_run_raises() -> None: + observer = UsageTrackingExecutionObserver() + + def boom(): + raise RuntimeError("step blew up") + + with mock.patch.object(usage_collector, "record_usage") as record_usage: + with pytest.raises(RuntimeError): + observer.observe_workflow_run( + workflow=_compiled_workflow(), + runtime_parameters={}, + workflow_id="wf-internal-id", + fps=0, + is_preview=False, + run=boom, + ) + + details = record_usage.call_args.kwargs["resource_details"] + assert details["error_type"] == "RuntimeError" + + +def test_workflow_id_falls_back_to_the_definition_hash_when_absent() -> None: + observer = UsageTrackingExecutionObserver() + with mock.patch.object(usage_collector, "record_usage") as record_usage: + observer.observe_workflow_run( + workflow=_compiled_workflow(), + runtime_parameters={}, + workflow_id=None, + fps=0, + is_preview=False, + run=lambda: [], + ) + # Not "unknown": the collector hashes the resource details it extracted. + assert record_usage.call_args.kwargs["resource_id"] not in ("", "unknown") + + +def test_block_run_records_a_workflow_block_row_and_relays_the_duration() -> None: + # given + observer = UsageTrackingExecutionObserver() + block = SimpleNamespace( + _usage_resource_id="custom_python/abc123", + _api_key="block-key", + _usage_block_kind="custom_python", + _workflow_step_type="MeteredBlock", + _workflow_step_name="my_step", + ) + + def run(): + block_duration.record_block_duration( + duration=0.25, + source=block_duration.BLOCK_DURATION_SOURCE_LOCAL_RUNTIME, + ) + return {"result": 8} + + # when + with mock.patch.object(usage_collector, "record_usage") as record_usage: + result = observer.observe_block_run( + block=block, block_args=(), block_kwargs={"a": 1}, run=run + ) + + # then + assert result == {"result": 8} + params = record_usage.call_args.kwargs + assert params["category"] == "workflow_block" + assert params["resource_id"] == "custom_python/abc123" + assert params["api_key"] == "block-key" + assert params["execution_duration"] == pytest.approx(0.25) + assert params["resource_details"]["duration_source"] == "local_runtime" + assert params["resource_details"]["execution_mode"] == "local" + assert params["resource_details"]["step_name"] == "my_step" + + +def test_block_run_relays_the_duration_even_when_the_block_raises() -> None: + observer = UsageTrackingExecutionObserver() + block = SimpleNamespace(_usage_resource_id="custom_python/abc123", _api_key="k") + + def run(): + block_duration.record_block_duration( + duration=0.5, + source=block_duration.BLOCK_DURATION_SOURCE_REMOTE_RUNTIME, + ) + raise RuntimeError("boom") + + with mock.patch.object(usage_collector, "record_usage") as record_usage: + with pytest.raises(RuntimeError): + observer.observe_block_run( + block=block, block_args=(), block_kwargs={}, run=run + ) + + params = record_usage.call_args.kwargs + assert params["execution_duration"] == pytest.approx(0.5) + assert params["resource_details"]["duration_source"] == "remote_runtime" + + +def test_a_stale_host_measurement_cannot_be_billed_to_the_next_block() -> None: + # given - a measurement nobody consumed, as a failed usage recording leaves + server_channel.record_measured_block_execution( + duration=9.0, source=server_channel.BLOCK_DURATION_SOURCE_REMOTE_RUNTIME + ) + observer = UsageTrackingExecutionObserver() + block = SimpleNamespace(_usage_resource_id="custom_python/abc123", _api_key="k") + + # when - the next block publishes nothing of its own + with mock.patch.object(usage_collector, "record_usage") as record_usage: + observer.observe_block_run( + block=block, block_args=(), block_kwargs={}, run=lambda: {"r": 1} + ) + + # then - the decorator's own wall clock, not the 9 seconds + params = record_usage.call_args.kwargs + assert params["execution_duration"] < 9.0 + assert params["resource_details"]["duration_source"] == "decorator_wall_clock" + + +def test_model_run_records_a_model_row_for_the_block_that_owns_the_model() -> None: + observer = UsageTrackingExecutionObserver() + block = SimpleNamespace(_api_key="sam-key") + + with mock.patch.object(usage_collector, "record_usage") as record_usage: + result = observer.observe_model_run( + block=block, + model_id="sam2video", + images=[object(), object()], + run=lambda: [{"masks": None}], + ) + + assert result == [{"masks": None}] + params = record_usage.call_args.kwargs + assert params["category"] == "model" + assert params["resource_id"] == "sam2video" + assert params["api_key"] == "sam-key" + assert params["frames"] == 2 + + +def test_step_context_is_captured_in_the_caller_and_rebound_in_the_worker() -> None: + # given - the pipeline's session id, bound in the thread that submits work + observer = UsageTrackingExecutionObserver() + token = stream_session_id.set("camera-7") + try: + context = observer.capture_step_context() + finally: + stream_session_id.reset(token) + seen = {} + + def worker(): + with observer.step_scope(context=context, step_name="a_step"): + seen["value"] = stream_session_id.get() + + # when + with ThreadPoolExecutor(max_workers=1) as executor: + executor.submit(worker).result() + + # then + assert isinstance(context, ServerStepContext) + assert seen["value"] == "camera-7" + + +def test_step_scope_clears_a_previous_requests_session_id() -> None: + # given - pool threads are reused; a previous pipeline's id must not leak + observer = UsageTrackingExecutionObserver() + context = observer.capture_step_context() # no session bound here + seen = {} + + def worker(): + stream_session_id.set("stale-stream") + with observer.step_scope(context=context, step_name="a_step"): + seen["value"] = stream_session_id.get() + + # when + with ThreadPoolExecutor(max_workers=1) as executor: + executor.submit(worker).result() + + # then + assert seen["value"] is None diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_observer_row_parity.py b/tests/inference/unit_tests/core/interfaces/test_workflows_observer_row_parity.py new file mode 100644 index 0000000000..79ad476ef4 --- /dev/null +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_observer_row_parity.py @@ -0,0 +1,233 @@ +"""The adapter records exactly what the decorator used to record. + +Before this phase, `run_workflow`, the dynamic-block entrypoint and the SAM +video `run` were decorated directly. Now the server wraps them from outside. +The claim that "the extraction logic is called, not duplicated" is only worth +anything if the rows are identical, so each test below defines the *legacy* +decorated entry point - same signature, same category - runs both under a +frozen clock, and compares the kwargs the collector was handed. + +`GCP_SERVERLESS` is pinned False so `_apply_duration_floor` does not lift a +frozen 0.25s to the serverless minimum in one arm and not the other. +""" + +from types import SimpleNamespace +from unittest import mock + +import pytest + +from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, +) +from inference.core.workflows.execution_engine.v1.dynamic_blocks import block_duration +from inference.usage_tracking import collector as collector_module +from inference.usage_tracking.collector import usage_collector + +# Two readings per decorated call: t1 before, t2 after. +FROZEN_CLOCK = [100.0, 100.25] + + +@pytest.fixture(autouse=True) +def _deterministic_collector(): + block_duration.clear_block_duration() + with mock.patch.object(collector_module, "GCP_SERVERLESS", False): + yield + block_duration.clear_block_duration() + + +def _record_once(call) -> dict: + """Run `call` with a frozen clock and return the kwargs the collector got.""" + with mock.patch.object( + collector_module.time, "time", side_effect=list(FROZEN_CLOCK) + ), mock.patch.object(usage_collector, "record_usage") as record_usage: + call() + return record_usage.call_args.kwargs + + +def _compiled_workflow() -> SimpleNamespace: + return SimpleNamespace( + init_parameters={"workflows_core.api_key": "parity-key"}, + workflow_json={"steps": [{"type": "SomeBlock", "name": "a_step"}]}, + ) + + +# --- workflows category ---------------------------------------------------- + + +@usage_collector("workflows") +def _legacy_run_workflow( + workflow, + runtime_parameters, + max_concurrent_steps, + kinds_serializers, + serialize_results=False, + profiler=None, + executor=None, + step_error_handler=None, + defer_stream_pipeline_flush=False, + resolve_output_futures=True, +): + """`run_workflow`'s pre-phase signature, decorated the way it used to be.""" + return [{"out": 1}] + + +def test_workflow_row_is_identical_to_the_decorated_entry_point() -> None: + # given + workflow = _compiled_workflow() + runtime_parameters = {"image": [SimpleNamespace(_image_reference="s3://x")]} + observer = UsageTrackingExecutionObserver() + + # when + legacy = _record_once( + lambda: _legacy_run_workflow( + workflow=workflow, + runtime_parameters=runtime_parameters, + max_concurrent_steps=1, + kinds_serializers=None, + usage_fps=12.5, + usage_workflow_id="wf-internal-id", + usage_workflow_preview=True, + ) + ) + adapted = _record_once( + lambda: observer.observe_workflow_run( + workflow=workflow, + runtime_parameters=runtime_parameters, + workflow_id="wf-internal-id", + fps=12.5, + is_preview=True, + run=lambda: [{"out": 1}], + ) + ) + + # then + assert adapted == legacy + + +# --- workflow_block category ---------------------------------------------- + + +@usage_collector("workflow_block") +def _legacy_metered_run(self, block_args, block_kwargs): + """`block_scaffolding._metered_run`'s pre-phase shape.""" + return self._run_dynamic_block(*block_args, **block_kwargs) + + +class _FakeBlock: + _usage_resource_id = "custom_python/abc123" + _api_key = "parity-key" + _usage_block_kind = "custom_python" + _usage_block_type = "MeteredBlock" + _workflow_step_type = "MeteredBlock" + _workflow_step_name = "my_step" + + def _run_dynamic_block(self, *args, **kwargs): + return {"result": 8} + + +def test_block_row_is_identical_to_the_decorated_entry_point() -> None: + # given - neither arm publishes a measured duration, so both fall back to + # the decorator's (frozen) wall clock + block = _FakeBlock() + observer = UsageTrackingExecutionObserver() + + # when + legacy = _record_once( + lambda: _legacy_metered_run(block, block_args=(), block_kwargs={"a": 1}) + ) + adapted = _record_once( + lambda: observer.observe_block_run( + block=block, + block_args=(), + block_kwargs={"a": 1}, + run=lambda: block._run_dynamic_block(a=1), + ) + ) + + # then + assert adapted == legacy + assert adapted["execution_duration"] == pytest.approx(0.25) + + +def test_block_row_is_identical_when_a_duration_was_measured() -> None: + # given - the engine's channel in the adapted arm, the host's channel in + # the legacy arm: the relay must make them indistinguishable + from inference.usage_tracking import block_execution as server_channel + + block = _FakeBlock() + observer = UsageTrackingExecutionObserver() + + def legacy_call(): + server_channel.record_measured_block_execution( + duration=0.5, source=server_channel.BLOCK_DURATION_SOURCE_LOCAL_RUNTIME + ) + return _legacy_metered_run(block, block_args=(), block_kwargs={}) + + def adapted_call(): + def run(): + block_duration.record_block_duration( + duration=0.5, + source=block_duration.BLOCK_DURATION_SOURCE_LOCAL_RUNTIME, + ) + return {"result": 8} + + return observer.observe_block_run( + block=block, block_args=(), block_kwargs={}, run=run + ) + + # when + legacy = _record_once(legacy_call) + adapted = _record_once(adapted_call) + + # then + assert adapted == legacy + assert adapted["resource_details"]["duration_source"] == "local_runtime" + assert adapted["execution_duration"] == pytest.approx(0.5) + + +# --- model category -------------------------------------------------------- + + +class _FakeSamBlock: + _api_key = "parity-key" + + @usage_collector("model") + def legacy_run( + self, images, boxes, model_id, prompt_mode, prompt_interval, threshold + ): + """SAM2's pre-phase decorated `run`.""" + return [{"masks": None}] + + def body(self, images, model_id): + return [{"masks": None}] + + +def test_model_row_is_identical_to_the_decorated_entry_point() -> None: + # given + block = _FakeSamBlock() + observer = UsageTrackingExecutionObserver() + images = [object(), object()] + + # when + legacy = _record_once( + lambda: block.legacy_run( + images=images, + boxes=None, + model_id="sam2video/small", + prompt_mode="first_frame", + prompt_interval=30, + threshold=0.0, + ) + ) + adapted = _record_once( + lambda: observer.observe_model_run( + block=block, + model_id="sam2video/small", + images=images, + run=lambda: block.body(images, "sam2video/small"), + ) + ) + + # then + assert adapted == legacy + assert adapted["frames"] == 2 From 92fcd3e95fc7ca4d3b88e038594388a787316d02 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:03:37 +0200 Subject: [PATCH 23/86] feat(server): bind the workflows execution observer at every composition root Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/interfaces/http/http_api.py | 5 + .../interfaces/stream/inference_pipeline.py | 6 + inference/core/workflows/core_steps/loader.py | 4 + .../lib/workflows/local_image_adapter.py | 4 + ...st_workflows_execution_observer_binding.py | 399 ++++++++++++++++++ 5 files changed, 418 insertions(+) create mode 100644 tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer_binding.py diff --git a/inference/core/interfaces/http/http_api.py b/inference/core/interfaces/http/http_api.py index ecde586f42..a4e5be1af4 100644 --- a/inference/core/interfaces/http/http_api.py +++ b/inference/core/interfaces/http/http_api.py @@ -301,6 +301,9 @@ deregister_webrtc_session, refresh_webrtc_session, ) +from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, +) from inference.core.interfaces.workflows_step_error_handlers import ( resolve_step_error_handler, ) @@ -1608,6 +1611,7 @@ def process_workflow_inference_request( "workflows_core.inner_workflow_dispatch_depth": ( workflow_request.inner_workflow_dispatch_depth ), + "workflows_core.execution_observer": UsageTrackingExecutionObserver(), } ) with start_span( @@ -2565,6 +2569,7 @@ def validate_workflow( "workflows_core.api_key": api_key, "workflows_core.background_tasks": None, "workflows_core.step_execution_mode": step_execution_mode, + "workflows_core.execution_observer": UsageTrackingExecutionObserver(), } ) _ = ExecutionEngine.init( diff --git a/inference/core/interfaces/stream/inference_pipeline.py b/inference/core/interfaces/stream/inference_pipeline.py index c1fc96eb31..d2e6c98ad1 100644 --- a/inference/core/interfaces/stream/inference_pipeline.py +++ b/inference/core/interfaces/stream/inference_pipeline.py @@ -700,6 +700,9 @@ def init_with_workflow( WorkflowRunner, wrap_workflow_runner_for_stream_pipeline, ) + from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, + ) from inference.core.interfaces.workflows_step_error_handlers import ( resolve_step_error_handler, ) @@ -751,6 +754,9 @@ def init_with_workflow( thread_pool_executor ) workflow_init_parameters["workflows_core.disable_sinks"] = disable_sinks + workflow_init_parameters["workflows_core.execution_observer"] = ( + UsageTrackingExecutionObserver() + ) # setdefault semantics: a caller's workflow_init_parameters may # already carry an explicit inner_workflow_spec_resolver. install_workflows_platform_bindings(workflow_init_parameters) diff --git a/inference/core/workflows/core_steps/loader.py b/inference/core/workflows/core_steps/loader.py index a0c910c7e4..83ae92db20 100644 --- a/inference/core/workflows/core_steps/loader.py +++ b/inference/core/workflows/core_steps/loader.py @@ -1543,6 +1543,7 @@ Kind, ) from inference.core.workflows.prototypes.block import WorkflowBlock +from inference.core.workflows.prototypes.observer import NULL_EXECUTION_OBSERVER from inference.core.workflows.prototypes.platform_client import OFFLINE_PLATFORM_CLIENT from inference.core.workflows.utils.in_memory_cache import InMemoryWorkflowsCache @@ -1555,6 +1556,9 @@ "cache": InMemoryWorkflowsCache(), "step_execution_mode": StepExecutionMode(WORKFLOWS_STEP_EXECUTION_MODE), "background_tasks": None, + # A no-op by default: billing and tracing are the host's, and a host binds + # its own through `workflows_core.execution_observer`. + "execution_observer": NULL_EXECUTION_OBSERVER, "thread_pool_executor": None, "inner_workflow_remote_target": WORKFLOWS_INNER_WORKFLOW_REMOTE_TARGET, "inner_workflow_dispatch_depth": 0, diff --git a/inference_cli/lib/workflows/local_image_adapter.py b/inference_cli/lib/workflows/local_image_adapter.py index c4ba3f902c..1afbeffdf4 100644 --- a/inference_cli/lib/workflows/local_image_adapter.py +++ b/inference_cli/lib/workflows/local_image_adapter.py @@ -12,6 +12,9 @@ from inference.core.interfaces.roboflow_platform_client import ( install_workflows_platform_bindings, ) +from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, +) from inference.core.interfaces.workflows_step_error_handlers import ( resolve_step_error_handler, ) @@ -453,6 +456,7 @@ def _run_workflow_for_single_image_with_inference( "workflows_core.model_manager": model_manager, "workflows_core.api_key": api_key, "workflows_core.thread_pool_executor": thread_pool_executor, + "workflows_core.execution_observer": UsageTrackingExecutionObserver(), } if workflows_execution_engine_init_params: workflow_init_parameters.update(workflows_execution_engine_init_params) diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer_binding.py b/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer_binding.py new file mode 100644 index 0000000000..6ea5af3277 --- /dev/null +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer_binding.py @@ -0,0 +1,399 @@ +"""Every server composition root must bind the execution observer. + +The engine's default is the workflows-local `NullExecutionObserver`, which +records nothing - so a root that forgets the binding keeps working, keeps its +tests green, and silently stops billing. + +Two independent guards. The structural one *discovers* engine construction +sites across the production tree rather than trusting a hard-coded list, and +replays each call's own `init_parameters` bindings, so neither a new root in a +new file, nor a stray matching string in an unused dictionary, nor a +dictionary that was overwritten before the call can fool it. The runtime ones +drive each of the four roots and assert the observer that actually reaches +`ExecutionEngine.init`. +""" + +import ast +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, +) +from inference.core.workflows.core_steps.loader import REGISTERED_INITIALIZERS +from inference.core.workflows.execution_engine.core import ExecutionEngine +from inference.core.workflows.prototypes.observer import ( + ExecutionObserver, + NullExecutionObserver, +) + +# tests/inference/unit_tests/core/interfaces/ -> five levels up is the repo root +REPO_ROOT = Path(__file__).resolve().parents[5] +SEARCH_ROOTS = ("inference", "inference_cli") +# The engine itself constructs engines (nested workflows, tests of the engine); +# only *server* construction sites are composition roots. +EXCLUDED_SUBTREE = REPO_ROOT / "inference" / "core" / "workflows" +BINDING_KEY = "workflows_core.execution_observer" + +# Every production site that builds an execution engine, and how many times. +# A new entry here without a binding fails; a new site not listed here fails. +EXPECTED_ROOTS = { + "inference/core/interfaces/http/http_api.py": 2, + "inference/core/interfaces/stream/inference_pipeline.py": 1, + "inference_cli/lib/workflows/local_image_adapter.py": 1, +} + + +def _python_files(): + for root in SEARCH_ROOTS: + for path in sorted((REPO_ROOT / root).rglob("*.py")): + if EXCLUDED_SUBTREE in path.parents: + continue + yield path + + +def _is_engine_init(node: ast.AST) -> bool: + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "init" + and getattr(node.func.value, "id", None) == "ExecutionEngine" + ) + + +def _parents(tree: ast.AST) -> dict: + parents = {} + for node in ast.walk(tree): + for child in ast.iter_child_nodes(node): + parents[child] = node + return parents + + +def _enclosing_scope(node: ast.AST, parents: dict) -> ast.AST: + current = parents.get(node) + while current is not None and not isinstance( + current, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Module) + ): + current = parents.get(current) + return current + + +def _dict_literal_keys(node: ast.Dict) -> set: + return { + key.value + for key in node.keys + if isinstance(key, ast.Constant) and isinstance(key.value, str) + } + + +def _literal_dict_in(value: ast.AST): + """The dict literal a binding assignment builds, if it builds one. + + Either the literal itself, or a literal handed to a wrapper call as its + first positional argument - the shape Phase 9 gives the HTTP roots, + `install_workflows_platform_bindings({...})`. Anything else (a name, a + call without a literal, a comprehension) is opaque: the helper then knows + nothing about the keys, and says so by starting from an empty set. + """ + if isinstance(value, ast.Dict): + return value + if ( + isinstance(value, ast.Call) + and value.args + and isinstance(value.args[0], ast.Dict) + ): + return value.args[0] + return None + + +def _keys_bound_to_name( + scope: ast.AST, parents: dict, name: str, before_lineno: int +) -> set: + """String keys `name` holds when line `before_lineno` runs. + + Replays the assignments to `name` in this scope, in line order, up to the + engine construction: a rebinding (`name = {...}`, `name = wrapper({...})`, + `name = anything_else`) replaces whatever the name held, and a subscript + assignment (`name["k"] = ...`) adds a key. Three restrictions matter. + Without the scope check, an unrelated nested function that builds its own + dictionary under the same variable name satisfies the assertion for an + outer call that binds nothing. Without the line check, an assignment made + after the engine was constructed counts. Without the replay order, a + dictionary that was overwritten before the call (`params = {...binding...}; + params = {}`) still reports the binding it lost. + """ + assignments = sorted( + ( + node + for node in ast.walk(scope) + if isinstance(node, ast.Assign) + and _enclosing_scope(node, parents) is scope + and node.lineno < before_lineno + ), + key=lambda node: node.lineno, + ) + keys = set() + for node in assignments: + for target in node.targets: + if isinstance(target, ast.Name) and target.id == name: + literal = _literal_dict_in(node.value) + keys = _dict_literal_keys(literal) if literal is not None else set() + elif ( + isinstance(target, ast.Subscript) + and isinstance(target.value, ast.Name) + and target.value.id == name + and isinstance(target.slice, ast.Constant) + and isinstance(target.slice.value, str) + ): + keys.add(target.slice.value) + return keys + + +def _init_parameters_keys(call: ast.Call, scope: ast.AST, parents: dict) -> set: + argument = next( + (kw.value for kw in call.keywords if kw.arg == "init_parameters"), None + ) + if argument is None: + return set() + if isinstance(argument, ast.Name): + return _keys_bound_to_name(scope, parents, argument.id, call.lineno) + literal = _literal_dict_in(argument) + return _dict_literal_keys(literal) if literal is not None else set() + + +def _discovered_engine_init_calls(): + """(relative path, call node, enclosing scope, parent map) per production site.""" + found = [] + for path in _python_files(): + tree = ast.parse(path.read_text(encoding="utf-8")) + parents = _parents(tree) + for node in ast.walk(tree): + if _is_engine_init(node): + found.append( + ( + str(path.relative_to(REPO_ROOT)), + node, + _enclosing_scope(node, parents), + parents, + ) + ) + return found + + +def _keys_for_source(source: str) -> set: + """Run the structural helper over a synthetic root.""" + tree = ast.parse(source) + parents = _parents(tree) + call = next(node for node in ast.walk(tree) if _is_engine_init(node)) + return _init_parameters_keys(call, _enclosing_scope(call, parents), parents) + + +def test_the_set_of_composition_roots_is_exactly_the_expected_one() -> None: + counts = {} + for relative, _, _, _ in _discovered_engine_init_calls(): + counts[relative] = counts.get(relative, 0) + 1 + assert counts == EXPECTED_ROOTS + + +def test_every_discovered_root_binds_the_observer_in_its_own_parameters() -> None: + for relative, call, scope, parents in _discovered_engine_init_calls(): + keys = _init_parameters_keys(call, scope, parents) + assert BINDING_KEY in keys, (relative, call.lineno, sorted(keys)) + + +def test_the_structural_helper_rejects_a_binding_from_another_scope() -> None: + """A nested function's own dictionary must not satisfy an outer call.""" + source = ( + "def root():\n" + " params = {}\n" + " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" + "\n" + " def unrelated():\n" + ' params = {"workflows_core.execution_observer": None}\n' + " return params\n" + ) + assert _keys_for_source(source) == set() + + +def test_the_structural_helper_ignores_an_overwritten_dictionary() -> None: + """`params = {binding}; params = {}` binds nothing - the last rebinding wins.""" + source = ( + "def root():\n" + ' params = {"workflows_core.execution_observer": None}\n' + " params = {}\n" + " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" + ) + assert _keys_for_source(source) == set() + # ...and a key set after the rebinding, before the call, does count. + source_with_subscript = ( + "def root():\n" + ' params = {"workflows_core.execution_observer": None}\n' + " params = {}\n" + ' params["workflows_core.api_key"] = "k"\n' + " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" + ) + assert _keys_for_source(source_with_subscript) == {"workflows_core.api_key"} + + +def test_the_structural_helper_reads_a_literal_handed_to_a_wrapper_call() -> None: + """Phase 9 wraps the HTTP literals: `install_workflows_platform_bindings({...})`.""" + source = ( + "def root():\n" + " params = install_workflows_platform_bindings(\n" + ' {"workflows_core.api_key": "k", "workflows_core.execution_observer": None}\n' + " )\n" + " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" + ) + assert _keys_for_source(source) == { + "workflows_core.api_key", + "workflows_core.execution_observer", + } + # A wrapper that is handed a *name* is opaque: nothing can be claimed. + opaque = ( + "def root():\n" + ' params = {"workflows_core.execution_observer": None}\n' + " params = install_workflows_platform_bindings(params)\n" + " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" + ) + assert _keys_for_source(opaque) == set() + + +def test_the_registered_default_is_the_null_observer() -> None: + # Blocks that declare the parameter and get no host binding resolve to a + # no-op, so a workflows-only process runs without a billing stack. + assert isinstance( + REGISTERED_INITIALIZERS["execution_observer"], NullExecutionObserver + ) + + +def test_the_bound_observer_and_the_default_share_the_protocol() -> None: + assert isinstance(UsageTrackingExecutionObserver(), ExecutionObserver) + assert isinstance(REGISTERED_INITIALIZERS["execution_observer"], ExecutionObserver) + + +# -------------------------------------------------------------------------- +# Runtime: what actually reaches ExecutionEngine.init at each of the four roots +# -------------------------------------------------------------------------- + + +@pytest.fixture +def captured_engine_init(monkeypatch): + """Capture `init_parameters` and return a MagicMock engine.""" + calls = [] + + def fake_init(**kwargs): + calls.append(kwargs) + return MagicMock() + + monkeypatch.setattr(ExecutionEngine, "init", fake_init) + return calls + + +def _bound_observer(kwargs: dict): + return kwargs["init_parameters"][BINDING_KEY] + + +class _DummyInstrumentator: + def __init__(self, app, model_manager, endpoint="/metrics"): + self.app = app + self.model_manager = model_manager + self.endpoint = endpoint + + def set_stream_manager_client(self, stream_manager_client) -> None: + self.stream_manager_client = stream_manager_client + + +def _http_client(monkeypatch): + from starlette.testclient import TestClient + + import inference.core.interfaces.http.http_api as http_api + + monkeypatch.setattr(http_api, "InferenceInstrumentator", _DummyInstrumentator) + model_manager = MagicMock() + model_manager.pingback = None + model_manager.num_errors = 0 + return TestClient(http_api.HttpInterface(model_manager=model_manager).app) + + +_TRIVIAL_WORKFLOW = { + "version": "1.0", + "inputs": [{"type": "WorkflowParameter", "name": "value"}], + "steps": [], + "outputs": [], +} + + +def test_http_run_root_binds_the_observer(monkeypatch, captured_engine_init) -> None: + client = _http_client(monkeypatch) + client.post( + "/workflows/run", + json={ + "api_key": "binding-key", + "specification": _TRIVIAL_WORKFLOW, + "inputs": {"value": 1}, + }, + ) + assert captured_engine_init, "the route never reached ExecutionEngine.init" + assert isinstance( + _bound_observer(captured_engine_init[-1]), UsageTrackingExecutionObserver + ) + + +def test_http_validate_root_binds_the_observer( + monkeypatch, captured_engine_init +) -> None: + client = _http_client(monkeypatch) + client.post("/workflows/validate?api_key=binding-key", json=_TRIVIAL_WORKFLOW) + assert captured_engine_init, "the route never reached ExecutionEngine.init" + assert isinstance( + _bound_observer(captured_engine_init[-1]), UsageTrackingExecutionObserver + ) + + +def test_pipeline_root_binds_the_observer(monkeypatch, captured_engine_init) -> None: + from inference.core.interfaces.stream.inference_pipeline import InferencePipeline + + monkeypatch.setattr( + InferencePipeline, "init_with_custom_logic", MagicMock(return_value=MagicMock()) + ) + InferencePipeline.init_with_workflow( + video_reference="video.mp4", + workflow_specification={"version": "1.0"}, + model_manager=MagicMock(), + ) + assert captured_engine_init, "init_with_workflow never reached ExecutionEngine.init" + assert isinstance( + _bound_observer(captured_engine_init[-1]), UsageTrackingExecutionObserver + ) + + +def test_cli_root_binds_the_observer( + monkeypatch, tmp_path, captured_engine_init +) -> None: + from concurrent.futures import ThreadPoolExecutor + + import numpy as np + + from inference_cli.lib.workflows import local_image_adapter + + monkeypatch.setattr( + local_image_adapter.cv2, "imread", lambda _: np.zeros((2, 2, 3)) + ) + with ThreadPoolExecutor(max_workers=1) as pool: + local_image_adapter._run_workflow_for_single_image_with_inference( + model_manager=MagicMock(), + image_path=str(tmp_path / "image.jpg"), + workflow_specification={"version": "1.0"}, + workflow_id=None, + image_input_name="image", + workflow_parameters=None, + api_key="binding-key", + thread_pool_executor=pool, + max_concurrent_workflows_steps=1, + ) + assert captured_engine_init, "the CLI adapter never reached ExecutionEngine.init" + assert isinstance( + _bound_observer(captured_engine_init[-1]), UsageTrackingExecutionObserver + ) From 805830ea0503c4e66ca76f4f211668e2edfcaa3f Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:06:23 +0200 Subject: [PATCH 24/86] refactor(workflows): route engine billing and tracing through ExecutionObserver Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../workflows/execution_engine/v1/core.py | 93 ++++- .../execution_engine/v1/executor/core.py | 147 ++++--- .../http/test_workflow_billing_contract.py | 132 ++++++ .../test_workflows_execution_observer.py | 97 +++++ .../test_workflows_trace_parentage.py | 388 ++++++++++++++++++ .../unit_tests/decontamination_baseline.txt | 5 +- .../executor/test_step_context.py | 57 --- .../executor/test_step_observer.py | 121 ++++++ .../test_observer_resolution.py | 201 +++++++++ 9 files changed, 1091 insertions(+), 150 deletions(-) create mode 100644 tests/inference/unit_tests/test_workflows_trace_parentage.py delete mode 100644 tests/workflows/unit_tests/execution_engine/executor/test_step_context.py create mode 100644 tests/workflows/unit_tests/execution_engine/executor/test_step_observer.py create mode 100644 tests/workflows/unit_tests/execution_engine/test_observer_resolution.py diff --git a/inference/core/workflows/execution_engine/v1/core.py b/inference/core/workflows/execution_engine/v1/core.py index 9b8ed14297..303d9ed258 100644 --- a/inference/core/workflows/execution_engine/v1/core.py +++ b/inference/core/workflows/execution_engine/v1/core.py @@ -1,6 +1,7 @@ import logging import os from concurrent.futures import ThreadPoolExecutor +from functools import partial from typing import Any, Callable, Dict, List, Optional, Set, Union from packaging.version import Version @@ -48,6 +49,10 @@ is_workflow_selector, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.observer import ( + NULL_EXECUTION_OBSERVER, + ExecutionObserver, +) from inference.core.workflows.prototypes.workspace_resolver import ( NULL_WORKSPACE_RESOLVER, ) @@ -116,6 +121,51 @@ def _retrieve_step_execution_mode( return StepExecutionMode(value) +def _resolve_execution_observer( + init_parameters: Dict[str, Any], +) -> ExecutionObserver: + """Resolve the observer once and republish it for every consumer. + + Three lookup rules have to agree and, left alone, do not: + + * ``_retrieve_init_parameter`` accepts ``workflows_core.execution_observer`` + *or* the bare name, and calls the value when it is callable - so a host + may legitimately bind a factory, or bind without a namespace. + * ``retrieve_init_parameter_values`` gives a block whatever object is under + ``.execution_observer``, without calling it. + * ``REGISTERED_INITIALIZERS`` defaults are namespaced to ``workflows_core.*`` + by ``load_core_blocks_initializers``, which is a different namespace from + ``dynamic_workflows_blocks`` - not a fallback for it. + + So a bare binding would leave dynamic blocks with the null observer, and a + factory would hand them the function itself. Resolving here and writing the + resolved object back under both namespaced keys makes all three agree, and + calls a factory exactly once per engine. + + ``init_parameters`` is the engine's PRIVATE copy (see ``init``), never the + caller's dictionary: writing a resolved factory result into a dictionary + the caller reuses would hand every later engine this one's observer. + + A host that deliberately wants a *different* observer for custom-Python + blocks keeps that: an explicit ``dynamic_workflows_blocks.execution_observer`` + is resolved separately and wins for those blocks. + """ + dynamic_key = "dynamic_workflows_blocks.execution_observer" + dynamic_override = init_parameters.get(dynamic_key) + if callable(dynamic_override): + dynamic_override = dynamic_override() + observer = _retrieve_init_parameter( + init_parameters=init_parameters, parameter_name="execution_observer" + ) + if observer is None: + observer = NULL_EXECUTION_OBSERVER + init_parameters["workflows_core.execution_observer"] = observer + init_parameters[dynamic_key] = ( + dynamic_override if dynamic_override is not None else observer + ) + return observer + + def _is_locally_executed_platform_model( dependency: DependentResource, step_execution_mode: StepExecutionMode, @@ -322,6 +372,11 @@ def init( ) step_error_handler = REGISTERED_STEP_ERROR_HANDLERS[step_error_handler] _mirror_dynamic_block_parameters(init_parameters) + # Phase 6 - resolve once, so the engine and every block observe through + # the same object. Writes both namespaced keys; see + # `_resolve_execution_observer`. After the mirror, before compilation: + # blocks are constructed during `compile_workflow`. + execution_observer = _resolve_execution_observer(init_parameters) if profiler is None: profiler = NullWorkflowsProfiler.init() @@ -378,6 +433,7 @@ def init( pre_init_model_manager=pre_init_model_manager, pre_init_api_key=pre_init_api_key, pre_init_step_execution_mode=pre_init_step_execution_mode, + execution_observer=execution_observer, ) def __init__( @@ -394,6 +450,7 @@ def __init__( pre_init_model_manager: Optional[Any] = None, pre_init_api_key: Optional[str] = None, pre_init_step_execution_mode: Optional[StepExecutionMode] = None, + execution_observer: Optional[ExecutionObserver] = None, ): self._compiled_workflow = compiled_workflow self._max_concurrent_steps = max_concurrent_steps @@ -408,6 +465,11 @@ def __init__( self._pre_init_api_key = pre_init_api_key self._pre_init_step_execution_mode = pre_init_step_execution_mode self._pending_dependencies_resolution_attempted = False + self._execution_observer = ( + execution_observer + if execution_observer is not None + else NULL_EXECUTION_OBSERVER + ) def run( self, @@ -450,20 +512,26 @@ def run( self._workflow_id, ) usage_workflow_id = self._workflow_id - result = run_workflow( + result = self._execution_observer.observe_workflow_run( workflow=self._compiled_workflow, runtime_parameters=runtime_parameters, - max_concurrent_steps=self._max_concurrent_steps, - usage_fps=fps, - usage_workflow_id=usage_workflow_id, - usage_workflow_preview=_is_preview, - kinds_serializers=self._compiled_workflow.kinds_serializers, - serialize_results=serialize_results, - profiler=self._profiler, - executor=self._executor, - step_error_handler=self._step_error_handler, - defer_stream_pipeline_flush=defer_stream_pipeline_flush, - resolve_output_futures=resolve_output_futures, + workflow_id=usage_workflow_id, + fps=fps, + is_preview=_is_preview, + run=partial( + run_workflow, + workflow=self._compiled_workflow, + runtime_parameters=runtime_parameters, + max_concurrent_steps=self._max_concurrent_steps, + kinds_serializers=self._compiled_workflow.kinds_serializers, + serialize_results=serialize_results, + profiler=self._profiler, + executor=self._executor, + step_error_handler=self._step_error_handler, + defer_stream_pipeline_flush=defer_stream_pipeline_flush, + resolve_output_futures=resolve_output_futures, + observer=self._execution_observer, + ), ) self._profiler.end_workflow_run() return result @@ -497,6 +565,7 @@ def flush_stream_pipeline( profiler=self._profiler, executor=self._executor, step_error_handler=self._step_error_handler, + observer=self._execution_observer, ) self._profiler.end_workflow_run() return result diff --git a/inference/core/workflows/execution_engine/v1/executor/core.py b/inference/core/workflows/execution_engine/v1/executor/core.py index 6d1aa230d7..ff64792864 100644 --- a/inference/core/workflows/execution_engine/v1/executor/core.py +++ b/inference/core/workflows/execution_engine/v1/executor/core.py @@ -22,12 +22,6 @@ remote_processing_times = None from inference.core.env import INFERENCE_DEBUG_OUTPUT_DIR -from inference.core.telemetry import ( - attach_context, - capture_context, - detach_context, - start_span, -) from inference.core.workflows.errors import ( BlockTraceback, StepExecutionError, @@ -65,8 +59,10 @@ run_steps_in_parallel, ) from inference.core.workflows.prototypes.block import WorkflowBlock -from inference.usage_tracking.collector import usage_collector -from inference.usage_tracking.stream_session import stream_session_id +from inference.core.workflows.prototypes.observer import ( + NULL_EXECUTION_OBSERVER, + ExecutionObserver, +) logger = logging.getLogger(__name__) @@ -96,7 +92,6 @@ def _store_crash_info( logger.error(f"Failed to store crash info: {e}") -@usage_collector("workflows") @execution_phase( name="workflow_execution", categories=["execution_engine_operation"], @@ -112,20 +107,21 @@ def run_workflow( step_error_handler: Optional[Callable[[Exception], None]] = None, defer_stream_pipeline_flush: bool = False, resolve_output_futures: bool = True, + observer: ExecutionObserver = NULL_EXECUTION_OBSERVER, ) -> List[Dict[str, Any]]: - with start_span("workflow.run"): - return _run_workflow( - workflow=workflow, - runtime_parameters=runtime_parameters, - max_concurrent_steps=max_concurrent_steps, - kinds_serializers=kinds_serializers, - serialize_results=serialize_results, - profiler=profiler, - executor=executor, - step_error_handler=step_error_handler, - defer_stream_pipeline_flush=defer_stream_pipeline_flush, - resolve_output_futures=resolve_output_futures, - ) + return _run_workflow( + workflow=workflow, + runtime_parameters=runtime_parameters, + max_concurrent_steps=max_concurrent_steps, + kinds_serializers=kinds_serializers, + serialize_results=serialize_results, + profiler=profiler, + executor=executor, + step_error_handler=step_error_handler, + defer_stream_pipeline_flush=defer_stream_pipeline_flush, + resolve_output_futures=resolve_output_futures, + observer=observer, + ) def _run_workflow( @@ -139,6 +135,7 @@ def _run_workflow( step_error_handler: Optional[Callable[[Exception], None]] = None, defer_stream_pipeline_flush: bool = False, resolve_output_futures: bool = True, + observer: ExecutionObserver = NULL_EXECUTION_OBSERVER, ) -> List[Dict[str, Any]]: execution_data_manager = ExecutionDataManager.init( execution_graph=workflow.execution_graph, @@ -160,6 +157,7 @@ def _run_workflow( profiler=profiler, executor=executor, step_error_handler=step_error_handler, + observer=observer, ) next_steps = execution_coordinator.get_steps_to_execute_next( profiler=profiler @@ -199,6 +197,7 @@ def flush_stream_pipeline_workflow( profiler: Optional[WorkflowsProfiler] = None, executor: Optional[ThreadPoolExecutor] = None, step_error_handler: Optional[Callable[[Exception], None]] = None, + observer: ExecutionObserver = NULL_EXECUTION_OBSERVER, ) -> List[Dict[str, Any]]: execution_data_manager = ExecutionDataManager.init( execution_graph=workflow.execution_graph, @@ -236,6 +235,7 @@ def flush_stream_pipeline_workflow( executor=executor, step_error_handler=step_error_handler, workflow_execution_id=workflow_execution_id, + observer=observer, ) next_steps = execution_coordinator.get_steps_to_execute_next(profiler=profiler) return construct_workflow_output( @@ -341,6 +341,7 @@ def execute_steps( profiler: Optional[WorkflowsProfiler] = None, executor: Optional[ThreadPoolExecutor] = None, step_error_handler: Optional[Callable[[str, Exception], None]] = None, + observer: ExecutionObserver = NULL_EXECUTION_OBSERVER, ) -> None: if remote_processing_times is not None: processing_time_collector = remote_processing_times.get() @@ -355,9 +356,9 @@ def execute_steps( # set in this thread do not propagate into ThreadPoolExecutor workers. debug_collector = current_debug_collector.get() debug_trace = current_debug_trace.get() - pipeline_stream_session_id = stream_session_id.get() - # Capture OTel context so it can be re-attached inside worker threads - otel_ctx = capture_context() + # Whatever the host needs inside a worker thread, snapshotted here for the + # same reason. + step_context = observer.capture_step_context() logger.debug(f"Executing steps: {next_steps}.") steps_functions = [ partial( @@ -371,9 +372,9 @@ def execute_steps( duration_minimum_value=duration_minimum_value, debug_collector=debug_collector, debug_trace=debug_trace, - pipeline_stream_session_id=pipeline_stream_session_id, step_error_handler=step_error_handler, - otel_ctx=otel_ctx, + observer=observer, + step_context=step_context, ) for step_selector in next_steps ] @@ -397,9 +398,9 @@ def safe_execute_step( duration_minimum_value=None, debug_collector=None, debug_trace=None, - pipeline_stream_session_id=None, step_error_handler: Optional[Callable[[str, Exception], None]] = None, - otel_ctx=None, + observer: ExecutionObserver = NULL_EXECUTION_OBSERVER, + step_context=None, ) -> None: if execution_id is not None and workflow_execution_id: execution_id.set(workflow_execution_id) @@ -412,58 +413,50 @@ def safe_execute_step( # this thread, silently accumulating logs on a dead object. current_debug_collector.set(debug_collector) current_debug_trace.set(debug_trace) - stream_session_id.set(pipeline_stream_session_id) step_name = get_last_chunk_of_selector(selector=step_selector) current_debug_step_name.set(step_name) - # Re-attach OTel context in worker thread so trace propagation works. - # Must detach when done — threads are reused in the pool, and leaked - # contexts cause incorrect span parenting on subsequent tasks. - _otel_token = attach_context(otel_ctx) if profiler is None: profiler = NullWorkflowsProfiler.init() - try: - with start_span("workflow.step", {"workflow.step": step_name}): - try: - logger.debug( - f"started execution of: {step_selector} - {datetime.now().isoformat()}" - ) - run_step( - step_selector=step_selector, - workflow=workflow, - execution_data_manager=execution_data_manager, - profiler=profiler, - ) - logger.debug( - f"finished execution of: {step_selector} - {datetime.now().isoformat()}" - ) - except WorkflowError: - raise - except Exception as error: - if step_error_handler: - step_error_handler(step_name, error) - logger.exception( - f"Execution of step {step_selector} encountered error." - ) - error_traceback = "".join( - traceback.format_exception(type(error), error, error.__traceback__) - ) - block_traceback = BlockTraceback( - traceback=error_traceback, - error_line=getattr(error, "error_line", None), - code_snippet=getattr(error, "code_snippet", None), - stdout=getattr(error, "stdout", None), - stderr=getattr(error, "stderr", None), - ) - raise StepExecutionError( - block_id=step_name, - block_type=workflow.steps[step_name].manifest.type, - block_traceback=block_traceback, - public_message=str(error), - context="workflow_execution | step_execution", - inner_error=error, - ) from error - finally: - detach_context(_otel_token) + # The host re-establishes its own context here, for this step only: pool + # threads are reused, so anything it binds it must also unbind. + with observer.step_scope(context=step_context, step_name=step_name): + try: + logger.debug( + f"started execution of: {step_selector} - {datetime.now().isoformat()}" + ) + run_step( + step_selector=step_selector, + workflow=workflow, + execution_data_manager=execution_data_manager, + profiler=profiler, + ) + logger.debug( + f"finished execution of: {step_selector} - {datetime.now().isoformat()}" + ) + except WorkflowError: + raise + except Exception as error: + if step_error_handler: + step_error_handler(step_name, error) + logger.exception(f"Execution of step {step_selector} encountered error.") + error_traceback = "".join( + traceback.format_exception(type(error), error, error.__traceback__) + ) + block_traceback = BlockTraceback( + traceback=error_traceback, + error_line=getattr(error, "error_line", None), + code_snippet=getattr(error, "code_snippet", None), + stdout=getattr(error, "stdout", None), + stderr=getattr(error, "stderr", None), + ) + raise StepExecutionError( + block_id=step_name, + block_type=workflow.steps[step_name].manifest.type, + block_traceback=block_traceback, + public_message=str(error), + context="workflow_execution | step_execution", + inner_error=error, + ) from error def run_step( diff --git a/tests/inference/unit_tests/core/interfaces/http/test_workflow_billing_contract.py b/tests/inference/unit_tests/core/interfaces/http/test_workflow_billing_contract.py index d545d57a07..dc14e225b6 100644 --- a/tests/inference/unit_tests/core/interfaces/http/test_workflow_billing_contract.py +++ b/tests/inference/unit_tests/core/interfaces/http/test_workflow_billing_contract.py @@ -224,3 +224,135 @@ def test_preview_flag_reaches_workflow_and_block_rows(monkeypatch): assert preview_by_category["workflows"] is True assert preview_by_category["workflow_block"] is True assert _billable_by_category(api_key)["workflow_block"] is True + + +_FAILING_BLOCK_CODE = """ +def run(self, value) -> BlockResult: + raise RuntimeError("block exploded") +""" + + +def _failing_specification() -> dict: + return { + "version": "1.0", + "inputs": [{"type": "WorkflowParameter", "name": "value"}], + "dynamic_blocks_definitions": [ + { + "type": "DynamicBlockDefinition", + "manifest": { + "type": "ManifestDescription", + "block_type": "ExplodingBlock", + "inputs": { + "value": { + "type": "DynamicInputDefinition", + "selector_types": ["input_parameter"], + } + }, + "outputs": { + "result": {"type": "DynamicOutputDefinition", "kind": []} + }, + }, + "code": { + "type": "PythonCode", + "run_function_code": _FAILING_BLOCK_CODE, + }, + } + ], + "steps": [{"type": "ExplodingBlock", "name": "boom", "value": "$inputs.value"}], + "outputs": [ + {"type": "JsonField", "name": "result", "selector": "$steps.boom.result"} + ], + } + + +def _row_for_category(api_key: str, category: str) -> dict: + """The single usage row recorded for `api_key` in `category`.""" + rows = [ + row + for key, row in _rows_for_api_key(api_key).items() + if key.split(":", 1)[0] == category + ] + assert len(rows) == 1, rows + return rows[0] + + +def test_workflow_row_carries_the_identity_the_engine_computed(monkeypatch): + # given + client = _build_test_client(monkeypatch) + api_key = "workflow-row-identity-key" + + # when + response = client.post( + "/workflows/run", + json={ + "api_key": api_key, + "specification": _specification(api_key), + "inputs": {"value": 1}, + }, + ) + + # then - every field the removed decorator derived from its own arguments + assert response.status_code == 200 + row = _row_for_category(api_key, "workflows") + assert row["fps"] == 0 + assert row["api_key_hash"] + assert row["resource_id"] + details = json.loads(row["resource_details"]) + assert details["steps"] == ["FakeModelBlock:fake_model"] + assert details["billable"] is True + assert details["is_preview"] is False + + +def test_a_failing_run_still_bills_an_error_row(monkeypatch): + """A run that raises inside a block is billed, and the row says why. + + Real route, real engine, real collector - nothing about the recording is + mocked, because the error path is the one that used to live inside the + decorator on `run_workflow`. + """ + # given + client = _build_test_client(monkeypatch) + api_key = "workflow-error-row-key" + + # when + response = client.post( + "/workflows/run", + json={ + "api_key": api_key, + "specification": _failing_specification(), + "inputs": {"value": 1}, + }, + ) + + # then + assert response.status_code != 200 + details = json.loads(_row_for_category(api_key, "workflows")["resource_details"]) + # The user's exception, not the engine's wrapper: `create_dynamic_block_code_error` + # raises `DynamicBlockCodeError` carrying `inner_error`; it is a `WorkflowError`, + # so `safe_execute_step` re-raises it unwrapped, and the collector prefers + # `inner_error_type` over the exception's own class name. + assert details["error_type"] == "RuntimeError" + + +def test_an_unbound_observer_records_no_workflow_row(monkeypatch): + """The failure mode this whole phase has to make loud. + + An engine initialised without `workflows_core.execution_observer` runs the + workflow and returns results - and bills nothing. Pinned so a root that + loses its binding fails here as well as in the AST check. + """ + # given + from inference.core.workflows.execution_engine.core import ExecutionEngine + + api_key = "unbound-observer-key" + + # when + engine = ExecutionEngine.init( + workflow_definition=_specification(api_key), + init_parameters={"workflows_core.api_key": api_key}, + ) + engine.run(runtime_parameters={"value": 1}) + + # then + categories = {key.split(":", 1)[0] for key in _rows_for_api_key(api_key)} + assert "workflows" not in categories diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer.py b/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer.py index 1efe1dabf9..91b8cbfa3a 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer.py @@ -244,3 +244,100 @@ def worker(): # then assert seen["value"] is None + + +_STREAM_PROBE_BLOCK = """ +def run(self, value) -> BlockResult: + return {"result": value} +""" + +_STREAM_PROBE_WORKFLOW = { + "version": "1.0", + "inputs": [{"type": "WorkflowParameter", "name": "value"}], + "dynamic_blocks_definitions": [ + { + "type": "DynamicBlockDefinition", + "manifest": { + "type": "ManifestDescription", + "block_type": "StreamProbe", + "inputs": { + "value": { + "type": "DynamicInputDefinition", + "selector_types": ["input_parameter"], + } + }, + "outputs": {"result": {"type": "DynamicOutputDefinition", "kind": []}}, + }, + "code": {"type": "PythonCode", "run_function_code": _STREAM_PROBE_BLOCK}, + } + ], + "steps": [{"type": "StreamProbe", "name": "probe", "value": "$inputs.value"}], + "outputs": [ + {"type": "JsonField", "name": "result", "selector": "$steps.probe.result"} + ], +} + + +def _rows_by_category(recorded: dict) -> dict: + rows = [row for per_key in recorded.values() for row in per_key.values()] + return {row["category"]: row for row in rows} + + +def test_stream_session_reaches_the_workflow_and_the_block_rows() -> None: + """A pipeline's session id must be on every row its run produces. + + Real engine, real observer, real collector; only the usage dictionary is a + throwaway. The session id is bound in the calling thread, and the block row + is recorded inside a pool worker - so this is the end-to-end statement of + what `capture_step_context` / `step_scope` exist for. + """ + # given + from inference.core.workflows.execution_engine.core import ExecutionEngine + + observer = UsageTrackingExecutionObserver() + engine = ExecutionEngine.init( + workflow_definition=_STREAM_PROBE_WORKFLOW, + init_parameters={ + "workflows_core.api_key": "stream-identity-key", + "workflows_core.execution_observer": observer, + }, + ) + recorded = usage_collector.empty_usage_dict(exec_session_id="test-session") + + # when + token = stream_session_id.set("camera-7") + try: + with mock.patch.object(usage_collector, "_usage", recorded): + engine.run(runtime_parameters={"value": 1}) + finally: + stream_session_id.reset(token) + + # then + by_category = _rows_by_category(recorded) + assert set(by_category) == {"workflows", "workflow_block"} + assert by_category["workflows"]["stream_session_id"] == "camera-7" + assert by_category["workflow_block"]["stream_session_id"] == "camera-7" + + +def test_a_run_without_a_stream_session_produces_rows_without_one() -> None: + # given - the same workflow, no session bound anywhere + from inference.core.workflows.execution_engine.core import ExecutionEngine + + engine = ExecutionEngine.init( + workflow_definition=_STREAM_PROBE_WORKFLOW, + init_parameters={ + "workflows_core.api_key": "no-stream-key", + "workflows_core.execution_observer": UsageTrackingExecutionObserver(), + }, + ) + recorded = usage_collector.empty_usage_dict(exec_session_id="test-session") + + # when + with mock.patch.object(usage_collector, "_usage", recorded): + engine.run(runtime_parameters={"value": 1}) + + # then - no stale id from a previous test's pool thread + by_category = _rows_by_category(recorded) + assert set(by_category) == {"workflows", "workflow_block"} + for row in by_category.values(): + assert not row.get("stream_session_id") diff --git a/tests/inference/unit_tests/test_workflows_trace_parentage.py b/tests/inference/unit_tests/test_workflows_trace_parentage.py new file mode 100644 index 0000000000..a93f5261df --- /dev/null +++ b/tests/inference/unit_tests/test_workflows_trace_parentage.py @@ -0,0 +1,388 @@ +"""Span parentage across the engine's thread pool, before and after the port. + +The tracer provider is process-global and `inference.core.telemetry` caches the +tracer in a module global, so every scenario runs in a child interpreter that +installs an in-memory exporter before importing anything from `inference`. + +Four scenarios, one child each. `bound` and `unbound` are the port itself: +with the host bound the tree is exactly what HEAD produced; with no host bound +the engine must emit *no* spans of its own, which is what proves the tracing +moved into the observer rather than merely surviving. `failing` is the +exception path this phase relocates (`executor/core.py:465`'s `detach_context` +moves into the observer's `step_scope`): a step that raises inside its own +span, then another run on the *same single-worker executor*, checking that +every span of the failed run closed with an ERROR status under the right +parent, that the caller's current span is intact afterwards, that the worker +thread has nothing attached, and that the second run's tree is clean. +`remote` is a custom-Python block executed through the real `ModalExecutor` +with only the HTTP session faked: the existing client-side behaviour is that +the step span wraps the remote call, the executor opens no span of its own, +and no trace context is sent to the sandbox - this phase must preserve all +three, not improve them. + +The child's environment is **built from scratch** - a short passthrough list +plus explicit pins - and the child runs in an **empty working directory**, +because neither inheriting nor popping is enough: + +* `inference_models/_offline.py:166` writes + `_ROBOFLOW_INFERENCE_OFFLINE_MODE_AT_PROCESS_START` into `os.environ` at + import time and `_decide_offline_mode` prefers that latch over + `OFFLINE_MODE`, so `{**os.environ, "OFFLINE_MODE": "False"}` inherits the + parent's decision and `telemetry._otel_enabled()` then disables every span. +* `inference/core/env.py:21` runs `load_dotenv(os.getcwd() + "/.env")`, which + fills in a *missing* key from a `.env` in the working directory. Setting + `WORKFLOWS_PLUGINS=""` to defeat that is not an option: + `blocks_loader.get_plugin_modules` splits the string on commas and + `import_module("")` raises `ValueError: Empty module name` (executed). So the + key is left ABSENT (`get_plugin_modules` returns `[]` for an absent key) and + the child's cwd is an empty temporary directory, which has no `.env`. +* `inference/core/env.py:1172-1179` re-inserts the enterprise plugin into + `WORKFLOWS_PLUGINS` whenever `LOAD_ENTERPRISE_BLOCKS` is true, so that flag + is pinned false. (After Phase 9, `env.py` also inserts the Roboflow-platform + plugin unconditionally; it is server code that imports cleanly here and runs + no step, so it does not change the span tree.) +* The custom-Python execution mode is pinned per scenario (`local`, or `modal` + with fake Modal credentials so `MODAL_AVAILABLE` is true), and + `DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER` can name a handler the engine does not + register - it registers only `"legacy"`. +* With the cwd empty, `PYTHONPATH` must carry the repo root explicitly, or the + venv's editable install resolves `inference` from a different checkout. + +`scripts/workflows_isolation_probe.py:379-406` is the precedent; this goes +further by building the environment rather than filtering it. +""" + +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +PROBE_TIMEOUT_SECONDS = 300 + +# tests/inference/unit_tests/ -> three levels up is the repo root +REPO_ROOT = Path(__file__).resolve().parents[3] + +_PROBE_TEMPLATE = r""" +import json +import sys +from concurrent.futures import ThreadPoolExecutor +from unittest import mock + +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +_EXPORTER = InMemorySpanExporter() +_PROVIDER = TracerProvider() +_PROVIDER.add_span_processor(SimpleSpanProcessor(_EXPORTER)) +trace.set_tracer_provider(_PROVIDER) + +from inference.core.telemetry import start_span +from inference.core.workflows.execution_engine.core import ExecutionEngine +from inference.core.workflows.execution_engine.v1.dynamic_blocks import ( + block_scaffolding, + modal_executor, +) + +SCENARIO = "__SCENARIO__" + +# The block opens its own span, then fails on request - inside that span, the +# way a model call that raises would. +BLOCK_CODE = ''' +def run(self, value) -> BlockResult: + from inference.core.telemetry import start_span + + with start_span("model.infer"): + if value == "boom": + raise RuntimeError("boom") + return {"result": True} +''' + +SPEC = { + "version": "1.0", + "inputs": [{"type": "WorkflowParameter", "name": "value"}], + "dynamic_blocks_definitions": [ + { + "type": "DynamicBlockDefinition", + "manifest": { + "type": "ManifestDescription", + "block_type": "SpanProbe", + "inputs": { + "value": { + "type": "DynamicInputDefinition", + "selector_types": ["input_parameter"], + } + }, + "outputs": {"result": {"type": "DynamicOutputDefinition", "kind": []}}, + }, + "code": {"type": "PythonCode", "run_function_code": BLOCK_CODE}, + } + ], + "steps": [{"type": "SpanProbe", "name": "probe", "value": "$inputs.value"}], + "outputs": [ + {"type": "JsonField", "name": "result", "selector": "$steps.probe.result"} + ], +} + + +class _StubWorkspaceResolver: + def resolve_workspace(self, api_key): + return "test-workspace" + + +class _FakeResponse: + status_code = 200 + text = "" + + def json(self): + return { + "success": True, + "result": json.dumps({"result": True}), + "execution_time_seconds": 0.1, + } + + +class _FakeSession: + # The transport, one level below `_post_execute`, so the headers the + # client would send are observable. + + def __init__(self): + self.headers_sent = [] + + def post(self, url, data=None, timeout=None, headers=None): + self.headers_sent.append(dict(headers or {})) + return _FakeResponse() + + +def _spans_by_run(): + # Spans grouped per trace, each group in start order, traces in start order. + spans = _EXPORTER.get_finished_spans() + by_id = {s.context.span_id: s.name for s in spans} + runs = {} + for span in spans: + runs.setdefault(span.context.trace_id, []).append(span) + ordered = sorted(runs.values(), key=lambda group: min(s.start_time for s in group)) + return [ + [ + { + "name": s.name, + "parent": by_id.get(s.parent.span_id) if s.parent else None, + "status": s.status.status_code.name, + } + for s in sorted(group, key=lambda s: s.start_time) + ] + for group in ordered + ] + + +init_parameters = {"workflows_core.api_key": "probe-key"} +if SCENARIO != "unbound": + from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, + ) + + init_parameters["workflows_core.execution_observer"] = ( + UsageTrackingExecutionObserver() + ) + +facts = {} +if SCENARIO in ("bound", "unbound"): + engine = ExecutionEngine.init(workflow_definition=SPEC, init_parameters=init_parameters) + with start_span("http.request"): + engine.run(runtime_parameters={"value": 1}) +elif SCENARIO == "failing": + # One worker, so the second run provably reuses the thread the failing + # step ran on. + with ThreadPoolExecutor(max_workers=1) as executor: + engine = ExecutionEngine.init( + workflow_definition=SPEC, init_parameters=init_parameters, executor=executor + ) + with start_span("http.request"): + try: + engine.run(runtime_parameters={"value": "boom"}) + except Exception as error: # the step failure, wrapped by the engine + facts["raised"] = type(error).__name__ + # Still inside the request span: the run's own spans must have closed. + facts["caller_current_after_failure"] = trace.get_current_span().name + # Nothing may remain attached in the worker thread's own context. + facts["worker_has_current_span"] = executor.submit( + lambda: trace.get_current_span().get_span_context().is_valid + ).result() + with start_span("http.request"): + facts["second_run"] = engine.run(runtime_parameters={"value": 1}) +elif SCENARIO == "remote": + session = _FakeSession() + real_executor = modal_executor.ModalExecutor("test-workspace") + patches = [ + mock.patch.object(modal_executor, "validate_code_in_modal", lambda *a, **k: True), + mock.patch.object(modal_executor, "get_modal_executor", lambda workspace_id=None: real_executor), + mock.patch.object(modal_executor.ModalExecutor, "_get_endpoint_url", return_value="https://example.invalid"), + mock.patch.object(modal_executor.ModalExecutor, "_get_session", return_value=session), + ] + if hasattr(block_scaffolding, "get_roboflow_workspace"): # before Phase 9 + patches.append( + mock.patch.object(block_scaffolding, "get_roboflow_workspace", return_value="test-workspace") + ) + else: # after Phase 9: the injected resolver answers + init_parameters["workflows_core.workspace_resolver"] = _StubWorkspaceResolver() + for patch in patches: + patch.start() + engine = ExecutionEngine.init(workflow_definition=SPEC, init_parameters=init_parameters) + with start_span("http.request"): + facts["result"] = engine.run(runtime_parameters={"value": 1}) + facts["requests_sent"] = len(session.headers_sent) + facts["trace_headers_sent"] = sorted( + key for headers in session.headers_sent for key in headers if key.lower() in ("traceparent", "tracestate") + ) +else: + raise SystemExit(f"unknown scenario {SCENARIO}") + +print("RUNS:" + json.dumps(_spans_by_run())) +print("FACTS:" + json.dumps(facts, default=str)) +""" + + +# Only these are inherited; everything else the child needs is set below. +# Anything not listed here is deliberately absent, so a developer's shell or a +# CI job cannot change what this test measures. `WORKFLOWS_PLUGINS` is absent +# on purpose (see the module docstring). +PASSTHROUGH_ENV_KEYS = ("PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "VIRTUAL_ENV") + + +def _child_env(scenario: str) -> dict: + env = {key: os.environ[key] for key in PASSTHROUGH_ENV_KEYS if key in os.environ} + env.update( + { + # The repo root explicitly: the child's cwd is an empty directory. + "PYTHONPATH": f"{REPO_ROOT}{os.pathsep}{REPO_ROOT / 'inference_models'}", + # The process latch beats OFFLINE_MODE in `_decide_offline_mode` + # (`inference_models/_offline.py:69-72`), so it has to say the same + # thing rather than be absent. + "_ROBOFLOW_INFERENCE_OFFLINE_MODE_AT_PROCESS_START": "False", + "OFFLINE_MODE": "False", # telemetry helpers are no-ops offline + # `env.py:1172-1179` re-inserts the enterprise plugin into + # WORKFLOWS_PLUGINS whenever this is true. + "LOAD_ENTERPRISE_BLOCKS": "False", + # The engine registers only "legacy" (`v1/core.py:59-61`). + "DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER": "legacy", + "WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE": ( + "modal" if scenario == "remote" else "local" + ), + "ALLOW_CUSTOM_PYTHON_EXECUTION_IN_WORKFLOWS": "True", + "ENABLE_TENSOR_DATA_REPRESENTATION": "False", + "DISABLE_VERSION_CHECK": "True", + "PYTHONOPTIMIZE": "0", + } + ) + if scenario == "remote": + # Fake credentials make `modal_executor.MODAL_AVAILABLE` true; the HTTP + # session is replaced inside the child, so nothing is ever sent. + env.update( + { + "MODAL_TOKEN_ID": "probe-token", + "MODAL_TOKEN_SECRET": "probe-secret", + "WEBEXEC_TRANSPORT": "http", + } + ) + return env + + +def _run(scenario: str) -> tuple: + """(spans grouped per run, facts) reported by the child for `scenario`.""" + probe = _PROBE_TEMPLATE.replace("__SCENARIO__", scenario) + with tempfile.TemporaryDirectory() as empty_cwd: # no `.env` here + proc = subprocess.run( + [sys.executable, "-c", probe], + env=_child_env(scenario), + cwd=empty_cwd, + capture_output=True, + text=True, + timeout=PROBE_TIMEOUT_SECONDS, + ) + assert proc.returncode == 0, proc.stderr + lines = { + line.split(":", 1)[0]: json.loads(line.split(":", 1)[1]) + for line in proc.stdout.splitlines() + if line.startswith(("RUNS:", "FACTS:")) + } + assert set(lines) == {"RUNS", "FACTS"}, proc.stdout + return lines["RUNS"], lines["FACTS"] + + +def _span(name: str, parent, status: str = "UNSET") -> dict: + return {"name": name, "parent": parent, "status": status} + + +# The tree HEAD produces for one successful local block, in start order. +_LOCAL_RUN = [ + _span("http.request", None), + _span("workflow.run", "http.request"), + _span("workflow.step", "workflow.run"), + _span("model.infer", "workflow.step"), +] + + +def test_model_call_inside_a_block_is_a_child_of_the_step_span() -> None: + # given / when + runs, _ = _run("bound") + + # then - the exact tree HEAD produced, preserved through the observer + assert runs == [_LOCAL_RUN] + + +def test_without_a_host_the_engine_emits_no_spans_of_its_own() -> None: + # given / when + runs, _ = _run("unbound") + + # then - the block's own span parents straight to the caller's + assert runs == [[_span("http.request", None), _span("model.infer", "http.request")]] + + +def test_a_failing_step_closes_its_spans_and_leaves_nothing_behind() -> None: + """The exception path: `step_scope` must detach on failure, and the + observer's `workflow.run` span must close, so a second run on the same + worker thread starts clean.""" + # given / when + runs, facts = _run("failing") + + # then - the failed run: every span under its right parent, ERROR status + # recorded by the span that raised and by the two that wrapped it + assert runs == [ + [ + _span("http.request", None), + _span("workflow.run", "http.request", "ERROR"), + _span("workflow.step", "workflow.run", "ERROR"), + _span("model.infer", "workflow.step", "ERROR"), + ], + _LOCAL_RUN, + ] + # the engine re-raised the user-code error under its usual wrapper + assert facts["raised"] == "DynamicBlockCodeError" + # the caller's own span was current again once the run had raised + assert facts["caller_current_after_failure"] == "http.request" + # nothing stayed attached in the worker thread that ran the failed step + assert facts["worker_has_current_span"] is False + assert facts["second_run"] == [{"result": True}] + + +def test_a_remote_block_keeps_the_existing_client_side_tracing() -> None: + """Through the real `ModalExecutor` with only the HTTP session faked: the + step span wraps the remote call, the executor opens no span of its own, and + no trace context is sent to the sandbox - exactly as before the port.""" + # given / when + runs, facts = _run("remote") + + # then + assert runs == [ + [ + _span("http.request", None), + _span("workflow.run", "http.request"), + _span("workflow.step", "workflow.run"), + ] + ] + assert facts["result"] == [{"result": True}] + assert facts["requests_sent"] == 1 + assert facts["trace_headers_sent"] == [] diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 510bc7c627..8ddb97a43c 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 265 +# Count: 262 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env @@ -259,9 +259,6 @@ inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py in inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py inference.usage_tracking.block_execution inference/core/workflows/execution_engine/v1/dynamic_blocks/representation_boundary.py inference.core.env inference/core/workflows/execution_engine/v1/executor/core.py inference.core.env -inference/core/workflows/execution_engine/v1/executor/core.py inference.core.telemetry -inference/core/workflows/execution_engine/v1/executor/core.py inference.usage_tracking.collector -inference/core/workflows/execution_engine/v1/executor/core.py inference.usage_tracking.stream_session inference/core/workflows/execution_engine/v1/executor/output_constructor.py inference.core.env inference/core/workflows/execution_engine/v1/executor/utils.py inference.core.env inference/core/workflows/execution_engine/v1/inner_workflow/compiler_bridge.py inference.core.env diff --git a/tests/workflows/unit_tests/execution_engine/executor/test_step_context.py b/tests/workflows/unit_tests/execution_engine/executor/test_step_context.py deleted file mode 100644 index 029d886bca..0000000000 --- a/tests/workflows/unit_tests/execution_engine/executor/test_step_context.py +++ /dev/null @@ -1,57 +0,0 @@ -from concurrent.futures import ThreadPoolExecutor -from unittest import mock - -from inference.core.workflows.execution_engine.v1.executor import core -from inference.usage_tracking.stream_session import stream_session_id - - -@mock.patch.object(core, "run_step") -def test_safe_execute_step_rebinds_stream_session_id_in_worker_thread(run_step_mock): - # given - blocks (e.g. vision events, dataset upload) read the pipeline's - # stream session id from the contextvar, which does not propagate into - # ThreadPoolExecutor workers on its own - seen = {} - - def capture(**kwargs): - seen["stream_session_id"] = stream_session_id.get() - - run_step_mock.side_effect = capture - - # when - with ThreadPoolExecutor(max_workers=1) as executor: - executor.submit( - core.safe_execute_step, - step_selector="$steps.some_step", - workflow=mock.MagicMock(), - execution_data_manager=mock.MagicMock(), - pipeline_stream_session_id="stream-a", - ).result() - - # then - assert seen["stream_session_id"] == "stream-a" - - -@mock.patch.object(core, "run_step") -def test_safe_execute_step_clears_stale_stream_session_id(run_step_mock): - # given - pool threads are reused; a previous pipeline's id must not leak - seen = {} - - def capture(**kwargs): - seen["stream_session_id"] = stream_session_id.get() - - run_step_mock.side_effect = capture - - def stale_then_execute(): - stream_session_id.set("stale-stream") - core.safe_execute_step( - step_selector="$steps.some_step", - workflow=mock.MagicMock(), - execution_data_manager=mock.MagicMock(), - ) - - # when - with ThreadPoolExecutor(max_workers=1) as executor: - executor.submit(stale_then_execute).result() - - # then - assert seen["stream_session_id"] is None diff --git a/tests/workflows/unit_tests/execution_engine/executor/test_step_observer.py b/tests/workflows/unit_tests/execution_engine/executor/test_step_observer.py new file mode 100644 index 0000000000..f8540cb7e1 --- /dev/null +++ b/tests/workflows/unit_tests/execution_engine/executor/test_step_observer.py @@ -0,0 +1,121 @@ +"""The engine's half of the capture/attach split, with no host bound. + +The engine already re-enters a snapshot of the submitting thread's whole +`contextvars` context per task (`executor/utils.py`), so a host usually needs +nothing extra. A host that must *attach and detach* something - OpenTelemetry - +does, and the engine owns *when* that happens. These tests pin the when, with a +fake observer; nothing here imports the server. +""" + +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from threading import get_ident +from unittest import mock + +from inference.core.workflows.execution_engine.v1.executor import core + + +class RecordingObserver: + def __init__(self): + self.captured_in = None + self.scopes = [] + self.capture_count = 0 + + def observe_workflow_run( + self, *, workflow, runtime_parameters, workflow_id, fps, is_preview, run + ): + return run() + + def capture_step_context(self): + self.capture_count += 1 + self.captured_in = get_ident() + return {"token": self.capture_count} + + @contextmanager + def step_scope(self, *, context, step_name): + self.scopes.append((get_ident(), context, step_name)) + yield + + def observe_block_run(self, *, block, block_args, block_kwargs, run): + return run() + + def observe_model_run(self, *, block, model_id, images, run): + return run() + + +@mock.patch.object(core, "run_step") +def test_step_context_is_captured_once_in_the_caller_and_entered_per_step( + run_step_mock, +) -> None: + # given + observer = RecordingObserver() + + # when + with ThreadPoolExecutor(max_workers=2) as executor: + core.execute_steps( + next_steps=["$steps.first", "$steps.second"], + workflow=mock.MagicMock(), + execution_data_manager=mock.MagicMock(), + max_concurrent_steps=2, + workflow_execution_id="exec-1", + executor=executor, + observer=observer, + ) + + # then - one snapshot, taken here; one scope per step, entered elsewhere + assert observer.capture_count == 1 + assert observer.captured_in == get_ident() + assert sorted(step_name for _, _, step_name in observer.scopes) == [ + "first", + "second", + ] + assert all(context == {"token": 1} for _, context, _ in observer.scopes) + assert all(thread_id != get_ident() for thread_id, _, _ in observer.scopes) + + +@mock.patch.object(core, "run_step") +def test_step_scope_wraps_the_step_and_sees_its_failure(run_step_mock) -> None: + # given + observer = RecordingObserver() + exits = [] + + @contextmanager + def recording_scope(*, context, step_name): + try: + yield + finally: + exits.append(step_name) + + observer.step_scope = recording_scope + run_step_mock.side_effect = RuntimeError("block failed") + workflow = mock.MagicMock() + workflow.steps = {"only": mock.MagicMock()} + + # when + try: + core.safe_execute_step( + step_selector="$steps.only", + workflow=workflow, + execution_data_manager=mock.MagicMock(), + observer=observer, + step_context={"token": 1}, + ) + except Exception: + pass + + # then + assert exits == ["only"] + + +@mock.patch.object(core, "run_step") +def test_the_default_observer_needs_no_binding(run_step_mock) -> None: + # given - a direct engine caller with no host: must not raise + # when + core.safe_execute_step( + step_selector="$steps.only", + workflow=mock.MagicMock(), + execution_data_manager=mock.MagicMock(), + ) + + # then + run_step_mock.assert_called_once() diff --git a/tests/workflows/unit_tests/execution_engine/test_observer_resolution.py b/tests/workflows/unit_tests/execution_engine/test_observer_resolution.py new file mode 100644 index 0000000000..0ce8cd191e --- /dev/null +++ b/tests/workflows/unit_tests/execution_engine/test_observer_resolution.py @@ -0,0 +1,201 @@ +"""The observer is resolved once per engine, into the engine's own copy. + +`_retrieve_init_parameter` accepts `workflows_core.execution_observer` or the +bare name and calls a factory; the steps initialiser gives a block whatever +object sits under `dynamic_workflows_blocks.execution_observer` without +calling it. So the engine resolves the observer once and republishes the +resolved object under both keys - into a private copy of `init_parameters`, +never the caller's dictionary: a root reuses its dictionary for the next +engine, and a written-back factory result would make every later engine +inherit the first one's observer. Nothing here imports the server. +""" + +import pytest + +from inference.core.workflows.execution_engine.core import ExecutionEngine +from inference.core.workflows.execution_engine.v1.core import ( + _resolve_execution_observer, +) +from inference.core.workflows.prototypes.observer import ( + NULL_EXECUTION_OBSERVER, + NullExecutionObserver, +) + +CORE_KEY = "workflows_core.execution_observer" +DYNAMIC_KEY = "dynamic_workflows_blocks.execution_observer" + +_TRIVIAL_WORKFLOW = { + "version": "1.0", + "inputs": [{"type": "WorkflowParameter", "name": "value"}], + "steps": [], + "outputs": [], +} + + +# --- the resolver on plain dictionaries ----------------------------------- + + +def test_resolver_returns_the_null_observer_when_nothing_is_bound() -> None: + parameters = {"workflows_core.api_key": "k"} + + observer = _resolve_execution_observer(parameters) + + assert observer is NULL_EXECUTION_OBSERVER + assert parameters[CORE_KEY] is NULL_EXECUTION_OBSERVER + assert parameters[DYNAMIC_KEY] is NULL_EXECUTION_OBSERVER + + +def test_resolver_publishes_a_namespaced_object_under_both_keys() -> None: + bound = NullExecutionObserver() + parameters = {CORE_KEY: bound} + + assert _resolve_execution_observer(parameters) is bound + assert parameters[CORE_KEY] is bound + assert parameters[DYNAMIC_KEY] is bound + + +def test_resolver_accepts_a_bare_key() -> None: + # `_retrieve_init_parameter` accepts the un-namespaced name, so a host may + # bind it that way; dynamic blocks must not be left with the null observer. + bound = NullExecutionObserver() + parameters = {"execution_observer": bound} + + assert _resolve_execution_observer(parameters) is bound + assert parameters[CORE_KEY] is bound + assert parameters[DYNAMIC_KEY] is bound + + +def test_resolver_calls_a_factory_once_and_publishes_the_instance() -> None: + made = [] + + def factory(): + made.append(NullExecutionObserver()) + return made[-1] + + parameters = {CORE_KEY: factory} + + observer = _resolve_execution_observer(parameters) + + assert made == [observer] + assert parameters[CORE_KEY] is observer + assert parameters[DYNAMIC_KEY] is observer + + +def test_resolver_keeps_an_explicit_dynamic_override() -> None: + core, dynamic = NullExecutionObserver(), NullExecutionObserver() + parameters = {CORE_KEY: core, DYNAMIC_KEY: lambda: dynamic} + + assert _resolve_execution_observer(parameters) is core + assert parameters[CORE_KEY] is core + assert parameters[DYNAMIC_KEY] is dynamic + + +# --- the engine ------------------------------------------------------------ + + +def _engine(init_parameters: dict): + """The versioned engine behind the facade, built from a trivial workflow.""" + return ExecutionEngine.init( + workflow_definition=_TRIVIAL_WORKFLOW, init_parameters=init_parameters + )._engine + + +def test_the_engine_defaults_to_the_null_observer() -> None: + engine = _engine({"workflows_core.api_key": "k"}) + + assert engine._execution_observer is NULL_EXECUTION_OBSERVER + + +@pytest.mark.parametrize("key", [CORE_KEY, "execution_observer"]) +def test_the_engine_holds_the_bound_observer(key: str) -> None: + bound = NullExecutionObserver() + + engine = _engine({"workflows_core.api_key": "k", key: bound}) + + assert engine._execution_observer is bound + + +def test_the_engine_holds_the_instance_a_factory_made() -> None: + made = [] + + def factory(): + made.append(NullExecutionObserver()) + return made[-1] + + engine = _engine({CORE_KEY: factory}) + + assert len(made) == 1 + assert engine._execution_observer is made[0] + + +def test_engine_initialisation_leaves_the_callers_parameters_untouched() -> None: + """The caller's dictionary is not the engine's scratch space. + + Before this phase `ExecutionEngineV1.init` already wrote + `dynamic_workflows_blocks.api_key` into it; with a resolved observer + written back too, a root reusing its dictionary would hand every later + engine the first engine's observer. + """ + + def factory(): + return NullExecutionObserver() + + parameters = {"workflows_core.api_key": "k", CORE_KEY: factory} + before = dict(parameters) + + _engine(parameters) + + assert parameters == before, sorted(set(parameters) ^ set(before)) + assert parameters[CORE_KEY] is factory + + +def test_two_engines_from_one_factory_binding_each_get_their_own_observer() -> None: + made = [] + + def factory(): + made.append(NullExecutionObserver()) + return made[-1] + + parameters = {"workflows_core.api_key": "k", CORE_KEY: factory} + + first = _engine(parameters) + second = _engine(parameters) + + assert len(made) == 2 + assert first._execution_observer is made[0] + assert second._execution_observer is made[1] + + +def test_replacing_the_bound_observer_reaches_the_next_engine_only() -> None: + first_observer, second_observer = NullExecutionObserver(), NullExecutionObserver() + parameters = {"workflows_core.api_key": "k", CORE_KEY: first_observer} + + first = _engine(parameters) + parameters[CORE_KEY] = second_observer + second = _engine(parameters) + + assert first._execution_observer is first_observer + assert second._execution_observer is second_observer + + +class _FalseValuedObserver(NullExecutionObserver): + """A conforming observer whose truth value is False. + + An observer that buffers what it records is naturally sized; empty, it is + falsy. `steps_initialiser` hands an explicit value on unchanged and the + resolver substitutes only for `None`, so a constructor that tests truth + instead of `is not None` would silently swap such an observer for the + null one. + """ + + def __len__(self) -> int: + return 0 + + +def test_a_false_valued_observer_survives_engine_construction() -> None: + bound = _FalseValuedObserver() + assert not bound # the premise: a valid observer that is falsy + + engine = _engine({"workflows_core.api_key": "k", CORE_KEY: bound}) + + assert engine._execution_observer is bound From fe25da6e2feb05406e129e885a4a8e184fcb50e3 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:53:34 +0200 Subject: [PATCH 25/86] refactor(workflows): observe custom-python block execution through the port Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../v1/dynamic_blocks/block_scaffolding.py | 85 ++--- .../v1/dynamic_blocks/modal_executor.py | 12 +- .../http/test_workflow_billing_contract.py | 6 +- .../unit_tests/decontamination_baseline.txt | 5 +- .../dynamic_blocs/test_block_scaffolding.py | 8 +- .../test_block_usage_metering.py | 301 ++++++++++++++++-- .../test_modal_execution_timing.py | 18 +- .../test_observer_resolution.py | 129 ++++++++ 8 files changed, 464 insertions(+), 100 deletions(-) diff --git a/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py b/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py index 160b409a44..5c2d152139 100644 --- a/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py +++ b/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py @@ -7,6 +7,7 @@ import types from contextlib import contextmanager from dataclasses import dataclass +from functools import partial from typing import Any, Dict, List, Optional, Type from inference.core.env import ( @@ -21,6 +22,14 @@ DynamicBlockError, WorkflowEnvironmentConfigurationError, ) +from inference.core.workflows.execution_engine.v1.dynamic_blocks.block_duration import ( + BLOCK_DURATION_SOURCE_CLIENT_WALL_CLOCK, + BLOCK_DURATION_SOURCE_LOCAL_RUNTIME, + BLOCK_DURATION_SOURCE_UNAVAILABLE, + clear_block_duration, + peek_block_duration, + record_block_duration, +) from inference.core.workflows.execution_engine.v1.dynamic_blocks.debug_logs import ( get_active_collector, ) @@ -44,19 +53,14 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.prototypes.observer import ( + NULL_EXECUTION_OBSERVER, + ExecutionObserver, +) from inference.core.workflows.prototypes.workspace_resolver import ( NULL_WORKSPACE_RESOLVER, WorkspaceResolver, ) -from inference.usage_tracking.block_execution import ( - BLOCK_DURATION_SOURCE_CLIENT_WALL_CLOCK, - BLOCK_DURATION_SOURCE_LOCAL_RUNTIME, - BLOCK_DURATION_SOURCE_UNAVAILABLE, - clear_measured_block_execution, - peek_measured_block_execution, - record_measured_block_execution, -) -from inference.usage_tracking.collector import usage_collector logger = logging.getLogger(__name__) @@ -218,37 +222,28 @@ def compute_block_code_fingerprint(python_code: PythonCode) -> str: return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16] -@usage_collector("workflow_block") -def _metered_run(self, block_args, block_kwargs) -> BlockResult: - """Usage-metered entrypoint shared by every assembled dynamic block. - - Decorated once here rather than per assembled closure: the usage decorator - memoizes signatures keyed by function object, and workflows are recompiled - often enough that decorating each closure would pin every dynamic module - for the life of the process. - - The block's own inputs are carried in a single ``block_kwargs`` mapping - rather than spread over ``**kwargs``. A dynamic block's parameter names come - straight from the workflow definition with no reserved-name validation, so - spreading them would let an input named after one of the decorator's - keyword-only arguments (``usage_billable``, ``usage_api_key``, - ``usage_inference_test_run``, ...) bind to it - suppressing billing or - redirecting the row - and be swallowed before the user's function ever saw - it. Nesting also keeps those names out of ``collect_func_params``' flattened - view, where ``api_key`` / ``service_secret`` / ``source_info`` feed the - collector's generic fallbacks. - """ - return self._run_dynamic_block(*block_args, **block_kwargs) - - def _usage_tracked_run(self, *args, **kwargs) -> BlockResult: - """Undecorated ``run`` seen by the engine; see :func:`_metered_run`.""" + """The ``run`` the engine sees; the host observes the call inside it. + + The block's own inputs are handed to the observer in a single + ``block_kwargs`` mapping rather than spread over ``**kwargs``. A dynamic + block's parameter names come straight from the workflow definition with no + reserved-name validation, so spreading them would let an input named after + one of a host's own bookkeeping arguments bind to it - suppressing billing + or redirecting a usage row - and be swallowed before the user's function + ever saw it. + """ # `run()` is called once per SIMD element inside a single context # (`executor/core.py` `run_simd_step_in_non_batch_mode`), so a measurement - # left behind by an element whose usage recording failed must not be billed - # to the next one. - clear_measured_block_execution() - return _metered_run(self, block_args=args, block_kwargs=kwargs) + # left behind by an element whose observation failed must not be billed to + # the next one. + clear_block_duration() + return self._execution_observer.observe_block_run( + block=self, + block_args=args, + block_kwargs=kwargs, + run=partial(self._run_dynamic_block, *args, **kwargs), + ) def _record_remote_block_execution( @@ -272,15 +267,15 @@ def _record_remote_block_execution( client's wall clock: the block did run, and over-reporting by the round trip beats reporting nothing. `duration_source` marks it as an estimate. """ - if peek_measured_block_execution() is not None: + if peek_block_duration() is not None: return if error is not None: - record_measured_block_execution( + record_block_duration( duration=0, source=BLOCK_DURATION_SOURCE_UNAVAILABLE, ) return - record_measured_block_execution( + record_block_duration( duration=wall_clock_duration, source=BLOCK_DURATION_SOURCE_CLIENT_WALL_CLOCK, ) @@ -422,7 +417,7 @@ def run_dynamic_block(self, *args, **kwargs) -> BlockResult: try: result = run_function(self, *args, **kwargs) finally: - record_measured_block_execution( + record_block_duration( duration=time.monotonic() - started_at, source=BLOCK_DURATION_SOURCE_LOCAL_RUNTIME, ) @@ -462,10 +457,16 @@ def constructor( self, api_key: Optional[str] = None, workspace_resolver: WorkspaceResolver = NULL_WORKSPACE_RESOLVER, + execution_observer: Optional[ExecutionObserver] = None, ): self._init_results = init_function() self._api_key = api_key self._workspace_resolver = workspace_resolver + self._execution_observer = ( + execution_observer + if execution_observer is not None + else NULL_EXECUTION_OBSERVER + ) def get_workflow_context(self) -> Dict[str, Any]: return { @@ -477,7 +478,7 @@ def get_workflow_context(self) -> Dict[str, Any]: @classmethod def get_init_parameters(cls) -> List[str]: - return ["api_key", "workspace_resolver"] + return ["api_key", "workspace_resolver", "execution_observer"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: diff --git a/inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py b/inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py index abd71a8b75..69a0865db9 100644 --- a/inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py +++ b/inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py @@ -56,6 +56,10 @@ ) from inference.core.workflows.errors import DynamicBlockCodeError, DynamicBlockError from inference.core.workflows.execution_engine.entities.base import ParentOrigin +from inference.core.workflows.execution_engine.v1.dynamic_blocks.block_duration import ( + BLOCK_DURATION_SOURCE_REMOTE_RUNTIME, + record_block_duration, +) from inference.core.workflows.execution_engine.v1.dynamic_blocks.entities import ( PythonCode, ) @@ -64,10 +68,6 @@ extract_code_snippet, ) from inference.core.workflows.prototypes.block import BlockResult -from inference.usage_tracking.block_execution import ( - BLOCK_DURATION_SOURCE_REMOTE_RUNTIME, - record_measured_block_execution, -) logger = logging.getLogger(__name__) @@ -651,7 +651,7 @@ def execute_remote( # Published before the failure branch below raises, so an errored # block is still billed for the time the sandbox spent on it. - record_measured_block_execution( + record_block_duration( duration=result.get("execution_time_seconds"), source=BLOCK_DURATION_SOURCE_REMOTE_RUNTIME, ) @@ -2021,7 +2021,7 @@ def _execute_ws( # Published before _raise_code_error below, so an errored block is # still billed for the time the sandbox spent on it. - record_measured_block_execution( + record_block_duration( duration=result.get("execution_time_seconds"), source=BLOCK_DURATION_SOURCE_REMOTE_RUNTIME, ) diff --git a/tests/inference/unit_tests/core/interfaces/http/test_workflow_billing_contract.py b/tests/inference/unit_tests/core/interfaces/http/test_workflow_billing_contract.py index dc14e225b6..f03d9ef2bb 100644 --- a/tests/inference/unit_tests/core/interfaces/http/test_workflow_billing_contract.py +++ b/tests/inference/unit_tests/core/interfaces/http/test_workflow_billing_contract.py @@ -117,16 +117,14 @@ def _rows_for_api_key(api_key: str) -> dict: collector module. Read both, and accept either the raw key or its hash. """ import inference.core.interfaces.http.http_api as http_api - from inference.core.workflows.execution_engine.v1.dynamic_blocks import ( - block_scaffolding, - ) + from inference.core.interfaces import workflows_execution_observer from inference.usage_tracking.collector import usage_collector rows = {} for collector in ( usage_collector, http_api.usage_collector, - block_scaffolding.usage_collector, + workflows_execution_observer.usage_collector, ): hashed = collector._hashed_api_keys.get(api_key) for bucket_key in (api_key, hashed): diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 8ddb97a43c..14c7e4d804 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 262 +# Count: 259 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env @@ -252,11 +252,8 @@ inference/core/workflows/execution_engine/v1/core.py inference.core.env inference/core/workflows/execution_engine/v1/dynamic_blocks/block_assembler.py inference.core.env inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.env inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.env (exec'd string) -inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.usage_tracking.block_execution -inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.usage_tracking.collector inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py inference.core.env inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py inference.core.utils.image_utils -inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py inference.usage_tracking.block_execution inference/core/workflows/execution_engine/v1/dynamic_blocks/representation_boundary.py inference.core.env inference/core/workflows/execution_engine/v1/executor/core.py inference.core.env inference/core/workflows/execution_engine/v1/executor/output_constructor.py inference.core.env diff --git a/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_scaffolding.py b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_scaffolding.py index ad53acf2df..f61e281bbd 100644 --- a/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_scaffolding.py +++ b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_scaffolding.py @@ -138,9 +138,9 @@ def run_function(self, a, b) -> BlockResult: execution_result = workflow_block_instance.run(a=3, b=5) # then - assert {"api_key", "workspace_resolver"} <= set( + assert {"api_key", "workspace_resolver", "execution_observer"} <= set( workflow_block_class.get_init_parameters() - ), "Expected api_key and workspace_resolver parameters defined" + ), "Expected api_key, workspace_resolver and execution_observer parameters defined" assert ( workflow_block_class.get_manifest() == BlockManifest ), "Expected manifest to be returned" @@ -174,9 +174,9 @@ def run_function(self, a, b) -> BlockResult: execution_result = workflow_block_instance.run(a=3, b=5) # then - assert {"api_key", "workspace_resolver"} <= set( + assert {"api_key", "workspace_resolver", "execution_observer"} <= set( workflow_block_class.get_init_parameters() - ), "Expected api_key and workspace_resolver parameters defined" + ), "Expected api_key, workspace_resolver and execution_observer parameters defined" assert ( workflow_block_class.get_manifest() == BlockManifest ), "Expected manifest to be returned" diff --git a/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_usage_metering.py b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_usage_metering.py index 3cfb78f357..b6fdb8f394 100644 --- a/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_usage_metering.py +++ b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_block_usage_metering.py @@ -7,11 +7,15 @@ remotely. """ +import json from contextlib import contextmanager from unittest import mock import pytest +from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, +) from inference.core.workflows.core_steps.formatters.expression.v1 import BlockManifest from inference.core.workflows.errors import DynamicBlockCodeError, DynamicBlockError from inference.core.workflows.execution_engine.entities.base import Batch @@ -19,6 +23,14 @@ block_scaffolding, modal_executor, ) +from inference.core.workflows.execution_engine.v1.dynamic_blocks.block_duration import ( + BLOCK_DURATION_SOURCE_CLIENT_WALL_CLOCK, + BLOCK_DURATION_SOURCE_LOCAL_RUNTIME, + BLOCK_DURATION_SOURCE_REMOTE_RUNTIME, + BLOCK_DURATION_SOURCE_UNAVAILABLE, + clear_block_duration, + record_block_duration, +) from inference.core.workflows.execution_engine.v1.dynamic_blocks.block_scaffolding import ( assembly_custom_python_block, compute_block_code_fingerprint, @@ -27,32 +39,34 @@ PythonCode, ) from inference.usage_tracking.block_execution import ( - BLOCK_DURATION_SOURCE_CLIENT_WALL_CLOCK, BLOCK_DURATION_SOURCE_DECORATOR_WALL_CLOCK, - BLOCK_DURATION_SOURCE_LOCAL_RUNTIME, - BLOCK_DURATION_SOURCE_REMOTE_RUNTIME, - BLOCK_DURATION_SOURCE_UNAVAILABLE, BLOCK_EXECUTION_MODE_LOCAL, BLOCK_EXECUTION_MODE_REMOTE, - clear_measured_block_execution, - record_measured_block_execution, ) from inference.usage_tracking.collector import usage_collector +# These tests assert on the usage rows a *server* records, so they instantiate +# blocks the way the server's composition roots bind them. +SERVER_OBSERVER = UsageTrackingExecutionObserver() + class _StubWorkspaceResolver: - """Phase 9 replaced the module-level workspace lookup with an injected - `WorkspaceResolver`; the modal tests name their sandbox through it.""" + """Phase 9's `WorkspaceResolver` shape, answered locally.""" + + def __init__(self, workspace="test-workspace"): + self._workspace = workspace + self.calls = [] def resolve_workspace(self, api_key): - return "test-workspace" + self.calls.append(api_key) + return self._workspace @pytest.fixture(autouse=True) def cleared_block_execution(): - clear_measured_block_execution() + clear_block_duration() yield - clear_measured_block_execution() + clear_block_duration() def _clear_modal_executor_cache() -> None: @@ -67,6 +81,31 @@ def isolated_modal_executor_cache(): _clear_modal_executor_cache() +def _modal_block(block_class, api_key="workflow-api-key", workspace="test-workspace"): + """A server-observed block whose Modal arm resolves a non-anonymous workspace. + + After Phase 9 the generated class declares `workspace_resolver` and the + block asks it; before Phase 9 the arm calls + `block_scaffolding.get_roboflow_workspace`, which + `_legacy_workspace_lookup_pinned` pins. Both orders run the same test body. + """ + kwargs = {"api_key": api_key, "execution_observer": SERVER_OBSERVER} + if "workspace_resolver" in block_class.get_init_parameters(): + kwargs["workspace_resolver"] = _StubWorkspaceResolver(workspace) + return block_class(**kwargs) + + +@contextmanager +def _legacy_workspace_lookup_pinned(workspace="test-workspace"): + if hasattr(block_scaffolding, "get_roboflow_workspace"): + with mock.patch.object( + block_scaffolding, "get_roboflow_workspace", return_value=workspace + ): + yield + else: + yield + + def _assemble_block(run_function: str, unique_identifier: str, api_key=None): python_code = PythonCode( type="PythonCode", @@ -110,7 +149,7 @@ def test_block_input_named_after_a_usage_kwarg_reaches_the_user_function(): """ # given block_class, _ = _assemble_block(_RESERVED_NAME_BLOCK, "metered-reserved-names") - block = block_class(api_key="workflow-api-key") + block = block_class(api_key="workflow-api-key", execution_observer=SERVER_OBSERVER) # when with mock.patch.object(usage_collector, "record_usage") as record_usage: @@ -127,7 +166,7 @@ def test_block_input_named_after_a_usage_kwarg_reaches_the_user_function(): def test_local_block_records_a_workflow_block_row_with_its_own_runtime(): # given block_class, python_code = _assemble_block(_PASSTHROUGH_BLOCK, "metered-local") - block = block_class(api_key="workflow-api-key") + block = block_class(api_key="workflow-api-key", execution_observer=SERVER_OBSERVER) block._workflow_step_name = "my_step" block._workflow_step_type = "MeteredBlock" clock = iter([100.0, 100.25]) @@ -163,7 +202,7 @@ def test_local_block_records_a_workflow_block_row_with_its_own_runtime(): def test_local_block_that_raises_is_still_billed_for_the_time_it_ran(): # given block_class, _ = _assemble_block(_FAILING_BLOCK, "metered-local-failing") - block = block_class(api_key="workflow-api-key") + block = block_class(api_key="workflow-api-key", execution_observer=SERVER_OBSERVER) clock = iter([100.0, 100.25]) # when @@ -204,7 +243,9 @@ def _run_modal_block(execute_remote, unique_identifier): executor_instance = mock.MagicMock() executor_instance.execute_remote.side_effect = execute_remote block = block_class( - api_key="workflow-api-key", workspace_resolver=_StubWorkspaceResolver() + api_key="workflow-api-key", + workspace_resolver=_StubWorkspaceResolver(), + execution_observer=SERVER_OBSERVER, ) with mock.patch.object( @@ -225,7 +266,7 @@ def test_modal_block_is_billed_for_the_runtime_the_sandbox_reported( # given - the sandbox measured 0.25s of user code; the client call around it # also covers serialization and the round trip def execute_remote(**kwargs): - record_measured_block_execution( + record_block_duration( duration=0.25, source=BLOCK_DURATION_SOURCE_REMOTE_RUNTIME ) return {"result": 8} @@ -273,13 +314,11 @@ def test_modal_runtime_is_not_reused_by_a_later_local_block( ): # given - a remote invocation whose reported runtime nobody consumed, which # is what a failure inside usage recording would leave behind - record_measured_block_execution( - duration=9.0, source=BLOCK_DURATION_SOURCE_REMOTE_RUNTIME - ) + record_block_duration(duration=9.0, source=BLOCK_DURATION_SOURCE_REMOTE_RUNTIME) # when - a local block runs next in the same thread block_class, _ = _assemble_block(_PASSTHROUGH_BLOCK, "metered-no-leak") - block = block_class(api_key="workflow-api-key") + block = block_class(api_key="workflow-api-key", execution_observer=SERVER_OBSERVER) with mock.patch.object(usage_collector, "record_usage") as record_usage: block.run(a=1, b=2) @@ -297,7 +336,9 @@ def _run_modal_block_expecting_error(execute_remote, unique_identifier, expected executor_instance = mock.MagicMock() executor_instance.execute_remote.side_effect = execute_remote block = block_class( - api_key="workflow-api-key", workspace_resolver=_StubWorkspaceResolver() + api_key="workflow-api-key", + workspace_resolver=_StubWorkspaceResolver(), + execution_observer=SERVER_OBSERVER, ) with mock.patch.object( @@ -365,7 +406,9 @@ def slow_acquire(workspace_id): block_class, _ = _assemble_block(_PASSTHROUGH_BLOCK, "metered-modal-acquire") block = block_class( - api_key="workflow-api-key", workspace_resolver=_StubWorkspaceResolver() + api_key="workflow-api-key", + workspace_resolver=_StubWorkspaceResolver(), + execution_observer=SERVER_OBSERVER, ) with mock.patch.object( @@ -401,13 +444,13 @@ def test_modal_block_ignores_a_bogus_runtime_and_falls_back_to_wall_clock( # given def execute_remote(**kwargs): - record_measured_block_execution( + record_block_duration( duration=float("nan"), source=BLOCK_DURATION_SOURCE_REMOTE_RUNTIME ) - record_measured_block_execution( + record_block_duration( duration=-1.0, source=BLOCK_DURATION_SOURCE_REMOTE_RUNTIME ) - record_measured_block_execution( + record_block_duration( duration="0.25", source=BLOCK_DURATION_SOURCE_REMOTE_RUNTIME ) return {"result": 8} @@ -435,7 +478,7 @@ def test_modal_user_code_error_is_billed_the_runtime_the_sandbox_reported( # given def execute_remote(**kwargs): - record_measured_block_execution( + record_block_duration( duration=0.25, source=BLOCK_DURATION_SOURCE_REMOTE_RUNTIME ) raise DynamicBlockCodeError( @@ -447,7 +490,9 @@ def execute_remote(**kwargs): executor_instance = mock.MagicMock() executor_instance.execute_remote.side_effect = execute_remote block = block_class( - api_key="workflow-api-key", workspace_resolver=_StubWorkspaceResolver() + api_key="workflow-api-key", + workspace_resolver=_StubWorkspaceResolver(), + execution_observer=SERVER_OBSERVER, ) # when @@ -475,7 +520,7 @@ def test_block_without_an_api_key_records_no_row(): """`record_usage` drops keyless rows; nothing should reach the payload.""" # given block_class, _ = _assemble_block(_PASSTHROUGH_BLOCK, "metered-no-api-key") - block = block_class(api_key=None) + block = block_class(api_key=None, execution_observer=SERVER_OBSERVER) # when with mock.patch.object(usage_collector, "_update_usage_payload") as update_payload: @@ -490,7 +535,7 @@ def test_batch_block_is_billed_one_frame_per_element(): """A batch-oriented block gets the whole batch in one `run()` call.""" # given block_class, _ = _assemble_block(_BATCH_BLOCK, "metered-batch") - block = block_class(api_key="workflow-api-key") + block = block_class(api_key="workflow-api-key", execution_observer=SERVER_OBSERVER) batch = Batch.init(content=[1, 2, 3, 4], indices=[(i,) for i in range(4)]) # when @@ -506,9 +551,11 @@ def test_two_steps_sharing_block_code_aggregate_into_one_row(): # given - two separately assembled classes with the same body first_class, python_code = _assemble_block(_PASSTHROUGH_BLOCK, "metered-shared-a") second_class, _ = _assemble_block(_PASSTHROUGH_BLOCK, "metered-shared-b") - first = first_class(api_key="workflow-api-key") + first = first_class(api_key="workflow-api-key", execution_observer=SERVER_OBSERVER) first._workflow_step_name = "step_one" - second = second_class(api_key="workflow-api-key") + second = second_class( + api_key="workflow-api-key", execution_observer=SERVER_OBSERVER + ) second._workflow_step_name = "step_two" # when - recorded against a throwaway usage dict rather than the singleton's @@ -529,3 +576,195 @@ def test_two_steps_sharing_block_code_aggregate_into_one_row(): f"custom_python/{compute_block_code_fingerprint(python_code)}" ) assert rows[0]["processed_frames"] == 2 + + +def test_a_dynamic_workflow_compiles_and_runs_with_no_observer_bound(): + """No host, no observer keys - the block must still be constructible. + + Dynamic blocks resolve init parameters under their own plugin namespace, + which the core initializer defaults do not cover, so the engine has to + supply the null observer explicitly. Without that this raises + `BlockInitParameterNotProvidedError` at compile time. + """ + # given + from inference.core.workflows.execution_engine.core import ExecutionEngine + + specification = { + "version": "1.0", + "inputs": [{"type": "WorkflowParameter", "name": "value"}], + "dynamic_blocks_definitions": [ + { + "type": "DynamicBlockDefinition", + "manifest": { + "type": "ManifestDescription", + "block_type": "UnboundProbe", + "inputs": { + "value": { + "type": "DynamicInputDefinition", + "selector_types": ["input_parameter"], + } + }, + "outputs": { + "result": {"type": "DynamicOutputDefinition", "kind": []} + }, + }, + "code": { + "type": "PythonCode", + "run_function_code": _PASSTHROUGH_BLOCK.replace( + "def run_function(self, a, b)", "def run(self, value)" + ).replace('{"result": a + b}', '{"result": value}'), + "run_function_name": "run", + }, + } + ], + "steps": [{"type": "UnboundProbe", "name": "probe", "value": "$inputs.value"}], + "outputs": [ + {"type": "JsonField", "name": "result", "selector": "$steps.probe.result"} + ], + } + + # when - no `workflows_core.execution_observer`, no dynamic override + engine = ExecutionEngine.init( + workflow_definition=specification, + init_parameters={"workflows_core.api_key": "no-observer-key"}, + ) + results = engine.run(runtime_parameters={"value": 7}) + + # then + assert results[0]["result"] == 7 + + +def _recorded_rows(run_block, frozen_clock=(100.0, 100.25)): + """Run `run_block` and return the workflow_block rows the collector built.""" + from inference.usage_tracking import collector as collector_module + + recorded = usage_collector.empty_usage_dict(exec_session_id="test-session") + with mock.patch.object( + collector_module, "GCP_SERVERLESS", False + ), mock.patch.object( + collector_module.time, "time", side_effect=list(frozen_clock) + ), mock.patch.object( + usage_collector, "_usage", recorded + ): + run_block() + return [ + row + for per_key in recorded.values() + for row in per_key.values() + if row.get("category") == "workflow_block" + ] + + +@contextmanager +def _real_modal_executor_with_faked_transport(post_execute_response): + """The production executor, with only the HTTP round trip replaced.""" + real_executor = modal_executor.ModalExecutor("test-workspace") + with mock.patch.object( + block_scaffolding, "WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE", "modal" + ), _legacy_workspace_lookup_pinned(), mock.patch.object( + modal_executor, "MODAL_AVAILABLE", True + ), mock.patch.object( + modal_executor, "get_modal_executor", lambda workspace_id=None: real_executor + ), mock.patch.object( + modal_executor.ModalExecutor, + "_get_endpoint_url", + return_value="https://example.invalid", + ), mock.patch.object( + modal_executor.ModalExecutor, + "_post_execute", + return_value=post_execute_response, + ): + yield + + +def test_local_block_emits_a_row_attributed_to_local_execution(): + # given + block_class, _ = _assemble_block(_PASSTHROUGH_BLOCK, "row-local") + block = block_class(api_key="workflow-api-key", execution_observer=SERVER_OBSERVER) + monotonic = iter([200.0, 200.4]) + + # when + with mock.patch.object( + block_scaffolding.time, "monotonic", side_effect=lambda: next(monotonic) + ): + rows = _recorded_rows(lambda: block.run(a=3, b=5)) + + # then + assert len(rows) == 1, rows + details = json.loads(rows[0]["resource_details"]) + assert details["duration_source"] == BLOCK_DURATION_SOURCE_LOCAL_RUNTIME + assert details["execution_mode"] == BLOCK_EXECUTION_MODE_LOCAL + assert rows[0]["execution_duration"] == pytest.approx(0.4) + + +def test_remote_block_emits_a_row_from_the_executors_own_publication( + isolated_modal_executor_cache, +): + """The sandbox's runtime, published by `ModalExecutor.execute_remote` itself. + + The client call around it also covers input serialization and the round + trip; that is not what must be billed. + """ + # given + block_class, _ = _assemble_block(_PASSTHROUGH_BLOCK, "row-remote-success") + block = _modal_block(block_class) + response = { + "success": True, + "result": json.dumps({"result": 8}), + "execution_time_seconds": 0.6, + } + + # when + def run_block(): + with _real_modal_executor_with_faked_transport(response): + assert block.run(a=3, b=5) == {"result": 8} + + rows = _recorded_rows(run_block) + + # then + assert len(rows) == 1, rows + details = json.loads(rows[0]["resource_details"]) + assert details["duration_source"] == BLOCK_DURATION_SOURCE_REMOTE_RUNTIME + assert details["execution_mode"] == BLOCK_EXECUTION_MODE_REMOTE + assert rows[0]["execution_duration"] == pytest.approx(0.6) + + +def test_remote_block_failing_inside_the_sandbox_is_billed_what_it_spent( + isolated_modal_executor_cache, +): + """The executor publishes before it raises, so a failed run is still billed. + + Distinct from a transport failure: the block did run, in the sandbox, for + the time the sandbox reported. + """ + # given + block_class, _ = _assemble_block(_PASSTHROUGH_BLOCK, "row-remote-failure") + block = _modal_block(block_class) + response = { + "success": False, + "error": "boom", + "error_type": "ValueError", + "execution_time_seconds": 0.4, + } + + # when + def run_block(): + with _real_modal_executor_with_faked_transport(response): + with pytest.raises(DynamicBlockCodeError): + block.run(a=3, b=5) + + rows = _recorded_rows(run_block) + + # then + assert len(rows) == 1, rows + details = json.loads(rows[0]["resource_details"]) + assert details["duration_source"] == BLOCK_DURATION_SOURCE_REMOTE_RUNTIME + assert details["execution_mode"] == BLOCK_EXECUTION_MODE_REMOTE + # The HTTP arm raises `DynamicBlockCodeError` for a sandbox-side failure + # WITHOUT an inner exception (`modal_executor.py:684-692` folds the + # sandbox's error type into the message), so `inner_error_type` is None + # and the collector records the wrapper's own class - unlike the local + # arm, where `create_dynamic_block_code_error` attaches the user's + # exception and the row says `RuntimeError`. + assert details["error_type"] == "DynamicBlockCodeError" + assert rows[0]["execution_duration"] == pytest.approx(0.4) diff --git a/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_modal_execution_timing.py b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_modal_execution_timing.py index cf473db2b2..1785854485 100644 --- a/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_modal_execution_timing.py +++ b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_modal_execution_timing.py @@ -14,17 +14,17 @@ import pytest from inference.core.workflows.execution_engine.v1.dynamic_blocks import modal_executor +from inference.core.workflows.execution_engine.v1.dynamic_blocks.block_duration import ( + BLOCK_DURATION_SOURCE_REMOTE_RUNTIME, + clear_block_duration, + consume_block_duration, +) from inference.core.workflows.execution_engine.v1.dynamic_blocks.entities import ( PythonCode, ) from inference.core.workflows.execution_engine.v1.dynamic_blocks.modal_executor import ( ModalExecutor, ) -from inference.usage_tracking.block_execution import ( - BLOCK_DURATION_SOURCE_REMOTE_RUNTIME, - clear_measured_block_execution, - consume_measured_block_execution, -) class _FakeModalImage: @@ -81,9 +81,9 @@ def modal_app_with_fake_modal(monkeypatch): @pytest.fixture(autouse=True) def cleared_remote_duration(): - clear_measured_block_execution() + clear_block_duration() yield - clear_measured_block_execution() + clear_block_duration() def _run_user_code_ws(modal_app, code: str, run_function_name: str, inputs: dict): @@ -160,7 +160,7 @@ def test_executor_publishes_the_runtime_the_sandbox_reported(): ) # then - measured = consume_measured_block_execution() + measured = consume_block_duration() assert measured.duration == 0.25 assert measured.source == BLOCK_DURATION_SOURCE_REMOTE_RUNTIME @@ -191,4 +191,4 @@ def test_executor_publishes_nothing_when_the_sandbox_reports_no_runtime(): ) # then - assert consume_measured_block_execution() is None + assert consume_block_duration() is None diff --git a/tests/workflows/unit_tests/execution_engine/test_observer_resolution.py b/tests/workflows/unit_tests/execution_engine/test_observer_resolution.py index 0ce8cd191e..3506eb212e 100644 --- a/tests/workflows/unit_tests/execution_engine/test_observer_resolution.py +++ b/tests/workflows/unit_tests/execution_engine/test_observer_resolution.py @@ -199,3 +199,132 @@ def test_a_false_valued_observer_survives_engine_construction() -> None: engine = _engine({"workflows_core.api_key": "k", CORE_KEY: bound}) assert engine._execution_observer is bound + + +# --- dynamic blocks (Task 6) ------------------------------------------------- + +_IDENTITY_BLOCK = """ +def run(self, value) -> BlockResult: + return {"result": value} +""" + +_IDENTITY_WORKFLOW = { + "version": "1.0", + "inputs": [{"type": "WorkflowParameter", "name": "value"}], + "dynamic_blocks_definitions": [ + { + "type": "DynamicBlockDefinition", + "manifest": { + "type": "ManifestDescription", + "block_type": "IdentityProbe", + "inputs": { + "value": { + "type": "DynamicInputDefinition", + "selector_types": ["input_parameter"], + } + }, + "outputs": {"result": {"type": "DynamicOutputDefinition", "kind": []}}, + }, + "code": {"type": "PythonCode", "run_function_code": _IDENTITY_BLOCK}, + } + ], + "steps": [{"type": "IdentityProbe", "name": "probe", "value": "$inputs.value"}], + "outputs": [ + {"type": "JsonField", "name": "result", "selector": "$steps.probe.result"} + ], +} + + +def _engine_and_block(init_parameters: dict): + """Build the engine and return it with its one (dynamic) step instance.""" + engine = ExecutionEngine.init( + workflow_definition=_IDENTITY_WORKFLOW, init_parameters=init_parameters + )._engine + block = next(iter(engine._compiled_workflow.steps.values())).step + return engine, block + + +def test_no_binding_gives_the_engine_and_the_block_the_null_observer() -> None: + engine, block = _engine_and_block({"workflows_core.api_key": "k"}) + + assert engine._execution_observer is NULL_EXECUTION_OBSERVER + assert block._execution_observer is NULL_EXECUTION_OBSERVER + + +def test_a_namespaced_object_reaches_the_engine_and_the_block() -> None: + bound = NullExecutionObserver() + + engine, block = _engine_and_block({"workflows_core.api_key": "k", CORE_KEY: bound}) + + assert engine._execution_observer is bound + assert block._execution_observer is bound + + +def test_a_bare_key_reaches_the_engine_and_the_block() -> None: + bound = NullExecutionObserver() + + engine, block = _engine_and_block( + {"workflows_core.api_key": "k", "execution_observer": bound} + ) + + assert engine._execution_observer is bound + assert block._execution_observer is bound + + +def test_a_factory_is_called_once_and_its_result_is_shared() -> None: + # A callable binding is invoked by `_retrieve_init_parameter`; a block must + # receive the *instance*, never the factory. + made = [] + + def factory(): + made.append(NullExecutionObserver()) + return made[-1] + + engine, block = _engine_and_block( + {"workflows_core.api_key": "k", CORE_KEY: factory} + ) + + assert len(made) == 1 + assert engine._execution_observer is made[0] + assert block._execution_observer is made[0] + + +def test_an_explicit_dynamic_override_wins_for_dynamic_blocks_only() -> None: + engine_observer, dynamic_observer = NullExecutionObserver(), NullExecutionObserver() + + engine, block = _engine_and_block( + { + "workflows_core.api_key": "k", + CORE_KEY: engine_observer, + DYNAMIC_KEY: dynamic_observer, + } + ) + + assert engine._execution_observer is engine_observer + assert block._execution_observer is dynamic_observer + + +def test_a_replaced_observer_reaches_the_next_engines_dynamic_block() -> None: + """With a shared, mutated dictionary the second engine's block kept the + first engine's automatically generated mirror - the private copy is what + makes the replacement reach the block.""" + first_observer, second_observer = NullExecutionObserver(), NullExecutionObserver() + parameters = {"workflows_core.api_key": "k", CORE_KEY: first_observer} + + _, first_block = _engine_and_block(parameters) + parameters[CORE_KEY] = second_observer + second_engine, second_block = _engine_and_block(parameters) + + assert first_block._execution_observer is first_observer + assert second_engine._execution_observer is second_observer + assert second_block._execution_observer is second_observer + assert DYNAMIC_KEY not in parameters + + +def test_a_false_valued_observer_reaches_the_dynamic_block() -> None: + bound = _FalseValuedObserver() + + engine, block = _engine_and_block({"workflows_core.api_key": "k", CORE_KEY: bound}) + + assert engine._execution_observer is bound + assert block._execution_observer is bound From fe9595e074bae78f3bac6eb0392a9ce9683af041 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:47:25 +0200 Subject: [PATCH 26/86] refactor(workflows): observe SAM2/SAM3 video model calls through the port Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../foundation/segment_anything2_video/v1.py | 48 +++++++++- .../foundation/segment_anything3_video/v1.py | 53 ++++++++++- .../test_workflows_execution_observer.py | 72 +++++++++++++++ .../test_model_usage_coverage.py | 74 +++++++++++++-- .../test_segment_anything2_video.py | 90 +++++++++++++++++++ .../test_segment_anything3_video.py | 82 ++++++++++++++++- .../unit_tests/decontamination_baseline.txt | 4 +- 7 files changed, 402 insertions(+), 21 deletions(-) diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py index 3c97034aa9..ed280b1285 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py @@ -21,6 +21,7 @@ ``boxes`` is ignored on frames where we only propagate. """ +from functools import partial from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union import supervision as sv @@ -66,11 +67,14 @@ WorkflowBlockManifest, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.observer import ( + NULL_EXECUTION_OBSERVER, + ExecutionObserver, +) from inference.core.workflows.prototypes.platform_client import ( OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) -from inference.usage_tracking.collector import usage_collector PromptMode = Literal["first_frame", "every_n_frames", "every_frame"] @@ -248,6 +252,7 @@ def __init__( api_key: Optional[str], step_execution_mode: StepExecutionMode, platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, + execution_observer: Optional[ExecutionObserver] = None, ): self._model_manager = model_manager self._api_key = api_key @@ -256,10 +261,21 @@ def __init__( self._current_model_id: Optional[str] = None self._sessions: Dict[str, VideoSessionBookkeeping] = {} self._platform_client = platform_client + self._execution_observer = ( + execution_observer + if execution_observer is not None + else NULL_EXECUTION_OBSERVER + ) @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode", "platform_client"] + return [ + "model_manager", + "api_key", + "step_execution_mode", + "platform_client", + "execution_observer", + ] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -285,7 +301,6 @@ def _get_model(self, model_id: str): self._sessions.clear() return self._model - @usage_collector("model") def run( self, images: Batch[WorkflowImageData], @@ -294,6 +309,33 @@ def run( prompt_mode: PromptMode, prompt_interval: int, threshold: float, + ) -> BlockResult: + # The remote-mode rejection stays *inside* the observed call: today it + # is raised under the usage decorator, so it is reported as an errored + # model row, and hoisting it here would silently stop reporting it. + return self._execution_observer.observe_model_run( + block=self, + model_id=model_id, + images=images, + run=partial( + self._tracked_run, + images=images, + boxes=boxes, + model_id=model_id, + prompt_mode=prompt_mode, + prompt_interval=prompt_interval, + threshold=threshold, + ), + ) + + def _tracked_run( + self, + images: Batch[WorkflowImageData], + boxes: Optional[Batch[sv.Detections]], + model_id: str, + prompt_mode: PromptMode, + prompt_interval: int, + threshold: float, ) -> BlockResult: if self._step_execution_mode is not StepExecutionMode.LOCAL: raise NotImplementedError(self._REMOTE_EXECUTION_NOT_SUPPORTED_MESSAGE) diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py index abedebfa25..2115cb6af0 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py @@ -18,6 +18,7 @@ """ from dataclasses import dataclass, field +from functools import partial from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union import numpy as np @@ -75,11 +76,14 @@ WorkflowBlockManifest, ) from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.observer import ( + NULL_EXECUTION_OBSERVER, + ExecutionObserver, +) from inference.core.workflows.prototypes.platform_client import ( OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) -from inference.usage_tracking.collector import usage_collector PromptMode = Literal["first_frame", "every_n_frames", "every_frame"] TrackingMode = Literal["concept", "visual"] @@ -343,6 +347,7 @@ def __init__( api_key: Optional[str], step_execution_mode: StepExecutionMode, platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, + execution_observer: Optional[ExecutionObserver] = None, ): self._model_manager = model_manager self._api_key = api_key @@ -352,10 +357,21 @@ def __init__( self._concept_sessions: Dict[str, _ConceptSessionBookkeeping] = {} self._visual_sessions: Dict[str, VideoSessionBookkeeping] = {} self._platform_client = platform_client + self._execution_observer = ( + execution_observer + if execution_observer is not None + else NULL_EXECUTION_OBSERVER + ) @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode", "platform_client"] + return [ + "model_manager", + "api_key", + "step_execution_mode", + "platform_client", + "execution_observer", + ] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -397,7 +413,7 @@ def run( ) -> BlockResult: if self._step_execution_mode is not StepExecutionMode.LOCAL: raise NotImplementedError(self._REMOTE_EXECUTION_NOT_SUPPORTED_MESSAGE) - # The usage decorator reads `model_id` off the tracked call, so the + # The observer reads `model_id` off the tracked call, so the # dispatch happens out here where the mode-dependent model is picked. selected_model_id = model_id if tracking_mode == "concept" else visual_model_id return self._tracked_run( @@ -412,7 +428,6 @@ def run( prompt_interval=prompt_interval, ) - @usage_collector("model") def _tracked_run( self, images: Batch[WorkflowImageData], @@ -424,6 +439,36 @@ def _tracked_run( boxes: Optional[Batch[sv.Detections]], prompt_mode: PromptMode, prompt_interval: int, + ) -> BlockResult: + return self._execution_observer.observe_model_run( + block=self, + model_id=model_id, + images=images, + run=partial( + self._run_tracked, + images=images, + class_names=class_names, + model_id=model_id, + threshold=threshold, + tracking_mode=tracking_mode, + points=points, + boxes=boxes, + prompt_mode=prompt_mode, + prompt_interval=prompt_interval, + ), + ) + + def _run_tracked( + self, + images: Batch[WorkflowImageData], + class_names: Optional[Union[List[str], str]], + model_id: str, + threshold: float, + tracking_mode: TrackingMode, + points: Optional[List[Any]], + boxes: Optional[Batch[sv.Detections]], + prompt_mode: PromptMode, + prompt_interval: int, ) -> BlockResult: model = self._get_model(model_id=model_id) if tracking_mode == "visual": diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer.py b/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer.py index 91b8cbfa3a..72bf233ad8 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer.py @@ -341,3 +341,75 @@ def test_a_run_without_a_stream_session_produces_rows_without_one() -> None: assert set(by_category) == {"workflows", "workflow_block"} for row in by_category.values(): assert not row.get("stream_session_id") + + +def test_sam_video_blocks_resolve_the_bound_observer_from_init_parameters() -> None: + """The blocks receive the observer the way the composition roots bind it. + + Resolved through the same initialiser the compiler uses, so a block whose + `get_init_parameters()` drifts stops reporting model rows loudly. + """ + # given + from inference.core.workflows.core_steps.loader import REGISTERED_INITIALIZERS + from inference.core.workflows.core_steps.models.foundation.segment_anything2_video.v1 import ( + SegmentAnything2VideoBlockV1, + ) + from inference.core.workflows.core_steps.models.foundation.segment_anything3_video.v1 import ( + SegmentAnything3VideoBlockV1, + ) + from inference.core.workflows.execution_engine.v1.compiler.steps_initialiser import ( + retrieve_init_parameters_values, + ) + + observer = UsageTrackingExecutionObserver() + initializers = { + f"workflows_core.{name}": value + for name, value in REGISTERED_INITIALIZERS.items() + } + + for block_class in (SegmentAnything2VideoBlockV1, SegmentAnything3VideoBlockV1): + # when + values = retrieve_init_parameters_values( + block_name="a_step", + block_init_parameters=block_class.get_init_parameters(), + block_source="workflows_core", + explicit_init_parameters={ + "workflows_core.model_manager": mock.MagicMock(), + "workflows_core.api_key": "sam-key", + "workflows_core.execution_observer": observer, + }, + initializers=initializers, + ) + + # then + assert values["execution_observer"] is observer + assert block_class(**values)._execution_observer is observer + + +def test_sam_video_blocks_keep_a_false_valued_observer() -> None: + """The constructors test `is not None`, not truth: a conforming observer + whose truth value is False (an empty recording buffer) must survive.""" + from inference.core.workflows.core_steps.common.entities import StepExecutionMode + from inference.core.workflows.core_steps.models.foundation.segment_anything2_video.v1 import ( + SegmentAnything2VideoBlockV1, + ) + from inference.core.workflows.core_steps.models.foundation.segment_anything3_video.v1 import ( + SegmentAnything3VideoBlockV1, + ) + from inference.core.workflows.prototypes.observer import NullExecutionObserver + + class _FalseValuedObserver(NullExecutionObserver): + def __len__(self) -> int: + return 0 + + bound = _FalseValuedObserver() + assert not bound + + for block_class in (SegmentAnything2VideoBlockV1, SegmentAnything3VideoBlockV1): + block = block_class( + model_manager=mock.MagicMock(), + api_key="sam-key", + step_execution_mode=StepExecutionMode.LOCAL, + execution_observer=bound, + ) + assert block._execution_observer is bound diff --git a/tests/inference/unit_tests/usage_tracking/test_model_usage_coverage.py b/tests/inference/unit_tests/usage_tracking/test_model_usage_coverage.py index c6e6bd859c..d52668f3a7 100644 --- a/tests/inference/unit_tests/usage_tracking/test_model_usage_coverage.py +++ b/tests/inference/unit_tests/usage_tracking/test_model_usage_coverage.py @@ -91,11 +91,14 @@ # Video trackers load ``AutoModel`` in the block and never go through # ModelManager, so the block's tracked entrypoint must emit the model-category -# row. SAM3 tracks ``_tracked_run`` so that ``run()`` can first swap in the -# visual model id the decorator should attribute usage to. -# Tensor-native siblings (v1_tensor.py) are not decorated; they currently -# emit no model-category row under ENABLE_TENSOR_DATA_REPRESENTATION. -BLOCKS_DECORATING_RUN = [ +# row. It does so through the workflows `ExecutionObserver` rather than the +# decorator - workflows no longer imports usage tracking - so the check is that +# the method routes through `observe_model_run` and that the block declares the +# observer as an init parameter. SAM3 tracks `_tracked_run` so that `run()` can +# first swap in the visual model id the row should be attributed to. +# Tensor-native siblings (v1_tensor.py) are not observed; they currently emit +# no model-category row under ENABLE_TENSOR_DATA_REPRESENTATION. +BLOCKS_OBSERVING_MODEL_RUN = [ ( "inference.core.workflows.core_steps.models.foundation.segment_anything2_video.v1", "SegmentAnything2VideoBlockV1", @@ -184,9 +187,64 @@ def test_model_infer_from_request_is_usage_collected(module_path, class_name): _assert_method_is_usage_collected(module_path, class_name, "infer_from_request") -@pytest.mark.parametrize("module_path, class_name, method_name", BLOCKS_DECORATING_RUN) -def test_video_block_run_is_usage_collected(module_path, class_name, method_name): - _assert_method_is_usage_collected(module_path, class_name, method_name) +def _class_method_calls_observe_model_run( + source: str, class_name: str, method_name: str +) -> bool: + tree = ast.parse(source) + for node in tree.body: + if not isinstance(node, ast.ClassDef) or node.name != class_name: + continue + for item in node.body: + if not isinstance(item, ast.FunctionDef) or item.name != method_name: + continue + return any( + isinstance(call, ast.Call) + and isinstance(call.func, ast.Attribute) + and call.func.attr == "observe_model_run" + for call in ast.walk(item) + ) + return False + + +def _class_declares_observer_init_parameter(source: str, class_name: str) -> bool: + tree = ast.parse(source) + for node in tree.body: + if not isinstance(node, ast.ClassDef) or node.name != class_name: + continue + for item in node.body: + if not isinstance(item, ast.FunctionDef): + continue + if item.name != "get_init_parameters": + continue + return any( + isinstance(constant, ast.Constant) + and constant.value == "execution_observer" + for constant in ast.walk(item) + ) + return False + + +@pytest.mark.parametrize( + "module_path, class_name, method_name", BLOCKS_OBSERVING_MODEL_RUN +) +def test_video_block_run_reports_a_model_row(module_path, class_name, method_name): + source_path = _module_source_path(module_path) + source = source_path.read_text() + + assert _class_method_calls_observe_model_run(source, class_name, method_name), ( + f"{class_name}.{method_name}() in {source_path} must route through " + "self._execution_observer.observe_model_run(...)" + ) + assert _class_declares_observer_init_parameter(source, class_name), ( + f"{class_name} in {source_path} must declare 'execution_observer' in " + "get_init_parameters(), or it will be handed the null observer" + ) + + +def test_observer_detection_helper_rejects_an_unobserved_method(): + source = "class Fake:\n def run(self, images, model_id):\n return None\n" + + assert not _class_method_calls_observe_model_run(source, "Fake", "run") def test_detection_helper_rejects_undecorated_function(): diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything2_video.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything2_video.py index d6f4865308..c6ebb66630 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything2_video.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything2_video.py @@ -454,3 +454,93 @@ def test_no_boxes_and_no_session_emits_empty_detections(): assert isinstance(dets, sv.Detections) assert len(dets) == 0 assert fake.calls == [] + + +def _recorded_model_rows(run_block): + """Run `run_block` and return the model-category rows the collector built.""" + from unittest.mock import patch + + from inference.usage_tracking import collector as collector_module + from inference.usage_tracking.collector import usage_collector + + recorded = usage_collector.empty_usage_dict(exec_session_id="test-session") + with patch.object(collector_module, "GCP_SERVERLESS", False), patch.object( + usage_collector, "_usage", recorded + ): + run_block() + return [ + row + for per_key in recorded.values() + for row in per_key.values() + if row.get("category") == "model" + ] + + +def test_sam2_video_run_emits_a_model_row_for_the_model_it_ran(): + """The block loads AutoModel itself, so nothing else reports its usage.""" + # given + from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, + ) + + block, _ = _make_block_with_fake_model() + block._api_key = "sam2-usage-key" + block._execution_observer = UsageTrackingExecutionObserver() + boxes = _make_box_detections() + + # when + rows = _recorded_model_rows( + lambda: block.run( + images=[_make_frame(frame_number=0), _make_frame(frame_number=1)], + boxes=[boxes, boxes], + model_id="sam2video/small", + prompt_mode="first_frame", + prompt_interval=30, + threshold=0.0, + ) + ) + + # then + assert len(rows) == 1, rows + assert rows[0]["resource_id"] == "sam2video/small" + assert rows[0]["processed_frames"] == 2 + + +def test_sam2_video_remote_mode_rejection_is_billed_as_an_errored_model_row(): + """The rejection is raised inside the observed call, as it was inside the + decorator - so it stays visible in per-model telemetry.""" + # given + import json + + import pytest + + from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, + ) + + block = SegmentAnything2VideoBlockV1( + model_manager=MagicMock(), + api_key="sam2-usage-key", + step_execution_mode=StepExecutionMode.REMOTE, + execution_observer=UsageTrackingExecutionObserver(), + ) + + # when + def run_block(): + with pytest.raises(NotImplementedError): + block.run( + images=[_make_frame()], + boxes=None, + model_id="sam2video/small", + prompt_mode="first_frame", + prompt_interval=30, + threshold=0.0, + ) + + rows = _recorded_model_rows(run_block) + + # then + assert len(rows) == 1, rows + assert ( + json.loads(rows[0]["resource_details"])["error_type"] == "NotImplementedError" + ) diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_video.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_video.py index 558f5e725d..98d7107db1 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_video.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_video.py @@ -775,9 +775,10 @@ def test_visual_stream_restart_reprompts(block_factory): ) assert [call[0] for call in fake.calls] == ["prompt", "track", "prompt"] - assert [ - call[1]["frame_idx"] for call in fake.calls if call[0] == "prompt" - ] == [5, 0] + assert [call[1]["frame_idx"] for call in fake.calls if call[0] == "prompt"] == [ + 5, + 0, + ] assert [ call[1]["had_prior_state"] for call in fake.calls if call[0] == "prompt" ] == [False, False] @@ -945,3 +946,78 @@ def test_tensor_visual_boxes_and_points_keep_class_zero_distinct_from_point_prom 0: "vehicle", -1: "foreground", } + + +def _recorded_model_rows(run_block): + """Run `run_block` and return the model-category rows the collector built.""" + from unittest.mock import patch + + from inference.usage_tracking import collector as collector_module + from inference.usage_tracking.collector import usage_collector + + recorded = usage_collector.empty_usage_dict(exec_session_id="test-session") + with patch.object(collector_module, "GCP_SERVERLESS", False), patch.object( + usage_collector, "_usage", recorded + ): + run_block() + return [ + row + for per_key in recorded.values() + for row in per_key.values() + if row.get("category") == "model" + ] + + +def test_sam3_concept_mode_row_is_attributed_to_the_concept_model(): + # given + from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, + ) + + block, _ = _make_block_with_fake_model() + block._api_key = "sam3-usage-key" + block._execution_observer = UsageTrackingExecutionObserver() + + # when + rows = _recorded_model_rows( + lambda: block.run( + images=[_make_frame()], + class_names=["person"], + model_id="sam3video", + visual_model_id="sam3trackervideo", + threshold=0.0, + ) + ) + + # then + assert len(rows) == 1, rows + assert rows[0]["resource_id"] == "sam3video" + + +def test_sam3_visual_mode_row_is_attributed_to_the_visual_model(): + """`run()` swaps the model id before the observed call; the row must follow.""" + # given + from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, + ) + + block, _ = _make_visual_block_with_fake_model() + block._api_key = "sam3-usage-key" + block._execution_observer = UsageTrackingExecutionObserver() + + # when + rows = _recorded_model_rows( + lambda: block.run( + images=[_make_frame()], + class_names=None, + model_id="sam3video", + visual_model_id="sam3trackervideo", + threshold=0.0, + tracking_mode="visual", + points=[{"x": 10, "y": 12, "positive": True}], + ) + ) + + # then + assert len(rows) == 1, rows + assert rows[0]["resource_id"] == "sam3trackervideo" diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 14c7e4d804..dbf944e075 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 259 +# Count: 257 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env @@ -136,7 +136,6 @@ inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py in inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.responses.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py inference.usage_tracking.collector inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.responses.inference @@ -165,7 +164,6 @@ inference/core/workflows/core_steps/models/foundation/segment_anything3_interact inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py inference.usage_tracking.collector inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/segment_anything_common/streaming_video_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py inference.core.entities.requests.inference From dad60d66d12729b7a7e8559eb5ea7fbf00535ad2 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:10:09 +0200 Subject: [PATCH 27/86] test(workflows): pin the Phase 6 row removals and the removed usage kwargs Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../test_observer_decontamination.py | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/workflows/unit_tests/test_observer_decontamination.py diff --git a/tests/workflows/unit_tests/test_observer_decontamination.py b/tests/workflows/unit_tests/test_observer_decontamination.py new file mode 100644 index 0000000000..582c6b4ce3 --- /dev/null +++ b/tests/workflows/unit_tests/test_observer_decontamination.py @@ -0,0 +1,90 @@ +"""What Phase 6 removed, asserted by row and by AST - never by grep. + +Two natural-looking greps cannot return zero and would make a false gate: both +SAM `v1_tensor.py` siblings mention `usage_collector` in prose comments +explaining why they are *not* metered. So the statements below are (a) these +exact eight `(path, module)` pairs are absent from the lint baseline, and (b) +no call under `execution_engine/` passes the three `usage_*` keyword arguments +any more - `usage_workflow_id` legitimately survives as a local variable in +`v1/core.py`, so only the *keyword* can be checked. + +This file deliberately asserts **nothing about rows other phases own**. An +earlier draft required `executor/core.py`'s `inference.core.env` row to be +present; Phase 5 removes that row, so the assertion would have made one phase +order or the other fail. "Nothing else was swept up" is checked instead by the +shared baseline-diff checker in Global Constraints, which compares against this +phase's own start commit and is therefore order-independent. +""" + +import ast +from pathlib import Path + +import pytest + +# tests/workflows/unit_tests/ -> three levels up is the repo root +REPO_ROOT = Path(__file__).resolve().parents[3] +BASELINE = ( + REPO_ROOT / "tests" / "workflows" / "unit_tests" / "decontamination_baseline.txt" +) +ENGINE_ROOT = REPO_ROOT / "inference" / "core" / "workflows" / "execution_engine" + +ROWS_OWNED_BY_PHASE_6 = [ + ( + "inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py", + "inference.usage_tracking.collector", + ), + ( + "inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py", + "inference.usage_tracking.collector", + ), + ( + "inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py", + "inference.usage_tracking.block_execution", + ), + ( + "inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py", + "inference.usage_tracking.collector", + ), + ( + "inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py", + "inference.usage_tracking.block_execution", + ), + ( + "inference/core/workflows/execution_engine/v1/executor/core.py", + "inference.core.telemetry", + ), + ( + "inference/core/workflows/execution_engine/v1/executor/core.py", + "inference.usage_tracking.collector", + ), + ( + "inference/core/workflows/execution_engine/v1/executor/core.py", + "inference.usage_tracking.stream_session", + ), +] + +REMOVED_USAGE_KEYWORDS = {"usage_fps", "usage_workflow_id", "usage_workflow_preview"} + + +def _baseline_rows() -> set: + return set(BASELINE.read_text(encoding="utf-8").splitlines()) + + +@pytest.mark.parametrize("path, module", ROWS_OWNED_BY_PHASE_6) +def test_phase_6_row_is_gone_from_the_baseline(path: str, module: str) -> None: + assert f"{path}\t{module}" not in _baseline_rows() + + +def test_the_engine_no_longer_passes_usage_keyword_arguments() -> None: + offenders = [] + for path in sorted(ENGINE_ROOT.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + for keyword in node.keywords: + if keyword.arg in REMOVED_USAGE_KEYWORDS: + offenders.append( + (str(path.relative_to(REPO_ROOT)), node.lineno, keyword.arg) + ) + assert offenders == [] From 7c25163518ac4419c1dee39339e623c24f399280 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:39:02 +0200 Subject: [PATCH 28/86] refactor(workflows): drop dead image_utils import from qwen_vlm/v1 Removes dead imports `encode_image_to_jpeg_bytes` and `load_image` from `inference.core.utils.image_utils` in qwen_vlm/v1.py. These symbols were imported but never used, and nothing re-exports them from the module. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../core/workflows/core_steps/models/foundation/qwen_vlm/v1.py | 1 - tests/workflows/unit_tests/decontamination_baseline.txt | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py index 92fe879131..0ddc6b6f27 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py @@ -34,7 +34,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.openrouter import ( PRIVACY_LEVEL_LITERAL, diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index dbf944e075..d423db2350 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 257 +# Count: 256 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env @@ -121,7 +121,6 @@ inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py inference.co inference/core/workflows/core_steps/models/foundation/qwen3vl/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py inference.core.env inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py inference.core.utils.image_utils From 98a616344bd8a7b561bacb6d0fc8bb8398b5dc34 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:46:28 +0200 Subject: [PATCH 29/86] feat(workflows): vendor pure image encode/decode helpers Add WorkflowImageLoadError to inference.core.workflows.errors, then vendor six pure cv2 image helpers into a workflows-local module inference.core.workflows.utils.image_encoding, pinned to the server originals with 20 differential tests covering byte equality, EXIF orientation, and error semantics. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/workflows/errors.py | 17 ++ .../core/workflows/utils/image_encoding.py | 124 ++++++++++++ .../unit_tests/utils/test_image_encoding.py | 188 ++++++++++++++++++ 3 files changed, 329 insertions(+) create mode 100644 inference/core/workflows/utils/image_encoding.py create mode 100644 tests/workflows/unit_tests/utils/test_image_encoding.py diff --git a/inference/core/workflows/errors.py b/inference/core/workflows/errors.py index 0483130265..843bc9bd4b 100644 --- a/inference/core/workflows/errors.py +++ b/inference/core/workflows/errors.py @@ -267,6 +267,23 @@ class RuntimeInputError(WorkflowExecutionEngineError): pass +class WorkflowImageLoadError(WorkflowExecutionEngineError): + """An image could not be loaded because the capability it needs is absent. + + The workflows-local `ImageCodec` default (see + `inference.core.workflows.prototypes.image_codec`) refuses URL fetching, + local-filesystem reads and pickled-numpy payloads outright: each carries a + host policy (SSRF allow/deny lists plus redirect and address validation, + `ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM`, `ALLOW_NUMPY_INPUT`) that + Workflows must not reimplement. A host that wants them installs a codec that + provides them - the Roboflow inference server does so in + `inference.core.interfaces.workflows_image_codec`. + + Maps to HTTP 500 through the generic `WorkflowError` arm of + `inference/core/interfaces/http/error_handlers.py:571`. + """ + + class WorkflowExecutionEngineVersionError(WorkflowError): pass diff --git a/inference/core/workflows/utils/image_encoding.py b/inference/core/workflows/utils/image_encoding.py new file mode 100644 index 0000000000..c2d27acee2 --- /dev/null +++ b/inference/core/workflows/utils/image_encoding.py @@ -0,0 +1,124 @@ +"""Pure image encode/decode helpers used by Workflows. + +Copied from `inference.core.utils.image_utils` so that `inference/core/workflows` +does not import the server package for arithmetic. Every function here is a pure +transformation of bytes and arrays: no network, no filesystem, no `pickle`, no +environment reads. Everything needing one of those lives behind the `ImageCodec` +port (`inference.core.workflows.prototypes.image_codec`). + +`encode_image_to_jpeg_bytes`, `choose_image_decoding_flags` and +`convert_gray_image_to_bgr` are byte-for-byte copies. `decode_base64_image`, +`decode_encoded_image_bytes` and `ensure_valid_numpy_image` copy +`load_image_base64`, `load_image_from_encoded_bytes` and `validate_numpy_image` +with `inference.core.exceptions.InputImageLoadError` / `InvalidNumpyInput` +replaced by `WorkflowImageLoadError`. Do not "improve" any of them: +`tests/workflows/unit_tests/utils/test_image_encoding.py` pins them to the +originals by encoded bytes, decoded pixels, EXIF behaviour and signature. +""" + +import binascii +import re +from typing import Union + +import cv2 +import numpy as np +import pybase64 + +from inference.core.workflows.errors import WorkflowImageLoadError + +BASE64_DATA_TYPE_PATTERN = re.compile(r"^data:image\/[a-z]+;base64,") + + +def encode_image_to_jpeg_bytes(image: np.ndarray, jpeg_quality: int = 90) -> bytes: + """Encode a BGR numpy image to JPEG bytes.""" + encoding_param = [int(cv2.IMWRITE_JPEG_QUALITY), jpeg_quality] + _, img_encoded = cv2.imencode(".jpg", image, encoding_param) + return np.array(img_encoded).tobytes() + + +def choose_image_decoding_flags(disable_preproc_auto_orient: bool) -> int: + """Pick the OpenCV decoding flags for the requested auto-orient policy.""" + cv_imread_flags = cv2.IMREAD_COLOR + if disable_preproc_auto_orient: + cv_imread_flags = cv_imread_flags | cv2.IMREAD_IGNORE_ORIENTATION + return cv_imread_flags + + +def convert_gray_image_to_bgr(image: np.ndarray) -> np.ndarray: + """Expand a single-channel image to 3-channel BGR; pass BGR through.""" + if len(image.shape) == 2 or image.shape[2] == 1: + image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) + return image + + +def decode_base64_image( + value: Union[str, bytes], cv_imread_flags=cv2.IMREAD_COLOR +) -> np.ndarray: + """Decode a base64 payload (optionally data-URL prefixed) into a BGR image.""" + if not isinstance(value, str): + try: + value = value.decode("utf-8") + except UnicodeDecodeError as error: + raise WorkflowImageLoadError( + public_message="Invalid base64 input: the image payload contains raw " + "bytes instead of a base64-encoded string.", + context="workflow_execution | image_decoding", + inner_error=error, + ) from error + value = BASE64_DATA_TYPE_PATTERN.sub("", value) + try: + decoded = pybase64.b64decode(value) + except binascii.Error as error: + raise WorkflowImageLoadError( + public_message="Malformed base64 input image.", + context="workflow_execution | image_decoding", + inner_error=error, + ) from error + if len(decoded) == 0: + raise WorkflowImageLoadError( + public_message="Empty image payload.", + context="workflow_execution | image_decoding", + ) + image_np = np.frombuffer(decoded, np.uint8) + result = cv2.imdecode(image_np, cv_imread_flags) + if result is None: + raise WorkflowImageLoadError( + public_message="Malformed base64 input image.", + context="workflow_execution | image_decoding", + ) + return result + + +def decode_encoded_image_bytes( + value: bytes, cv_imread_flags: int = cv2.IMREAD_COLOR +) -> np.ndarray: + """Decode raw encoded image bytes (JPEG/PNG/...) into a BGR image.""" + image_np = np.asarray(bytearray(value), dtype=np.uint8) + image = cv2.imdecode(image_np, cv_imread_flags) + if image is None: + raise WorkflowImageLoadError( + public_message="Data is not image.", + context="workflow_execution | image_decoding", + ) + return image + + +def ensure_valid_numpy_image(data: np.ndarray) -> np.ndarray: + """Validate an array is a usable image and return it unchanged.""" + if not issubclass(type(data), np.ndarray): + raise WorkflowImageLoadError( + public_message="Data provided as input could not be decoded into " + "np.ndarray object.", + context="workflow_execution | image_decoding", + ) + if len(data.shape) != 3 and len(data.shape) != 2: + raise WorkflowImageLoadError( + public_message="For image given as np.ndarray expected 2 or 3 dimensions.", + context="workflow_execution | image_decoding", + ) + if len(data.shape) == 3 and data.shape[-1] != 3 and data.shape[-1] != 1: + raise WorkflowImageLoadError( + public_message="For image given as np.ndarray expected 1 or 3 channels.", + context="workflow_execution | image_decoding", + ) + return data diff --git a/tests/workflows/unit_tests/utils/test_image_encoding.py b/tests/workflows/unit_tests/utils/test_image_encoding.py new file mode 100644 index 0000000000..f4c0357589 --- /dev/null +++ b/tests/workflows/unit_tests/utils/test_image_encoding.py @@ -0,0 +1,188 @@ +import ast +import base64 +import inspect +import io +from pathlib import Path + +import cv2 +import numpy as np +import pytest +from PIL import Image + +from inference.core.utils import image_utils as server_image_utils +from inference.core.workflows.errors import WorkflowImageLoadError +from inference.core.workflows.utils.image_encoding import ( + choose_image_decoding_flags, + convert_gray_image_to_bgr, + decode_base64_image, + decode_encoded_image_bytes, + encode_image_to_jpeg_bytes, + ensure_valid_numpy_image, +) + +# 48 rows x 64 cols, deliberately NOT square so an EXIF rotation is visible. +COLOUR_IMAGE = np.dstack( + [ + np.tile(np.arange(64, dtype=np.uint8), (48, 1)), + np.full((48, 64), 17, dtype=np.uint8), + np.tile(np.arange(48, dtype=np.uint8).reshape(48, 1), (1, 64)), + ] +) +GRAY_IMAGE = np.tile(np.arange(64, dtype=np.uint8), (48, 1)) + + +def _jpeg_with_exif_orientation_6() -> bytes: + """A 48x64 JPEG whose EXIF says 'rotate 90 CW'. + + cv2.IMREAD_COLOR honours that tag and returns (64, 48, 3); + cv2.IMREAD_COLOR | cv2.IMREAD_IGNORE_ORIENTATION returns (48, 64, 3). + That difference is the whole point of `cv_imread_flags`. + """ + pillow_image = Image.fromarray(COLOUR_IMAGE[:, :, ::-1]) # BGR -> RGB + exif = pillow_image.getexif() + exif[274] = 6 # Orientation + buffer = io.BytesIO() + pillow_image.save(buffer, format="JPEG", exif=exif, quality=95) + return buffer.getvalue() + + +@pytest.mark.parametrize("jpeg_quality", [1, 30, 90, 95, 100]) +def test_vendored_jpeg_encoder_is_byte_identical_to_the_server_encoder( + jpeg_quality: int, +) -> None: + # A JPEG encoder that "looks right" but differs by a byte changes every + # base64 payload every VLM block sends. Byte equality, not visual equality. + for image in (COLOUR_IMAGE, GRAY_IMAGE): + assert encode_image_to_jpeg_bytes( + image, jpeg_quality=jpeg_quality + ) == server_image_utils.encode_image_to_jpeg_bytes( + image, jpeg_quality=jpeg_quality + ) + + +@pytest.mark.parametrize( + "ours,theirs", + [ + ("encode_image_to_jpeg_bytes", "encode_image_to_jpeg_bytes"), + ("choose_image_decoding_flags", "choose_image_decoding_flags"), + ("convert_gray_image_to_bgr", "convert_gray_image_to_bgr"), + ("decode_base64_image", "load_image_base64"), + ("decode_encoded_image_bytes", "load_image_from_encoded_bytes"), + ], +) +def test_vendored_signatures_match_the_server_originals(ours: str, theirs: str) -> None: + import inference.core.workflows.utils.image_encoding as vendored + + mine = inspect.signature(getattr(vendored, ours)) + reference = inspect.signature(getattr(server_image_utils, theirs)) + assert [(p.name, p.kind, p.default) for p in mine.parameters.values()] == [ + (p.name, p.kind, p.default) for p in reference.parameters.values() + ] + + +def test_vendored_decoding_flags_match_the_server_helper() -> None: + for disable in (True, False): + assert choose_image_decoding_flags( + disable_preproc_auto_orient=disable + ) == server_image_utils.choose_image_decoding_flags( + disable_preproc_auto_orient=disable + ) + + +def test_vendored_gray_to_bgr_matches_the_server_helper() -> None: + for image in (COLOUR_IMAGE, GRAY_IMAGE, GRAY_IMAGE.reshape(48, 64, 1)): + assert np.array_equal( + convert_gray_image_to_bgr(image.copy()), + server_image_utils.convert_gray_image_to_bgr(image.copy()), + ) + + +def test_vendored_base64_decoder_matches_the_server_decoder() -> None: + payload = base64.b64encode( + server_image_utils.encode_image_to_jpeg_bytes(COLOUR_IMAGE) + ).decode("ascii") + for value in ( + payload, + payload.encode("ascii"), + f"data:image/jpeg;base64,{payload}", + ): + assert np.array_equal( + decode_base64_image(value), + server_image_utils.load_image_base64(value), + ) + + +@pytest.mark.parametrize("disable_preproc_auto_orient", [True, False]) +def test_vendored_base64_decoder_honours_exif_flags_exactly( + disable_preproc_auto_orient: bool, +) -> None: + # Round-1 Defect 5: dropping cv_imread_flags silently rotated images. + payload = base64.b64encode(_jpeg_with_exif_orientation_6()).decode("ascii") + flags = choose_image_decoding_flags( + disable_preproc_auto_orient=disable_preproc_auto_orient + ) + ours = decode_base64_image(payload, cv_imread_flags=flags) + theirs = server_image_utils.load_image_base64(payload, cv_imread_flags=flags) + assert ours.shape == theirs.shape + assert np.array_equal(ours, theirs) + + +def test_the_exif_fixture_actually_distinguishes_the_two_flag_settings() -> None: + # Guards the guard: if Pillow ever stopped writing the tag, the test above + # would pass vacuously. + payload = base64.b64encode(_jpeg_with_exif_orientation_6()).decode("ascii") + honoured = decode_base64_image( + payload, cv_imread_flags=choose_image_decoding_flags(False) + ) + ignored = decode_base64_image( + payload, cv_imread_flags=choose_image_decoding_flags(True) + ) + assert honoured.shape[:2] != ignored.shape[:2] + + +def test_vendored_encoded_bytes_decoder_matches_the_server_decoder() -> None: + raw = server_image_utils.encode_image_to_jpeg_bytes(COLOUR_IMAGE) + assert np.array_equal( + decode_encoded_image_bytes(raw), + server_image_utils.load_image_from_encoded_bytes(raw), + ) + + +def test_vendored_decoders_raise_a_workflows_error_not_a_server_error() -> None: + with pytest.raises(WorkflowImageLoadError): + decode_base64_image("!!!not base64!!!") + with pytest.raises(WorkflowImageLoadError): + decode_base64_image("") + with pytest.raises(WorkflowImageLoadError): + decode_encoded_image_bytes(b"not an image") + + +def test_ensure_valid_numpy_image_accepts_and_rejects_like_the_server() -> None: + assert ensure_valid_numpy_image(COLOUR_IMAGE) is COLOUR_IMAGE + assert ensure_valid_numpy_image(GRAY_IMAGE) is GRAY_IMAGE + for bad in (np.zeros((4,), dtype=np.uint8), np.zeros((4, 4, 5), dtype=np.uint8)): + with pytest.raises(WorkflowImageLoadError): + ensure_valid_numpy_image(bad) + with pytest.raises(Exception): + server_image_utils.validate_numpy_image(bad) + + +def test_module_does_not_import_the_server_package_or_do_io() -> None: + source = Path("inference/core/workflows/utils/image_encoding.py").read_text( + encoding="utf-8" + ) + tree = ast.parse(source) + modules = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + modules.update(a.name for a in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + modules.add(node.module) + forbidden = { + m + for m in modules + if (m == "inference" or m.startswith("inference.")) + and not m.startswith("inference.core.workflows") + } + assert not forbidden, forbidden + assert not modules & {"requests", "socket", "urllib", "pickle", "tldextract", "os"} From c8e457d6681a0f11e9f6a143abbf1a070a9f886d Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:52:48 +0200 Subject: [PATCH 30/86] feat(workflows): add ImageCodec port with a refusing, lock-guarded registry Introduces the ImageCodec Protocol (load_image, fetch_url, decode_string, ensure_local_file_load_allowed), the workflows-local WorkflowsLocalImageCodec default that refuses URL fetching, local-filesystem reads and pickled-numpy payloads outright, and a lock-guarded set-once process registry (get_image_codec / set_image_codec / reset_image_codec). Later tasks route image loading through this port instead of importing the server package. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../core/workflows/prototypes/image_codec.py | 275 ++++++++++++++ .../unit_tests/prototypes/test_image_codec.py | 358 ++++++++++++++++++ 2 files changed, 633 insertions(+) create mode 100644 inference/core/workflows/prototypes/image_codec.py create mode 100644 tests/workflows/unit_tests/prototypes/test_image_codec.py diff --git a/inference/core/workflows/prototypes/image_codec.py b/inference/core/workflows/prototypes/image_codec.py new file mode 100644 index 0000000000..bc3fd90846 --- /dev/null +++ b/inference/core/workflows/prototypes/image_codec.py @@ -0,0 +1,275 @@ +"""The port through which Workflows load images. + +Every image source carrying a *host policy* goes through here: + +* URL fetching - the SSRF surface. `inference.core.utils.image_utils` enforces + `OFFLINE_MODE`, `ALLOW_URL_INPUT`, the scheme and FQDN rules, the + `WHITELISTED_/BLACKLISTED_DESTINATIONS_FOR_URL_INPUT` lists, per-hop redirect + re-validation, `MAX_IMAGE_URL_REDIRECTS`, and non-global-address rejection + with IP pinning (`inference.core.utils.url_input`). +* Local-filesystem reads - `ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM`. +* Pickled-numpy payloads - `ALLOW_NUMPY_INPUT`, the gate before `pickle.loads`. + +None of those rules is reimplemented here and none may be. The Roboflow +inference server injects its guarded implementation +(`inference.core.interfaces.workflows_image_codec`); standalone Workflows falls +back to `WorkflowsLocalImageCodec`, which refuses all three outright rather than +doing them unguarded. + +TWO INJECTION PATHS, by consumer: + +* Engine-scoped, via `init_parameters["workflows_core.image_codec"]`: the + runtime-input image deserializers. `ExecutionEngineV1.init` rebinds the image + kind deserializer on a COPIED `kinds_deserializers` map (the map is served out + of `COMPILATION_CACHE`). This is the request boundary and the only genuinely + per-engine consumer. +* Process-level, via this registry: `WorkflowImageData` (constructed at six + production sites outside any engine, plus three alternative constructors), the + ~24 module-level free functions in VLM blocks that call `load_image`, and + `modal/modal_app.py`'s sandbox deserialization. None of those has an engine in + scope. + +In production both paths receive the same server singleton, so they cannot +disagree. `set_image_codec` is lock-synchronised and set-once: re-installing the +IDENTICAL object is a no-op, installing a different one raises. A process +serving two codecs would apply two different SSRF policies depending on request +timing, which is a bug, not a configuration. +""" + +import threading +from typing import Any, Optional, Protocol, Tuple, Union + +import cv2 +import numpy as np + +from inference.core.workflows.errors import ( + WorkflowEnvironmentConfigurationError, + WorkflowImageLoadError, +) +from inference.core.workflows.utils.image_encoding import ( + choose_image_decoding_flags, + convert_gray_image_to_bgr, + decode_base64_image, + decode_encoded_image_bytes, + ensure_valid_numpy_image, +) + +_LOCAL_FILESYSTEM_REFUSAL = ( + "Loading images from the local filesystem is not available: no host image " + "codec is installed, so Workflows refuses filesystem access rather than " + "reading a path unguarded." +) +_URL_REFUSAL = ( + "Loading images from a URL is not available: no host image codec is " + "installed, so Workflows refuses network access rather than fetching a URL " + "without the host's SSRF policy." +) +_PICKLE_REFUSAL = ( + "Serialised numpy image payloads are not supported: deserializing them is " + "arbitrary code execution and is gated by the host, not by Workflows." +) + + +class ImageCodec(Protocol): + """Host-supplied image loading. + + Every signature mirrors the `inference.core.utils.image_utils` function it + fronts, `cv_imread_flags` included - dropping it silently re-enables EXIF + auto-orientation on the paths that asked for it to be off. + + Deliberately NOT `runtime_checkable`: nothing does an `isinstance` check, and + a structural check would not verify the guarantees that matter here. + """ + + def load_image( + self, value: Any, disable_preproc_auto_orient: bool = False + ) -> Tuple[np.ndarray, bool]: + """Load an `inference`-format image and return `(bgr_image, is_bgr)`. + + `value` is a `{"type": ..., "value": ...}` dict as produced by + `WorkflowImageData.to_inference_format()` - `numpy_object`, `base64`, + `url` or `file` - or a bare `np.ndarray` / string. The `url` and `file` + branches carry the host's URL and filesystem policy. + """ + ... + + def fetch_url( + self, value: str, cv_imread_flags: int = cv2.IMREAD_COLOR + ) -> np.ndarray: + """Fetch an image over http(s) under the host's SSRF policy, BGR.""" + ... + + def decode_string( + self, + value: Union[str, bytes, bytearray], + cv_imread_flags: int = cv2.IMREAD_COLOR, + ) -> Tuple[np.ndarray, bool]: + """Decode an in-memory payload (base64 / raw encoded bytes) to + `(bgr_image, is_bgr)`. The host implementation also honours its + `ALLOW_NUMPY_INPUT` pickle gate.""" + ... + + def ensure_local_file_load_allowed(self, path: str) -> None: + """Raise unless the host permits reading images off the local disk. + + A gate rather than a loader on purpose: Workflows reads local files with + two decoders (`cv2.imread` for numpy, `torchvision.io.read_file` + + `decode_image` for tensors) whose EXIF behaviour must not change, and + both belong inside Workflows. Only the *permission* is the host's, and on + the server side it has exactly one owner: + `inference.core.utils.image_utils.ensure_local_file_load_allowed`. + """ + ... + + +class WorkflowsLocalImageCodec: + """The standalone default: in-memory decoding only, everything else refused. + + Decodes `np.ndarray`, `{"type": "numpy_object"}`, `{"type": "base64"}` and + raw encoded bytes / base64 strings, forwarding `cv_imread_flags` on every + path. Refuses URL fetching, local-filesystem reads and serialised numpy + payloads with `WorkflowImageLoadError`. It never opens a socket, never + touches the filesystem and never imports `pickle`. + """ + + def load_image( + self, value: Any, disable_preproc_auto_orient: bool = False + ) -> Tuple[np.ndarray, bool]: + flags = choose_image_decoding_flags( + disable_preproc_auto_orient=disable_preproc_auto_orient + ) + if isinstance(value, dict) and "type" in value and "value" in value: + declared, payload = value["type"], value["value"] + if declared == "url": + raise WorkflowImageLoadError( + public_message=_URL_REFUSAL, + context="workflow_execution | image_loading", + ) + if declared == "file": + raise WorkflowImageLoadError( + public_message=_LOCAL_FILESYSTEM_REFUSAL, + context="workflow_execution | image_loading", + ) + if declared == "numpy": + raise WorkflowImageLoadError( + public_message=_PICKLE_REFUSAL, + context="workflow_execution | image_loading", + ) + if declared == "numpy_object": + return ( + convert_gray_image_to_bgr(ensure_valid_numpy_image(payload)), + True, + ) + if declared == "base64": + return ( + convert_gray_image_to_bgr( + decode_base64_image(payload, cv_imread_flags=flags) + ), + True, + ) + raise WorkflowImageLoadError( + public_message=f"Unsupported declared image type: `{declared}`.", + context="workflow_execution | image_loading", + ) + if isinstance(value, (np.ndarray, np.generic)): + return convert_gray_image_to_bgr(ensure_valid_numpy_image(value)), True + if isinstance(value, str) and value.startswith("http"): + raise WorkflowImageLoadError( + public_message=_URL_REFUSAL, + context="workflow_execution | image_loading", + ) + if isinstance(value, (str, bytes, bytearray)): + # Flags are FORWARDED, not recomputed: `disable_preproc_auto_orient` + # must survive the inferred-type path exactly as it does in + # `image_utils.load_image_with_inferred_type`. The grayscale->BGR + # promotion belongs to THIS layer (image_utils.py:111), not to + # `decode_string` - see the note there. + decoded, is_bgr = self.decode_string(value, cv_imread_flags=flags) + return convert_gray_image_to_bgr(decoded), is_bgr + raise WorkflowImageLoadError( + public_message=f"Could not load an image from a value of type " + f"`{type(value).__name__}`.", + context="workflow_execution | image_loading", + ) + + def fetch_url( + self, value: str, cv_imread_flags: int = cv2.IMREAD_COLOR + ) -> np.ndarray: + raise WorkflowImageLoadError( + public_message=_URL_REFUSAL, + context="workflow_execution | image_loading", + ) + + def decode_string( + self, + value: Union[str, bytes, bytearray], + cv_imread_flags: int = cv2.IMREAD_COLOR, + ) -> Tuple[np.ndarray, bool]: + # NO grayscale->BGR promotion here. `attempt_loading_image_from_string` + # (image_utils.py:238-262) returns the decoder's result untouched and + # `load_image` promotes at image_utils.py:111; doing it here diverges on + # cv2.IMREAD_GRAYSCALE ((6, 9) upstream vs (6, 9, 3) here). + try: + return decode_base64_image(value, cv_imread_flags=cv_imread_flags), True + except WorkflowImageLoadError: + pass + try: + return ( + decode_encoded_image_bytes(value, cv_imread_flags=cv_imread_flags), + True, + ) + except (WorkflowImageLoadError, TypeError, ValueError): + pass + raise WorkflowImageLoadError( + public_message="Input image format could not be inferred from the payload. " + + _PICKLE_REFUSAL, + context="workflow_execution | image_loading", + ) + + def ensure_local_file_load_allowed(self, path: str) -> None: + raise WorkflowImageLoadError( + public_message=_LOCAL_FILESYSTEM_REFUSAL, + context="workflow_execution | image_loading", + ) + + +_DEFAULT_CODEC = WorkflowsLocalImageCodec() +_INSTALLED_CODEC: Optional[ImageCodec] = None +_INSTALL_LOCK = threading.Lock() + + +def get_image_codec() -> ImageCodec: + """The process-wide codec, or the refusing default if none is installed.""" + codec = _INSTALLED_CODEC + return codec if codec is not None else _DEFAULT_CODEC + + +def set_image_codec(codec: ImageCodec) -> None: + """Install the host's codec for this process. + + Re-installing the IDENTICAL object is a no-op - the server calls this once + per request with a module-level singleton. Installing a DIFFERENT object + raises: a process serving two codecs would apply two different SSRF policies + depending on request timing. + + The check and the assignment are under one lock. Without it a concurrent + pair of installs can both observe `None` and both write, which is exactly + what the round-1 review reproduced. + """ + global _INSTALLED_CODEC + with _INSTALL_LOCK: + if _INSTALLED_CODEC is not None and _INSTALLED_CODEC is not codec: + raise WorkflowEnvironmentConfigurationError( + public_message="A different image codec is already installed for " + "this process. The image codec is process-wide and may only be " + "installed once; re-installing the identical object is allowed.", + context="workflow_compilation | engine_initialisation", + ) + _INSTALLED_CODEC = codec + + +def reset_image_codec() -> None: + """Clear the installed codec. Tests only - never call this from a server.""" + global _INSTALLED_CODEC + with _INSTALL_LOCK: + _INSTALLED_CODEC = None diff --git a/tests/workflows/unit_tests/prototypes/test_image_codec.py b/tests/workflows/unit_tests/prototypes/test_image_codec.py new file mode 100644 index 0000000000..5d14065182 --- /dev/null +++ b/tests/workflows/unit_tests/prototypes/test_image_codec.py @@ -0,0 +1,358 @@ +import ast +import base64 +import inspect +import io +import pickle +import threading +from pathlib import Path + +import cv2 +import numpy as np +import pytest +from PIL import Image + +from inference.core.workflows.errors import ( + WorkflowEnvironmentConfigurationError, + WorkflowImageLoadError, +) +from inference.core.workflows.prototypes.image_codec import ( + ImageCodec, + WorkflowsLocalImageCodec, + get_image_codec, + reset_image_codec, + set_image_codec, +) +from inference.core.workflows.utils.image_encoding import ( + choose_image_decoding_flags, + encode_image_to_jpeg_bytes, +) + +IMAGE = np.dstack( + [ + np.tile(np.arange(64, dtype=np.uint8), (48, 1)), + np.full((48, 64), 17, dtype=np.uint8), + np.tile(np.arange(48, dtype=np.uint8).reshape(48, 1), (1, 64)), + ] +) + + +@pytest.fixture(autouse=True) +def _clean_registry(): + # The registry is process-wide and set-once; every test here must start and + # finish with it empty, or the next test inherits an installed codec. + reset_image_codec() + yield + reset_image_codec() + + +def _b64() -> str: + return base64.b64encode(encode_image_to_jpeg_bytes(IMAGE)).decode("ascii") + + +def _b64_with_exif_orientation_6() -> str: + pillow_image = Image.fromarray(IMAGE[:, :, ::-1]) + exif = pillow_image.getexif() + exif[274] = 6 + buffer = io.BytesIO() + pillow_image.save(buffer, format="JPEG", exif=exif, quality=95) + return base64.b64encode(buffer.getvalue()).decode("ascii") + + +def test_protocol_declares_exactly_the_four_injected_capabilities() -> None: + declared = { + name + for name, value in vars(ImageCodec).items() + if not name.startswith("_") and callable(value) + } + assert declared == { + "load_image", + "fetch_url", + "decode_string", + "ensure_local_file_load_allowed", + } + + +@pytest.mark.parametrize( + "name", + ["load_image", "fetch_url", "decode_string", "ensure_local_file_load_allowed"], +) +def test_default_codec_satisfies_the_protocol_signatures(name: str) -> None: + port = inspect.signature(getattr(ImageCodec, name)) + impl = inspect.signature(getattr(WorkflowsLocalImageCodec, name)) + assert [(p.name, p.kind, p.default) for p in port.parameters.values()] == [ + (p.name, p.kind, p.default) for p in impl.parameters.values() + ] + + +def test_port_signatures_mirror_the_server_functions_they_front() -> None: + # The delegates in utils/images.py are import-compatible replacements for + # the image_utils names, so the port must carry cv_imread_flags too. + from inference.core.utils import image_utils as server_image_utils + + def _tail(signature): + return [(p.name, p.kind, p.default) for p in signature.parameters.values()][ + 1: + ] # drop `self` + + assert _tail(inspect.signature(ImageCodec.fetch_url)) == [ + (p.name, p.kind, p.default) + for p in inspect.signature( + server_image_utils.load_image_from_url + ).parameters.values() + ] + assert _tail(inspect.signature(ImageCodec.decode_string)) == [ + (p.name, p.kind, p.default) + for p in inspect.signature( + server_image_utils.attempt_loading_image_from_string + ).parameters.values() + ] + assert _tail(inspect.signature(ImageCodec.load_image)) == [ + (p.name, p.kind, p.default) + for p in inspect.signature(server_image_utils.load_image).parameters.values() + ] + + +def test_default_refuses_url_fetching() -> None: + with pytest.raises(WorkflowImageLoadError, match="URL"): + WorkflowsLocalImageCodec().fetch_url("https://example.com/image.jpg") + + +def test_default_refuses_local_filesystem_loads(tmp_path) -> None: + real_file = tmp_path / "present.jpg" + real_file.write_bytes(encode_image_to_jpeg_bytes(IMAGE)) + with pytest.raises(WorkflowImageLoadError, match="local filesystem"): + WorkflowsLocalImageCodec().ensure_local_file_load_allowed(str(real_file)) + + +def test_default_load_image_refuses_url_and_file_declarations() -> None: + codec = WorkflowsLocalImageCodec() + with pytest.raises(WorkflowImageLoadError, match="URL"): + codec.load_image({"type": "url", "value": "https://example.com/i.jpg"}) + with pytest.raises(WorkflowImageLoadError, match="local filesystem"): + codec.load_image({"type": "file", "value": "/etc/passwd"}) + with pytest.raises(WorkflowImageLoadError, match="URL"): + codec.load_image("https://example.com/i.jpg") + + +def test_default_refuses_pickled_numpy_payloads() -> None: + # image_utils.load_image_from_numpy_str reaches pickle.loads behind + # ALLOW_NUMPY_INPUT. The workflows-local default has no such switch: it + # never unpickles, whatever the payload claims to be. + payload = base64.b64encode(pickle.dumps(IMAGE)).decode("ascii") + codec = WorkflowsLocalImageCodec() + with pytest.raises(WorkflowImageLoadError): + codec.load_image({"type": "numpy", "value": payload}) + with pytest.raises(WorkflowImageLoadError): + codec.decode_string(payload) + + +def test_default_never_imports_or_calls_pickle() -> None: + # Round-2 Defect 3: a prose scan (`"pickle" not in source`) fails on the + # module's own docstrings, which name the gate they refuse to own. Check the + # AST instead: no import of `pickle`, and no attribute call on a name + # `pickle`. + tree = ast.parse( + Path("inference/core/workflows/prototypes/image_codec.py").read_text( + encoding="utf-8" + ) + ) + imported = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imported.add(node.module.split(".")[0]) + assert "pickle" not in imported + + called_on = { + node.func.value.id + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + } + assert "pickle" not in called_on + + +def test_default_decodes_base64_numpy_and_raw_bytes() -> None: + codec = WorkflowsLocalImageCodec() + + decoded, is_bgr = codec.decode_string(_b64()) + assert is_bgr is True and decoded.shape == IMAGE.shape + + decoded, is_bgr = codec.decode_string(encode_image_to_jpeg_bytes(IMAGE)) + assert is_bgr is True and decoded.shape == IMAGE.shape + + decoded, is_bgr = codec.load_image({"type": "base64", "value": _b64()}) + assert is_bgr is True and decoded.shape == IMAGE.shape + + decoded, is_bgr = codec.load_image({"type": "numpy_object", "value": IMAGE}) + assert is_bgr is True and decoded is IMAGE + + decoded, is_bgr = codec.load_image(IMAGE) + assert is_bgr is True and decoded is IMAGE + + +@pytest.mark.parametrize("disable_preproc_auto_orient", [True, False]) +def test_default_forwards_decoding_flags_on_every_in_memory_path( + disable_preproc_auto_orient: bool, +) -> None: + # Round-1 Defect 5: the round-0 default computed flags then dropped them for + # bare strings, silently re-enabling EXIF auto-orientation. Compare against + # the server loader, which is the behaviour being replaced. + from inference.core.utils import image_utils as server_image_utils + + codec = WorkflowsLocalImageCodec() + payload = _b64_with_exif_orientation_6() + flags = choose_image_decoding_flags( + disable_preproc_auto_orient=disable_preproc_auto_orient + ) + + declared, _ = codec.load_image( + {"type": "base64", "value": payload}, + disable_preproc_auto_orient=disable_preproc_auto_orient, + ) + inferred, _ = codec.load_image( + payload, disable_preproc_auto_orient=disable_preproc_auto_orient + ) + reference, _ = server_image_utils.load_image( + {"type": "base64", "value": payload}, + disable_preproc_auto_orient=disable_preproc_auto_orient, + ) + direct, _ = codec.decode_string(payload, cv_imread_flags=flags) + + assert declared.shape == inferred.shape == reference.shape == direct.shape + assert np.array_equal(declared, reference) + assert np.array_equal(inferred, reference) + + +@pytest.mark.parametrize( + "cv_imread_flags", + [cv2.IMREAD_COLOR, cv2.IMREAD_GRAYSCALE, cv2.IMREAD_UNCHANGED], +) +def test_default_decode_string_does_not_promote_like_the_server_decoder( + cv_imread_flags: int, +) -> None: + # Round-2 Defect 8: `attempt_loading_image_from_string` (image_utils.py:238) + # returns the decoder's result untouched; the grayscale->BGR promotion is a + # `load_image` concern (image_utils.py:111). Promoting inside decode_string + # made IMREAD_GRAYSCALE return (6, 9, 3) where the original returns (6, 9). + from inference.core.utils import image_utils as server_image_utils + + codec = WorkflowsLocalImageCodec() + payload = _b64() + ours, ours_is_bgr = codec.decode_string(payload, cv_imread_flags=cv_imread_flags) + theirs, theirs_is_bgr = server_image_utils.attempt_loading_image_from_string( + value=payload, cv_imread_flags=cv_imread_flags + ) + assert ours.shape == theirs.shape + assert ours_is_bgr == theirs_is_bgr + assert np.array_equal(ours, theirs) + + +def test_default_load_image_promotes_grayscale_to_bgr() -> None: + gray = np.tile(np.arange(64, dtype=np.uint8), (48, 1)) + decoded, _ = WorkflowsLocalImageCodec().load_image( + {"type": "numpy_object", "value": gray} + ) + assert decoded.shape == (48, 64, 3) + + +def test_registry_returns_the_refusing_default_until_a_host_installs_one() -> None: + assert isinstance(get_image_codec(), WorkflowsLocalImageCodec) + + +def test_installing_a_codec_makes_it_the_process_codec() -> None: + codec = WorkflowsLocalImageCodec() + set_image_codec(codec) + assert get_image_codec() is codec + + +def test_installing_the_same_codec_twice_is_a_no_op() -> None: + codec = WorkflowsLocalImageCodec() + set_image_codec(codec) + set_image_codec(codec) + assert get_image_codec() is codec + + +def test_installing_a_conflicting_codec_is_an_error() -> None: + first = WorkflowsLocalImageCodec() + set_image_codec(first) + with pytest.raises(WorkflowEnvironmentConfigurationError): + set_image_codec(WorkflowsLocalImageCodec()) + assert get_image_codec() is first + + +def test_two_threads_installing_different_codecs_leave_exactly_one_winner() -> None: + # Round-1 Defect 2: the round-0 setter's check-and-set was unsynchronised and + # a controlled two-thread run installed BOTH codecs. The barrier maximises + # the overlap; the lock must make exactly one call win and the other raise. + barrier = threading.Barrier(2) + outcomes = [] + outcomes_lock = threading.Lock() + + def worker(codec) -> None: + barrier.wait(timeout=5) + try: + set_image_codec(codec) + result = ("installed", codec) + except WorkflowEnvironmentConfigurationError: + result = ("refused", codec) + with outcomes_lock: + outcomes.append(result) + + candidates = [WorkflowsLocalImageCodec(), WorkflowsLocalImageCodec()] + threads = [threading.Thread(target=worker, args=(c,)) for c in candidates] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10) + assert not thread.is_alive() + + assert sorted(status for status, _ in outcomes) == ["installed", "refused"] + winner = next(codec for status, codec in outcomes if status == "installed") + assert get_image_codec() is winner + + +def test_many_threads_installing_the_same_codec_all_succeed() -> None: + # The production case: every request re-installs the same server singleton. + codec = WorkflowsLocalImageCodec() + barrier = threading.Barrier(8) + failures = [] + + def worker() -> None: + barrier.wait(timeout=5) + try: + set_image_codec(codec) + except Exception as error: # noqa: BLE001 - the test is the assertion + failures.append(error) + + threads = [threading.Thread(target=worker) for _ in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10) + + assert failures == [] + assert get_image_codec() is codec + + +def test_module_stays_free_of_the_server_package_and_of_io() -> None: + source = Path("inference/core/workflows/prototypes/image_codec.py").read_text( + encoding="utf-8" + ) + tree = ast.parse(source) + modules = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + modules.update(a.name for a in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + modules.add(node.module) + forbidden = { + m + for m in modules + if (m == "inference" or m.startswith("inference.")) + and not m.startswith("inference.core.workflows") + } + assert not forbidden, forbidden + assert not modules & {"requests", "socket", "urllib", "pickle", "tldextract", "os"} From 65cf21d427f098d1d77da0e778cbebdc742a9d23 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:00:52 +0200 Subject: [PATCH 31/86] feat(workflows): add ImageCodec delegates to utils.images Add four module-level delegates to inference/core/workflows/utils/images that route image loading operations through the installed ImageCodec, plus re-export encode_image_to_jpeg_bytes. Includes comprehensive tests covering routing, signature matching, and fallback behavior. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/workflows/utils/images.py | 67 +++++++- .../unit_tests/utils/test_images_delegates.py | 146 ++++++++++++++++++ 2 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 tests/workflows/unit_tests/utils/test_images_delegates.py diff --git a/inference/core/workflows/utils/images.py b/inference/core/workflows/utils/images.py index d5e16a9b46..6a0a9411c2 100644 --- a/inference/core/workflows/utils/images.py +++ b/inference/core/workflows/utils/images.py @@ -7,10 +7,23 @@ every workflows caller passes a numpy image. """ -from typing import Tuple +from typing import Any, Tuple, Union import cv2 import numpy as np +from _io import _IOBase + +from inference.core.workflows.prototypes.image_codec import get_image_codec +from inference.core.workflows.utils.image_encoding import encode_image_to_jpeg_bytes + +__all__ = [ + "attempt_loading_image_from_string", + "downscale_image_keeping_aspect_ratio", + "encode_image_to_jpeg_bytes", + "ensure_local_image_load_allowed", + "load_image", + "load_image_from_url", +] def downscale_image_keeping_aspect_ratio( @@ -62,3 +75,55 @@ def _resize_image_keeping_aspect_ratio( "This is most likely a bug. Contact Roboflow team through github issues " "(https://github.com/roboflow/inference/issues) providing full context of the problem" ) + + +def load_image( + value: Any, + disable_preproc_auto_orient: bool = False, +) -> Tuple[np.ndarray, bool]: + """Load an `inference`-format image through the installed `ImageCodec`. + + Same name, signature and `(image, is_bgr)` return as + `inference.core.utils.image_utils.load_image`, deliberately: the 24 blocks + calling it were repointed by changing only the module path in their import + line. The URL, local-file and pickle policy this used to apply directly now + comes from the host's codec. + """ + return get_image_codec().load_image( + value, disable_preproc_auto_orient=disable_preproc_auto_orient + ) + + +def load_image_from_url( + value: str, cv_imread_flags: int = cv2.IMREAD_COLOR +) -> np.ndarray: + """Fetch an image over http(s) through the installed `ImageCodec`. + + The keyword name `value` is part of the contract: `entities/base.py` calls it + by keyword and `visual_search/test_v1.py:117` asserts on that call. + """ + return get_image_codec().fetch_url(value, cv_imread_flags=cv_imread_flags) + + +def attempt_loading_image_from_string( + value: Union[str, bytes, bytearray, _IOBase], + cv_imread_flags: int = cv2.IMREAD_COLOR, +) -> Tuple[np.ndarray, bool]: + """Decode an in-memory image payload through the installed `ImageCodec`. + + Routed rather than vendored because the host implementation's last fallback + is a pickled-numpy payload behind `ALLOW_NUMPY_INPUT` - a gate Workflows must + not own. + """ + return get_image_codec().decode_string(value, cv_imread_flags=cv_imread_flags) + + +def ensure_local_image_load_allowed(path: str) -> None: + """Ask the installed `ImageCodec` whether reading `path` off disk is allowed. + + Called before the two local-file decoders Workflows keeps for itself: + `cv2.imread` in `WorkflowImageData.numpy_image` and + `torchvision.io.read_file` + `decode_image` in + `WorkflowImageData._decode_source_to_tensor`. + """ + get_image_codec().ensure_local_file_load_allowed(path) diff --git a/tests/workflows/unit_tests/utils/test_images_delegates.py b/tests/workflows/unit_tests/utils/test_images_delegates.py new file mode 100644 index 0000000000..f3f56918f4 --- /dev/null +++ b/tests/workflows/unit_tests/utils/test_images_delegates.py @@ -0,0 +1,146 @@ +import inspect +from unittest import mock + +import cv2 +import numpy as np +import pytest + +from inference.core.utils import image_utils as server_image_utils +from inference.core.workflows.errors import WorkflowImageLoadError +from inference.core.workflows.prototypes.image_codec import ( + reset_image_codec, + set_image_codec, +) +from inference.core.workflows.utils import images as workflows_images +from inference.core.workflows.utils.images import ( + attempt_loading_image_from_string, + encode_image_to_jpeg_bytes, + ensure_local_image_load_allowed, + load_image, + load_image_from_url, +) + +IMAGE = np.zeros((8, 12, 3), dtype=np.uint8) + + +@pytest.fixture(autouse=True) +def _clean_registry(): + reset_image_codec() + yield + reset_image_codec() + + +class _RecordingCodec: + def __init__(self): + self.calls = [] + + def load_image(self, value, disable_preproc_auto_orient=False): + self.calls.append(("load_image", value, disable_preproc_auto_orient)) + return IMAGE, True + + def fetch_url(self, value, cv_imread_flags=cv2.IMREAD_COLOR): + self.calls.append(("fetch_url", value, cv_imread_flags)) + return IMAGE + + def decode_string(self, value, cv_imread_flags=cv2.IMREAD_COLOR): + self.calls.append(("decode_string", value, cv_imread_flags)) + return IMAGE, True + + def ensure_local_file_load_allowed(self, path): + self.calls.append(("ensure_local_file_load_allowed", path)) + + +@pytest.mark.parametrize( + "name", + [ + "load_image", + "load_image_from_url", + "attempt_loading_image_from_string", + "encode_image_to_jpeg_bytes", + ], +) +def test_delegate_names_shadow_the_server_names_exactly(name: str) -> None: + # The codemod in Task 10.8 rewrites only the module path in each import + # statement, so every name the repointed files import must exist here. + assert hasattr(workflows_images, name) + + +@pytest.mark.parametrize( + "name", + ["load_image", "load_image_from_url", "attempt_loading_image_from_string"], +) +def test_delegate_signatures_match_the_server_functions(name: str) -> None: + # Round-1 Defect 5: round-0 dropped cv_imread_flags from two of the three. + ours = inspect.signature(getattr(workflows_images, name)) + theirs = inspect.signature(getattr(server_image_utils, name)) + assert [(p.name, p.kind, p.default) for p in ours.parameters.values()] == [ + (p.name, p.kind, p.default) for p in theirs.parameters.values() + ] + + +def test_load_image_from_url_keeps_the_value_keyword() -> None: + # base.py:573/627 and deserializers.py:123 call it as + # `load_image_from_url(value=...)` and existing tests assert on that keyword. + assert list(inspect.signature(load_image_from_url).parameters)[0] == "value" + + +def test_delegates_route_through_the_installed_codec() -> None: + codec = _RecordingCodec() + set_image_codec(codec) + + assert load_image({"type": "numpy_object", "value": IMAGE})[1] is True + assert load_image_from_url(value="https://example.com/i.jpg") is IMAGE + assert attempt_loading_image_from_string("payload")[0] is IMAGE + ensure_local_image_load_allowed("/tmp/x.jpg") + + assert codec.calls == [ + ("load_image", {"type": "numpy_object", "value": IMAGE}, False), + ("fetch_url", "https://example.com/i.jpg", cv2.IMREAD_COLOR), + ("decode_string", "payload", cv2.IMREAD_COLOR), + ("ensure_local_file_load_allowed", "/tmp/x.jpg"), + ] + + +def test_delegates_forward_decoding_flags() -> None: + codec = _RecordingCodec() + set_image_codec(codec) + flags = cv2.IMREAD_COLOR | cv2.IMREAD_IGNORE_ORIENTATION + + load_image_from_url(value="https://example.com/i.jpg", cv_imread_flags=flags) + attempt_loading_image_from_string("payload", cv_imread_flags=flags) + load_image({"type": "base64", "value": "x"}, disable_preproc_auto_orient=True) + + assert codec.calls == [ + ("fetch_url", "https://example.com/i.jpg", flags), + ("decode_string", "payload", flags), + ("load_image", {"type": "base64", "value": "x"}, True), + ] + + +def test_delegates_refuse_when_no_host_codec_is_installed() -> None: + with pytest.raises(WorkflowImageLoadError): + load_image_from_url(value="https://example.com/i.jpg") + with pytest.raises(WorkflowImageLoadError): + ensure_local_image_load_allowed("/etc/passwd") + with pytest.raises(WorkflowImageLoadError): + load_image({"type": "url", "value": "https://example.com/i.jpg"}) + + +def test_encode_is_not_routed_through_the_codec() -> None: + # D3: JPEG encoding is pure cv2, vendored rather than injected. It must work + # with no codec installed at all. + assert encode_image_to_jpeg_bytes( + IMAGE, jpeg_quality=95 + ) == server_image_utils.encode_image_to_jpeg_bytes(IMAGE, jpeg_quality=95) + + +def test_module_level_patching_still_works() -> None: + # Four existing tests do `mock.patch.object(, "load_image_from_url")`. + # That relies on the name being a module attribute, not a method lookup. + with mock.patch.object(workflows_images, "load_image_from_url") as patched: + patched.return_value = IMAGE + assert ( + workflows_images.load_image_from_url(value="https://x.example.com/y.jpg") + is IMAGE + ) + patched.assert_called_once_with(value="https://x.example.com/y.jpg") From cfab21c7d8a95067309795a2b8503355bbb96134 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:12:20 +0200 Subject: [PATCH 32/86] feat(server): guarded ImageCodec adapter and single-owner local-file guard Extract ensure_local_file_load_allowed() as the single owner of the ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM check in image_utils.py, and add inference/core/interfaces/workflows_image_codec.py (ServerImageCodec, GUARDED_IMAGE_CODEC, resolve_image_codec, install_guarded_image_codec, bind_image_codec) as the server's ImageCodec adapter for Workflows. A 44-case security parity suite proves every server guard (OFFLINE_MODE, URL enablement, scheme, FQDN, allow/deny lists, redirect re-validation, non-global address rejection with IP pinning + SNI + Host header, pickle gate, local-file flag) still applies through the adapter. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../core/interfaces/workflows_image_codec.py | 109 ++++ inference/core/utils/image_utils.py | 25 +- .../interfaces/test_workflows_image_codec.py | 568 ++++++++++++++++++ .../core/utils/test_local_file_permission.py | 81 +++ 4 files changed, 778 insertions(+), 5 deletions(-) create mode 100644 inference/core/interfaces/workflows_image_codec.py create mode 100644 tests/inference/unit_tests/core/interfaces/test_workflows_image_codec.py create mode 100644 tests/inference/unit_tests/core/utils/test_local_file_permission.py diff --git a/inference/core/interfaces/workflows_image_codec.py b/inference/core/interfaces/workflows_image_codec.py new file mode 100644 index 0000000000..a306e3f0d7 --- /dev/null +++ b/inference/core/interfaces/workflows_image_codec.py @@ -0,0 +1,109 @@ +"""The Roboflow inference server's implementation of the Workflows `ImageCodec`. + +Four thin forwards to `inference.core.utils.image_utils`, so every guard the +server already owns keeps applying to images loaded inside Workflows: +`OFFLINE_MODE`, `ALLOW_URL_INPUT`, the scheme and FQDN rules, the +`WHITELISTED_/BLACKLISTED_DESTINATIONS_FOR_URL_INPUT` lists, per-hop redirect +re-validation with `MAX_IMAGE_URL_REDIRECTS`, non-global-address rejection with +IP pinning (`inference.core.utils.url_input`), `ALLOW_NUMPY_INPUT` in front of +`pickle.loads`, and `ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM`. + +NOTHING here reimplements a rule, and nothing here holds a snapshot of one: + +* the MODULE is imported and its attributes are called, never `from ... import + load_image_from_url`. A function alias captured at import time would make + `mock.patch.object(image_utils, "load_image_from_url")` a no-op, so tests + believing they had neutered a guard would silently exercise the real one; +* the local-filesystem permission is `image_utils.ensure_local_file_load_allowed`, + the same function `load_image_with_known_type` calls. One flag read, one + message, one owner. + +Two entry points for the two injection paths (see +`inference.core.workflows.prototypes.image_codec` for which consumer uses which): +`resolve_image_codec()` for `init_parameters["workflows_core.image_codec"]`, and +`install_guarded_image_codec()` for the process registry. Both hand out the same +singleton, so the two paths cannot disagree. +""" + +from typing import Any, Dict, Tuple, Union + +import cv2 +import numpy as np + +from inference.core.utils import image_utils +from inference.core.workflows.prototypes.image_codec import ImageCodec, set_image_codec + + +class ServerImageCodec: + """`ImageCodec` backed by the server's guarded image loaders.""" + + def load_image( + self, value: Any, disable_preproc_auto_orient: bool = False + ) -> Tuple[np.ndarray, bool]: + return image_utils.load_image( + value, disable_preproc_auto_orient=disable_preproc_auto_orient + ) + + def fetch_url( + self, value: str, cv_imread_flags: int = cv2.IMREAD_COLOR + ) -> np.ndarray: + return image_utils.load_image_from_url( + value=value, cv_imread_flags=cv_imread_flags + ) + + def decode_string( + self, + value: Union[str, bytes, bytearray], + cv_imread_flags: int = cv2.IMREAD_COLOR, + ) -> Tuple[np.ndarray, bool]: + return image_utils.attempt_loading_image_from_string( + value=value, cv_imread_flags=cv_imread_flags + ) + + def ensure_local_file_load_allowed(self, path: str) -> None: + # `path` is accepted for the port's sake and deliberately not consulted: + # the server's policy is a single global switch, and restating it here + # would create the second owner the constraints forbid. + image_utils.ensure_local_file_load_allowed() + + +GUARDED_IMAGE_CODEC = ServerImageCodec() + + +def resolve_image_codec() -> ServerImageCodec: + """The codec the composition roots put in `init_parameters`.""" + return GUARDED_IMAGE_CODEC + + +def install_guarded_image_codec() -> None: + """Make the guarded codec the process-wide codec. + + A module-level singleton, so repeated calls (one per request at the HTTP + roots) are identity no-ops rather than conflicting installs. + """ + set_image_codec(GUARDED_IMAGE_CODEC) + + +def bind_image_codec(init_parameters: Dict[str, Any]) -> ImageCodec: + """Give BOTH injection paths the same codec object, and return it. + + Call this at every composition root AFTER any caller-supplied overrides have + been merged into `init_parameters` - `inference_cli/lib/workflows/ + local_image_adapter.py:453-454` merges them after the dict is built, so a + codec written before that point would be silently replaced on Path A while + Path B kept the guarded one. The two paths are not alternatives for the same + image: Path A deserializes the input and stores `image_reference` + (`deserializers.py:138`), and a later block re-loads that reference through + Path B (`base.py:573`, or `to_inference_format()` -> `load_image`), so a + split would apply two different SSRF policies inside one run. + + `setdefault` honours a deliberate override; `set_image_codec` then makes the + same object process-wide and, by its set-once rule, turns an override that + disagrees with an already-installed codec into a loud + `WorkflowEnvironmentConfigurationError` instead of a silent split. + """ + codec = init_parameters.setdefault( + "workflows_core.image_codec", GUARDED_IMAGE_CODEC + ) + set_image_codec(codec) + return codec diff --git a/inference/core/utils/image_utils.py b/inference/core/utils/image_utils.py index d6740311f0..35ab3d1100 100644 --- a/inference/core/utils/image_utils.py +++ b/inference/core/utils/image_utils.py @@ -158,6 +158,24 @@ def extract_image_payload_and_type(value: Any) -> Tuple[Any, Optional[ImageType] return value, ImageType(image_type.lower()) +def ensure_local_file_load_allowed() -> None: + """Raise unless this deployment permits reading images off the local disk. + + The single owner of `ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM` on the read + path. Called by `load_image_with_known_type` below, and by the Workflows + image-codec adapter (`inference.core.interfaces.workflows_image_codec`), + which needs the permission without the load: Workflows keeps its own two + local decoders (`cv2.imread` and `torchvision.io.read_file` + + `decode_image`) whose EXIF behaviour must not change. + """ + if not ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM: + message = "Loading images from local filesystem is disabled." + raise InputImageLoadError( + message=message, + public_message=message, + ) + + def load_image_with_known_type( value: Any, image_type: ImageType, @@ -175,11 +193,8 @@ def load_image_with_known_type( Returns: Tuple[np.ndarray, bool]: A tuple of the loaded image as a numpy array and a boolean indicating if the image is in BGR format. """ - if image_type is ImageType.FILE and not ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM: - raise InputImageLoadError( - message="Loading images from local filesystem is disabled.", - public_message="Loading images from local filesystem is disabled.", - ) + if image_type is ImageType.FILE: + ensure_local_file_load_allowed() loader = IMAGE_LOADERS[image_type] is_bgr = True if image_type is not ImageType.PILLOW else False image = loader(value, cv_imread_flags) diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_image_codec.py b/tests/inference/unit_tests/core/interfaces/test_workflows_image_codec.py new file mode 100644 index 0000000000..a87ab20e7b --- /dev/null +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_image_codec.py @@ -0,0 +1,568 @@ +"""Security parity for the injected image codec. + +Every guard `inference/core/workflows` used to reach directly is exercised HERE, +through `ServerImageCodec`, never through `image_utils` directly - so an adapter +that stopped forwarding, or started reimplementing, fails these tests. + +Idioms are the ones the existing image-loading suite uses +(`tests/inference/unit_tests/core/utils/test_image_utils.py`): + +* env values are module-level constants captured at import, so + `mock.patch.object(image_utils, "", value)` is what takes effect; +* `requests_mock` intercepts at the transport-adapter level, which REPLACES the + SSRF-protected adapter - so address-classification tests must not use it; +* `mock.patch.object(url_input.socket, "getaddrinfo", ...)` covers DNS. + +Hostnames are subdomains of `example.com`, never `*.example`: under the packaged +suffix list the latter has an EMPTY fqdn and is rejected at `image_utils.py:454` +before any allow/deny-list check ever runs (verified: +`tldextract.TLDExtract(suffix_list_urls=())("cdn.allowed.example").fqdn == ""`). +""" + +import socket +from unittest import mock + +import cv2 +import numpy as np +import pytest +from requests_mock import Mocker +from urllib3.connectionpool import HTTPSConnectionPool + +from inference.core.exceptions import InputImageLoadError, InvalidImageTypeDeclared +from inference.core.interfaces.workflows_image_codec import ( + GUARDED_IMAGE_CODEC, + ServerImageCodec, + bind_image_codec, + install_guarded_image_codec, + resolve_image_codec, +) +from inference.core.utils import image_utils, url_input +from inference.core.workflows.prototypes.image_codec import ( + ImageCodec, + get_image_codec, + reset_image_codec, +) + +CODEC = ServerImageCodec() + +ALLOWED_HOST = "cdn.allowed.example.com" +DENIED_HOST = "metadata.internal.example.com" +REBINDING_HOST = "evil.example.com" + + +@pytest.fixture(autouse=True) +def _clean_registry_and_proxy_env(monkeypatch): + # A developer's HTTP(S)_PROXY defers IP pinning and emits a warning + # (url_input.py:225-227), which would make the pinning test pass vacuously. + for name in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("NO_PROXY", "*") + monkeypatch.setenv("no_proxy", "*") + reset_image_codec() + yield + reset_image_codec() + + +def _fake_getaddrinfo(ip: str): + def _inner(host, port, *args, **kwargs): + return [ + (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (ip, port)) + ] + + return _inner + + +# -------------------------------------------------------------------------- +# URL policy: enablement +# -------------------------------------------------------------------------- + + +@mock.patch.object(image_utils, "ALLOW_URL_INPUT", False) +def test_adapter_refuses_url_when_url_input_is_disabled() -> None: + with pytest.raises(InvalidImageTypeDeclared): + CODEC.fetch_url(f"https://{ALLOWED_HOST}/image.jpg") + + +@mock.patch.object(image_utils, "_fetch_image_bytes_from_url") +@mock.patch.object(image_utils, "_validate_url_destination") +@mock.patch.object(image_utils, "OFFLINE_MODE", True) +def test_adapter_refuses_url_in_offline_mode_before_touching_the_network( + validate_url_destination_mock: mock.MagicMock, + fetch_image_bytes_mock: mock.MagicMock, +) -> None: + with pytest.raises(InputImageLoadError, match="OFFLINE_MODE"): + CODEC.fetch_url(f"https://{ALLOWED_HOST}/image.jpg") + validate_url_destination_mock.assert_not_called() + fetch_image_bytes_mock.assert_not_called() + + +# -------------------------------------------------------------------------- +# URL policy: scheme +# -------------------------------------------------------------------------- + + +@mock.patch.object(image_utils, "ALLOW_URL_INPUT", True) +@mock.patch.object(image_utils, "ALLOW_NON_HTTPS_URL_INPUT", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT_WITHOUT_FQDN", True) +@mock.patch.object(image_utils, "WHITELISTED_DESTINATIONS_FOR_URL_INPUT", None) +@mock.patch.object(image_utils, "BLACKLISTED_DESTINATIONS_FOR_URL_INPUT", None) +@pytest.mark.parametrize( + "url", + [ + "http://cdn.allowed.example.com/image.jpg", + "ftp://cdn.allowed.example.com/image.jpg", + "file:///etc/passwd", + "gopher://127.0.0.1:11211/_stats", + ], +) +def test_adapter_rejects_non_https_schemes(url: str) -> None: + with pytest.raises(InputImageLoadError, match="non https"): + CODEC.fetch_url(url) + + +# -------------------------------------------------------------------------- +# URL policy: FQDN, allow-list, deny-list +# -------------------------------------------------------------------------- + + +@mock.patch.object(image_utils, "ALLOW_URL_INPUT", True) +@mock.patch.object(image_utils, "ALLOW_NON_HTTPS_URL_INPUT", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT_WITHOUT_FQDN", False) +@mock.patch.object(image_utils, "WHITELISTED_DESTINATIONS_FOR_URL_INPUT", None) +@mock.patch.object(image_utils, "BLACKLISTED_DESTINATIONS_FOR_URL_INPUT", None) +def test_adapter_rejects_url_without_fqdn() -> None: + with pytest.raises(InputImageLoadError, match="FQDN"): + CODEC.fetch_url("https://127.0.0.1/image.jpg") + + +@mock.patch.object(image_utils, "ALLOW_URL_INPUT", True) +@mock.patch.object(image_utils, "ALLOW_NON_HTTPS_URL_INPUT", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT_WITHOUT_FQDN", False) +@mock.patch.object(image_utils, "BLACKLISTED_DESTINATIONS_FOR_URL_INPUT", None) +@mock.patch.object( + image_utils, "WHITELISTED_DESTINATIONS_FOR_URL_INPUT", {ALLOWED_HOST} +) +def test_adapter_rejects_a_destination_outside_the_allow_list() -> None: + with pytest.raises(InputImageLoadError, match="whitelisted"): + CODEC.fetch_url("https://not.allowed.example.com/image.jpg") + + +@mock.patch.object(image_utils, "VALIDATE_IMAGE_URL_REDIRECTS", False) +@mock.patch.object(image_utils, "ALLOW_URL_TO_NON_GLOBAL_ADDRESSES", True) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT", True) +@mock.patch.object(image_utils, "ALLOW_NON_HTTPS_URL_INPUT", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT_WITHOUT_FQDN", False) +@mock.patch.object(image_utils, "BLACKLISTED_DESTINATIONS_FOR_URL_INPUT", None) +@mock.patch.object( + image_utils, "WHITELISTED_DESTINATIONS_FOR_URL_INPUT", {ALLOWED_HOST} +) +def test_adapter_accepts_an_allow_listed_destination( + requests_mock: Mocker, + image_as_numpy: np.ndarray, + image_as_png_bytes: bytes, +) -> None: + url = f"https://{ALLOWED_HOST}/image.png" + requests_mock.get(url, content=image_as_png_bytes) + + result = CODEC.fetch_url(url) + + assert np.allclose(image_as_numpy, result) + + +@mock.patch.object(image_utils, "ALLOW_URL_INPUT", True) +@mock.patch.object(image_utils, "ALLOW_NON_HTTPS_URL_INPUT", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT_WITHOUT_FQDN", False) +@mock.patch.object(image_utils, "WHITELISTED_DESTINATIONS_FOR_URL_INPUT", None) +@mock.patch.object(image_utils, "BLACKLISTED_DESTINATIONS_FOR_URL_INPUT", {DENIED_HOST}) +def test_adapter_rejects_a_deny_listed_destination() -> None: + with pytest.raises(InputImageLoadError, match="blacklisted"): + CODEC.fetch_url(f"https://{DENIED_HOST}/latest/meta-data") + + +@mock.patch.object(image_utils, "ALLOW_URL_INPUT", True) +@mock.patch.object(image_utils, "ALLOW_NON_HTTPS_URL_INPUT", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT_WITHOUT_FQDN", True) +@mock.patch.object(image_utils, "BLACKLISTED_DESTINATIONS_FOR_URL_INPUT", None) +@mock.patch.object( + image_utils, "WHITELISTED_DESTINATIONS_FOR_URL_INPUT", {ALLOWED_HOST} +) +def test_adapter_rejects_the_backslash_authority_allow_list_bypass() -> None: + # `https://localhost:6666\@cdn.allowed.example.com/x` parses one way in a + # browser and another in urllib; the guard rejects backslashes in the + # authority (image_utils.py:439). + with pytest.raises(InputImageLoadError): + CODEC.fetch_url(f"https://localhost:6666\\@{ALLOWED_HOST}/image.jpg") + + +# -------------------------------------------------------------------------- +# URL policy: redirects +# -------------------------------------------------------------------------- + + +@mock.patch.object(image_utils, "VALIDATE_IMAGE_URL_REDIRECTS", True) +@mock.patch.object(image_utils, "ALLOW_URL_TO_NON_GLOBAL_ADDRESSES", True) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT", True) +@mock.patch.object(image_utils, "ALLOW_NON_HTTPS_URL_INPUT", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT_WITHOUT_FQDN", False) +@mock.patch.object(image_utils, "WHITELISTED_DESTINATIONS_FOR_URL_INPUT", None) +@mock.patch.object(image_utils, "BLACKLISTED_DESTINATIONS_FOR_URL_INPUT", {DENIED_HOST}) +def test_adapter_re_validates_every_redirect_hop(requests_mock: Mocker) -> None: + start = f"https://{ALLOWED_HOST}/image.jpg" + internal = f"https://{DENIED_HOST}/latest/meta-data" + requests_mock.get(start, status_code=302, headers={"Location": internal}) + + with pytest.raises(InputImageLoadError, match="blacklisted"): + CODEC.fetch_url(start) + + +@mock.patch.object(image_utils, "VALIDATE_IMAGE_URL_REDIRECTS", True) +@mock.patch.object(image_utils, "ALLOW_URL_TO_NON_GLOBAL_ADDRESSES", True) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT", True) +@mock.patch.object(image_utils, "ALLOW_NON_HTTPS_URL_INPUT", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT_WITHOUT_FQDN", False) +@mock.patch.object(image_utils, "WHITELISTED_DESTINATIONS_FOR_URL_INPUT", None) +@mock.patch.object(image_utils, "BLACKLISTED_DESTINATIONS_FOR_URL_INPUT", None) +@mock.patch.object(image_utils, "MAX_IMAGE_URL_REDIRECTS", 2) +def test_adapter_enforces_the_redirect_hop_cap(requests_mock: Mocker) -> None: + for index in range(6): + requests_mock.get( + f"https://hop{index}.example.com/image.jpg", + status_code=302, + headers={"Location": f"https://hop{index + 1}.example.com/image.jpg"}, + ) + + # `fetch_url_content_validating_redirects` raises `TooManyRedirects` + # (url_input.py:387), which `load_image_from_url:421-426` converts. + with pytest.raises(InputImageLoadError): + CODEC.fetch_url("https://hop0.example.com/image.jpg") + + +# -------------------------------------------------------------------------- +# URL policy: address classification. +# NO requests_mock here: it replaces the transport adapter, and the SSRF adapter +# would never run. +# -------------------------------------------------------------------------- + + +@mock.patch.object(image_utils, "VALIDATE_IMAGE_URL_REDIRECTS", False) +@mock.patch.object(image_utils, "ALLOW_URL_TO_NON_GLOBAL_ADDRESSES", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT", True) +@mock.patch.object(image_utils, "ALLOW_NON_HTTPS_URL_INPUT", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT_WITHOUT_FQDN", True) +@mock.patch.object(image_utils, "WHITELISTED_DESTINATIONS_FOR_URL_INPUT", None) +@mock.patch.object(image_utils, "BLACKLISTED_DESTINATIONS_FOR_URL_INPUT", None) +@pytest.mark.parametrize( + "url", + [ + "https://10.0.0.1/image.jpg", + "https://127.0.0.1/image.jpg", + "https://169.254.169.254/latest/meta-data", + "https://[::1]/image.jpg", + "https://[::ffff:127.0.0.1]/image.jpg", + "https://100.64.0.1/image.jpg", + ], +) +def test_adapter_blocks_non_global_address_literals(url: str) -> None: + with pytest.raises(InputImageLoadError) as error: + CODEC.fetch_url(url) + assert "not allowed" in str(error.value).lower() + + +@mock.patch.object(image_utils, "VALIDATE_IMAGE_URL_REDIRECTS", False) +@mock.patch.object(image_utils, "ALLOW_URL_TO_NON_GLOBAL_ADDRESSES", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT", True) +@mock.patch.object(image_utils, "ALLOW_NON_HTTPS_URL_INPUT", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT_WITHOUT_FQDN", False) +@mock.patch.object(image_utils, "WHITELISTED_DESTINATIONS_FOR_URL_INPUT", None) +@mock.patch.object(image_utils, "BLACKLISTED_DESTINATIONS_FOR_URL_INPUT", None) +def test_adapter_blocks_a_public_hostname_resolving_to_loopback(monkeypatch) -> None: + resolved = [] + + def _rebinding_getaddrinfo(host, port, *args, **kwargs): + resolved.append(host) + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("127.0.0.1", port), + ) + ] + + monkeypatch.setattr(url_input.socket, "getaddrinfo", _rebinding_getaddrinfo) + + with pytest.raises(InputImageLoadError) as error: + CODEC.fetch_url(f"https://{REBINDING_HOST}/image.jpg") + + assert "not allowed" in str(error.value).lower() + # The patch is process-global and background threads may resolve unrelated + # hosts; assert on the target host only. + assert resolved.count(REBINDING_HOST) == 1 + + +@mock.patch.object(image_utils, "VALIDATE_IMAGE_URL_REDIRECTS", False) +@mock.patch.object(image_utils, "ALLOW_URL_TO_NON_GLOBAL_ADDRESSES", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT", True) +@mock.patch.object(image_utils, "ALLOW_NON_HTTPS_URL_INPUT", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT_WITHOUT_FQDN", False) +@mock.patch.object(image_utils, "WHITELISTED_DESTINATIONS_FOR_URL_INPUT", None) +@mock.patch.object(image_utils, "BLACKLISTED_DESTINATIONS_FOR_URL_INPUT", None) +def test_adapter_pins_the_connection_and_preserves_the_tls_hostname( + monkeypatch, +) -> None: + # Round-1 Defect 7: rejecting loopback does not prove pinning. This asserts + # the pool the fetch actually opens is pinned to the validated IP while + # certificate verification, SNI and the `Host` header still target the + # original hostname (url_input.py:159-167, :195-219). + captured = {} + + class _StopBeforeSocket(Exception): + pass + + def _capturing_urlopen(self, *args, **kwargs): + captured["host"] = self.host + captured["assert_hostname"] = getattr(self, "assert_hostname", None) + # `conn_kw["server_hostname"]` IS the SNI setting (url_input.py:217). + captured["server_hostname"] = self.conn_kw.get("server_hostname") + # The adapter rewrites the request's `Host` header to the original + # hostname before dialling the pinned IP (url_input.py:159-167); the + # pool receives it in `urlopen(headers=...)`. + captured["host_header"] = kwargs["headers"].get("Host") + raise _StopBeforeSocket() + + monkeypatch.setattr( + url_input.socket, "getaddrinfo", _fake_getaddrinfo("93.184.216.34") + ) + monkeypatch.setattr(HTTPSConnectionPool, "urlopen", _capturing_urlopen) + + with pytest.raises(_StopBeforeSocket): + CODEC.fetch_url(f"https://{ALLOWED_HOST}/image.jpg") + + # Pinned to the validated IP... + assert captured["host"] == "93.184.216.34", captured + # ...while certificate verification and SNI both still target the hostname. + assert captured["assert_hostname"] == ALLOWED_HOST, captured + assert captured["server_hostname"] == ALLOWED_HOST, captured + # ...and the vhost the origin sees is still the hostname, not the IP + # (round-3 Defect 2). + assert captured["host_header"] == ALLOWED_HOST, captured + + +# -------------------------------------------------------------------------- +# Local filesystem policy - one owner, in image_utils +# -------------------------------------------------------------------------- + + +@mock.patch.object(image_utils, "ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM", True) +def test_adapter_permits_local_file_loads_when_the_flag_is_on( + image_as_local_path: str, +) -> None: + assert CODEC.ensure_local_file_load_allowed(image_as_local_path) is None + + +@mock.patch.object(image_utils, "ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM", False) +def test_adapter_refuses_local_file_loads_when_the_flag_is_off( + image_as_local_path: str, +) -> None: + with pytest.raises(InputImageLoadError, match="local filesystem"): + CODEC.ensure_local_file_load_allowed(image_as_local_path) + + +def test_adapter_calls_the_single_owner_rather_than_restating_the_rule() -> None: + # Round-1 Defect 6: the adapter must not hold its own flag snapshot. + with mock.patch.object(image_utils, "ensure_local_file_load_allowed") as guard: + CODEC.ensure_local_file_load_allowed("/tmp/whatever.jpg") + guard.assert_called_once_with() + + +@mock.patch.object(image_utils, "ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM", False) +def test_adapter_load_image_refuses_a_declared_file_payload_when_disabled( + image_as_local_path: str, +) -> None: + with pytest.raises(InputImageLoadError, match="local filesystem"): + CODEC.load_image({"type": "file", "value": image_as_local_path}) + + +# -------------------------------------------------------------------------- +# Pickle gate +# -------------------------------------------------------------------------- + + +@mock.patch.object(image_utils, "ALLOW_NUMPY_INPUT", False) +def test_adapter_decode_string_keeps_the_pickle_gate_shut_by_default( + image_as_base64_encoded_pickled_bytes: bytes, +) -> None: + with pytest.raises(InputImageLoadError): + CODEC.decode_string(image_as_base64_encoded_pickled_bytes) + + +@mock.patch.object(image_utils, "ALLOW_NUMPY_INPUT", True) +def test_adapter_decode_string_honours_an_opened_pickle_gate( + image_as_base64_encoded_pickled_bytes: bytes, + image_as_numpy: np.ndarray, +) -> None: + decoded, is_bgr = CODEC.decode_string(image_as_base64_encoded_pickled_bytes) + assert is_bgr is True + assert np.allclose(decoded, image_as_numpy) + + +@mock.patch.object(image_utils, "ALLOW_NUMPY_INPUT", False) +def test_adapter_load_image_refuses_a_declared_numpy_payload_when_disabled( + image_as_base64_encoded_pickled_bytes: bytes, +) -> None: + with pytest.raises(InvalidImageTypeDeclared): + CODEC.load_image( + {"type": "numpy", "value": image_as_base64_encoded_pickled_bytes} + ) + + +# -------------------------------------------------------------------------- +# Happy paths, flags and wiring +# -------------------------------------------------------------------------- + + +def test_adapter_decodes_base64_like_the_server( + image_as_jpeg_base64_string: str, +) -> None: + decoded, is_bgr = CODEC.decode_string(image_as_jpeg_base64_string) + reference, reference_is_bgr = image_utils.attempt_loading_image_from_string( + value=image_as_jpeg_base64_string + ) + assert is_bgr == reference_is_bgr + assert np.array_equal(decoded, reference) + + +@pytest.mark.parametrize("disable_preproc_auto_orient", [True, False]) +def test_adapter_forwards_decoding_flags( + disable_preproc_auto_orient: bool, image_as_jpeg_base64_string: str +) -> None: + flags = image_utils.choose_image_decoding_flags( + disable_preproc_auto_orient=disable_preproc_auto_orient + ) + ours, _ = CODEC.decode_string(image_as_jpeg_base64_string, cv_imread_flags=flags) + theirs, _ = image_utils.attempt_loading_image_from_string( + value=image_as_jpeg_base64_string, cv_imread_flags=flags + ) + assert np.array_equal(ours, theirs) + + ours_dispatch, _ = CODEC.load_image( + {"type": "base64", "value": image_as_jpeg_base64_string}, + disable_preproc_auto_orient=disable_preproc_auto_orient, + ) + theirs_dispatch, _ = image_utils.load_image( + {"type": "base64", "value": image_as_jpeg_base64_string}, + disable_preproc_auto_orient=disable_preproc_auto_orient, + ) + assert np.array_equal(ours_dispatch, theirs_dispatch) + + +@pytest.mark.parametrize( + "cv_imread_flags", + [cv2.IMREAD_COLOR, cv2.IMREAD_GRAYSCALE, cv2.IMREAD_UNCHANGED], +) +def test_adapter_decode_string_returns_exactly_what_the_server_decoder_returns( + cv_imread_flags: int, image_as_jpeg_base64_string: str +) -> None: + # Round-2 Defect 8, adapter side: the forward must not reshape the result. + ours, ours_is_bgr = CODEC.decode_string( + image_as_jpeg_base64_string, cv_imread_flags=cv_imread_flags + ) + theirs, theirs_is_bgr = image_utils.attempt_loading_image_from_string( + value=image_as_jpeg_base64_string, cv_imread_flags=cv_imread_flags + ) + assert ours.shape == theirs.shape + assert ours_is_bgr == theirs_is_bgr + assert np.array_equal(ours, theirs) + + +def test_adapter_load_image_matches_the_server_loader_for_numpy_payloads( + image_as_numpy: np.ndarray, +) -> None: + ours = CODEC.load_image({"type": "numpy_object", "value": image_as_numpy}) + theirs = image_utils.load_image({"type": "numpy_object", "value": image_as_numpy}) + assert np.array_equal(ours[0], theirs[0]) + assert ours[1] == theirs[1] + + +@pytest.mark.parametrize( + "name", + ["load_image", "fetch_url", "decode_string", "ensure_local_file_load_allowed"], +) +def test_adapter_satisfies_the_port_signatures(name: str) -> None: + import inspect + + port = inspect.signature(getattr(ImageCodec, name)) + impl = inspect.signature(getattr(ServerImageCodec, name)) + assert [(p.name, p.kind, p.default) for p in port.parameters.values()] == [ + (p.name, p.kind, p.default) for p in impl.parameters.values() + ] + + +def test_bind_image_codec_writes_and_installs_the_same_object() -> None: + init_parameters = {"workflows_core.api_key": "k"} + bound = bind_image_codec(init_parameters) + assert bound is GUARDED_IMAGE_CODEC + assert init_parameters["workflows_core.image_codec"] is GUARDED_IMAGE_CODEC + assert get_image_codec() is GUARDED_IMAGE_CODEC + + +def test_bind_image_codec_honours_a_caller_override_on_both_paths() -> None: + # Round-2 Defect 1: an override must move BOTH paths, not just Path A. + from inference.core.workflows.prototypes.image_codec import WorkflowsLocalImageCodec + + override = WorkflowsLocalImageCodec() + init_parameters = {"workflows_core.image_codec": override} + bound = bind_image_codec(init_parameters) + assert bound is override + assert init_parameters["workflows_core.image_codec"] is override + assert get_image_codec() is override + + +def test_bind_image_codec_refuses_an_override_that_conflicts_with_an_install() -> None: + from inference.core.workflows.errors import WorkflowEnvironmentConfigurationError + from inference.core.workflows.prototypes.image_codec import WorkflowsLocalImageCodec + + install_guarded_image_codec() + with pytest.raises(WorkflowEnvironmentConfigurationError): + bind_image_codec({"workflows_core.image_codec": WorkflowsLocalImageCodec()}) + assert get_image_codec() is GUARDED_IMAGE_CODEC + + +def test_resolve_and_install_share_one_singleton() -> None: + assert resolve_image_codec() is GUARDED_IMAGE_CODEC + install_guarded_image_codec() + assert get_image_codec() is GUARDED_IMAGE_CODEC + install_guarded_image_codec() # idempotent + assert get_image_codec() is GUARDED_IMAGE_CODEC + + +def test_adapter_forwards_rather_than_reimplements() -> None: + # Round-1 Defect 3: the round-0 adapter held function ALIASES, so these + # patches did not land and the real guards ran instead. The adapter must + # import the MODULE and call attributes. + sentinel_image = np.zeros((2, 2, 3), dtype=np.uint8) + + with mock.patch.object(image_utils, "load_image_from_url") as forwarded: + forwarded.return_value = sentinel_image + assert CODEC.fetch_url(f"https://{ALLOWED_HOST}/i.jpg") is sentinel_image + forwarded.assert_called_once_with( + value=f"https://{ALLOWED_HOST}/i.jpg", + cv_imread_flags=image_utils.cv2.IMREAD_COLOR, + ) + + with mock.patch.object( + image_utils, "attempt_loading_image_from_string" + ) as forwarded: + forwarded.return_value = (sentinel_image, True) + assert CODEC.decode_string("payload")[0] is sentinel_image + forwarded.assert_called_once_with( + value="payload", cv_imread_flags=image_utils.cv2.IMREAD_COLOR + ) + + with mock.patch.object(image_utils, "load_image") as forwarded: + forwarded.return_value = (sentinel_image, True) + assert CODEC.load_image({"type": "base64", "value": "x"})[0] is sentinel_image + forwarded.assert_called_once_with( + {"type": "base64", "value": "x"}, disable_preproc_auto_orient=False + ) diff --git a/tests/inference/unit_tests/core/utils/test_local_file_permission.py b/tests/inference/unit_tests/core/utils/test_local_file_permission.py new file mode 100644 index 0000000000..9f9ea9867a --- /dev/null +++ b/tests/inference/unit_tests/core/utils/test_local_file_permission.py @@ -0,0 +1,81 @@ +"""One owner for the local-filesystem permission. + +`inference/core/workflows` cannot read `ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM`, +so the permission is injected through the `ImageCodec` port. To avoid a second +copy of the rule on the server side (which "injected, never reimplemented" +forbids), the check is a named helper in `image_utils` that both the existing +loader and the codec adapter call. +""" + +from unittest import mock + +import pytest + +from inference.core.exceptions import InputImageLoadError +from inference.core.utils import image_utils +from inference.core.utils.image_utils import ( + ImageType, + ensure_local_file_load_allowed, + load_image, + load_image_with_known_type, +) + + +@mock.patch.object(image_utils, "ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM", True) +def test_helper_permits_when_the_flag_is_on() -> None: + assert ensure_local_file_load_allowed() is None + + +@mock.patch.object(image_utils, "ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM", False) +def test_helper_refuses_when_the_flag_is_off() -> None: + with pytest.raises(InputImageLoadError) as error: + ensure_local_file_load_allowed() + assert "local filesystem" in str(error.value) + + +@pytest.mark.parametrize("flag_value", [True, False]) +def test_declared_file_loader_delegates_to_the_helper( + flag_value: bool, image_as_local_path: str +) -> None: + # The extraction must not change `load_image_with_known_type`'s behaviour: + # the loader raises exactly when the helper raises, with the same type. + with mock.patch.object( + image_utils, "ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM", flag_value + ): + helper_raised = False + try: + ensure_local_file_load_allowed() + except InputImageLoadError: + helper_raised = True + + loader_raised = False + try: + load_image_with_known_type( + value=image_as_local_path, image_type=ImageType.FILE + ) + except InputImageLoadError: + loader_raised = True + + dispatcher_raised = False + try: + load_image({"type": "file", "value": image_as_local_path}) + except InputImageLoadError: + dispatcher_raised = True + + assert helper_raised == loader_raised == dispatcher_raised == (not flag_value) + + +@mock.patch.object(image_utils, "ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM", False) +def test_the_loader_actually_calls_the_helper(image_as_local_path: str) -> None: + # Guards against the extraction being reverted into an inline check. + with mock.patch.object( + image_utils, "ensure_local_file_load_allowed" + ) as helper_mock: + helper_mock.side_effect = InputImageLoadError( + message="stub", public_message="stub" + ) + with pytest.raises(InputImageLoadError, match="stub"): + load_image_with_known_type( + value=image_as_local_path, image_type=ImageType.FILE + ) + helper_mock.assert_called_once_with() From 3840204dd969bfa6eb1c2e0520d8ce53891c6d84 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:21:45 +0200 Subject: [PATCH 33/86] test(server): pin the backslash and redirect-cap parity assertions to their guards Tighten two security-parity tests that previously accepted any InputImageLoadError: the backslash-authority bypass test now asserts the specific ValueError cause and message from the backslash guard (and that the transport was never reached), and the redirect-hop-cap test asserts the TooManyRedirects text and the measured request count (3) rather than any error. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../interfaces/test_workflows_image_codec.py | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_image_codec.py b/tests/inference/unit_tests/core/interfaces/test_workflows_image_codec.py index a87ab20e7b..7b8ea3fc28 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_image_codec.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_image_codec.py @@ -189,9 +189,20 @@ def test_adapter_rejects_a_deny_listed_destination() -> None: def test_adapter_rejects_the_backslash_authority_allow_list_bypass() -> None: # `https://localhost:6666\@cdn.allowed.example.com/x` parses one way in a # browser and another in urllib; the guard rejects backslashes in the - # authority (image_utils.py:439). - with pytest.raises(InputImageLoadError): - CODEC.fetch_url(f"https://localhost:6666\\@{ALLOWED_HOST}/image.jpg") + # authority (image_utils.py:454-455, raising `ValueError("URL authority + # contains a backslash")`, converted at :459-464 into + # `InputImageLoadError("Provided image URL is invalid")` with `from + # error`). Pinned to that specific guard - not just "any + # InputImageLoadError" - so the test cannot pass for the wrong reason + # (e.g. the allow-list rejecting the `localhost` authority instead). + with mock.patch.object(image_utils, "_fetch_image_bytes_from_url") as fetch_mock: + with pytest.raises( + InputImageLoadError, match="Provided image URL is invalid" + ) as error: + CODEC.fetch_url(f"https://localhost:6666\\@{ALLOWED_HOST}/image.jpg") + fetch_mock.assert_not_called() + assert isinstance(error.value.__cause__, ValueError) + assert "backslash" in str(error.value.__cause__) # -------------------------------------------------------------------------- @@ -232,10 +243,17 @@ def test_adapter_enforces_the_redirect_hop_cap(requests_mock: Mocker) -> None: ) # `fetch_url_content_validating_redirects` raises `TooManyRedirects` - # (url_input.py:387), which `load_image_from_url:421-426` converts. - with pytest.raises(InputImageLoadError): + # (url_input.py:386-387), and `load_image_from_url`'s + # `except (RequestException, ConnectionError)` (image_utils.py:437-441) + # folds it into the message WITHOUT `from` - so pin on the surfaced text + # rather than `__cause__`, which is not set here. + with pytest.raises(InputImageLoadError, match="Exceeded maximum of 2 redirects"): CODEC.fetch_url("https://hop0.example.com/image.jpg") + # `range(max_redirects + 1)` (url_input.py:366) allows 3 requests before + # raising: hop0, hop1, hop2. + assert requests_mock.call_count == 3 + # -------------------------------------------------------------------------- # URL policy: address classification. From b3ef19df830737aba25d9717cf99af9319d29b7a Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:23:01 +0200 Subject: [PATCH 34/86] feat(workflows): inject the image codec into the runtime-input deserializers Add a keyword-only `image_codec` parameter to `deserialize_image_kind` in `core_steps/common/deserializers.py` (which stops importing `inference.core.utils.image_utils`) and its `deserializers_tensor.py` forward. `ExecutionEngineV1.init` rebinds the image-kind deserializer on a copied `kinds_deserializers` map when `init_parameters["workflows_core. image_codec"]` is set, via `dataclasses.replace` on the frozen `CompiledWorkflow` (Path A of D2, engine-scoped injection). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../core_steps/common/deserializers.py | 16 +- .../core_steps/common/deserializers_tensor.py | 4 + .../workflows/execution_engine/v1/core.py | 46 ++++ .../unit_tests/decontamination_baseline.txt | 3 +- .../executor/test_runtime_input_assembler.py | 98 ++++++--- .../test_image_codec_injection.py | 199 ++++++++++++++++++ 6 files changed, 324 insertions(+), 42 deletions(-) create mode 100644 tests/workflows/unit_tests/execution_engine/test_image_codec_injection.py diff --git a/inference/core/workflows/core_steps/common/deserializers.py b/inference/core/workflows/core_steps/common/deserializers.py index af66b0e4d1..af646513f7 100644 --- a/inference/core/workflows/core_steps/common/deserializers.py +++ b/inference/core/workflows/core_steps/common/deserializers.py @@ -9,10 +9,6 @@ import supervision as sv from pydantic import ValidationError -from inference.core.utils.image_utils import ( - attempt_loading_image_from_string, - load_image_from_url, -) from inference.core.workflows.core_steps.common.utils import ( add_inference_keypoints_to_sv_detections, filter_out_invalid_polygons, @@ -68,6 +64,7 @@ VideoMetadata, WorkflowImageData, ) +from inference.core.workflows.prototypes.image_codec import ImageCodec, get_image_codec AnyNumber = Union[int, float] @@ -76,6 +73,8 @@ def deserialize_image_kind( parameter: str, image: Any, prevent_local_images_loading: bool = False, + *, + image_codec: Optional[ImageCodec] = None, ) -> WorkflowImageData: if isinstance(image, WorkflowImageData): return image @@ -116,20 +115,25 @@ def deserialize_image_kind( if is_image_dict: image = image["value"] if isinstance(image, str): + # Path A: the engine binds its own codec here (see + # ExecutionEngineV1.init); out-of-engine callers such as + # modal/modal_app.py fall back to the process registry. + codec = image_codec if image_codec is not None else get_image_codec() base64_image = None image_reference = None if image.startswith("http://") or image.startswith("https://"): image_reference = image - image = load_image_from_url(value=image) + image = codec.fetch_url(image) elif not prevent_local_images_loading and os.path.exists(image): # prevent_local_images_loading is introduced to eliminate # server vulnerability - namely it prevents local server # file system from being exploited. image_reference = image + codec.ensure_local_file_load_allowed(image) image = cv2.imread(image) else: base64_image = image - image = attempt_loading_image_from_string(image)[0] + image = codec.decode_string(image)[0] return WorkflowImageData( parent_metadata=parent_metadata, workflow_root_ancestor_metadata=workflow_root_ancestor_metadata, diff --git a/inference/core/workflows/core_steps/common/deserializers_tensor.py b/inference/core/workflows/core_steps/common/deserializers_tensor.py index 29d4387d31..b2e1f116f7 100644 --- a/inference/core/workflows/core_steps/common/deserializers_tensor.py +++ b/inference/core/workflows/core_steps/common/deserializers_tensor.py @@ -42,6 +42,7 @@ OriginCoordinatesSystem, WorkflowImageData, ) +from inference.core.workflows.prototypes.image_codec import ImageCodec from inference_models.models.base.classification import ( ClassificationPrediction, MultiLabelClassificationPrediction, @@ -88,6 +89,8 @@ def deserialize_image_kind( parameter: str, image: Any, prevent_local_images_loading: bool = False, + *, + image_codec: Optional["ImageCodec"] = None, ) -> WorkflowImageData: if isinstance(image, WorkflowImageData): return image @@ -125,6 +128,7 @@ def deserialize_image_kind( parameter=parameter, image=image, prevent_local_images_loading=prevent_local_images_loading, + image_codec=image_codec, ) diff --git a/inference/core/workflows/execution_engine/v1/core.py b/inference/core/workflows/execution_engine/v1/core.py index 303d9ed258..3b571e081d 100644 --- a/inference/core/workflows/execution_engine/v1/core.py +++ b/inference/core/workflows/execution_engine/v1/core.py @@ -1,6 +1,8 @@ +import inspect import logging import os from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace from functools import partial from typing import Any, Callable, Dict, List, Optional, Set, Union @@ -14,6 +16,7 @@ from inference.core.workflows.execution_engine.entities.engine import ( BaseExecutionEngine, ) +from inference.core.workflows.execution_engine.entities.types import IMAGE_KIND from inference.core.workflows.execution_engine.profiling.core import ( NullWorkflowsProfiler, WorkflowsProfiler, @@ -48,6 +51,7 @@ StepExecutionMode, is_workflow_selector, ) +from inference.core.workflows.prototypes.image_codec import ImageCodec from inference.core.workflows.prototypes.models_provider import ModelsProvider from inference.core.workflows.prototypes.observer import ( NULL_EXECUTION_OBSERVER, @@ -340,6 +344,39 @@ def _mirror_dynamic_block_parameters( ) +def _bind_image_codec_to_deserializers( + kinds_deserializers: Dict[str, Callable[..., Any]], + image_codec: ImageCodec, +) -> Dict[str, Callable[..., Any]]: + """Return a COPY of the map with the image deserializer bound to `image_codec`. + + A copy, not a mutation: `compile_workflow_graph` serves `kinds_deserializers` + out of `COMPILATION_CACHE` (`compiler/core.py:124-128`), so mutating it would + hand one engine's codec to every later engine compiled from the same + definition. + + A plugin may register its own image-kind deserializer with the historic + 3-argument signature; binding a keyword it does not accept would raise at run + time, so such a deserializer is left exactly as it is. + """ + deserializer = kinds_deserializers.get(IMAGE_KIND.name) + if deserializer is None: + return kinds_deserializers + try: + signature = inspect.signature(deserializer) + except (TypeError, ValueError): + return kinds_deserializers + accepts_codec = "image_codec" in signature.parameters or any( + parameter.kind is inspect.Parameter.VAR_KEYWORD + for parameter in signature.parameters.values() + ) + if not accepts_codec: + return kinds_deserializers + bound = dict(kinds_deserializers) + bound[IMAGE_KIND.name] = partial(deserializer, image_codec=image_codec) + return bound + + class ExecutionEngineV1(BaseExecutionEngine): @classmethod @@ -386,6 +423,15 @@ def init( execution_engine_version=EXECUTION_ENGINE_V1_VERSION, profiler=profiler, ) + image_codec = init_parameters.get("workflows_core.image_codec") + if image_codec is not None: + compiled_workflow = replace( + compiled_workflow, + kinds_deserializers=_bind_image_codec_to_deserializers( + kinds_deserializers=compiled_workflow.kinds_deserializers, + image_codec=image_codec, + ), + ) pre_init_dependencies_types = ( _parse_dependencies_pre_init(dependencies_pre_init=dependencies_pre_init) if dependencies_pre_init diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index d423db2350..6d8730b6ea 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,10 +1,9 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 256 +# Count: 255 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/common/deserializers.py inference.core.utils.image_utils inference/core/workflows/core_steps/common/deserializers_tensor.py inference.core.env inference/core/workflows/core_steps/common/openrouter.py inference.core.env inference/core/workflows/core_steps/common/openrouter.py inference.core.utils.image_utils diff --git a/tests/workflows/unit_tests/execution_engine/executor/test_runtime_input_assembler.py b/tests/workflows/unit_tests/execution_engine/executor/test_runtime_input_assembler.py index b513be5776..e923d3bc24 100644 --- a/tests/workflows/unit_tests/execution_engine/executor/test_runtime_input_assembler.py +++ b/tests/workflows/unit_tests/execution_engine/executor/test_runtime_input_assembler.py @@ -1,13 +1,10 @@ import time from datetime import datetime from typing import Any -from unittest import mock -from unittest.mock import MagicMock import numpy as np import pytest -from inference.core.workflows.core_steps.common import deserializers from inference.core.workflows.core_steps.loader import KINDS_DESERIALIZERS from inference.core.workflows.errors import RuntimeInputError from inference.core.workflows.execution_engine.entities.base import ( @@ -46,26 +43,48 @@ def test_assemble_runtime_parameters_when_image_is_not_provided() -> None: ) -@mock.patch.object(deserializers, "load_image_from_url") -def test_assemble_runtime_parameters_when_image_is_provided_as_single_element_dict( - load_image_from_url_mock: MagicMock, -) -> None: - # given - load_image_from_url_mock.return_value = np.zeros((192, 168, 3), dtype=np.uint8) - runtime_parameters = { - "image1": { - "type": "url", - "value": "https://some.com/image.jpg", +def test_assemble_runtime_parameters_when_image_is_provided_as_single_element_dict() -> ( + None +): + # given - the URL fetch is a host capability now; stand in for the host with + # a codec that returns a canned image instead of reaching the network. + from inference.core.workflows.prototypes.image_codec import ( + reset_image_codec, + set_image_codec, + ) + + class _CannedCodec: + def load_image(self, value, disable_preproc_auto_orient=False): + raise AssertionError("not used by this path") + + def fetch_url(self, value, cv_imread_flags=None): + return np.zeros((192, 168, 3), dtype=np.uint8) + + def decode_string(self, value, cv_imread_flags=None): + raise AssertionError("not used by this path") + + def ensure_local_file_load_allowed(self, path): + return None + + reset_image_codec() + set_image_codec(_CannedCodec()) + try: + runtime_parameters = { + "image1": { + "type": "url", + "value": "https://some.com/image.jpg", + } } - } - defined_inputs = [WorkflowImage(type="WorkflowImage", name="image1")] + defined_inputs = [WorkflowImage(type="WorkflowImage", name="image1")] - # when - result = assemble_runtime_parameters( - runtime_parameters=runtime_parameters, - defined_inputs=defined_inputs, - kinds_deserializers=KINDS_DESERIALIZERS, - ) + # when + result = assemble_runtime_parameters( + runtime_parameters=runtime_parameters, + defined_inputs=defined_inputs, + kinds_deserializers=KINDS_DESERIALIZERS, + ) + finally: + reset_image_codec() # then assert ( @@ -82,21 +101,32 @@ def test_assemble_runtime_parameters_when_image_is_provided_as_single_element_di def test_assemble_runtime_parameters_when_image_is_provided_as_single_element_dict_pointing_local_file_when_load_of_local_files_allowed( example_image_file: str, ) -> None: - # given - runtime_parameters = { - "image1": { - "type": "file", - "value": example_image_file, + # given - reading a local path is a host capability; the server's codec + # permits it while ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM is on (default). + from inference.core.interfaces.workflows_image_codec import ( + install_guarded_image_codec, + ) + from inference.core.workflows.prototypes.image_codec import reset_image_codec + + reset_image_codec() + install_guarded_image_codec() + try: + runtime_parameters = { + "image1": { + "type": "file", + "value": example_image_file, + } } - } - defined_inputs = [WorkflowImage(type="WorkflowImage", name="image1")] + defined_inputs = [WorkflowImage(type="WorkflowImage", name="image1")] - # when - result = assemble_runtime_parameters( - runtime_parameters=runtime_parameters, - defined_inputs=defined_inputs, - kinds_deserializers=KINDS_DESERIALIZERS, - ) + # when + result = assemble_runtime_parameters( + runtime_parameters=runtime_parameters, + defined_inputs=defined_inputs, + kinds_deserializers=KINDS_DESERIALIZERS, + ) + finally: + reset_image_codec() # then assert ( diff --git a/tests/workflows/unit_tests/execution_engine/test_image_codec_injection.py b/tests/workflows/unit_tests/execution_engine/test_image_codec_injection.py new file mode 100644 index 0000000000..fe342c02ef --- /dev/null +++ b/tests/workflows/unit_tests/execution_engine/test_image_codec_injection.py @@ -0,0 +1,199 @@ +import base64 + +import cv2 +import numpy as np +import pytest + +from inference.core.workflows.core_steps.loader import KINDS_DESERIALIZERS +from inference.core.workflows.errors import WorkflowImageLoadError +from inference.core.workflows.execution_engine.core import ExecutionEngine +from inference.core.workflows.execution_engine.entities.types import IMAGE_KIND +from inference.core.workflows.prototypes.image_codec import ( + WorkflowsLocalImageCodec, + reset_image_codec, +) + +BLUR_WORKFLOW = { + "version": "1.0", + "inputs": [{"type": "WorkflowImage", "name": "image"}], + "steps": [ + { + "type": "roboflow_core/image_blur@v1", + "name": "blur", + "image": "$inputs.image", + "blur_type": "gaussian", + "kernel_size": 5, + } + ], + "outputs": [ + {"type": "JsonField", "name": "blurred", "selector": "$steps.blur.image"} + ], +} + + +@pytest.fixture(autouse=True) +def _clean_registry(): + reset_image_codec() + yield + reset_image_codec() + + +class _RecordingCodec(WorkflowsLocalImageCodec): + """Behaves like the refusing default, but records what it was asked to do.""" + + def __init__(self): + self.calls = [] + + def decode_string(self, value, cv_imread_flags=cv2.IMREAD_COLOR): + self.calls.append("decode_string") + return super().decode_string(value, cv_imread_flags=cv_imread_flags) + + def fetch_url(self, value, cv_imread_flags=cv2.IMREAD_COLOR): + self.calls.append("fetch_url") + return super().fetch_url(value, cv_imread_flags=cv_imread_flags) + + +def _base64_image() -> str: + image = np.zeros((16, 24, 3), dtype=np.uint8) + image[..., 1] = 200 + return base64.b64encode(cv2.imencode(".png", image)[1].tobytes()).decode("ascii") + + +def test_engine_uses_the_injected_codec_for_image_inputs() -> None: + # The whole point of Path A: a codec handed to ExecutionEngine.init is what + # deserializes that engine's runtime images. + codec = _RecordingCodec() + engine = ExecutionEngine.init( + workflow_definition=BLUR_WORKFLOW, + init_parameters={"workflows_core.image_codec": codec}, + ) + + result = engine.run(runtime_parameters={"image": _base64_image()}) + + assert codec.calls == ["decode_string"] + assert result[0]["blurred"].numpy_image.shape == (16, 24, 3) + + +def test_injected_codec_refusal_propagates_from_a_real_run(monkeypatch) -> None: + # Round-4 Defect 1: before Path A exists, the stock deserializer hands this + # URL to the server loader with request_timeout=None (image_utils.py:475 -> + # url_input.py:340), so an unguarded RED run would depend on DNS and the + # network and could stall. DNS is faked and the socket is blocked BELOW + # address validation (the same blocker as Task 10.5 Step 8); the assertion + # on `transport_attempts` is what fails at RED, without any connection. + import socket + + import urllib3.connectionpool as connectionpool + + transport_attempts = [] + + def _blocked(*args, **kwargs): + transport_attempts.append("connection") + raise AssertionError("transport reached") + + def _fake_getaddrinfo(host, port, *args, **kwargs): + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("93.184.216.34", port), + ) + ] + + monkeypatch.setattr(socket, "getaddrinfo", _fake_getaddrinfo) + monkeypatch.setattr(connectionpool.HTTPConnectionPool, "_new_conn", _blocked) + monkeypatch.setattr(connectionpool.HTTPSConnectionPool, "_new_conn", _blocked) + + codec = _RecordingCodec() + engine = ExecutionEngine.init( + workflow_definition=BLUR_WORKFLOW, + init_parameters={"workflows_core.image_codec": codec}, + ) + + with pytest.raises(Exception) as error: + engine.run(runtime_parameters={"image": "https://cdn.example.com/i.jpg"}) + + assert ( + transport_attempts == [] + ), "the run reached the transport instead of the injected codec" + assert "URL" in str(error.value) + assert codec.calls == ["fetch_url"] + + +def test_injection_does_not_leak_into_another_engine() -> None: + # `kinds_deserializers` is served out of COMPILATION_CACHE + # (compiler/core.py:124-128) and CompiledWorkflow is frozen, so the rebinding + # MUST happen on a copied map. If it mutated the cached one, the second + # engine below would inherit the first engine's codec. + first_codec = _RecordingCodec() + first = ExecutionEngine.init( + workflow_definition=BLUR_WORKFLOW, + init_parameters={"workflows_core.image_codec": first_codec}, + ) + second = ExecutionEngine.init( + workflow_definition=BLUR_WORKFLOW, + init_parameters={}, + ) + + first.run(runtime_parameters={"image": _base64_image()}) + second.run(runtime_parameters={"image": _base64_image()}) + + assert first_codec.calls == ["decode_string"], "second engine leaked into the first" + + +def test_shared_registry_is_not_disturbed_by_engine_scoped_injection() -> None: + codec = _RecordingCodec() + ExecutionEngine.init( + workflow_definition=BLUR_WORKFLOW, + init_parameters={"workflows_core.image_codec": codec}, + ) + from inference.core.workflows.prototypes.image_codec import get_image_codec + + assert ( + get_image_codec() is not codec + ), "init_parameters must not install a process-wide codec as a side effect" + + +def test_engine_without_the_init_parameter_keeps_the_stock_deserializer() -> None: + # Round-2 Defect 3: `ExecutionEngine` is a wrapper that stores the versioned + # engine on `_engine` (`execution_engine/core.py:71`); it has no + # `_compiled_workflow` of its own. + engine = ExecutionEngine.init(workflow_definition=BLUR_WORKFLOW, init_parameters={}) + bound = engine._engine._compiled_workflow.kinds_deserializers[IMAGE_KIND.name] + assert bound is KINDS_DESERIALIZERS[IMAGE_KIND.name] + + +def test_engine_with_the_init_parameter_rebinds_only_the_image_kind() -> None: + codec = _RecordingCodec() + engine = ExecutionEngine.init( + workflow_definition=BLUR_WORKFLOW, + init_parameters={"workflows_core.image_codec": codec}, + ) + deserializers = engine._engine._compiled_workflow.kinds_deserializers + assert deserializers[IMAGE_KIND.name] is not KINDS_DESERIALIZERS[IMAGE_KIND.name] + assert deserializers[IMAGE_KIND.name].keywords == {"image_codec": codec} + # Every other kind is untouched, and the cached map itself is not mutated. + for kind, function in KINDS_DESERIALIZERS.items(): + if kind == IMAGE_KIND.name: + continue + assert deserializers[kind] is function + + +def test_a_plugin_deserializer_without_the_parameter_is_left_alone() -> None: + # Plugins may register their own image-kind deserializer with the historic + # 3-argument signature. Binding a keyword it does not accept would raise at + # run time, so the engine must detect that and leave it untouched. + from inference.core.workflows.execution_engine.v1 import core as v1_core + + def legacy_plugin_deserializer( + parameter, value, prevent_local_images_loading=False + ): + return value + + rebound = v1_core._bind_image_codec_to_deserializers( + kinds_deserializers={IMAGE_KIND.name: legacy_plugin_deserializer}, + image_codec=_RecordingCodec(), + ) + assert rebound[IMAGE_KIND.name] is legacy_plugin_deserializer From 783a9a0c7c4c7847a0f7f5369045bb046a15f8b0 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:11:34 +0200 Subject: [PATCH 35/86] feat(server): bind one image codec to both injection paths at every root Call bind_image_codec(workflow_init_parameters) before every ExecutionEngine.init at the HTTP run and validate routes, the InferencePipeline workflow root and the CLI single-image adapter (after its caller-override merge), so the engine-scoped init_parameters entry and the process registry always carry the same ServerImageCodec object. Adds an AST + real-engine binding test covering all four roots. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/interfaces/http/http_api.py | 7 + .../interfaces/stream/inference_pipeline.py | 2 + .../lib/workflows/local_image_adapter.py | 5 + .../interfaces/test_image_codec_binding.py | 491 ++++++++++++++++++ 4 files changed, 505 insertions(+) create mode 100644 tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py diff --git a/inference/core/interfaces/http/http_api.py b/inference/core/interfaces/http/http_api.py index a4e5be1af4..4ea5e00b81 100644 --- a/inference/core/interfaces/http/http_api.py +++ b/inference/core/interfaces/http/http_api.py @@ -304,6 +304,7 @@ from inference.core.interfaces.workflows_execution_observer import ( UsageTrackingExecutionObserver, ) +from inference.core.interfaces.workflows_image_codec import bind_image_codec from inference.core.interfaces.workflows_step_error_handlers import ( resolve_step_error_handler, ) @@ -1614,6 +1615,11 @@ def process_workflow_inference_request( "workflows_core.execution_observer": UsageTrackingExecutionObserver(), } ) + # One codec for both injection paths - the engine deserializes the + # input with it, and WorkflowImageData / the block-level loaders + # re-load any stored reference with it (see + # workflows/prototypes/image_codec.py). Idempotent per request. + bind_image_codec(workflow_init_parameters) with start_span( "workflow.init", {"workflow.id": workflow_request.workflow_id or ""}, @@ -2572,6 +2578,7 @@ def validate_workflow( "workflows_core.execution_observer": UsageTrackingExecutionObserver(), } ) + bind_image_codec(workflow_init_parameters) _ = ExecutionEngine.init( workflow_definition=specification, init_parameters=workflow_init_parameters, diff --git a/inference/core/interfaces/stream/inference_pipeline.py b/inference/core/interfaces/stream/inference_pipeline.py index d2e6c98ad1..3f0923a684 100644 --- a/inference/core/interfaces/stream/inference_pipeline.py +++ b/inference/core/interfaces/stream/inference_pipeline.py @@ -703,6 +703,7 @@ def init_with_workflow( from inference.core.interfaces.workflows_execution_observer import ( UsageTrackingExecutionObserver, ) + from inference.core.interfaces.workflows_image_codec import bind_image_codec from inference.core.interfaces.workflows_step_error_handlers import ( resolve_step_error_handler, ) @@ -760,6 +761,7 @@ def init_with_workflow( # setdefault semantics: a caller's workflow_init_parameters may # already carry an explicit inner_workflow_spec_resolver. install_workflows_platform_bindings(workflow_init_parameters) + bind_image_codec(workflow_init_parameters) execution_engine = ExecutionEngine.init( workflow_definition=workflow_specification, init_parameters=workflow_init_parameters, diff --git a/inference_cli/lib/workflows/local_image_adapter.py b/inference_cli/lib/workflows/local_image_adapter.py index 1afbeffdf4..595945b7c4 100644 --- a/inference_cli/lib/workflows/local_image_adapter.py +++ b/inference_cli/lib/workflows/local_image_adapter.py @@ -15,6 +15,7 @@ from inference.core.interfaces.workflows_execution_observer import ( UsageTrackingExecutionObserver, ) +from inference.core.interfaces.workflows_image_codec import bind_image_codec from inference.core.interfaces.workflows_step_error_handlers import ( resolve_step_error_handler, ) @@ -461,6 +462,10 @@ def _run_workflow_for_single_image_with_inference( if workflows_execution_engine_init_params: workflow_init_parameters.update(workflows_execution_engine_init_params) install_workflows_platform_bindings(workflow_init_parameters) + # AFTER the override merge, never before: a codec written earlier would be + # replaced here on Path A while the process registry kept the guarded one + # (round-2 Defect 1). + bind_image_codec(workflow_init_parameters) execution_engine = ExecutionEngine.init( workflow_definition=workflow_specification, init_parameters=workflow_init_parameters, diff --git a/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py b/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py new file mode 100644 index 0000000000..8ad6b3fe28 --- /dev/null +++ b/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py @@ -0,0 +1,491 @@ +"""One codec, both paths, at every server entry point into the engine. + +Round-1 Defect 7 / round-2 Defect 2 / round-3 Defect 2: counting installer calls +anywhere in a file accepts a call placed AFTER `ExecutionEngine.init`, or inside +a nested function that is never reached, and proves nothing about the +dictionary that actually reaches the engine; and a root test that replaces the +engine proves nothing about image behaviour. So this file has two halves: + +* STRUCTURE - an AST test that resolves each `ExecutionEngine.init` call to its + innermost enclosing function, scans that function WITHOUT descending into + nested functions, and requires a `bind_image_codec()` call earlier in it + whose argument is the very Name passed as `init_parameters=`. +* EXECUTION - every root runs the REAL `ExecutionEngine.init` (the fixture only + records the `init_parameters` object on its way through), compiles a + model-free workflow, and runs it: the HTTP run route on a base64 input and on + URL inputs that the server's URL policy must accept / refuse, the validate + route through compilation, the pipeline root through the `on_video_frame` + callable it hands to `init_with_custom_logic`, and the CLI root end to end + including a caller override and a conflicting override. + +Fixtures reused from the repository: +`tests/inference/unit_tests/core/interfaces/http/test_http_api.py` +(`_build_plain_interface`), the `InferencePipeline.init_with_workflow` pattern +from `tests/inference/unit_tests/core/interfaces/stream/test_interface_pipeline.py`, +and the real CLI function `_run_workflow_for_single_image_with_inference`. +""" + +import ast +import base64 +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from pathlib import Path +from unittest import mock +from unittest.mock import MagicMock + +import cv2 +import numpy as np +import pytest +from fastapi.testclient import TestClient +from requests_mock import Mocker + +from inference.core.interfaces.workflows_image_codec import ( + GUARDED_IMAGE_CODEC, + install_guarded_image_codec, +) +from inference.core.utils import image_utils +from inference.core.workflows.errors import WorkflowEnvironmentConfigurationError +from inference.core.workflows.execution_engine.core import ExecutionEngine +from inference.core.workflows.execution_engine.entities.types import IMAGE_KIND +from inference.core.workflows.prototypes.image_codec import ( + WorkflowsLocalImageCodec, + get_image_codec, + reset_image_codec, +) +from tests.inference.unit_tests.core.interfaces.http.test_http_api import ( + _build_plain_interface, +) + +# tests/inference/unit_tests/core/interfaces/ -> five levels up is the repo root +REPO_ROOT = Path(__file__).resolve().parents[5] +COMPOSITION_ROOTS = [ + "inference/core/interfaces/http/http_api.py", + "inference/core/interfaces/stream/inference_pipeline.py", + "inference_cli/lib/workflows/local_image_adapter.py", +] +CODEC_INIT_PARAMETER = "workflows_core.image_codec" +ALLOWED_HOST = "cdn.allowed.example.com" +DENIED_HOST = "metadata.internal.example.com" + +# Model-free, so every root can compile and run it with a MagicMock model manager. +BLUR_WORKFLOW = { + "version": "1.0", + "inputs": [{"type": "WorkflowImage", "name": "image"}], + "steps": [ + { + "type": "roboflow_core/image_blur@v1", + "name": "blur", + "image": "$inputs.image", + "blur_type": "gaussian", + "kernel_size": 5, + } + ], + "outputs": [ + {"type": "JsonField", "name": "blurred", "selector": "$steps.blur.image"} + ], +} + + +@pytest.fixture(autouse=True) +def _clean_registry(): + reset_image_codec() + yield + reset_image_codec() + + +@pytest.fixture +def forwarded_engine_init(monkeypatch) -> dict: + """Record what reaches `ExecutionEngine.init`, then run the REAL initializer. + + Round-3 Defect 2: a mocked engine proves the dict was built, not that the + engine compiled with it or that images flow through the bound codec. The + patch lands on the class, which is the same object `http_api`, + `inference_pipeline` (function-local import) and `local_image_adapter` + all resolve. + """ + real_init = ExecutionEngine.init # bound classmethod, captured before patching + captured = {"init_parameters": [], "engines": []} + + def _forwarding_init(**kwargs): + captured["init_parameters"].append(kwargs["init_parameters"]) + engine = real_init(**kwargs) + captured["engines"].append(engine) + return engine + + monkeypatch.setattr(ExecutionEngine, "init", _forwarding_init) + return captured + + +def _png_bytes() -> bytes: + image = np.zeros((16, 24, 3), dtype=np.uint8) + image[..., 1] = 200 + return cv2.imencode(".png", image)[1].tobytes() + + +def _png_base64() -> str: + return base64.b64encode(_png_bytes()).decode("ascii") + + +def _decode_serialised_image(payload: dict) -> np.ndarray: + assert payload["type"] == "base64", payload + return cv2.imdecode( + np.frombuffer(base64.b64decode(payload["value"]), np.uint8), cv2.IMREAD_COLOR + ) + + +def _assert_one_object_on_both_paths(captured: dict, expected) -> None: + assert len(captured["init_parameters"]) == 1, "expected exactly one engine init" + # Path A: the SAME dict object the root handed to the engine carries the codec... + assert captured["init_parameters"][0][CODEC_INIT_PARAMETER] is expected + # ...and the engine really rebound its image deserializer to that object... + engine = captured["engines"][0] + bound = engine._engine._compiled_workflow.kinds_deserializers[IMAGE_KIND.name] + assert bound.keywords == {"image_codec": expected} + # ...while Path B (the process registry) holds the identical object. + assert get_image_codec() is expected + + +# -------------------------------------------------------------------------- +# Structure: the binding must precede the engine and target the SAME dict +# -------------------------------------------------------------------------- + + +def _parent_map(tree: ast.AST) -> dict: + parents = {} + for node in ast.walk(tree): + for child in ast.iter_child_nodes(node): + parents[child] = node + return parents + + +def _enclosing_function(node, parents): + current = parents.get(node) + while current is not None: + if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef)): + return current + current = parents.get(current) + return None + + +def _nodes_in_scope(scope): + """Nodes belonging to `scope`, NOT descending into nested functions. + + `ast.walk` would happily accept a binding buried in an uncalled inner + helper; this generator stops at every function/lambda boundary. + """ + stack = list(ast.iter_child_nodes(scope)) + while stack: + node = stack.pop() + yield node + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + continue + stack.extend(ast.iter_child_nodes(node)) + + +def _engine_init_calls(tree: ast.AST) -> list: + return [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "init" + and getattr(node.func.value, "id", None) == "ExecutionEngine" + ] + + +def _bind_calls_in_scope(scope) -> list: + return [ + node + for node in _nodes_in_scope(scope) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "bind_image_codec" + ] + + +@pytest.mark.parametrize("relative", COMPOSITION_ROOTS) +def test_each_engine_init_is_preceded_by_a_binding_of_its_own_parameters( + relative: str, +) -> None: + tree = ast.parse((REPO_ROOT / relative).read_text(encoding="utf-8")) + parents = _parent_map(tree) + engine_inits = _engine_init_calls(tree) + assert engine_inits, relative + + for call in engine_inits: + scope = _enclosing_function(call, parents) + assert scope is not None, (relative, call.lineno) + + init_parameters_kwarg = next( + (k for k in call.keywords if k.arg == "init_parameters"), None + ) + assert init_parameters_kwarg is not None, (relative, call.lineno) + assert isinstance(init_parameters_kwarg.value, ast.Name), ( + f"{relative}:{call.lineno} - init_parameters must be a named dict so " + f"the binding can be matched against it" + ) + parameters_name = init_parameters_kwarg.value.id + + matching = [ + bind + for bind in _bind_calls_in_scope(scope) + if bind.lineno < call.lineno + and bind.args + and isinstance(bind.args[0], ast.Name) + and bind.args[0].id == parameters_name + ] + assert matching, ( + f"{relative}:{call.lineno} - no bind_image_codec({parameters_name}) " + f"before ExecutionEngine.init inside {scope.name}" + ) + + +@pytest.mark.parametrize("relative", COMPOSITION_ROOTS) +def test_every_composition_root_imports_the_binder(relative: str) -> None: + tree = ast.parse((REPO_ROOT / relative).read_text(encoding="utf-8")) + imported = { + alias.name + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + and node.module == "inference.core.interfaces.workflows_image_codec" + for alias in node.names + } + assert "bind_image_codec" in imported, (relative, sorted(imported)) + + +def test_the_scope_scan_rejects_a_binding_hidden_in_a_nested_function() -> None: + # Guards the guard (round-2 Defect 2): `ast.walk` accepted this shape. + source = ( + "def root():\n" + " def never_called():\n" + " bind_image_codec(params)\n" + " params = {}\n" + " ExecutionEngine.init(init_parameters=params)\n" + ) + tree = ast.parse(source) + scope = tree.body[0] + assert _bind_calls_in_scope(scope) == [] + assert [ + n + for n in ast.walk(scope) + if isinstance(n, ast.Call) + and isinstance(n.func, ast.Name) + and n.func.id == "bind_image_codec" + ] + + +def test_http_api_binds_at_both_engine_entry_points() -> None: + tree = ast.parse( + (REPO_ROOT / "inference/core/interfaces/http/http_api.py").read_text( + encoding="utf-8" + ) + ) + assert len(_engine_init_calls(tree)) == 2 + + +# -------------------------------------------------------------------------- +# Execution: the HTTP run route, with a real engine and real image loading +# -------------------------------------------------------------------------- + + +def test_http_run_route_runs_a_real_engine_and_loads_the_input_through_the_bound_codec( + monkeypatch, forwarded_engine_init +) -> None: + interface, _ = _build_plain_interface(monkeypatch) + payload = _png_base64() + + # `ServerImageCodec.decode_string` calls the MODULE attribute, so a spy + # installed on `image_utils` sees exactly the calls that went through the + # bound codec - and still runs the real decoder. + with mock.patch.object( + image_utils, + "attempt_loading_image_from_string", + wraps=image_utils.attempt_loading_image_from_string, + ) as server_decoder, TestClient(interface.app) as client: + response = client.post( + "/workflows/run", + headers={"Authorization": "Bearer header-key"}, + json={"specification": BLUR_WORKFLOW, "inputs": {"image": payload}}, + ) + + assert response.status_code == 200, response.text + _assert_one_object_on_both_paths(forwarded_engine_init, GUARDED_IMAGE_CODEC) + server_decoder.assert_called_once() + assert server_decoder.call_args.kwargs["value"] == payload + blurred = _decode_serialised_image(response.json()["outputs"][0]["blurred"]) + assert blurred.shape == (16, 24, 3) + + +@mock.patch.object(image_utils, "ALLOW_URL_INPUT", True) +@mock.patch.object(image_utils, "ALLOW_NON_HTTPS_URL_INPUT", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT_WITHOUT_FQDN", False) +@mock.patch.object(image_utils, "WHITELISTED_DESTINATIONS_FOR_URL_INPUT", None) +@mock.patch.object(image_utils, "BLACKLISTED_DESTINATIONS_FOR_URL_INPUT", {DENIED_HOST}) +def test_http_run_route_refuses_a_deny_listed_url_input_through_the_bound_codec( + monkeypatch, forwarded_engine_init +) -> None: + # The SSRF deny-list is the server's; Path A must reach it for a URL that + # arrives as a workflow input. The engine is real, the request is refused + # before any transport, and the refusal surfaces as a client error. + interface, _ = _build_plain_interface(monkeypatch) + + with TestClient(interface.app) as client: + response = client.post( + "/workflows/run", + headers={"Authorization": "Bearer header-key"}, + json={ + "specification": BLUR_WORKFLOW, + "inputs": {"image": f"https://{DENIED_HOST}/latest/meta-data"}, + }, + ) + + assert response.status_code == 400, response.text + assert "blacklisted" in response.text + _assert_one_object_on_both_paths(forwarded_engine_init, GUARDED_IMAGE_CODEC) + + +@mock.patch.object(image_utils, "VALIDATE_IMAGE_URL_REDIRECTS", False) +@mock.patch.object(image_utils, "ALLOW_URL_TO_NON_GLOBAL_ADDRESSES", True) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT", True) +@mock.patch.object(image_utils, "ALLOW_NON_HTTPS_URL_INPUT", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT_WITHOUT_FQDN", False) +@mock.patch.object(image_utils, "BLACKLISTED_DESTINATIONS_FOR_URL_INPUT", None) +@mock.patch.object( + image_utils, "WHITELISTED_DESTINATIONS_FOR_URL_INPUT", {ALLOWED_HOST} +) +def test_http_run_route_fetches_an_allow_listed_url_input_through_the_bound_codec( + monkeypatch, forwarded_engine_init, requests_mock: Mocker +) -> None: + # `requests_mock` replaces the transport adapter (so the SSRF *address* + # adapter is not exercised here - the parity suite covers it); the + # allow-list check runs before the transport and is what this proves. + # `TestClient` speaks httpx, so `requests_mock` does not intercept it. + url = f"https://{ALLOWED_HOST}/image.png" + requests_mock.get(url, content=_png_bytes()) + interface, _ = _build_plain_interface(monkeypatch) + + with TestClient(interface.app) as client: + response = client.post( + "/workflows/run", + headers={"Authorization": "Bearer header-key"}, + json={"specification": BLUR_WORKFLOW, "inputs": {"image": url}}, + ) + + assert response.status_code == 200, response.text + assert requests_mock.call_count == 1 + assert requests_mock.request_history[0].url == url + _assert_one_object_on_both_paths(forwarded_engine_init, GUARDED_IMAGE_CODEC) + blurred = _decode_serialised_image(response.json()["outputs"][0]["blurred"]) + assert blurred.shape == (16, 24, 3) + + +def test_http_validate_route_compiles_a_real_engine_with_the_guarded_codec( + monkeypatch, forwarded_engine_init +) -> None: + interface, _ = _build_plain_interface(monkeypatch) + + with TestClient(interface.app) as client: + response = client.post( + "/workflows/validate?api_key=some-key", json=BLUR_WORKFLOW + ) + + assert response.status_code == 200, response.text + assert response.json() == {"status": "ok"} + _assert_one_object_on_both_paths(forwarded_engine_init, GUARDED_IMAGE_CODEC) + + +# -------------------------------------------------------------------------- +# Execution: the pipeline root +# -------------------------------------------------------------------------- + + +def test_pipeline_root_runs_a_real_engine_with_the_guarded_codec( + monkeypatch, forwarded_engine_init +) -> None: + # `init_with_custom_logic` is the only thing replaced (it would open + # `video.mp4`); the engine, the WorkflowRunner and the frame are real. + from inference.core.interfaces.camera.entities import VideoFrame + from inference.core.interfaces.stream.inference_pipeline import InferencePipeline + + init_with_custom_logic = MagicMock(return_value=MagicMock()) + monkeypatch.setattr( + InferencePipeline, "init_with_custom_logic", init_with_custom_logic + ) + + InferencePipeline.init_with_workflow( + video_reference="video.mp4", + workflow_specification=BLUR_WORKFLOW, + model_manager=MagicMock(), + image_input_name="image", + ) + + _assert_one_object_on_both_paths(forwarded_engine_init, GUARDED_IMAGE_CODEC) + on_video_frame = init_with_custom_logic.call_args.kwargs["on_video_frame"] + frame = VideoFrame( + image=np.zeros((16, 24, 3), dtype=np.uint8), + frame_id=1, + frame_timestamp=datetime.now(), + ) + results = on_video_frame([frame]) + assert results[0]["blurred"].numpy_image.shape == (16, 24, 3) + + +# -------------------------------------------------------------------------- +# Execution: the CLI root, including the override that split the paths (R2-1) +# -------------------------------------------------------------------------- + + +def _run_cli_root(tmp_path, init_params=None) -> dict: + from inference_cli.lib.workflows import local_image_adapter + + image_path = str(tmp_path / "frame.png") + assert cv2.imwrite(image_path, np.zeros((16, 24, 3), dtype=np.uint8)) + # A real executor: the engine runs its steps on the executor it is given. + with ThreadPoolExecutor(max_workers=1) as executor: + return local_image_adapter._run_workflow_for_single_image_with_inference( + model_manager=MagicMock(), + image_path=image_path, + workflow_specification=BLUR_WORKFLOW, + workflow_id=None, + image_input_name="image", + workflow_parameters=None, + api_key=None, + thread_pool_executor=executor, + max_concurrent_workflows_steps=1, + workflows_execution_engine_init_params=init_params, + ) + + +def test_cli_root_runs_a_real_engine_with_the_guarded_codec( + tmp_path, forwarded_engine_init +) -> None: + result = _run_cli_root(tmp_path) + _assert_one_object_on_both_paths(forwarded_engine_init, GUARDED_IMAGE_CODEC) + assert _decode_serialised_image(result["blurred"]).shape == (16, 24, 3) + + +def test_cli_override_moves_both_paths_together( + tmp_path, forwarded_engine_init +) -> None: + # Round-2 Defect 1: `local_image_adapter.py` merges caller overrides AFTER + # the dict is built, so a codec written before that point would change + # Path A only. Binding after the merge keeps the two paths identical, and + # the real engine still compiles and runs with the override. + override = WorkflowsLocalImageCodec() + result = _run_cli_root(tmp_path, init_params={CODEC_INIT_PARAMETER: override}) + _assert_one_object_on_both_paths(forwarded_engine_init, override) + assert _decode_serialised_image(result["blurred"]).shape == (16, 24, 3) + + +def test_cli_override_conflicting_with_an_install_is_refused_before_the_engine_starts( + tmp_path, forwarded_engine_init +) -> None: + install_guarded_image_codec() + with pytest.raises(WorkflowEnvironmentConfigurationError): + _run_cli_root( + tmp_path, init_params={CODEC_INIT_PARAMETER: WorkflowsLocalImageCodec()} + ) + assert ( + forwarded_engine_init["init_parameters"] == [] + ), "refused after the engine started" + assert get_image_codec() is GUARDED_IMAGE_CODEC From 7b7879e87f02800a01cf5af9388f479023c46286 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:47:08 +0200 Subject: [PATCH 36/86] refactor(workflows): route image loading through the ImageCodec port Repoint the remaining 35 files/36 statements under inference/core/workflows that still imported from inference.core.utils.image_utils to the workflows-local inference.core.workflows.utils.images port (Task 10.4), via an AST-based codemod (scripts/repoint_image_utils_imports.py). Gate WorkflowImageData's two local-file decoders (numpy_image's cv2.imread branch and _decode_source_to_tensor's read_file/decode_image branch) behind ensure_local_image_load_allowed, so ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM now applies to all three previously-unguarded local-file read paths. Add a guarded_image_codec fixture for tests that read real local files through WorkflowImageData, a refusal test for the no-host-codec default, a new server-side test file covering the flag on all three gated paths, and two appended tests to test_image_codec_binding.py proving one image agrees across both the engine-input and downstream-block reload paths, and that a reference-only WorkflowImageData outside any engine goes through the process codec registry (with faked DNS/blocked transport per Ruling R10-C so the test does not depend on real network access). Regenerate the shrink-only decontamination baseline: 255 -> 220, zero image_utils import rows remaining in inference/core/workflows. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../workflows/core_steps/common/openrouter.py | 2 +- .../core_steps/fusion/image_stack/v1.py | 2 +- .../models/foundation/anthropic_claude/v1.py | 7 +- .../models/foundation/anthropic_claude/v2.py | 7 +- .../models/foundation/anthropic_claude/v3.py | 7 +- .../models/foundation/anthropic_claude/v4.py | 7 +- .../models/foundation/google_gemini/v1.py | 2 +- .../models/foundation/google_gemini/v2.py | 2 +- .../models/foundation/google_gemini/v3.py | 2 +- .../models/foundation/google_gemini/v4.py | 2 +- .../models/foundation/google_gemini/v5.py | 2 +- .../models/foundation/google_gemma/v1.py | 2 +- .../models/foundation/kimi_openrouter/v1.py | 2 +- .../models/foundation/llama_vision/v1.py | 2 +- .../core_steps/models/foundation/lmm/v1.py | 2 +- .../models/foundation/meta_vlm/v1.py | 2 +- .../models/foundation/meta_vlm/v2.py | 2 +- .../core_steps/models/foundation/openai/v1.py | 2 +- .../core_steps/models/foundation/openai/v2.py | 2 +- .../core_steps/models/foundation/openai/v3.py | 2 +- .../core_steps/models/foundation/openai/v4.py | 2 +- .../core_steps/models/foundation/openai/v5.py | 2 +- .../core_steps/models/foundation/openai/v6.py | 2 +- .../models/foundation/openai_compatible/v1.py | 2 +- .../foundation/qwen3_5_openrouter/v1.py | 2 +- .../foundation/qwen3_6_openrouter/v1.py | 2 +- .../models/foundation/qwen_vlm/v2.py | 2 +- .../models/foundation/qwen_vlm/v3.py | 2 +- .../models/foundation/spacexai/v1.py | 2 +- .../models/foundation/spacexai/v2.py | 2 +- .../models/foundation/zai_vlm/v1.py | 2 +- .../core_steps/sinks/email_notification/v2.py | 2 +- .../core_steps/sinks/twilio/sms/v2.py | 2 +- .../execution_engine/entities/base.py | 17 +- .../v1/dynamic_blocks/modal_executor.py | 4 +- scripts/repoint_image_utils_imports.py | 87 ++++++++ .../interfaces/test_image_codec_binding.py | 186 ++++++++++++++++++ .../test_image_codec_local_file_paths.py | 83 ++++++++ .../unit_tests/decontamination_baseline.txt | 37 +--- .../execution_engine/entities/conftest.py | 20 ++ .../execution_engine/entities/test_base.py | 32 ++- 41 files changed, 468 insertions(+), 84 deletions(-) create mode 100644 scripts/repoint_image_utils_imports.py create mode 100644 tests/inference/unit_tests/core/interfaces/test_image_codec_local_file_paths.py diff --git a/inference/core/workflows/core_steps/common/openrouter.py b/inference/core/workflows/core_steps/common/openrouter.py index 8314f7eaca..69162cef85 100644 --- a/inference/core/workflows/core_steps/common/openrouter.py +++ b/inference/core/workflows/core_steps/common/openrouter.py @@ -31,7 +31,6 @@ from pydantic import ConfigDict, Field from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.token_usage import ( parse_chat_completion_usage, ) @@ -57,6 +56,7 @@ RoboflowAPIForbiddenError, RoboflowAPIUnsuccessfulRequestError, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image logger = logging.getLogger(__name__) diff --git a/inference/core/workflows/core_steps/fusion/image_stack/v1.py b/inference/core/workflows/core_steps/fusion/image_stack/v1.py index 6eecd42546..08a33e407d 100644 --- a/inference/core/workflows/core_steps/fusion/image_stack/v1.py +++ b/inference/core/workflows/core_steps/fusion/image_stack/v1.py @@ -5,7 +5,6 @@ import numpy as np from pydantic import ConfigDict, Field, field_validator -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, @@ -28,6 +27,7 @@ WorkflowBlock, WorkflowBlockManifest, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes MAX_STACK_SIZE = 64 MAX_RESOLUTION_WIDTH = 1920 diff --git a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py index 56cf3edaf4..6568ca9518 100644 --- a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py @@ -9,7 +9,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.core_steps.models.foundation.anthropic_claude.model_capabilities import ( @@ -40,7 +39,11 @@ is_workflow_selector, third_party_model, ) -from inference.core.workflows.utils.images import downscale_image_keeping_aspect_ratio +from inference.core.workflows.utils.images import ( + downscale_image_keeping_aspect_ratio, + encode_image_to_jpeg_bytes, + load_image, +) SUPPORTED_TASK_TYPES_LIST = [ "unconstrained", diff --git a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py index cceb75135d..d5cf69716a 100644 --- a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py @@ -8,7 +8,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.core_steps.models.foundation.anthropic_claude.model_capabilities import ( @@ -40,7 +39,11 @@ is_workflow_selector, third_party_model, ) -from inference.core.workflows.utils.images import downscale_image_keeping_aspect_ratio +from inference.core.workflows.utils.images import ( + downscale_image_keeping_aspect_ratio, + encode_image_to_jpeg_bytes, + load_image, +) CLAUDE_MODELS = [ { diff --git a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py index 3ede0743bc..b86f4c1dbb 100644 --- a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py @@ -9,7 +9,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.core_steps.models.foundation.anthropic_claude.model_capabilities import ( @@ -46,7 +45,11 @@ OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) -from inference.core.workflows.utils.images import downscale_image_keeping_aspect_ratio +from inference.core.workflows.utils.images import ( + downscale_image_keeping_aspect_ratio, + encode_image_to_jpeg_bytes, + load_image, +) CLAUDE_MODELS = [ { diff --git a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py index 9159d124f5..b168f13206 100644 --- a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py +++ b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py @@ -23,7 +23,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.token_usage import ( TOKEN_OUTPUT_DEFINITIONS, parse_responses_api_usage, @@ -67,7 +66,11 @@ OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) -from inference.core.workflows.utils.images import downscale_image_keeping_aspect_ratio +from inference.core.workflows.utils.images import ( + downscale_image_keeping_aspect_ratio, + encode_image_to_jpeg_bytes, + load_image, +) CLAUDE_MODELS = [ { diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py index 43edd3a5ab..82b64896f1 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py @@ -9,7 +9,6 @@ from requests import Response from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.execution_engine.entities.base import ( @@ -35,6 +34,7 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image GOOGLE_API_KEY_PATTERN = re.compile(r"key=(.[^&]*)") GOOGLE_API_KEY_VALUE_GROUP = 1 diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py index eba1e2172a..8260fc8fb3 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py @@ -9,7 +9,6 @@ from requests import Response from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.execution_engine.entities.base import ( @@ -35,6 +34,7 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image GOOGLE_API_KEY_PATTERN = re.compile(r"key=(.[^&]*)") GOOGLE_API_KEY_VALUE_GROUP = 1 diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py index 2c7b005e66..e0ecaf1d94 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py @@ -9,7 +9,6 @@ from requests import Response from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.execution_engine.entities.base import ( @@ -41,6 +40,7 @@ OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image GOOGLE_API_KEY_PATTERN = re.compile(r"key=(.[^&]*)") GOOGLE_API_KEY_VALUE_GROUP = 1 diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py index b514f1a36f..54e4523127 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py @@ -9,7 +9,6 @@ from requests import Response from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.execution_engine.entities.base import ( @@ -41,6 +40,7 @@ OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image GOOGLE_API_KEY_PATTERN = re.compile(r"key=(.[^&]*)") GOOGLE_API_KEY_VALUE_GROUP = 1 diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py index eaa85732a6..3fe7065cb5 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py @@ -9,7 +9,6 @@ from requests import Response from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.reasoning import ( attach_reasoning_levels, models_supporting_reasoning, @@ -50,6 +49,7 @@ OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image GOOGLE_API_KEY_PATTERN = re.compile(r"key=(.[^&]*)") GOOGLE_API_KEY_VALUE_GROUP = 1 diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py b/inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py index 0b965b9369..8e7816213b 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py @@ -7,7 +7,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.execution_engine.entities.base import ( @@ -34,6 +33,7 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image MODEL_VERSION_MAPPING = { "Gemma 4 31B - OpenRouter": "google/gemma-4-31b-it", diff --git a/inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py b/inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py index 77a11d0fb1..1028ae18d4 100644 --- a/inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py @@ -7,7 +7,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.execution_engine.entities.base import ( @@ -34,6 +33,7 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image MODEL_VERSION_MAPPING = { "Kimi K2.5 - OpenRouter": "moonshotai/kimi-k2.5", diff --git a/inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py b/inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py index 7d9fc13fb3..cb7b066d68 100644 --- a/inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py @@ -7,7 +7,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.execution_engine.entities.base import ( @@ -33,6 +32,7 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image MODEL_VERSION_MAPPING = { "11B (Free) - OpenRouter": "meta-llama/llama-3.2-11b-vision-instruct:free", diff --git a/inference/core/workflows/core_steps/models/foundation/lmm/v1.py b/inference/core/workflows/core_steps/models/foundation/lmm/v1.py index e70a0e602b..1b6873a76d 100644 --- a/inference/core/workflows/core_steps/models/foundation/lmm/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/lmm/v1.py @@ -13,7 +13,6 @@ WORKFLOWS_REMOTE_API_TARGET, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( load_core_model, @@ -51,6 +50,7 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image from inference_sdk import InferenceHTTPClient GPT_4V_MODEL_TYPE = "gpt_4v" diff --git a/inference/core/workflows/core_steps/models/foundation/meta_vlm/v1.py b/inference/core/workflows/core_steps/models/foundation/meta_vlm/v1.py index ee7a2bc87a..82c1c7eb38 100644 --- a/inference/core/workflows/core_steps/models/foundation/meta_vlm/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/meta_vlm/v1.py @@ -13,7 +13,6 @@ import numpy as np from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.openrouter import ( PRIVACY_LEVEL_LITERAL, PRIVACY_LEVEL_METADATA, @@ -51,6 +50,7 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes MODEL_VARIANTS: Dict[str, str] = { "Muse Spark 1.1": "meta/muse-spark-1.1", diff --git a/inference/core/workflows/core_steps/models/foundation/meta_vlm/v2.py b/inference/core/workflows/core_steps/models/foundation/meta_vlm/v2.py index aa766ea8c4..4caeab05e5 100644 --- a/inference/core/workflows/core_steps/models/foundation/meta_vlm/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/meta_vlm/v2.py @@ -16,7 +16,6 @@ import numpy as np from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.openrouter import ( PRIVACY_LEVEL_LITERAL, PRIVACY_LEVEL_METADATA, @@ -61,6 +60,7 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes # Spark `none` is HTTP 400. Glimmer has no `minimal`. MODEL_VARIANTS: Dict[str, Dict[str, Any]] = { diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v1.py b/inference/core/workflows/core_steps/models/foundation/openai/v1.py index 538fa58f66..50a649d69e 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v1.py @@ -8,7 +8,6 @@ from pydantic import BaseModel, ConfigDict, Field from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.execution_engine.constants import ( PARENT_ID_KEY, @@ -38,6 +37,7 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image NOT_DETECTED_VALUE = "not_detected" JSON_MARKDOWN_BLOCK_PATTERN = re.compile(r"```json\n([\s\S]*?)\n```") diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v2.py b/inference/core/workflows/core_steps/models/foundation/openai/v2.py index ac791fa9f7..e8b8aa012d 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v2.py @@ -8,7 +8,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.execution_engine.entities.base import ( @@ -34,6 +33,7 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image SUPPORTED_TASK_TYPES_LIST = [ "unconstrained", diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v3.py b/inference/core/workflows/core_steps/models/foundation/openai/v3.py index e1d3ead1e1..857c8e92c9 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v3.py @@ -9,7 +9,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.execution_engine.entities.base import ( @@ -40,6 +39,7 @@ OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image SUPPORTED_TASK_TYPES_LIST = [ "unconstrained", diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v4.py b/inference/core/workflows/core_steps/models/foundation/openai/v4.py index 26e3259434..c3ed4ca4c7 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v4.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v4.py @@ -8,7 +8,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.execution_engine.entities.base import ( @@ -39,6 +38,7 @@ OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image OPENAI_MODELS = [ { diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v5.py b/inference/core/workflows/core_steps/models/foundation/openai/v5.py index a95543858a..0cfa23f155 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v5.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v5.py @@ -10,7 +10,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import ( DETECTION_MAX_EDGE_PIXELS, run_in_parallel, @@ -45,6 +44,7 @@ OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image # Detection prompt styles (selected per model based on a 17-model x 10-format # x 100-image benchmark; see the object-detection prompt builders below): diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v6.py b/inference/core/workflows/core_steps/models/foundation/openai/v6.py index 2ea97a1c97..d9b27a9f52 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v6.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v6.py @@ -10,7 +10,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.reasoning import ( attach_reasoning_levels, models_supporting_reasoning, @@ -54,6 +53,7 @@ OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image # Detection prompt styles (selected per model based on a 17-model x 10-format # x 100-image benchmark; see the object-detection prompt builders below): diff --git a/inference/core/workflows/core_steps/models/foundation/openai_compatible/v1.py b/inference/core/workflows/core_steps/models/foundation/openai_compatible/v1.py index 950ca64398..c021549caf 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai_compatible/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/openai_compatible/v1.py @@ -9,7 +9,6 @@ from pydantic import ConfigDict, Field from inference.core.env import OPENAI_COMPATIBLE_ALLOWED_BASE_URLS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.query_language.entities.operations import ( AllOperationsType, ) @@ -35,6 +34,7 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes logger = logging.getLogger(__name__) diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py index 4ab187ef3f..7ae04a7dbe 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py @@ -7,7 +7,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.execution_engine.entities.base import ( @@ -34,6 +33,7 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image MODEL_VERSION_MAPPING = { "Qwen 3.5 9B - OpenRouter": "qwen/qwen3.5-9b", diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py index 3726df50c2..cfdd1feab7 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py @@ -7,7 +7,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.execution_engine.entities.base import ( @@ -34,6 +33,7 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image MODEL_VERSION_MAPPING = { "Qwen 3.6 27B - OpenRouter": "qwen/qwen3.6-27b", diff --git a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py index 63f5065ba2..f1ace3fd1a 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py @@ -39,7 +39,6 @@ LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.openrouter import ( PRIVACY_LEVEL_LITERAL, @@ -85,6 +84,7 @@ OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes from inference_sdk import InferenceHTTPClient # --------------------------------------------------------------------------- diff --git a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py index 1a17b72a06..0cf0df0586 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py @@ -42,7 +42,6 @@ LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.openrouter import ( PRIVACY_LEVEL_LITERAL, @@ -95,6 +94,7 @@ OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes from inference_sdk import InferenceHTTPClient # --------------------------------------------------------------------------- diff --git a/inference/core/workflows/core_steps/models/foundation/spacexai/v1.py b/inference/core/workflows/core_steps/models/foundation/spacexai/v1.py index 964b663dd0..85fcd7cca9 100644 --- a/inference/core/workflows/core_steps/models/foundation/spacexai/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/spacexai/v1.py @@ -18,7 +18,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.execution_engine.entities.base import ( @@ -49,6 +48,7 @@ OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image XAI_BASE_URL = "https://api.x.ai/v1" diff --git a/inference/core/workflows/core_steps/models/foundation/spacexai/v2.py b/inference/core/workflows/core_steps/models/foundation/spacexai/v2.py index ee4bc82b85..de232108de 100644 --- a/inference/core/workflows/core_steps/models/foundation/spacexai/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/spacexai/v2.py @@ -18,7 +18,6 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.reasoning import ( attach_reasoning_levels, validate_reasoning_level, @@ -57,6 +56,7 @@ OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image XAI_BASE_URL = "https://api.x.ai/v1" diff --git a/inference/core/workflows/core_steps/models/foundation/zai_vlm/v1.py b/inference/core/workflows/core_steps/models/foundation/zai_vlm/v1.py index ae4d703caf..8ff8e7d02b 100644 --- a/inference/core/workflows/core_steps/models/foundation/zai_vlm/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/zai_vlm/v1.py @@ -16,7 +16,6 @@ import numpy as np from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.openrouter import ( PRIVACY_LEVEL_LITERAL, PRIVACY_LEVEL_METADATA, @@ -62,6 +61,7 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes # Ported verbatim from vlm-exam's `_NORMALIZED_XYXY_PROMPT_TEMPLATE`, the # detection coordinate format pinned for GLM 5V Turbo in the benchmark diff --git a/inference/core/workflows/core_steps/sinks/email_notification/v2.py b/inference/core/workflows/core_steps/sinks/email_notification/v2.py index edf02627f9..bb0bb34b57 100644 --- a/inference/core/workflows/core_steps/sinks/email_notification/v2.py +++ b/inference/core/workflows/core_steps/sinks/email_notification/v2.py @@ -8,7 +8,6 @@ from pydantic import ConfigDict, Field, field_validator -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.query_language.entities.operations import ( AllOperationsType, ) @@ -46,6 +45,7 @@ OFFLINE_PLATFORM_CLIENT, RoboflowPlatformClient, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes LONG_DESCRIPTION = """ The **Email Notification** block allows users to send email notifications as part of a workflow. diff --git a/inference/core/workflows/core_steps/sinks/twilio/sms/v2.py b/inference/core/workflows/core_steps/sinks/twilio/sms/v2.py index 3e16e872b2..866855fc79 100644 --- a/inference/core/workflows/core_steps/sinks/twilio/sms/v2.py +++ b/inference/core/workflows/core_steps/sinks/twilio/sms/v2.py @@ -12,7 +12,6 @@ from pydantic import ConfigDict, Field from twilio.rest import Client -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.query_language.entities.operations import ( AllOperationsType, ) @@ -50,6 +49,7 @@ RoboflowAPIForbiddenError, RoboflowAPIUnsuccessfulRequestError, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes LONG_DESCRIPTION = """ The **Twilio SMS/MMS Notification** block allows users to send text and multimedia messages as part of a workflow. diff --git a/inference/core/workflows/execution_engine/entities/base.py b/inference/core/workflows/execution_engine/entities/base.py index 2ba1f18c7a..9984a4f943 100644 --- a/inference/core/workflows/execution_engine/entities/base.py +++ b/inference/core/workflows/execution_engine/entities/base.py @@ -30,17 +30,18 @@ ENABLE_TENSOR_DATA_REPRESENTATION, WORKFLOWS_IMAGE_TENSOR_DEVICE, ) -from inference.core.utils.image_utils import ( - attempt_loading_image_from_string, - encode_image_to_jpeg_bytes, - load_image_from_url, -) from inference.core.workflows.execution_engine.entities.types import ( IMAGE_KIND, VIDEO_METADATA_KIND, WILDCARD_KIND, Kind, ) +from inference.core.workflows.utils.images import ( + attempt_loading_image_from_string, + encode_image_to_jpeg_bytes, + ensure_local_image_load_allowed, + load_image_from_url, +) class OutputDefinition(BaseModel): @@ -572,6 +573,11 @@ def numpy_image(self) -> np.ndarray: ) or self._image_reference.startswith("https://"): self._numpy_image = load_image_from_url(value=self._image_reference) else: + # Local-filesystem access is a host capability: ask before reading. + # The decoder stays here - cv2.imread applies EXIF orientation and + # `_decode_source_to_tensor` deliberately mirrors that, so neither + # decoder may move behind the port. + ensure_local_image_load_allowed(self._image_reference) self._numpy_image = cv2.imread(self._image_reference) return self._numpy_image @@ -626,6 +632,7 @@ def _decode_source_to_tensor(self) -> torch.Tensor: ) or self._image_reference.startswith("https://"): hwc_bgr = load_image_from_url(value=self._image_reference) return torch.from_numpy(hwc_bgr[:, :, ::-1].copy()).permute(2, 0, 1) + ensure_local_image_load_allowed(self._image_reference) return decode_image( read_file(self._image_reference), mode=ImageReadMode.RGB, diff --git a/inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py b/inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py index 69a0865db9..35dfc9bcc6 100644 --- a/inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py +++ b/inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py @@ -45,7 +45,6 @@ WEBEXEC_WS_IDLE_RELEASE_SECONDS, WEBEXEC_WS_READ_TIMEOUT_SECONDS, ) -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.deserializers import ( deserialize_image_kind, deserialize_rle_detections_kind, @@ -68,6 +67,7 @@ extract_code_snippet, ) from inference.core.workflows.prototypes.block import BlockResult +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes logger = logging.getLogger(__name__) @@ -857,11 +857,11 @@ def validate_syntax(): def _serialize_image_for_msgpack(image: Any) -> dict: """Encode a WorkflowImageData as a dict with raw JPEG bytes (no base64).""" from inference.core.env import WEBEXEC_JPEG_QUALITY - from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.serializers import ( serialize_video_metadata_kind, ) from inference.core.workflows.execution_engine.entities.base import ParentOrigin + from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes jpeg_bytes: bytes = encode_image_to_jpeg_bytes( image.numpy_image, diff --git a/scripts/repoint_image_utils_imports.py b/scripts/repoint_image_utils_imports.py new file mode 100644 index 0000000000..63fc757f32 --- /dev/null +++ b/scripts/repoint_image_utils_imports.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python +"""Repoint `from inference.core.utils.image_utils import ...` inside +`inference/core/workflows` to `inference.core.workflows.utils.images`. + +Only the module path changes; every imported name already exists under the new +path (`inference/core/workflows/utils/images.py`), so no body edit is needed. + +Located by AST, so function-local imports are caught (`modal_executor.py` has one +at line 860). Applied by substring replacement on the statement's FIRST line, +which is where the module path always is for both the single-line and the +parenthesised forms. Files are read and written as BYTES and split with +`splitlines(keepends=True)`, so the one CRLF file in the set +(`core_steps/models/foundation/llama_vision/v1.py`) keeps its line endings. + +The script is order-independent with respect to Phase 9: it rewrites whatever it +finds and prints the counts, so it works whether or not the six relocating files +are still in the tree. + +Usage: + PYTHONPATH=inference_models python scripts/repoint_image_utils_imports.py [--check] +""" + +import argparse +import ast +import sys +from pathlib import Path + +OLD_MODULE = "inference.core.utils.image_utils" +NEW_MODULE = "inference.core.workflows.utils.images" +WORKFLOWS_ROOT = Path("inference/core/workflows") + + +def statements_to_rewrite(path: Path) -> list: + source = path.read_bytes().decode("utf-8") + tree = ast.parse(source) + return sorted( + { + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module == OLD_MODULE + } + ) + + +def rewrite(path: Path, check_only: bool) -> int: + linenos = statements_to_rewrite(path) + if not linenos: + return 0 + raw = path.read_bytes().decode("utf-8") + lines = raw.splitlines(keepends=True) + for lineno in linenos: + index = lineno - 1 + if OLD_MODULE not in lines[index]: + raise SystemExit( + f"{path}:{lineno}: expected the module path on the statement's " + f"first line, found: {lines[index]!r}" + ) + lines[index] = lines[index].replace(OLD_MODULE, NEW_MODULE) + if not check_only: + path.write_bytes("".join(lines).encode("utf-8")) + return len(linenos) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true", help="report, do not write") + args = parser.parse_args() + + if not WORKFLOWS_ROOT.is_dir(): + raise SystemExit("run me from the repository root") + + files = 0 + statements = 0 + for path in sorted(WORKFLOWS_ROOT.rglob("*.py")): + if "__pycache__" in path.parts: + continue + rewritten = rewrite(path, check_only=args.check) + if rewritten: + files += 1 + statements += rewritten + print(f"{path}: {rewritten} statement(s)") + print(f"TOTAL files={files} statements={statements}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py b/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py index 8ad6b3fe28..688fcdcb92 100644 --- a/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py +++ b/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py @@ -489,3 +489,189 @@ def test_cli_override_conflicting_with_an_install_is_refused_before_the_engine_s forwarded_engine_init["init_parameters"] == [] ), "refused after the engine started" assert get_image_codec() is GUARDED_IMAGE_CODEC + + +# -------------------------------------------------------------------------- +# Behaviour (appended by Task 10.8, after `base.py` and the block files are +# repointed): ONE image goes through both paths, so the two must agree +# -------------------------------------------------------------------------- + + +PASSTHROUGH_WORKFLOW = { + "version": "1.0", + "inputs": [{"type": "WorkflowImage", "name": "image"}], + "steps": [], + "outputs": [{"type": "JsonField", "name": "image", "selector": "$inputs.image"}], +} + + +@mock.patch.object(image_utils, "VALIDATE_IMAGE_URL_REDIRECTS", False) +@mock.patch.object(image_utils, "ALLOW_URL_TO_NON_GLOBAL_ADDRESSES", True) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT", True) +@mock.patch.object(image_utils, "ALLOW_NON_HTTPS_URL_INPUT", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT_WITHOUT_FQDN", False) +@mock.patch.object(image_utils, "BLACKLISTED_DESTINATIONS_FOR_URL_INPUT", None) +@mock.patch.object( + image_utils, "WHITELISTED_DESTINATIONS_FOR_URL_INPUT", {ALLOWED_HOST} +) +def test_one_image_uses_both_paths_and_they_must_agree(requests_mock: Mocker) -> None: + # The mechanism (round-4 Defect 2): Path A deserializes the URL input and + # keeps the decoded pixels AND the reference (deserializers.py:133-138); + # `numpy_image` then serves the cached pixels (base.py:549) - no reload + # there. The reload happens downstream: `to_inference_format()` preserves + # the URL (base.py:733-740) and a VLM block calls `load_image` on that dict + # (openai/v1.py:288, before the endpoint call at :293). Both stages must go + # through the SAME codec, so this uses the real guarded adapter with + # recording, the real `image_utils` guards, `requests_mock` as the + # transport, and stubs only `client.chat.completions.create`. + from inference.core.interfaces.workflows_image_codec import ( + ServerImageCodec, + bind_image_codec, + ) + from inference.core.workflows.core_steps.models.foundation.openai.v1 import ( + execute_gpt_4v_request, + ) + from inference.core.workflows.execution_engine.entities.base import ( + WorkflowImageData, + ) + + class _RecordingGuardedCodec(ServerImageCodec): + def __init__(self): + self.calls = [] + + def fetch_url(self, value, cv_imread_flags=cv2.IMREAD_COLOR): + self.calls.append(("fetch_url", value)) + return super().fetch_url(value, cv_imread_flags=cv_imread_flags) + + def load_image(self, value, disable_preproc_auto_orient=False): + self.calls.append(("load_image", value)) + return super().load_image( + value, disable_preproc_auto_orient=disable_preproc_auto_orient + ) + + url = f"https://{ALLOWED_HOST}/image.png" + requests_mock.get(url, content=_png_bytes()) + recorder = _RecordingGuardedCodec() + init_parameters = {CODEC_INIT_PARAMETER: recorder} + assert bind_image_codec(init_parameters) is recorder + assert get_image_codec() is recorder + + # Stage 1 - Path A: the engine deserializes the URL input through the + # bound codec and hands the SAME WorkflowImageData out as its output. + engine = ExecutionEngine.init( + workflow_definition=PASSTHROUGH_WORKFLOW, init_parameters=init_parameters + ) + image = engine.run(runtime_parameters={"image": url})[0]["image"] + assert isinstance(image, WorkflowImageData) + assert image.numpy_image.shape == (16, 24, 3) # cached pixels, no reload + assert recorder.calls == [("fetch_url", url)] + assert requests_mock.call_count == 1 + + # Stage 2 - Path B: the downstream block re-loads THAT image from its + # inference-format dict through the process registry - the same object. + payload = image.to_inference_format() + assert payload == {"type": "url", "value": url} + client = MagicMock() + client.chat.completions.create.return_value.choices = [ + MagicMock(message=MagicMock(content="a green rectangle")) + ] + result = execute_gpt_4v_request( + client=client, + image=payload, + prompt="describe", + lmm_config=MagicMock( + gpt_model_version="gpt-4o", gpt_image_detail="auto", max_tokens=16 + ), + ) + + assert result == { + "content": "a green rectangle", + "image": {"width": 24, "height": 16}, + } + assert recorder.calls == [("fetch_url", url), ("load_image", payload)] + assert requests_mock.call_count == 2 + assert {request.url for request in requests_mock.request_history} == {url} + client.chat.completions.create.assert_called_once() + + +@mock.patch.object(image_utils, "VALIDATE_IMAGE_URL_REDIRECTS", False) +@mock.patch.object(image_utils, "ALLOW_URL_TO_NON_GLOBAL_ADDRESSES", True) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT", True) +@mock.patch.object(image_utils, "ALLOW_NON_HTTPS_URL_INPUT", False) +@mock.patch.object(image_utils, "ALLOW_URL_INPUT_WITHOUT_FQDN", False) +@mock.patch.object(image_utils, "BLACKLISTED_DESTINATIONS_FOR_URL_INPUT", None) +@mock.patch.object(image_utils, "WHITELISTED_DESTINATIONS_FOR_URL_INPUT", None) +def test_reference_born_image_outside_any_engine_uses_the_process_codec( + monkeypatch, +) -> None: + # Narrower than the test above, and deliberately so: this covers the + # supported reference-only construction API - + # `WorkflowImageData(parent_metadata=..., image_reference=...)` with no + # cached pixels - so its first `numpy_image` read fetches through the + # registry (base.py:573). No engine, no Path A. The production + # constructors (e.g. `inference/core/models/inference_models_adapters.py`, + # `modal/modal_app.py`) pass `numpy_image` and therefore serve cached + # pixels (base.py:549) without loading. + # The recorder overrides just `fetch_url`, which is the only method this + # path can reach; the transport is blocked below address validation so a + # regression to the server loader fails fast. DNS is faked and the proxy + # env is cleared (CR-1 / Ruling R10-C, Task 10.6's pattern): with a + # reverted/missing `base.py` repoint the server loader calls + # `socket.getaddrinfo` (inference/core/utils/url_input.py:121) BEFORE the + # blocker, so an unguarded RED would otherwise depend on real DNS. + import socket + + import urllib3.connectionpool as connectionpool + + from inference.core.interfaces.workflows_image_codec import bind_image_codec + from inference.core.workflows.execution_engine.entities.base import ( + ImageParentMetadata, + WorkflowImageData, + ) + + for name in ( + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", + "ALL_PROXY", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("NO_PROXY", "*") + monkeypatch.setenv("no_proxy", "*") + + def _fake_getaddrinfo(host, port, *args, **kwargs): + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("93.184.216.34", port), + ) + ] + + monkeypatch.setattr(socket, "getaddrinfo", _fake_getaddrinfo) + + class _RecordingCodec(WorkflowsLocalImageCodec): + def __init__(self): + self.calls = [] + + def fetch_url(self, value, cv_imread_flags=cv2.IMREAD_COLOR): + self.calls.append(("fetch_url", value)) + return np.zeros((16, 24, 3), dtype=np.uint8) + + def _blocked(*args, **kwargs): + raise AssertionError("transport reached") + + recorder = _RecordingCodec() + assert bind_image_codec({CODEC_INIT_PARAMETER: recorder}) is recorder + reference_image = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="p"), + image_reference="https://cdn.example.com/other.jpg", + ) + with mock.patch.object( + connectionpool.HTTPConnectionPool, "_new_conn", _blocked + ), mock.patch.object(connectionpool.HTTPSConnectionPool, "_new_conn", _blocked): + assert reference_image.numpy_image.shape == (16, 24, 3) + assert recorder.calls == [("fetch_url", "https://cdn.example.com/other.jpg")] diff --git a/tests/inference/unit_tests/core/interfaces/test_image_codec_local_file_paths.py b/tests/inference/unit_tests/core/interfaces/test_image_codec_local_file_paths.py new file mode 100644 index 0000000000..1815cbe26e --- /dev/null +++ b/tests/inference/unit_tests/core/interfaces/test_image_codec_local_file_paths.py @@ -0,0 +1,83 @@ +"""ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM applies to all three gated paths. + +Before Phase 10 the flag only covered `image_utils.load_image`'s declared-FILE +and inferred-path branches; `WorkflowImageData`'s two decoders and the runtime +deserializer read local paths without consulting it. Release note 1. +""" + +from unittest import mock + +import cv2 +import numpy as np +import pytest + +from inference.core.exceptions import InputImageLoadError +from inference.core.interfaces.workflows_image_codec import install_guarded_image_codec +from inference.core.utils import image_utils +from inference.core.workflows.core_steps.common.deserializers import ( + deserialize_image_kind, +) +from inference.core.workflows.errors import RuntimeInputError +from inference.core.workflows.execution_engine.entities.base import ( + ImageParentMetadata, + WorkflowImageData, +) +from inference.core.workflows.prototypes.image_codec import reset_image_codec + + +@pytest.fixture() +def image_file(tmp_path) -> str: + path = str(tmp_path / "source.png") + assert cv2.imwrite(path, np.zeros((4, 6, 3), dtype=np.uint8)) + return path + + +@pytest.fixture(autouse=True) +def _guarded_codec(): + reset_image_codec() + install_guarded_image_codec() + yield + reset_image_codec() + + +@mock.patch.object(image_utils, "ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM", True) +def test_all_three_paths_read_the_file_when_the_flag_is_on(image_file: str) -> None: + numpy_born = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="p"), image_reference=image_file + ) + assert numpy_born.numpy_image.shape == (4, 6, 3) + + tensor_born = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="p"), image_reference=image_file + ) + assert tuple(tensor_born.tensor_image.shape) == (3, 4, 6) + + deserialized = deserialize_image_kind("image", image_file) + assert deserialized.numpy_image.shape == (4, 6, 3) + + +@mock.patch.object(image_utils, "ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM", False) +def test_numpy_path_is_refused_when_the_flag_is_off(image_file: str) -> None: + image = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="p"), image_reference=image_file + ) + with pytest.raises(InputImageLoadError, match="local filesystem"): + _ = image.numpy_image + + +@mock.patch.object(image_utils, "ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM", False) +def test_tensor_path_is_refused_when_the_flag_is_off(image_file: str) -> None: + image = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="p"), image_reference=image_file + ) + with pytest.raises(InputImageLoadError, match="local filesystem"): + _ = image.tensor_image + + +@mock.patch.object(image_utils, "ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM", False) +def test_deserializer_path_is_refused_when_the_flag_is_off(image_file: str) -> None: + # deserialize_image_kind wraps every failure in RuntimeInputError; the cause + # is the local-filesystem refusal. + with pytest.raises(RuntimeInputError) as error: + deserialize_image_kind("image", image_file) + assert "local filesystem" in str(error.value) diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 6d8730b6ea..7076c25643 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,12 +1,11 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 255 +# Count: 220 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env inference/core/workflows/core_steps/common/deserializers_tensor.py inference.core.env inference/core/workflows/core_steps/common/openrouter.py inference.core.env -inference/core/workflows/core_steps/common/openrouter.py inference.core.utils.image_utils inference/core/workflows/core_steps/common/query_language/operations/classification_results/base.py inference.core.env inference/core/workflows/core_steps/common/query_language/operations/detection/base.py inference.core.env inference/core/workflows/core_steps/common/query_language/operations/detections/base.py inference.core.env @@ -21,16 +20,11 @@ inference/core/workflows/core_steps/formatters/vlm_as_classifier/v2_tensor.py in inference/core/workflows/core_steps/formatters/vlm_as_detector/v1_tensor.py inference.core.env inference/core/workflows/core_steps/formatters/vlm_as_detector/v2_tensor.py inference.core.env inference/core/workflows/core_steps/fusion/detections_consensus/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/fusion/image_stack/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/loader.py inference.core.env inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py inference.core.env -inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py inference.core.env -inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py inference.core.env -inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py inference.core.env @@ -56,27 +50,16 @@ inference/core/workflows/core_steps/models/foundation/gaze/v1.py inference.core. inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/lmm/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/lmm/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/lmm_classifier/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/meta_vlm/v1.py inference.core.utils.image_utils -inference/core/workflows/core_steps/models/foundation/meta_vlm/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/moondream2/v1.py inference.core.entities.requests.moondream2 inference/core/workflows/core_steps/models/foundation/moondream2/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/moondream2/v1_tensor.py inference.core.env @@ -84,18 +67,11 @@ inference/core/workflows/core_steps/models/foundation/ocr/v1.py inference.core.e inference/core/workflows/core_steps/models/foundation/ocr/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/openai/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/openai/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/openai/v2.py inference.core.env -inference/core/workflows/core_steps/models/foundation/openai/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/openai/v3.py inference.core.env -inference/core/workflows/core_steps/models/foundation/openai/v3.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/openai/v4.py inference.core.env -inference/core/workflows/core_steps/models/foundation/openai/v4.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/openai/v5.py inference.core.env -inference/core/workflows/core_steps/models/foundation/openai/v5.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/openai/v6.py inference.core.env -inference/core/workflows/core_steps/models/foundation/openai/v6.py inference.core.utils.image_utils -inference/core/workflows/core_steps/models/foundation/openai_compatible/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py inference.core.entities.requests.perception_encoder inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py inference.core.env @@ -106,7 +82,6 @@ inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py infere inference/core/workflows/core_steps/models/foundation/qwen/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/qwen/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1_tensor.py inference.core.env @@ -114,7 +89,6 @@ inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py inference. inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py inference.core.env inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/qwen3vl/v1_tensor.py inference.core.env @@ -122,10 +96,8 @@ inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py inference.c inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py inference.core.env -inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py inference.core.env -inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py inference.core.env @@ -168,12 +140,9 @@ inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py inference.co inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/smolvlm/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/spacexai/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/spacexai/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/spacexai/v2.py inference.core.env -inference/core/workflows/core_steps/models/foundation/spacexai/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.entities.requests.yolo_world inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/zai_vlm/v1.py inference.core.utils.image_utils inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py inference.core.env inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1_tensor.py inference.core.env @@ -232,9 +201,7 @@ inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2_ten inference/core/workflows/core_steps/models/third_party/barcode_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/third_party/qr_code_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/secrets_providers/environment_secrets_store/v1.py inference.core.env -inference/core/workflows/core_steps/sinks/email_notification/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/sinks/local_file/v1.py inference.core.env -inference/core/workflows/core_steps/sinks/twilio/sms/v2.py inference.core.utils.image_utils inference/core/workflows/core_steps/transformations/detections_combine/v1_tensor.py inference.core.env inference/core/workflows/core_steps/transformations/detections_merge/v1_tensor.py inference.core.env inference/core/workflows/core_steps/transformations/detections_transformation/v1.py inference.core.env @@ -243,13 +210,11 @@ inference/core/workflows/core_steps/visualizations/common/base_tensor.py inferen inference/core/workflows/core_steps/visualizations/common/fonts/__init__.py inference.core.env inference/core/workflows/execution_engine/entities/base.py inference.core.entities.responses.action_recognition inference/core/workflows/execution_engine/entities/base.py inference.core.env -inference/core/workflows/execution_engine/entities/base.py inference.core.utils.image_utils inference/core/workflows/execution_engine/v1/core.py inference.core.env inference/core/workflows/execution_engine/v1/dynamic_blocks/block_assembler.py inference.core.env inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.env inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.env (exec'd string) inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py inference.core.env -inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py inference.core.utils.image_utils inference/core/workflows/execution_engine/v1/dynamic_blocks/representation_boundary.py inference.core.env inference/core/workflows/execution_engine/v1/executor/core.py inference.core.env inference/core/workflows/execution_engine/v1/executor/output_constructor.py inference.core.env diff --git a/tests/workflows/unit_tests/execution_engine/entities/conftest.py b/tests/workflows/unit_tests/execution_engine/entities/conftest.py index c05d37a6dd..a3b6028e94 100644 --- a/tests/workflows/unit_tests/execution_engine/entities/conftest.py +++ b/tests/workflows/unit_tests/execution_engine/entities/conftest.py @@ -8,3 +8,23 @@ def empty_directory() -> Generator[str, None, None]: with tempfile.TemporaryDirectory() as tmp_dir: yield tmp_dir + + +@pytest.fixture(scope="function") +def guarded_image_codec() -> Generator[None, None, None]: + """Install the server's image codec for tests that read a real local file. + + `WorkflowImageData` asks the installed codec for permission before touching + the filesystem. With no host codec the workflows-local default refuses - + which is the point of the port - so a test exercising the local-file branch + must stand in for the server, exactly as the composition roots do. + """ + from inference.core.interfaces.workflows_image_codec import ( + install_guarded_image_codec, + ) + from inference.core.workflows.prototypes.image_codec import reset_image_codec + + reset_image_codec() + install_guarded_image_codec() + yield + reset_image_codec() diff --git a/tests/workflows/unit_tests/execution_engine/entities/test_base.py b/tests/workflows/unit_tests/execution_engine/entities/test_base.py index c49350e3ff..29bc74d6aa 100644 --- a/tests/workflows/unit_tests/execution_engine/entities/test_base.py +++ b/tests/workflows/unit_tests/execution_engine/entities/test_base.py @@ -457,7 +457,9 @@ def test_getting_np_image_when_image_provided_in_base64_representation() -> None assert np.allclose(result, np.zeros((192, 168, 3), dtype=np.uint8)) -def test_getting_np_image_when_image_provided_as_file(empty_directory: str) -> None: +def test_getting_np_image_when_image_provided_as_file( + empty_directory: str, guarded_image_codec: None +) -> None: # given np_image = np.zeros((192, 168, 3), dtype=np.uint8) image_path = os.path.join(empty_directory, "file.jpg") @@ -532,7 +534,9 @@ def test_getting_base64_image_when_image_provided_in_base64_representation() -> assert result == base64_image -def test_getting_base64_image_when_image_provided_as_file(empty_directory: str) -> None: +def test_getting_base64_image_when_image_provided_as_file( + empty_directory: str, guarded_image_codec: None +) -> None: # given np_image = np.zeros((192, 168, 3), dtype=np.uint8) image_path = os.path.join(empty_directory, "file.jpg") @@ -1505,7 +1509,7 @@ def test_workflow_image_data_shape_read_fallback_materializes_per_flag() -> None def test_workflow_image_data_tensor_from_file_reference_does_not_cache_numpy( - tmp_path, + tmp_path, guarded_image_codec: None ) -> None: # given - a lossless PNG on disk referenced by path numpy_image = np.zeros((4, 6, 3), dtype=np.uint8) @@ -1553,7 +1557,7 @@ def test_workflow_image_data_declare_mutated_requires_materialised_representatio def test_workflow_image_data_declare_mutated_cuts_off_original_source( - tmp_path, + tmp_path, guarded_image_codec: None ) -> None: # given - a file-born image whose numpy got materialised and mutated numpy_image = np.zeros((4, 6, 3), dtype=np.uint8) @@ -1651,3 +1655,23 @@ def test_workflow_image_data_base64_rederived_after_mutation_is_valid_source() - # the POST-mutation content (JPEG re-encode is lossy, hence approximate) assert recovered.mean() > 150, "decoded pixels must reflect the mutation" assert tuple(recovered.shape) == (32, 32, 3) + + +def test_local_file_reference_is_refused_when_no_host_codec_is_installed( + tmp_path, +) -> None: + # No `guarded_image_codec` fixture on purpose: standalone Workflows must + # refuse filesystem access rather than read a path unguarded. + from inference.core.workflows.errors import WorkflowImageLoadError + from inference.core.workflows.prototypes.image_codec import reset_image_codec + + reset_image_codec() + path = str(tmp_path / "source.png") + assert cv2.imwrite(path, np.zeros((4, 6, 3), dtype=np.uint8)) + image = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="parent"), + image_reference=path, + ) + + with pytest.raises(WorkflowImageLoadError, match="local filesystem"): + _ = image.numpy_image From f7fd617dc9b20980631417e08e9398fafe577f34 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:24:24 +0200 Subject: [PATCH 37/86] fix(workflows): gate the icon alpha-recovery reload behind the image codec The Phase 10 whole-phase review found that the icon visualization blocks' alpha-recovery path (`icon._image_reference` -> `cv2.imread(..., IMREAD_UNCHANGED)`) reloaded a local file without consulting the host image codec, bypassing the local-filesystem gate Phase 10 introduced everywhere else in Workflows. Both blocks now call `ensure_local_image_load_allowed()` before the reload, outside the bare `except`, so a policy refusal propagates instead of being swallowed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../core_steps/visualizations/icon/v1.py | 5 + .../visualizations/icon/v1_tensor.py | 5 + .../test_icon_local_file_gate.py | 144 ++++++++++++++++++ .../test_image_codec_injection.py | 1 - 4 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 tests/workflows/unit_tests/core_steps/visualizations/test_icon_local_file_gate.py diff --git a/inference/core/workflows/core_steps/visualizations/icon/v1.py b/inference/core/workflows/core_steps/visualizations/icon/v1.py index 87f1d9ecff..2f23f9e44a 100644 --- a/inference/core/workflows/core_steps/visualizations/icon/v1.py +++ b/inference/core/workflows/core_steps/visualizations/icon/v1.py @@ -21,6 +21,7 @@ Selector, ) from inference.core.workflows.prototypes.block import BlockResult, WorkflowBlockManifest +from inference.core.workflows.utils.images import ensure_local_image_load_allowed TYPE: str = "roboflow_core/icon_visualization@v1" SHORT_DESCRIPTION = "Draw icons on an image either at specific static coordinates or dynamically based on detections." @@ -275,6 +276,10 @@ def run( and icon._image_reference and not icon._image_reference.startswith("http") ): + # Local-filesystem access is a host capability: ask before the + # alpha-recovery reload, and OUTSIDE the try below so a policy + # refusal propagates rather than being swallowed. + ensure_local_image_load_allowed(icon._image_reference) try: icon_with_alpha = cv2.imread( icon._image_reference, cv2.IMREAD_UNCHANGED diff --git a/inference/core/workflows/core_steps/visualizations/icon/v1_tensor.py b/inference/core/workflows/core_steps/visualizations/icon/v1_tensor.py index 26a1204c00..4dc6455af4 100644 --- a/inference/core/workflows/core_steps/visualizations/icon/v1_tensor.py +++ b/inference/core/workflows/core_steps/visualizations/icon/v1_tensor.py @@ -28,6 +28,7 @@ Selector, ) from inference.core.workflows.prototypes.block import BlockResult, WorkflowBlockManifest +from inference.core.workflows.utils.images import ensure_local_image_load_allowed TYPE: str = "roboflow_core/icon_visualization@v1" SHORT_DESCRIPTION = "Draw icons on an image either at specific static coordinates or dynamically based on detections." @@ -284,6 +285,10 @@ def run( and icon._image_reference and not icon._image_reference.startswith("http") ): + # Local-filesystem access is a host capability: ask before the + # alpha-recovery reload, and OUTSIDE the try below so a policy + # refusal propagates rather than being swallowed. + ensure_local_image_load_allowed(icon._image_reference) try: icon_with_alpha = cv2.imread( icon._image_reference, cv2.IMREAD_UNCHANGED diff --git a/tests/workflows/unit_tests/core_steps/visualizations/test_icon_local_file_gate.py b/tests/workflows/unit_tests/core_steps/visualizations/test_icon_local_file_gate.py new file mode 100644 index 0000000000..e1470f7f31 --- /dev/null +++ b/tests/workflows/unit_tests/core_steps/visualizations/test_icon_local_file_gate.py @@ -0,0 +1,144 @@ +from unittest import mock + +import cv2 +import numpy as np +import pytest + +from inference.core.exceptions import InputImageLoadError +from inference.core.interfaces.workflows_image_codec import install_guarded_image_codec +from inference.core.utils import image_utils +from inference.core.workflows.core_steps.visualizations.icon import v1 as icon_v1 +from inference.core.workflows.core_steps.visualizations.icon import ( + v1_tensor as icon_v1_tensor, +) +from inference.core.workflows.errors import WorkflowImageLoadError +from inference.core.workflows.execution_engine.entities.base import ( + ImageParentMetadata, + WorkflowImageData, +) +from inference.core.workflows.prototypes.image_codec import reset_image_codec + +BLOCK_MODULES = pytest.mark.parametrize( + "block_module", [icon_v1, icon_v1_tensor], ids=["numpy", "tensor"] +) + + +@pytest.fixture(autouse=True) +def _reset_codec(): + reset_image_codec() + yield + reset_image_codec() + + +def _background() -> WorkflowImageData: + return WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="background"), + numpy_image=np.ones((64, 64, 3), dtype=np.uint8) * 255, + ) + + +def _run(block_module, icon: WorkflowImageData): + block = block_module.IconVisualizationBlockV1() + return block.run( + image=_background(), + copy_image=True, + mode="static", + icon=icon, + predictions=None, + icon_width=16, + icon_height=16, + position=None, + x_position=10, + y_position=10, + ) + + +@BLOCK_MODULES +def test_icon_reload_is_refused_by_the_workflows_default_codec(tmp_path, block_module): + # given: no codec installed -> get_image_codec() falls back to the refusing + # WorkflowsLocalImageCodec default. + icon_path = tmp_path / "icon.png" + icon_bgr = np.zeros((16, 16, 3), dtype=np.uint8) + cv2.imwrite(str(icon_path), icon_bgr) + icon = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="icon"), + numpy_image=icon_bgr, + image_reference=str(icon_path), + ) + + # when / then + with mock.patch("cv2.imread") as imread_mock: + with pytest.raises(WorkflowImageLoadError, match="local filesystem"): + _run(block_module, icon) + imread_mock.assert_not_called() + + +@BLOCK_MODULES +def test_icon_reload_is_refused_when_the_server_flag_is_off(tmp_path, block_module): + # given: the guarded server codec is installed, but the deployment flag + # disables local-filesystem reads. + install_guarded_image_codec() + icon_path = tmp_path / "icon.png" + icon_bgr = np.zeros((16, 16, 3), dtype=np.uint8) + cv2.imwrite(str(icon_path), icon_bgr) + icon = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="icon"), + numpy_image=icon_bgr, + image_reference=str(icon_path), + ) + + # when / then + with mock.patch.object( + image_utils, "ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM", False + ): + with mock.patch("cv2.imread") as imread_mock: + with pytest.raises(InputImageLoadError, match="local filesystem"): + _run(block_module, icon) + imread_mock.assert_not_called() + + +@BLOCK_MODULES +def test_icon_reload_still_recovers_alpha_when_permitted(tmp_path, block_module): + # given: the guarded codec is installed and the flag is on (default) -> the + # gate must let the reload through unchanged. + install_guarded_image_codec() + icon_path = tmp_path / "icon.png" + icon_bgra = np.zeros((16, 16, 4), dtype=np.uint8) + icon_bgra[..., 3] = 255 + cv2.imwrite(str(icon_path), icon_bgra) + icon = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="icon"), + numpy_image=icon_bgra[..., :3].copy(), + image_reference=str(icon_path), + ) + + # when + with mock.patch.object( + image_utils, "ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM", True + ): + with mock.patch("cv2.imread", wraps=cv2.imread) as imread_mock: + output = _run(block_module, icon) + + # then: the reload happened (proving the guard sits before it) and the + # happy path still produces a valid image. `assert_any_call`, not + # `assert_called_once_with`: `sv.IconAnnotator` reloads the (temp) icon + # file internally via its own `cv2.imread` call, which the global patch + # also observes. + imread_mock.assert_any_call(str(icon_path), cv2.IMREAD_UNCHANGED) + result_image = output["image"].numpy_image + assert result_image.shape == (64, 64, 3) + assert result_image.dtype == np.uint8 + + +@BLOCK_MODULES +def test_icon_without_a_reference_never_consults_the_gate(block_module): + # given: no image_reference at all, and the refusing default codec + # installed (via the autouse reset) -> the gate must not be reached. + icon = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="icon"), + numpy_image=np.zeros((16, 16, 3), dtype=np.uint8), + ) + + # when / then: run() must succeed rather than raise. + output = _run(block_module, icon) + assert output["image"].numpy_image.shape == (64, 64, 3) diff --git a/tests/workflows/unit_tests/execution_engine/test_image_codec_injection.py b/tests/workflows/unit_tests/execution_engine/test_image_codec_injection.py index fe342c02ef..319393cc48 100644 --- a/tests/workflows/unit_tests/execution_engine/test_image_codec_injection.py +++ b/tests/workflows/unit_tests/execution_engine/test_image_codec_injection.py @@ -5,7 +5,6 @@ import pytest from inference.core.workflows.core_steps.loader import KINDS_DESERIALIZERS -from inference.core.workflows.errors import WorkflowImageLoadError from inference.core.workflows.execution_engine.core import ExecutionEngine from inference.core.workflows.execution_engine.entities.types import IMAGE_KIND from inference.core.workflows.prototypes.image_codec import ( From 1da288b76d8c916184a18f07e1509a162dfaa991 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:39:48 +0200 Subject: [PATCH 38/86] chore(workflows): add the inference.core.env import manifest generator AST walk over inference/core/workflows emitting the symbol -> call-site manifest (owned files/statements/symbols, Phase 9 rows reported separately) that every Phase 5 count is checked against. Measured on this tree: 67 symbols, 151 files, 155 statements, 0 Phase 9 rows. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- scripts/generate_env_import_manifest.py | 117 ++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 scripts/generate_env_import_manifest.py diff --git a/scripts/generate_env_import_manifest.py b/scripts/generate_env_import_manifest.py new file mode 100644 index 0000000000..1ff71c818d --- /dev/null +++ b/scripts/generate_env_import_manifest.py @@ -0,0 +1,117 @@ +"""Emit the `inference.core.env` symbol -> call-site manifest for the workflows tree. + +Every count in DECONTAMINATION.PLAN.PHASE-5.MD comes from this script. Phase 9 +owns the rows under `core_steps/sinks/roboflow/**` and +`core_steps/integrations/roboflow/**` (controller ruling R-S), so they are +reported separately and excluded from the "owned" figures. + + python scripts/generate_env_import_manifest.py --summary + python scripts/generate_env_import_manifest.py --json out.json +""" + +import argparse +import ast +import json +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS_ROOT = REPO_ROOT / "inference" / "core" / "workflows" +ENV_MODULE = "inference.core.env" +PHASE_9_PREFIXES = ( + WORKFLOWS_ROOT / "core_steps" / "sinks" / "roboflow", + WORKFLOWS_ROOT / "core_steps" / "integrations" / "roboflow", +) + + +def phase_9_owned(path: Path) -> bool: + return any(prefix in path.parents for prefix in PHASE_9_PREFIXES) + + +def _function_local_nodes(tree: ast.AST) -> set: + local = set() + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + for sub in ast.walk(node): + if isinstance(sub, ast.ImportFrom): + local.add(id(sub)) + return local + + +def build() -> dict: + symbols, owned_files, owned_statements = {}, set(), 0 + phase_9_files, phase_9_statements = set(), 0 + for path in sorted(WORKFLOWS_ROOT.rglob("*.py")): + if "__pycache__" in str(path): + continue + tree = ast.parse(path.read_bytes().decode("utf-8"), filename=str(path)) + local = _function_local_nodes(tree) + relative = path.relative_to(REPO_ROOT).as_posix() + deferred = phase_9_owned(path) + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom): + continue + if node.module != ENV_MODULE or node.level != 0: + continue + if deferred: + phase_9_files.add(relative) + phase_9_statements += 1 + continue + owned_files.add(relative) + owned_statements += 1 + for alias in node.names: + symbols.setdefault(alias.name, []).append( + { + "file": relative, + "line": node.lineno, + "function_local": id(node) in local, + } + ) + return { + "symbols": {k: symbols[k] for k in sorted(symbols)}, + "owned_symbols": sorted(symbols), + "owned_files": sorted(owned_files), + "owned_statements": owned_statements, + "phase_9_files": sorted(phase_9_files), + "phase_9_statements": phase_9_statements, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--json", metavar="PATH") + parser.add_argument("--summary", action="store_true") + args = parser.parse_args() + manifest = build() + if args.json: + Path(args.json).write_text(json.dumps(manifest, indent=1)) + if args.summary or not args.json: + print(f"owned symbols: {len(manifest['owned_symbols'])}") + print(f"owned files: {len(manifest['owned_files'])}") + print(f"owned statements: {manifest['owned_statements']}") + print(f"phase 9 files: {len(manifest['phase_9_files'])}") + print(f"phase 9 stmts: {manifest['phase_9_statements']}") + multi = [ + f + for f in manifest["owned_files"] + if sum( + 1 + for sites in manifest["symbols"].values() + for s in sites + if s["file"] == f + ) + and len( + { + s["line"] + for sites in manifest["symbols"].values() + for s in sites + if s["file"] == f + } + ) + > 1 + ] + print(f"files with >1 statement: {multi}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4a12d004f74cac8836a12ee53711b043703cff8d Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:42:43 +0200 Subject: [PATCH 39/86] feat(workflows): add WorkflowsConfiguration, its standalone default and the locked process registry Adds the nine frozen configuration group dataclasses, WorkflowsConfiguration, default_configuration, resolve_image_tensor_device, describe_configuration_difference, and the RLock-guarded set-once process registry (configure_process / get_configuration / reset_configuration / ensure_process_configuration_matches) in inference/core/workflows/configuration.py, plus the 67-constant environment.py facade and its 47-case test suite. Removes no baseline rows. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/workflows/configuration.py | 330 +++++++++ inference/core/workflows/environment.py | 130 ++++ .../unit_tests/test_configuration.py | 660 ++++++++++++++++++ 3 files changed, 1120 insertions(+) create mode 100644 inference/core/workflows/configuration.py create mode 100644 inference/core/workflows/environment.py create mode 100644 tests/workflows/unit_tests/test_configuration.py diff --git a/inference/core/workflows/configuration.py b/inference/core/workflows/configuration.py new file mode 100644 index 0000000000..0e44092951 --- /dev/null +++ b/inference/core/workflows/configuration.py @@ -0,0 +1,330 @@ +"""Injected configuration for `inference/core/workflows`. + +Every value the module used to take from `inference.core.env` is a field of +`WorkflowsConfiguration`, which the host builds from its own already-resolved +settings and installs once per process with `configure_process`. + +The configuration is PROCESS-WIDE in its entirety. Every one of the 67 values +is read from a module constant frozen at import - `core_steps/loader.py` +branches on the tensor flag while it is being imported, `offline.py:34` reads +`SECURE_GATEWAY`, `block_assembler.py:100` reads the custom-Python +authorization flag - so there is no field a second, differing configuration +could change after the fact. `ensure_process_configuration_matches` therefore +refuses ANY difference rather than accepting a value nothing would honour. + +Per-engine variance lives where it already did: the named init parameters +`workflows_core.{step_execution_mode, api_key, disable_sinks, +allow_access_to_file_system, allowed_write_directory, +allow_access_to_environmental_variables}`, resolved by +`steps_initialiser.retrieve_init_parameter_values`. + +`default_configuration()` is the STANDALONE fallback, not the production path. +Its values are the ones `inference/core/env.py` resolves for an EMPTY +environment. Every normalisation `env.py` performs - `str2bool`, `.lower()`, +the `OFFLINE_MODE` / `SECURE_GATEWAY` rewrites, the `PROJECT`-dependent hosted +URLs, the api-key-transport validation - stays in `env.py`; the host builder +copies the RESOLVED attributes. `resolve_image_tensor_device` is the single +copied piece of logic and is pinned by a differential test against `env.py`. +""" + +import threading +from dataclasses import dataclass, field, fields, is_dataclass +from typing import Any, List, Optional, Tuple + +from inference.core.workflows.errors import WorkflowEnvironmentConfigurationError + + +@dataclass(frozen=True) +class EngineConfiguration: + step_execution_mode: str = "local" + async_future_result_timeout: float = 60.0 + max_inner_workflow_depth: int = 4 + max_inner_workflow_count: int = 32 + allow_custom_python_execution: bool = True + custom_python_execution_mode: str = "local" + allow_blocks_accessing_local_storage: bool = True + allow_blocks_accessing_environmental_variables: bool = True + blocks_write_directory: Optional[str] = None + # Tuples, not lists: the configuration is frozen and compared by value. The + # facade re-materialises the `list` the call sites see today. + disabled_block_types: Tuple[str, ...] = () + disabled_block_patterns: Tuple[str, ...] = () + + +@dataclass(frozen=True) +class TensorConfiguration: + """Selects which block classes `core_steps/loader.py` imports and how + `WorkflowImageData` stores pixels. Block loading is process-global and + cached (`blocks_loader.py:252`), so these can never be per-engine.""" + + representation_enabled: bool = False + # `torch.device` when the flag is on and torch is importable, else None. + # Typed `Any` so this module never imports torch (an OPTIONAL dependency). + image_tensor_device: Optional[Any] = None + visualisation_validate_owners: bool = False + sam_video_mask_representation: str = "rle" + enforce_dense_instance_masks: bool = False + + +@dataclass(frozen=True) +class RemoteExecutionConfiguration: + api_target: str = "hosted" + api_key_transport: str = "both" + local_inference_api_url: str = "http://127.0.0.1:9001" + hosted_detect_url: str = "https://detect.roboflow.com" + hosted_classification_url: str = "https://classify.roboflow.com" + hosted_instance_segmentation_url: str = "https://outline.roboflow.com" + hosted_semantic_segmentation_url: str = "https://segment.roboflow.com" + hosted_core_model_url: str = "https://infer.roboflow.com" + max_step_batch_size: int = 1 + max_step_concurrent_requests: int = 8 + + +@dataclass(frozen=True) +class PlatformConfiguration: + api_base_url: str = "https://api.roboflow.com" + offline_mode: bool = False + secure_gateway: Optional[str] = None + gcp_serverless: bool = False + + +@dataclass(frozen=True) +class FontsConfiguration: + allow_download: bool = True + model_cache_dir: str = "/tmp/cache" + + +@dataclass(frozen=True) +class ModelsConfiguration: + lmm_enabled: bool = False + clip_version_id: str = "ViT-B-16" + core_model_sam2_enabled: bool = True + core_model_sam3_enabled: bool = True + core_model_pe_enabled: bool = True + core_model_gaze_enabled: bool = True + sam3_exec_mode: str = "local" + sam3_3d_objects_enabled: bool = False + florence2_enabled: bool = True + qwen_2_5_enabled: bool = True + qwen_3_enabled: bool = True + qwen_3_5_enabled: bool = True + smolvlm2_enabled: bool = True + moondream2_enabled: bool = True + depth_estimation_enabled: bool = True + cosmos3_enabled: bool = True + glm_ocr_enabled: bool = True + + +@dataclass(frozen=True) +class ModalConfiguration: + token_id: Optional[str] = None + token_secret: Optional[str] = field(default=None, repr=False) + workspace_name: str = "roboflow" + allow_anonymous_execution: bool = False + anonymous_workspace_name: str = "anonymous" + app_name: str = "webexec-roboflow-platform" + executor_idle_ttl_seconds: int = 1800 + jpeg_quality: int = 95 + transport: str = "http" + ws_connect_timeout_seconds: int = 30 + ws_read_timeout_seconds: int = 720 + ws_connection_pool_size: int = 1 + ws_fail_on_session_loss: bool = False + ws_idle_release_seconds: int = 120 + + +@dataclass(frozen=True) +class SecretsConfiguration: + api_key: Optional[str] = field(default=None, repr=False) + roboflow_internal_service_name: Optional[str] = None + roboflow_internal_service_secret: Optional[str] = field(default=None, repr=False) + + +@dataclass(frozen=True) +class DebugConfiguration: + output_dir: Optional[str] = None + + +@dataclass(frozen=True) +class WorkflowsConfiguration: + engine: EngineConfiguration = field(default_factory=EngineConfiguration) + tensor: TensorConfiguration = field(default_factory=TensorConfiguration) + remote: RemoteExecutionConfiguration = field( + default_factory=RemoteExecutionConfiguration + ) + platform: PlatformConfiguration = field(default_factory=PlatformConfiguration) + fonts: FontsConfiguration = field(default_factory=FontsConfiguration) + models: ModelsConfiguration = field(default_factory=ModelsConfiguration) + modal: ModalConfiguration = field(default_factory=ModalConfiguration) + secrets: SecretsConfiguration = field(default_factory=SecretsConfiguration) + debug: DebugConfiguration = field(default_factory=DebugConfiguration) + + +def default_configuration() -> WorkflowsConfiguration: + """The standalone fallback: every field at its documented default.""" + return WorkflowsConfiguration() + + +def resolve_image_tensor_device( + representation_enabled: bool, device: Optional[str] = None +) -> Optional[Any]: + """Materialise the tensor device exactly as `inference/core/env.py:1535-1547`. + + `torch` is an OPTIONAL dependency, so both the import and the device are + deferred behind the flag AND guarded on torch's presence. Off-flag or + torch-less the value is `None`. An invalid device string raises whatever + `torch.device` raises, which is what `env.py` does too - it does not guard + that call either. + + The `try` deliberately spans the import, the cuda autodetect AND the + materialisation, catching only `ImportError`, because that is exactly + `env.py:1536-1547`'s boundary (round-2 defect 5: the round-1 copy protected + only the import, so a `torch.cuda.is_available()` raising `ImportError` + would have propagated from the copy while `env.py` swallowed it). + `test_the_copy_preserves_the_originals_exception_boundary` pins both shapes. + """ + if not representation_enabled: + return None + try: + import torch + + if device is None: + device = "cuda" if torch.cuda.is_available() else "cpu" + return torch.device(device) + except ImportError: + return None + + +def describe_configuration_difference( + installed: WorkflowsConfiguration, candidate: WorkflowsConfiguration +) -> List[str]: + """`['group.field: != ', ...]`, empty when equal. + + Secret fields are compared but never rendered - the message says the field + differs, not what either value is. + """ + differences = [] + for group in fields(WorkflowsConfiguration): + installed_group = getattr(installed, group.name) + candidate_group = getattr(candidate, group.name) + if installed_group == candidate_group: + continue + if not is_dataclass(candidate_group): + differences.append(f"{group.name}: differs") + continue + for member in fields(installed_group): + left = getattr(installed_group, member.name) + right = getattr(candidate_group, member.name) + if left == right: + continue + if member.repr is False: + differences.append(f"{group.name}.{member.name}: differs (redacted)") + else: + differences.append(f"{group.name}.{member.name}: {left!r} != {right!r}") + return differences + + +_CONFIGURATION: Optional[WorkflowsConfiguration] = None +# One lock guards installation, the sticky fallback initialisation in +# `get_configuration` (which also assigns) and `reset_configuration`. Round-1 +# review reproduced two conflicting installs both succeeding through an +# unlocked check-then-assign; Phase 10's image-codec registry locks for the +# same reason. +_INSTALL_LOCK = threading.RLock() + + +def configure_process(configuration: WorkflowsConfiguration) -> None: + """Install the process-wide configuration. Set-once, by value. + + Installing the identical object, or an EQUAL one, is a no-op - frozen + dataclasses compare by value and the server memoises its instance. + Installing a DIFFERING one raises, naming every group and field that + differs. + """ + global _CONFIGURATION + with _INSTALL_LOCK: + if _CONFIGURATION is not None and _CONFIGURATION != configuration: + differences = describe_configuration_difference( + _CONFIGURATION, configuration + ) + raise WorkflowEnvironmentConfigurationError( + public_message=( + "A different WorkflowsConfiguration is already installed in " + "this process. Workflows configuration is process-level: " + "block registration and the image representation are decided " + "once, at import time, and every other value is frozen into " + "module constants. Install the configuration before importing " + "any workflows module, and install the same one everywhere. " + f"Differences: {differences}" + ), + context="workflow_configuration | process_installation", + ) + _CONFIGURATION = configuration + + +def get_configuration() -> WorkflowsConfiguration: + """The installed configuration, falling back to the standalone default. + + The fallback is STICKY: once read, it becomes the process configuration, so + a later `configure_process` with different values raises instead of + silently disagreeing with values already frozen into module constants. + """ + global _CONFIGURATION + with _INSTALL_LOCK: + if _CONFIGURATION is None: + _CONFIGURATION = default_configuration() + return _CONFIGURATION + + +def reset_configuration() -> None: + """Test-only hook: forget the installed configuration.""" + global _CONFIGURATION + with _INSTALL_LOCK: + _CONFIGURATION = None + + +def ensure_process_configuration_matches(configuration: Any) -> None: + """Refuse an engine-supplied configuration that differs from the process one. + + `init_parameters["workflows_core.configuration"]` is a CONSISTENCY + ASSERTION, not an override channel: the composition roots pass the object + the host installed, and a mismatch means the process is mis-wired. Because + every value is read from a module constant frozen at import, accepting a + differing value would mean accepting a value nothing honours. + + The caller decides PRESENCE (the engine calls this only when the key is in + `init_parameters`); this function judges the VALUE, and `None` is not a + valid value: an explicit `None` would be handed to every block that + declares `configuration` ahead of the registered default + (`steps_initialiser.py:124-125` prefers explicit values), so it is refused + like any other non-`WorkflowsConfiguration` (round-4 defect 1). + """ + if not isinstance(configuration, WorkflowsConfiguration): + # Only ever reached through the DEDICATED init-parameter keys, so a + # foreign value there is a mis-wiring, never a plugin's own parameter + # (`v1/core.py` deliberately does not fall back to the bare name). + raise WorkflowEnvironmentConfigurationError( + public_message=( + "`workflows_core.configuration` must be a WorkflowsConfiguration, " + f"got {type(configuration).__name__}." + ), + context="workflow_compilation | engine_initialisation", + ) + installed = get_configuration() + if configuration == installed: + return + differences = describe_configuration_difference(installed, configuration) + raise WorkflowEnvironmentConfigurationError( + public_message=( + "`workflows_core.configuration` differs from the WorkflowsConfiguration " + "installed in this process. Workflows configuration is process-level " + "and cannot be varied per engine; use the named init parameters " + "(`workflows_core.step_execution_mode`, `workflows_core.api_key`, " + "`workflows_core.disable_sinks`, " + "`workflows_core.allow_access_to_file_system`, " + "`workflows_core.allowed_write_directory`, " + "`workflows_core.allow_access_to_environmental_variables`) for " + f"per-engine values. Differences: {differences}" + ), + context="workflow_compilation | engine_initialisation", + ) diff --git a/inference/core/workflows/environment.py b/inference/core/workflows/environment.py new file mode 100644 index 0000000000..02821ac9bc --- /dev/null +++ b/inference/core/workflows/environment.py @@ -0,0 +1,130 @@ +"""The installed `WorkflowsConfiguration`, bound as module constants. + +This is the workflows-side replacement for `inference.core.env`: the same +UPPER_CASE names, the same types, the same values - sourced from the +configuration object the host installed with +`inference.core.workflows.configuration.configure_process`, never from +`os.environ`. + +The values are bound ONCE, at this module's import - exactly as +`inference/core/env.py` binds its own at its import. Importing this module +therefore freezes the process configuration; a later `configure_process` with +different values raises. Tests that need different values call +`configuration.reset_configuration()`, `configure_process(...)`, and +`importlib.reload` this module plus its consumer. +""" + +from inference.core.workflows.configuration import get_configuration + +_CONFIGURATION = get_configuration() + +# --- engine --- +WORKFLOWS_STEP_EXECUTION_MODE = _CONFIGURATION.engine.step_execution_mode +WORKFLOWS_ASYNC_FUTURE_RESULT_TIMEOUT = ( + _CONFIGURATION.engine.async_future_result_timeout +) +WORKFLOWS_MAX_INNER_WORKFLOW_DEPTH = _CONFIGURATION.engine.max_inner_workflow_depth +WORKFLOWS_MAX_INNER_WORKFLOW_COUNT = _CONFIGURATION.engine.max_inner_workflow_count +ALLOW_CUSTOM_PYTHON_EXECUTION_IN_WORKFLOWS = ( + _CONFIGURATION.engine.allow_custom_python_execution +) +WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE = ( + _CONFIGURATION.engine.custom_python_execution_mode +) +ALLOW_WORKFLOW_BLOCKS_ACCESSING_LOCAL_STORAGE = ( + _CONFIGURATION.engine.allow_blocks_accessing_local_storage +) +ALLOW_WORKFLOW_BLOCKS_ACCESSING_ENVIRONMENTAL_VARIABLES = ( + _CONFIGURATION.engine.allow_blocks_accessing_environmental_variables +) +WORKFLOW_BLOCKS_WRITE_DIRECTORY = _CONFIGURATION.engine.blocks_write_directory +WORKFLOW_DISABLED_BLOCK_TYPES = list(_CONFIGURATION.engine.disabled_block_types) +WORKFLOW_DISABLED_BLOCK_PATTERNS = list(_CONFIGURATION.engine.disabled_block_patterns) + +# --- tensor representation --- +ENABLE_TENSOR_DATA_REPRESENTATION = _CONFIGURATION.tensor.representation_enabled +WORKFLOWS_IMAGE_TENSOR_DEVICE = _CONFIGURATION.tensor.image_tensor_device +WORKFLOWS_TENSOR_VISUALISATION_VALIDATE_OWNERS = ( + _CONFIGURATION.tensor.visualisation_validate_owners +) +WORKFLOWS_SAM_VIDEO_MASK_REPRESENTATION = ( + _CONFIGURATION.tensor.sam_video_mask_representation +) +WORKFLOWS_ENFORCE_DENSE_INSTANCE_MASKS = ( + _CONFIGURATION.tensor.enforce_dense_instance_masks +) + +# --- remote step execution --- +WORKFLOWS_REMOTE_API_TARGET = _CONFIGURATION.remote.api_target +WORKFLOWS_REMOTE_API_KEY_TRANSPORT = _CONFIGURATION.remote.api_key_transport +LOCAL_INFERENCE_API_URL = _CONFIGURATION.remote.local_inference_api_url +HOSTED_DETECT_URL = _CONFIGURATION.remote.hosted_detect_url +HOSTED_CLASSIFICATION_URL = _CONFIGURATION.remote.hosted_classification_url +HOSTED_INSTANCE_SEGMENTATION_URL = ( + _CONFIGURATION.remote.hosted_instance_segmentation_url +) +HOSTED_SEMANTIC_SEGMENTATION_URL = ( + _CONFIGURATION.remote.hosted_semantic_segmentation_url +) +HOSTED_CORE_MODEL_URL = _CONFIGURATION.remote.hosted_core_model_url +WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE = ( + _CONFIGURATION.remote.max_step_batch_size +) +WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS = ( + _CONFIGURATION.remote.max_step_concurrent_requests +) + +# --- platform / deployment --- +API_BASE_URL = _CONFIGURATION.platform.api_base_url +OFFLINE_MODE = _CONFIGURATION.platform.offline_mode +SECURE_GATEWAY = _CONFIGURATION.platform.secure_gateway +GCP_SERVERLESS = _CONFIGURATION.platform.gcp_serverless + +# --- fonts --- +ALLOW_WORKFLOWS_FONTS_DOWNLOAD = _CONFIGURATION.fonts.allow_download +MODEL_CACHE_DIR = _CONFIGURATION.fonts.model_cache_dir + +# --- model feature flags --- +LMM_ENABLED = _CONFIGURATION.models.lmm_enabled +CLIP_VERSION_ID = _CONFIGURATION.models.clip_version_id +CORE_MODEL_SAM2_ENABLED = _CONFIGURATION.models.core_model_sam2_enabled +CORE_MODEL_SAM3_ENABLED = _CONFIGURATION.models.core_model_sam3_enabled +CORE_MODEL_PE_ENABLED = _CONFIGURATION.models.core_model_pe_enabled +CORE_MODEL_GAZE_ENABLED = _CONFIGURATION.models.core_model_gaze_enabled +SAM3_EXEC_MODE = _CONFIGURATION.models.sam3_exec_mode +SAM3_3D_OBJECTS_ENABLED = _CONFIGURATION.models.sam3_3d_objects_enabled +FLORENCE2_ENABLED = _CONFIGURATION.models.florence2_enabled +QWEN_2_5_ENABLED = _CONFIGURATION.models.qwen_2_5_enabled +QWEN_3_ENABLED = _CONFIGURATION.models.qwen_3_enabled +QWEN_3_5_ENABLED = _CONFIGURATION.models.qwen_3_5_enabled +SMOLVLM2_ENABLED = _CONFIGURATION.models.smolvlm2_enabled +MOONDREAM2_ENABLED = _CONFIGURATION.models.moondream2_enabled +DEPTH_ESTIMATION_ENABLED = _CONFIGURATION.models.depth_estimation_enabled +COSMOS3_ENABLED = _CONFIGURATION.models.cosmos3_enabled +GLM_OCR_ENABLED = _CONFIGURATION.models.glm_ocr_enabled + +# --- modal / webexec custom-python backend --- +MODAL_TOKEN_ID = _CONFIGURATION.modal.token_id +MODAL_TOKEN_SECRET = _CONFIGURATION.modal.token_secret +MODAL_WORKSPACE_NAME = _CONFIGURATION.modal.workspace_name +MODAL_ALLOW_ANONYMOUS_EXECUTION = _CONFIGURATION.modal.allow_anonymous_execution +MODAL_ANONYMOUS_WORKSPACE_NAME = _CONFIGURATION.modal.anonymous_workspace_name +WEBEXEC_MODAL_APP_NAME = _CONFIGURATION.modal.app_name +WEBEXEC_MODAL_EXECUTOR_IDLE_TTL_SECONDS = _CONFIGURATION.modal.executor_idle_ttl_seconds +WEBEXEC_JPEG_QUALITY = _CONFIGURATION.modal.jpeg_quality +WEBEXEC_TRANSPORT = _CONFIGURATION.modal.transport +WEBEXEC_WS_CONNECT_TIMEOUT_SECONDS = _CONFIGURATION.modal.ws_connect_timeout_seconds +WEBEXEC_WS_READ_TIMEOUT_SECONDS = _CONFIGURATION.modal.ws_read_timeout_seconds +WEBEXEC_WS_CONNECTION_POOL_SIZE = _CONFIGURATION.modal.ws_connection_pool_size +WEBEXEC_WS_FAIL_ON_SESSION_LOSS = _CONFIGURATION.modal.ws_fail_on_session_loss +WEBEXEC_WS_IDLE_RELEASE_SECONDS = _CONFIGURATION.modal.ws_idle_release_seconds + +# --- secrets --- +API_KEY = _CONFIGURATION.secrets.api_key +ROBOFLOW_INTERNAL_SERVICE_NAME = _CONFIGURATION.secrets.roboflow_internal_service_name +ROBOFLOW_INTERNAL_SERVICE_SECRET = ( + _CONFIGURATION.secrets.roboflow_internal_service_secret +) + +# --- debug --- +INFERENCE_DEBUG_OUTPUT_DIR = _CONFIGURATION.debug.output_dir diff --git a/tests/workflows/unit_tests/test_configuration.py b/tests/workflows/unit_tests/test_configuration.py new file mode 100644 index 0000000000..0c9cb6d3fe --- /dev/null +++ b/tests/workflows/unit_tests/test_configuration.py @@ -0,0 +1,660 @@ +import ast +import dataclasses +import importlib +import json +import os +import subprocess +import sys +import threading +from pathlib import Path +from typing import List + +import pytest + +# The facade is imported HERE, at collection time, on purpose (round-5 defect 1): +# it binds its 67 constants from `get_configuration()` at its FIRST import, and the +# autouse fixture below resets the registry before every test. If a test body +# performed the first import, the facade would freeze standalone defaults, the +# fixture would restore only `_CONFIGURATION`, and the server/facade parity test +# in tests/inference/.../test_workflows_configuration.py would fail under a +# legitimate restrictive deployment (both access flags False). At collection time +# the process registry still holds what the host installed. +import inference.core.workflows.environment as workflows_environment +from inference.core.workflows import configuration as configuration_module +from inference.core.workflows.configuration import ( + WorkflowsConfiguration, + configure_process, + default_configuration, + describe_configuration_difference, + ensure_process_configuration_matches, + get_configuration, + reset_configuration, + resolve_image_tensor_device, +) +from inference.core.workflows.errors import WorkflowEnvironmentConfigurationError + +REPO_ROOT = Path(__file__).resolve().parents[3] +ENV_PATH = REPO_ROOT / "inference" / "core" / "env.py" + +# Captured at collection time, right after the facade import above and before +# the autouse fixture below ever resets the registry: the configuration the +# host had installed (the server's, after Task 5.2; the sticky standalone +# default in a bare process). The facade must have been bound from THIS. +_INSTALLED_AT_COLLECTION = configuration_module.get_configuration() + + +@pytest.fixture(autouse=True) +def _isolated_process_configuration(): + # Every test in this file owns the process registry; restore whatever the + # session had installed so no other suite sees a reset one. + previous = configuration_module._CONFIGURATION + reset_configuration() + yield + with configuration_module._INSTALL_LOCK: + configuration_module._CONFIGURATION = previous + + +# -------------------------------------------------------------------------- +# Shape and defaults +# -------------------------------------------------------------------------- + + +def test_configuration_is_frozen_in_every_group() -> None: + configuration = default_configuration() + assert len(dataclasses.fields(configuration)) == 9 + total_fields = 0 + for group in dataclasses.fields(configuration): + value = getattr(configuration, group.name) + assert dataclasses.is_dataclass(value) + total_fields += len(dataclasses.fields(value)) + with pytest.raises(dataclasses.FrozenInstanceError): + setattr(value, dataclasses.fields(value)[0].name, "mutated") + assert total_fields == 67, total_fields + + +def test_default_configuration_matches_env_pys_empty_environment_defaults() -> None: + configuration = default_configuration() + assert configuration.engine.step_execution_mode == "local" + assert configuration.engine.async_future_result_timeout == 60.0 + assert configuration.engine.max_inner_workflow_depth == 4 + assert configuration.engine.max_inner_workflow_count == 32 + assert configuration.engine.allow_custom_python_execution is True + assert configuration.engine.custom_python_execution_mode == "local" + assert configuration.engine.allow_blocks_accessing_local_storage is True + assert configuration.engine.allow_blocks_accessing_environmental_variables is True + assert configuration.engine.blocks_write_directory is None + assert configuration.engine.disabled_block_types == () + assert configuration.engine.disabled_block_patterns == () + assert configuration.tensor.representation_enabled is False + assert configuration.tensor.image_tensor_device is None + assert configuration.tensor.visualisation_validate_owners is False + assert configuration.tensor.sam_video_mask_representation == "rle" + assert configuration.tensor.enforce_dense_instance_masks is False + assert configuration.remote.api_target == "hosted" + assert configuration.remote.api_key_transport == "both" + assert configuration.remote.local_inference_api_url == "http://127.0.0.1:9001" + assert configuration.remote.hosted_detect_url == "https://detect.roboflow.com" + assert ( + configuration.remote.hosted_classification_url + == "https://classify.roboflow.com" + ) + assert ( + configuration.remote.hosted_instance_segmentation_url + == "https://outline.roboflow.com" + ) + assert ( + configuration.remote.hosted_semantic_segmentation_url + == "https://segment.roboflow.com" + ) + assert configuration.remote.hosted_core_model_url == "https://infer.roboflow.com" + assert configuration.remote.max_step_batch_size == 1 + assert configuration.remote.max_step_concurrent_requests == 8 + assert configuration.platform.api_base_url == "https://api.roboflow.com" + assert configuration.platform.offline_mode is False + assert configuration.platform.secure_gateway is None + assert configuration.platform.gcp_serverless is False + assert configuration.fonts.allow_download is True + assert configuration.fonts.model_cache_dir == "/tmp/cache" + assert configuration.models.lmm_enabled is False + assert configuration.models.clip_version_id == "ViT-B-16" + assert configuration.models.sam3_exec_mode == "local" + assert configuration.models.sam3_3d_objects_enabled is False + assert configuration.modal.token_id is None + assert configuration.modal.workspace_name == "roboflow" + assert configuration.modal.app_name == "webexec-roboflow-platform" + assert configuration.modal.executor_idle_ttl_seconds == 1800 + assert configuration.modal.jpeg_quality == 95 + assert configuration.modal.transport == "http" + assert configuration.modal.ws_connect_timeout_seconds == 30 + assert configuration.modal.ws_read_timeout_seconds == 720 + assert configuration.modal.ws_connection_pool_size == 1 + assert configuration.modal.ws_fail_on_session_loss is False + assert configuration.modal.ws_idle_release_seconds == 120 + assert configuration.secrets.api_key is None + assert configuration.secrets.roboflow_internal_service_name is None + assert configuration.secrets.roboflow_internal_service_secret is None + assert configuration.debug.output_dir is None + + +def test_secrets_are_kept_out_of_the_repr() -> None: + base = default_configuration() + configuration = dataclasses.replace( + base, + secrets=dataclasses.replace( + base.secrets, + api_key="SECRET-API-KEY", + roboflow_internal_service_secret="SECRET-SERVICE-SECRET", + ), + modal=dataclasses.replace(base.modal, token_secret="SECRET-MODAL-TOKEN"), + ) + rendered = repr(configuration) + assert "SECRET-API-KEY" not in rendered + assert "SECRET-SERVICE-SECRET" not in rendered + assert "SECRET-MODAL-TOKEN" not in rendered + + +# -------------------------------------------------------------------------- +# Conflict variants - derived from the INSTALLED value, never hard-coded +# -------------------------------------------------------------------------- + +# Round-2 defect 4: hard-coded override values ("set representation_enabled=True") +# are equal to the installed value in the mandatory `ENABLE_TENSOR_DATA_REPRESENTATION=True` +# suite run, so `pytest.raises` never fires. Every variant is now derived by +# perturbing whatever is installed, and inequality is asserted first. + +CONFLICT_SENTINEL = "phase5-conflict-probe" + +GROUP_FIELDS = [ + ("engine", "allow_custom_python_execution"), + ("tensor", "representation_enabled"), + ("remote", "api_target"), + ("platform", "offline_mode"), + ("fonts", "allow_download"), + ("models", "sam3_exec_mode"), + ("modal", "transport"), + ("secrets", "roboflow_internal_service_name"), + ("debug", "output_dir"), +] + + +def perturb(value): + """A value guaranteed different from `value`, of a compatible kind.""" + if isinstance(value, bool): # before int - bool IS an int + return not value + if value is None: + return CONFLICT_SENTINEL + if isinstance(value, str): + return ( + CONFLICT_SENTINEL + if value != CONFLICT_SENTINEL + else CONFLICT_SENTINEL + "-2" + ) + if isinstance(value, (int, float)): + return value + 1 + if isinstance(value, tuple): + return value + (CONFLICT_SENTINEL,) + raise AssertionError(f"perturb() has no rule for {type(value)!r}") + + +def variant_of(base: WorkflowsConfiguration, group: str, field: str): + current = getattr(getattr(base, group), field) + changed = perturb(current) + assert changed != current, (group, field, current) + return dataclasses.replace( + base, **{group: dataclasses.replace(getattr(base, group), **{field: changed})} + ) + + +# -------------------------------------------------------------------------- +# Registry semantics +# -------------------------------------------------------------------------- + + +def test_get_configuration_falls_back_to_the_standalone_default() -> None: + assert get_configuration() == default_configuration() + + +def test_configure_process_accepts_an_equal_but_distinct_object() -> None: + first = variant_of(default_configuration(), "remote", "api_target") + configure_process(first) + configure_process(dataclasses.replace(first)) # equal, different identity + assert get_configuration() == first + + +def test_configure_process_accepts_the_identical_object_repeatedly() -> None: + configuration = default_configuration() + for _ in range(5): + configure_process(configuration) + assert get_configuration() is configuration + + +@pytest.mark.parametrize( + "group, field", GROUP_FIELDS, ids=[f"{g}.{f}" for g, f in GROUP_FIELDS] +) +def test_configure_process_refuses_a_conflicting_configuration(group, field) -> None: + configure_process(default_configuration()) + with pytest.raises(WorkflowEnvironmentConfigurationError) as raised: + configure_process(variant_of(default_configuration(), group, field)) + assert f"{group}.{field}" in raised.value.public_message + + +@pytest.mark.parametrize( + "group, field", GROUP_FIELDS, ids=[f"{g}.{f}" for g, f in GROUP_FIELDS] +) +def test_ensure_process_configuration_matches_refuses_every_group(group, field) -> None: + configure_process(default_configuration()) + with pytest.raises(WorkflowEnvironmentConfigurationError) as raised: + ensure_process_configuration_matches( + variant_of(default_configuration(), group, field) + ) + assert f"{group}.{field}" in raised.value.public_message + + +def test_ensure_process_configuration_matches_accepts_an_equal_object_and_refuses_none() -> ( + None +): + # Round-4 defect 1: the engine calls this only when the key is PRESENT, so + # a None here is an explicit None - which the resolver would hand to every + # configuration-consuming block ahead of the registered default. + configure_process(default_configuration()) + ensure_process_configuration_matches(default_configuration()) + with pytest.raises(WorkflowEnvironmentConfigurationError) as raised: + ensure_process_configuration_matches(None) + assert "NoneType" in raised.value.public_message + + +def test_ensure_process_configuration_matches_rejects_a_foreign_type() -> None: + # Round-2 defect 1: a value under the DEDICATED key that is not a + # WorkflowsConfiguration must be a workflows error, not an AttributeError + # from `describe_configuration_difference`. + configure_process(default_configuration()) + with pytest.raises(WorkflowEnvironmentConfigurationError) as raised: + ensure_process_configuration_matches({"threshold": 0.5}) + assert "WorkflowsConfiguration" in raised.value.public_message + + +def test_describe_configuration_difference_names_group_and_field() -> None: + base = default_configuration() + differences = describe_configuration_difference( + base, variant_of(base, "remote", "api_target") + ) + assert differences == [f"remote.api_target: 'hosted' != {CONFLICT_SENTINEL!r}"] + + +def test_describe_configuration_difference_redacts_secret_fields() -> None: + base = default_configuration() + changed = dataclasses.replace( + base, secrets=dataclasses.replace(base.secrets, api_key="SECRET-API-KEY") + ) + differences = describe_configuration_difference(base, changed) + assert differences == ["secrets.api_key: differs (redacted)"] + assert "SECRET-API-KEY" not in "".join(differences) + + +def test_configure_process_holds_the_install_lock_for_its_whole_critical_section() -> ( + None +): + """Deterministic proof of mutual exclusion (round-2 defect 8). + + The round-1 barrier test passed 100/100 times with the lock REMOVED, + because a barrier before the call does not force an interleaving between + the check and the assignment. This test holds `_INSTALL_LOCK` in the main + thread and shows a competing `configure_process` in another thread cannot + proceed until it is released - which is false the moment the `with` block + is deleted from `configure_process`. Verified against a two-implementation + probe: locked -> blocked=True, unlocked -> blocked=False. + """ + started, finished = threading.Event(), threading.Event() + failures = [] + + def _install() -> None: + started.set() + try: + configure_process(default_configuration()) + except BaseException as error: # noqa: BLE001 - reported, not raised + failures.append(error) + finished.set() + + worker = threading.Thread(target=_install) + with configuration_module._INSTALL_LOCK: + worker.start() + assert started.wait(timeout=5), "worker never started" + # The lock is held here: the worker MUST NOT get through. + assert not finished.wait(timeout=0.5), ( + "configure_process completed while _INSTALL_LOCK was held - its " + "critical section is not protected" + ) + worker.join(timeout=5) + assert finished.is_set(), "worker did not finish after the lock was released" + assert not failures, failures + + +@pytest.mark.parametrize( + "operation", + ["get_configuration", "reset_configuration"], +) +def test_the_other_registry_operations_hold_the_lock_too(operation) -> None: + started, finished = threading.Event(), threading.Event() + + def _call() -> None: + started.set() + getattr(configuration_module, operation)() + finished.set() + + worker = threading.Thread(target=_call) + with configuration_module._INSTALL_LOCK: + worker.start() + assert started.wait(timeout=5) + assert not finished.wait(timeout=0.5), operation + worker.join(timeout=5) + assert finished.is_set() + + +def test_two_conflicting_installs_end_with_exactly_one_winner() -> None: + barrier = threading.Barrier(2) + outcomes = [] + + def _install(configuration) -> None: + barrier.wait(timeout=5) + try: + configure_process(configuration) + outcomes.append("ok") + except WorkflowEnvironmentConfigurationError: + outcomes.append("refused") + + first = default_configuration() + second = variant_of(first, "tensor", "representation_enabled") + threads = [ + threading.Thread(target=_install, args=(first,)), + threading.Thread(target=_install, args=(second,)), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10) + assert not thread.is_alive() + assert sorted(outcomes) == ["ok", "refused"], outcomes + assert get_configuration() in (first, second) + + +# -------------------------------------------------------------------------- +# resolve_image_tensor_device - DIFFERENTIAL: the COPY against the ORIGINAL +# -------------------------------------------------------------------------- + +# Round-2 defect 5: the round-1 tests compared the copy against `env.py`'s +# already-materialised module attribute in a subprocess, which (a) never pinned +# `USE_INFERENCE_MODELS` (`env.py:1486` ANDs it into the flag, so a nominal +# "on" case could compare None with None) and (b) could not cover missing torch +# at all, because importing `inference.core.env` needs torch transitively. +# +# The harness below lifts `env.py`'s OWN resolution statement out by AST and +# executes it beside the copy under identical, controlled import conditions. +# No `inference.core.env` import is needed, so every cell - including missing +# torch and cuda-absent - is a true copy-vs-original comparison. + + +def _original_resolution_block() -> ast.If: + """`if ENABLE_TENSOR_DATA_REPRESENTATION: try: import torch ...` from env.py.""" + tree = ast.parse(ENV_PATH.read_text(encoding="utf-8")) + for node in tree.body: + if ( + isinstance(node, ast.If) + and isinstance(node.test, ast.Name) + and node.test.id == "ENABLE_TENSOR_DATA_REPRESENTATION" + and any(isinstance(statement, ast.Try) for statement in node.body) + ): + for statement in node.body: + if isinstance(statement, ast.Try) and any( + isinstance(inner, ast.Import) + and any(alias.name == "torch" for alias in inner.names) + for inner in ast.walk(statement) + ): + return node + raise AssertionError("env.py's device-resolution block was not found") + + +def _run_original(flag: bool, device: str | None): + node = _original_resolution_block() + namespace = { + "ENABLE_TENSOR_DATA_REPRESENTATION": flag, + "WORKFLOWS_IMAGE_TENSOR_DEVICE_STR": device, + "WORKFLOWS_IMAGE_TENSOR_DEVICE": None, + } + exec( + compile(ast.Module(body=[node], type_ignores=[]), str(ENV_PATH), "exec"), + namespace, + ) + return namespace["WORKFLOWS_IMAGE_TENSOR_DEVICE"] + + +@pytest.mark.parametrize( + "flag, device", + [(False, None), (False, "cuda"), (True, "cpu"), (True, None)], + ids=["off-nodevice", "off-device", "on-cpu", "on-autodetect"], +) +def test_the_copy_matches_the_original_resolution(flag, device) -> None: + pytest.importorskip("torch") + assert _run_original(flag, device) == resolve_image_tensor_device(flag, device) + + +def test_the_copy_matches_the_original_when_cuda_is_absent(monkeypatch) -> None: + torch = pytest.importorskip("torch") + monkeypatch.setattr(torch.cuda, "is_available", lambda: False) + original = _run_original(True, None) + copied = resolve_image_tensor_device(True, None) + assert original == copied == torch.device("cpu") + + +def test_the_copy_matches_the_original_when_torch_is_missing() -> None: + import builtins + + real_import = builtins.__import__ + + def _refuse_torch(name, *args, **kwargs): + if name == "torch": + raise ImportError("torch is not installed") + return real_import(name, *args, **kwargs) + + saved = sys.modules.pop("torch", None) + builtins.__import__ = _refuse_torch + try: + original = _run_original(True, None) + copied = resolve_image_tensor_device(True, None) + finally: + builtins.__import__ = real_import + if saved is not None: + sys.modules["torch"] = saved + assert original is None + assert copied is None + + +def test_the_copy_preserves_the_originals_exception_boundary() -> None: + """`env.py:1535-1547` wraps import + autodetect + materialisation in ONE + `try`, catching only `ImportError`. The copy must do the same, or a + `torch.cuda.is_available()` that raises `ImportError` would propagate from + the copy while `env.py` swallows it.""" + node = _original_resolution_block() + try_statement = next(s for s in node.body if isinstance(s, ast.Try)) + assert len(try_statement.body) == 3, ast.dump(try_statement) + assert [type(s).__name__ for s in try_statement.body] == ["Import", "If", "Assign"] + assert [handler.type.id for handler in try_statement.handlers] == ["ImportError"] + + copy_source = ast.parse( + Path(configuration_module.__file__).read_text(encoding="utf-8") + ) + function = next( + node + for node in ast.walk(copy_source) + if isinstance(node, ast.FunctionDef) + and node.name == "resolve_image_tensor_device" + ) + copied_try = next(s for s in ast.walk(function) if isinstance(s, ast.Try)) + assert [type(s).__name__ for s in copied_try.body] == ["Import", "If", "Return"] + assert [handler.type.id for handler in copied_try.handlers] == ["ImportError"] + + +def test_the_copy_rejects_an_invalid_device_exactly_as_the_original_does() -> None: + pytest.importorskip("torch") + # Neither guards `torch.device(...)`: an invalid string raises RuntimeError. + with pytest.raises(RuntimeError): + _run_original(True, "nonsense") + with pytest.raises(RuntimeError): + resolve_image_tensor_device(True, "nonsense") + + +def test_the_resolver_agrees_with_a_real_env_import(tmp_path) -> None: + """End-to-end cell: a subprocess imports the REAL `inference.core.env` with + the flag forced on, `USE_INFERENCE_MODELS` pinned (env.py:1486 ANDs it in), + and asserts the module's own effective flag before comparing devices.""" + pytest.importorskip("torch") + child = ( + "import json, os\n" + "from inference.core import env\n" + "from inference.core.workflows.configuration import resolve_image_tensor_device\n" + "raw = os.environ.get('WORKFLOWS_IMAGE_TENSOR_DEVICE')\n" + "print(json.dumps({\n" + " 'flag': env.ENABLE_TENSOR_DATA_REPRESENTATION,\n" + " 'equal': resolve_image_tensor_device(env.ENABLE_TENSOR_DATA_REPRESENTATION, raw)\n" + " == env.WORKFLOWS_IMAGE_TENSOR_DEVICE,\n" + " 'device': str(env.WORKFLOWS_IMAGE_TENSOR_DEVICE),\n" + "}))\n" + ) + child_env = { + **os.environ, + "PYTHONPATH": str(REPO_ROOT / "inference_models"), + "ENABLE_TENSOR_DATA_REPRESENTATION": "True", + "USE_INFERENCE_MODELS": "True", + "WORKFLOWS_IMAGE_TENSOR_DEVICE": "cpu", + } + completed = subprocess.run( + [sys.executable, "-c", child], + cwd=REPO_ROOT, + env=child_env, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + payload = json.loads(completed.stdout.strip().splitlines()[-1]) + # Pinning USE_INFERENCE_MODELS is what makes this cell meaningful: without + # it the flag can resolve False and both sides trivially compare None. + assert payload["flag"] is True, payload + assert payload["device"] == "cpu", payload + assert payload["equal"], payload + + +def test_the_resolver_is_none_when_the_flag_is_off() -> None: + assert resolve_image_tensor_device(False) is None + assert resolve_image_tensor_device(False, "cuda") is None + + +# -------------------------------------------------------------------------- +# The facade +# -------------------------------------------------------------------------- + + +def test_environment_facade_exports_every_owned_symbol() -> None: + # Deliberately value-free: this suite runs in BOTH tensor modes and the + # facade was bound from whatever configuration the process installed. The + # value contract is pinned server-side against `inference.core.env` by + # tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py. + # Uses the MODULE-LEVEL import: a first import inside this body (after the + # autouse reset) would freeze standalone defaults into the facade. + exported = { + name + for name in vars(workflows_environment) + if name.isupper() and not name.startswith("_") + } + assert len(exported) == 67, sorted(exported) + assert isinstance(workflows_environment.WORKFLOW_DISABLED_BLOCK_TYPES, list) + assert isinstance(workflows_environment.WORKFLOW_DISABLED_BLOCK_PATTERNS, list) + assert isinstance(workflows_environment.ENABLE_TENSOR_DATA_REPRESENTATION, bool) + + +def test_the_facade_was_bound_from_the_installed_configuration() -> None: + # Guards the collection-time import above (round-5 defect 1): the facade's + # constants must come from the configuration installed BEFORE this file's + # fixture ran, never from the standalone default a reset would install. + # Two access flags that a restrictive deployment legitimately sets False. + assert ( + workflows_environment.ALLOW_WORKFLOW_BLOCKS_ACCESSING_LOCAL_STORAGE + is _INSTALLED_AT_COLLECTION.engine.allow_blocks_accessing_local_storage + ) + assert ( + workflows_environment.ALLOW_WORKFLOW_BLOCKS_ACCESSING_ENVIRONMENTAL_VARIABLES + is _INSTALLED_AT_COLLECTION.engine.allow_blocks_accessing_environmental_variables + ) + assert workflows_environment.ENABLE_TENSOR_DATA_REPRESENTATION is ( + _INSTALLED_AT_COLLECTION.tensor.representation_enabled + ) + + +# -------------------------------------------------------------------------- +# The two new modules read no environment - ONE scanner for the whole phase +# -------------------------------------------------------------------------- + + +def environment_reads(tree: ast.AST) -> List[int]: + """Line numbers of every direct environment read in `tree`. + + ONE rule for the whole phase (round-3 defect 7): a call to `*.getenv(...)`, + a call on `*.environ` (`os.environ.get(...)`, `.setdefault(...)`, ...) and + a SUBSCRIPT of `*.environ` (`os.environ["X"]`). Prose - a docstring that + NAMES `os.environ` - is not a read (round-2 defect 3). Task 5.7's + `test_no_server_env_imports.py` imports this function, and so does its + preflight command, so there is exactly one scanner. + """ + reads = [] + for node in ast.walk(tree): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + if node.func.attr == "getenv": + reads.append(node.lineno) + elif ( + isinstance(node.func.value, ast.Attribute) + and node.func.value.attr == "environ" + ): + reads.append(node.lineno) + elif isinstance(node, ast.Subscript) and isinstance(node.value, ast.Attribute): + if node.value.attr == "environ": + reads.append(node.lineno) + return sorted(reads) + + +def test_environment_reads_sees_calls_and_subscripts_but_not_prose() -> None: + source = ( + '"""Values come from the configuration, never from os.environ."""\n' + "import os\n" + "A = os.getenv('A')\n" + "B = os.environ.get('B')\n" + "C = os.environ['C']\n" + "os.environ.setdefault('D', '1')\n" + ) + assert environment_reads(ast.parse(source)) == [3, 4, 5, 6] + assert environment_reads(ast.parse('"""never from os.environ"""\n')) == [] + + +@pytest.mark.parametrize( + "module_name", + ["inference.core.workflows.configuration", "inference.core.workflows.environment"], +) +def test_the_new_module_imports_nothing_from_the_server_and_reads_no_environment( + module_name, +) -> None: + # Round-2 defect 3: the round-1 gate was a raw substring scan and failed on + # the facade's own docstring. This looks at imports, calls and subscripts. + module = importlib.import_module(module_name) + tree = ast.parse(Path(module.__file__).read_text(encoding="utf-8")) + modules = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + modules.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + modules.add(node.module) + foreign = sorted( + m + for m in modules + if m == "inference" + or (m.startswith("inference.") and not m.startswith("inference.core.workflows")) + ) + assert not foreign, (module_name, foreign) + assert "os" not in modules, (module_name, "os must not be imported") + assert environment_reads(tree) == [], (module_name, environment_reads(tree)) From 2e7309f0ad6e838baa8341b493d2fa6f9eaf05d4 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:49:56 +0200 Subject: [PATCH 40/86] feat(server): build the Workflows configuration from env and wire it at the composition roots Adds inference/core/interfaces/workflows_configuration.py (build_configuration_from_env, memoised server_workflows_configuration, install_workflows_configuration), installs it from inference/core/__init__.py so it runs before any inference.core.workflows.* module body, and binds "workflows_core.configuration" at the four composition roots (HTTP run + validate literals; inference_pipeline.py and local_image_adapter.py via setdefault so a caller-supplied configuration still reaches the engine's conflict check). Adds the configured_workflows fixture to tests/workflows/conftest.py and the 86-case parity + wiring suite in tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py. Baseline rows removed: 0. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/__init__.py | 14 + inference/core/interfaces/http/http_api.py | 5 + .../interfaces/stream/inference_pipeline.py | 10 + .../interfaces/workflows_configuration.py | 143 ++++ .../lib/workflows/local_image_adapter.py | 6 + .../test_workflows_configuration.py | 653 ++++++++++++++++++ tests/workflows/conftest.py | 35 + 7 files changed, 866 insertions(+) create mode 100644 inference/core/interfaces/workflows_configuration.py create mode 100644 tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py diff --git a/inference/core/__init__.py b/inference/core/__init__.py index 2929888369..d58552817b 100644 --- a/inference/core/__init__.py +++ b/inference/core/__init__.py @@ -5,9 +5,23 @@ from packaging import version as packaging_version from inference.core.env import DISABLE_VERSION_CHECK, VERSION_CHECK_MODE + +# Hand the Workflows module its configuration before anything can import it. +# This is the ONE place the ordering is guaranteed: importing any +# `inference.core.workflows.*` module runs this file to completion first, so +# `core_steps/loader.py`'s import-time tensor branches and every module-level +# constant in `inference/core/workflows/environment.py` see the server's +# values. `inference.core.interfaces.workflows_configuration` imports only +# `inference.core.env` (already fully imported above) and the dependency-free +# `inference.core.workflows.configuration`, so this adds no import weight. +from inference.core.interfaces.workflows_configuration import ( + install_workflows_configuration, +) from inference.core.logger import logger from inference.core.version import __version__ +install_workflows_configuration() + latest_release = None last_checked = 0 cache_duration = 86400 # 24 hours diff --git a/inference/core/interfaces/http/http_api.py b/inference/core/interfaces/http/http_api.py index 4ea5e00b81..773b989175 100644 --- a/inference/core/interfaces/http/http_api.py +++ b/inference/core/interfaces/http/http_api.py @@ -301,6 +301,9 @@ deregister_webrtc_session, refresh_webrtc_session, ) +from inference.core.interfaces.workflows_configuration import ( + server_workflows_configuration, +) from inference.core.interfaces.workflows_execution_observer import ( UsageTrackingExecutionObserver, ) @@ -1613,6 +1616,7 @@ def process_workflow_inference_request( workflow_request.inner_workflow_dispatch_depth ), "workflows_core.execution_observer": UsageTrackingExecutionObserver(), + "workflows_core.configuration": server_workflows_configuration(), } ) # One codec for both injection paths - the engine deserializes the @@ -2576,6 +2580,7 @@ def validate_workflow( "workflows_core.background_tasks": None, "workflows_core.step_execution_mode": step_execution_mode, "workflows_core.execution_observer": UsageTrackingExecutionObserver(), + "workflows_core.configuration": server_workflows_configuration(), } ) bind_image_codec(workflow_init_parameters) diff --git a/inference/core/interfaces/stream/inference_pipeline.py b/inference/core/interfaces/stream/inference_pipeline.py index 3f0923a684..5d9238931d 100644 --- a/inference/core/interfaces/stream/inference_pipeline.py +++ b/inference/core/interfaces/stream/inference_pipeline.py @@ -700,6 +700,9 @@ def init_with_workflow( WorkflowRunner, wrap_workflow_runner_for_stream_pipeline, ) + from inference.core.interfaces.workflows_configuration import ( + server_workflows_configuration, + ) from inference.core.interfaces.workflows_execution_observer import ( UsageTrackingExecutionObserver, ) @@ -762,6 +765,13 @@ def init_with_workflow( # already carry an explicit inner_workflow_spec_resolver. install_workflows_platform_bindings(workflow_init_parameters) bind_image_codec(workflow_init_parameters) + # setdefault, not assignment: a caller-supplied configuration must + # reach `ExecutionEngine.init`, where a mismatch with the installed + # process configuration is reported. Overwriting it here would hide + # the mis-wiring the check exists to catch. + workflow_init_parameters.setdefault( + "workflows_core.configuration", server_workflows_configuration() + ) execution_engine = ExecutionEngine.init( workflow_definition=workflow_specification, init_parameters=workflow_init_parameters, diff --git a/inference/core/interfaces/workflows_configuration.py b/inference/core/interfaces/workflows_configuration.py new file mode 100644 index 0000000000..135c941904 --- /dev/null +++ b/inference/core/interfaces/workflows_configuration.py @@ -0,0 +1,143 @@ +"""Builds the Workflows configuration from the server's resolved settings. + +`inference/core/env.py` does the real work - `str2bool`, `.lower()`, the +`OFFLINE_MODE` / `SECURE_GATEWAY` rewrites of `WORKFLOWS_STEP_EXECUTION_MODE` +(`env.py:905-931`), `WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE` (`:953-960`) and +`SAM3_EXEC_MODE` (`:407-414`), the `PROJECT`-dependent hosted URLs, the +api-key-transport validation and the torch-device materialisation +(`:1524-1547`). This module ONLY copies the resolved attributes, so none of +that logic is duplicated and none of it can drift. + +`install_workflows_configuration()` is called from `inference/core/__init__.py`. +That is an import-system guarantee, not a convention: importing any +`inference.core.workflows.X` executes `inference/core/__init__.py` to +completion first, so the configuration is installed before any workflows +module body - including `core_steps/loader.py`'s tensor-mode branches - runs. +""" + +from typing import Optional + +from inference.core import env +from inference.core.workflows.configuration import ( + DebugConfiguration, + EngineConfiguration, + FontsConfiguration, + ModalConfiguration, + ModelsConfiguration, + PlatformConfiguration, + RemoteExecutionConfiguration, + SecretsConfiguration, + TensorConfiguration, + WorkflowsConfiguration, + configure_process, +) + +_SERVER_CONFIGURATION: Optional[WorkflowsConfiguration] = None + + +def build_configuration_from_env() -> WorkflowsConfiguration: + return WorkflowsConfiguration( + engine=EngineConfiguration( + step_execution_mode=env.WORKFLOWS_STEP_EXECUTION_MODE, + async_future_result_timeout=env.WORKFLOWS_ASYNC_FUTURE_RESULT_TIMEOUT, + max_inner_workflow_depth=env.WORKFLOWS_MAX_INNER_WORKFLOW_DEPTH, + max_inner_workflow_count=env.WORKFLOWS_MAX_INNER_WORKFLOW_COUNT, + allow_custom_python_execution=env.ALLOW_CUSTOM_PYTHON_EXECUTION_IN_WORKFLOWS, + custom_python_execution_mode=env.WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE, + allow_blocks_accessing_local_storage=env.ALLOW_WORKFLOW_BLOCKS_ACCESSING_LOCAL_STORAGE, + allow_blocks_accessing_environmental_variables=env.ALLOW_WORKFLOW_BLOCKS_ACCESSING_ENVIRONMENTAL_VARIABLES, + blocks_write_directory=env.WORKFLOW_BLOCKS_WRITE_DIRECTORY, + disabled_block_types=tuple(env.WORKFLOW_DISABLED_BLOCK_TYPES), + disabled_block_patterns=tuple(env.WORKFLOW_DISABLED_BLOCK_PATTERNS), + ), + tensor=TensorConfiguration( + representation_enabled=env.ENABLE_TENSOR_DATA_REPRESENTATION, + image_tensor_device=env.WORKFLOWS_IMAGE_TENSOR_DEVICE, + visualisation_validate_owners=env.WORKFLOWS_TENSOR_VISUALISATION_VALIDATE_OWNERS, + sam_video_mask_representation=env.WORKFLOWS_SAM_VIDEO_MASK_REPRESENTATION, + enforce_dense_instance_masks=env.WORKFLOWS_ENFORCE_DENSE_INSTANCE_MASKS, + ), + remote=RemoteExecutionConfiguration( + api_target=env.WORKFLOWS_REMOTE_API_TARGET, + api_key_transport=env.WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + local_inference_api_url=env.LOCAL_INFERENCE_API_URL, + hosted_detect_url=env.HOSTED_DETECT_URL, + hosted_classification_url=env.HOSTED_CLASSIFICATION_URL, + hosted_instance_segmentation_url=env.HOSTED_INSTANCE_SEGMENTATION_URL, + hosted_semantic_segmentation_url=env.HOSTED_SEMANTIC_SEGMENTATION_URL, + hosted_core_model_url=env.HOSTED_CORE_MODEL_URL, + max_step_batch_size=env.WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, + max_step_concurrent_requests=env.WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, + ), + platform=PlatformConfiguration( + api_base_url=env.API_BASE_URL, + offline_mode=env.OFFLINE_MODE, + secure_gateway=env.SECURE_GATEWAY, + gcp_serverless=env.GCP_SERVERLESS, + ), + fonts=FontsConfiguration( + allow_download=env.ALLOW_WORKFLOWS_FONTS_DOWNLOAD, + model_cache_dir=env.MODEL_CACHE_DIR, + ), + models=ModelsConfiguration( + lmm_enabled=env.LMM_ENABLED, + clip_version_id=env.CLIP_VERSION_ID, + core_model_sam2_enabled=env.CORE_MODEL_SAM2_ENABLED, + core_model_sam3_enabled=env.CORE_MODEL_SAM3_ENABLED, + core_model_pe_enabled=env.CORE_MODEL_PE_ENABLED, + core_model_gaze_enabled=env.CORE_MODEL_GAZE_ENABLED, + sam3_exec_mode=env.SAM3_EXEC_MODE, + sam3_3d_objects_enabled=env.SAM3_3D_OBJECTS_ENABLED, + florence2_enabled=env.FLORENCE2_ENABLED, + qwen_2_5_enabled=env.QWEN_2_5_ENABLED, + qwen_3_enabled=env.QWEN_3_ENABLED, + qwen_3_5_enabled=env.QWEN_3_5_ENABLED, + smolvlm2_enabled=env.SMOLVLM2_ENABLED, + moondream2_enabled=env.MOONDREAM2_ENABLED, + depth_estimation_enabled=env.DEPTH_ESTIMATION_ENABLED, + cosmos3_enabled=env.COSMOS3_ENABLED, + glm_ocr_enabled=env.GLM_OCR_ENABLED, + ), + modal=ModalConfiguration( + token_id=env.MODAL_TOKEN_ID, + token_secret=env.MODAL_TOKEN_SECRET, + workspace_name=env.MODAL_WORKSPACE_NAME, + allow_anonymous_execution=env.MODAL_ALLOW_ANONYMOUS_EXECUTION, + anonymous_workspace_name=env.MODAL_ANONYMOUS_WORKSPACE_NAME, + app_name=env.WEBEXEC_MODAL_APP_NAME, + executor_idle_ttl_seconds=env.WEBEXEC_MODAL_EXECUTOR_IDLE_TTL_SECONDS, + jpeg_quality=env.WEBEXEC_JPEG_QUALITY, + transport=env.WEBEXEC_TRANSPORT, + ws_connect_timeout_seconds=env.WEBEXEC_WS_CONNECT_TIMEOUT_SECONDS, + ws_read_timeout_seconds=env.WEBEXEC_WS_READ_TIMEOUT_SECONDS, + ws_connection_pool_size=env.WEBEXEC_WS_CONNECTION_POOL_SIZE, + ws_fail_on_session_loss=env.WEBEXEC_WS_FAIL_ON_SESSION_LOSS, + ws_idle_release_seconds=env.WEBEXEC_WS_IDLE_RELEASE_SECONDS, + ), + secrets=SecretsConfiguration( + api_key=env.API_KEY, + roboflow_internal_service_name=env.ROBOFLOW_INTERNAL_SERVICE_NAME, + roboflow_internal_service_secret=env.ROBOFLOW_INTERNAL_SERVICE_SECRET, + ), + debug=DebugConfiguration( + output_dir=env.INFERENCE_DEBUG_OUTPUT_DIR, + ), + ) + + +def server_workflows_configuration() -> WorkflowsConfiguration: + """The one configuration this process uses. Built once, reused thereafter. + + Memoised so every composition root passes the SAME object: the engine's + `ensure_process_configuration_matches` then short-circuits on identity, and + a repeated `install_workflows_configuration()` is a no-op. + """ + global _SERVER_CONFIGURATION + if _SERVER_CONFIGURATION is None: + _SERVER_CONFIGURATION = build_configuration_from_env() + return _SERVER_CONFIGURATION + + +def install_workflows_configuration() -> None: + """Hand the configuration to workflows. Idempotent by object identity.""" + configure_process(server_workflows_configuration()) diff --git a/inference_cli/lib/workflows/local_image_adapter.py b/inference_cli/lib/workflows/local_image_adapter.py index 595945b7c4..1956ecabfe 100644 --- a/inference_cli/lib/workflows/local_image_adapter.py +++ b/inference_cli/lib/workflows/local_image_adapter.py @@ -12,6 +12,9 @@ from inference.core.interfaces.roboflow_platform_client import ( install_workflows_platform_bindings, ) +from inference.core.interfaces.workflows_configuration import ( + server_workflows_configuration, +) from inference.core.interfaces.workflows_execution_observer import ( UsageTrackingExecutionObserver, ) @@ -466,6 +469,9 @@ def _run_workflow_for_single_image_with_inference( # replaced here on Path A while the process registry kept the guarded one # (round-2 Defect 1). bind_image_codec(workflow_init_parameters) + workflow_init_parameters.setdefault( + "workflows_core.configuration", server_workflows_configuration() + ) execution_engine = ExecutionEngine.init( workflow_definition=workflow_specification, init_parameters=workflow_init_parameters, diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py b/tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py new file mode 100644 index 0000000000..d841950a75 --- /dev/null +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py @@ -0,0 +1,653 @@ +import ast +import dataclasses +import json +import os +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import cv2 +import numpy as np +import pytest +from fastapi.testclient import TestClient + +# Module level on purpose (round-5 defect 1, D5 rule): the facade binds its 67 +# constants at its FIRST import from whatever the registry holds at that +# instant. Importing it here, at collection time, right after `inference.core` +# has installed the server configuration, guarantees the values compared below +# are the server's - never a standalone default frozen in by a later reset. +import inference.core.workflows.environment as workflows_environment +from inference.core import env +from inference.core.interfaces.workflows_configuration import ( + build_configuration_from_env, + install_workflows_configuration, + server_workflows_configuration, +) +from inference.core.workflows.execution_engine.core import ExecutionEngine +from tests.inference.unit_tests.core.interfaces.http.test_http_api import ( + _build_plain_interface, +) + +REPO_ROOT = Path(__file__).resolve().parents[5] +WORKFLOW_SPECIFICATION = {"version": "1.0", "inputs": [], "steps": [], "outputs": []} + +# (env.py attribute, how the same value is reached on the configuration). +# The drift gate below compares these keys with the facade's exports, so a +# symbol added to one and not the other fails - a hand-typed length assertion +# would not have (round-1 defect 9). +FIELDS = [ + ("WORKFLOWS_STEP_EXECUTION_MODE", lambda c: c.engine.step_execution_mode), + ( + "WORKFLOWS_ASYNC_FUTURE_RESULT_TIMEOUT", + lambda c: c.engine.async_future_result_timeout, + ), + ("WORKFLOWS_MAX_INNER_WORKFLOW_DEPTH", lambda c: c.engine.max_inner_workflow_depth), + ("WORKFLOWS_MAX_INNER_WORKFLOW_COUNT", lambda c: c.engine.max_inner_workflow_count), + ( + "ALLOW_CUSTOM_PYTHON_EXECUTION_IN_WORKFLOWS", + lambda c: c.engine.allow_custom_python_execution, + ), + ( + "WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE", + lambda c: c.engine.custom_python_execution_mode, + ), + ( + "ALLOW_WORKFLOW_BLOCKS_ACCESSING_LOCAL_STORAGE", + lambda c: c.engine.allow_blocks_accessing_local_storage, + ), + ( + "ALLOW_WORKFLOW_BLOCKS_ACCESSING_ENVIRONMENTAL_VARIABLES", + lambda c: c.engine.allow_blocks_accessing_environmental_variables, + ), + ("WORKFLOW_BLOCKS_WRITE_DIRECTORY", lambda c: c.engine.blocks_write_directory), + ("WORKFLOW_DISABLED_BLOCK_TYPES", lambda c: list(c.engine.disabled_block_types)), + ( + "WORKFLOW_DISABLED_BLOCK_PATTERNS", + lambda c: list(c.engine.disabled_block_patterns), + ), + ("ENABLE_TENSOR_DATA_REPRESENTATION", lambda c: c.tensor.representation_enabled), + ("WORKFLOWS_IMAGE_TENSOR_DEVICE", lambda c: c.tensor.image_tensor_device), + ( + "WORKFLOWS_TENSOR_VISUALISATION_VALIDATE_OWNERS", + lambda c: c.tensor.visualisation_validate_owners, + ), + ( + "WORKFLOWS_SAM_VIDEO_MASK_REPRESENTATION", + lambda c: c.tensor.sam_video_mask_representation, + ), + ( + "WORKFLOWS_ENFORCE_DENSE_INSTANCE_MASKS", + lambda c: c.tensor.enforce_dense_instance_masks, + ), + ("WORKFLOWS_REMOTE_API_TARGET", lambda c: c.remote.api_target), + ("WORKFLOWS_REMOTE_API_KEY_TRANSPORT", lambda c: c.remote.api_key_transport), + ("LOCAL_INFERENCE_API_URL", lambda c: c.remote.local_inference_api_url), + ("HOSTED_DETECT_URL", lambda c: c.remote.hosted_detect_url), + ("HOSTED_CLASSIFICATION_URL", lambda c: c.remote.hosted_classification_url), + ( + "HOSTED_INSTANCE_SEGMENTATION_URL", + lambda c: c.remote.hosted_instance_segmentation_url, + ), + ( + "HOSTED_SEMANTIC_SEGMENTATION_URL", + lambda c: c.remote.hosted_semantic_segmentation_url, + ), + ("HOSTED_CORE_MODEL_URL", lambda c: c.remote.hosted_core_model_url), + ( + "WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE", + lambda c: c.remote.max_step_batch_size, + ), + ( + "WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS", + lambda c: c.remote.max_step_concurrent_requests, + ), + ("API_BASE_URL", lambda c: c.platform.api_base_url), + ("OFFLINE_MODE", lambda c: c.platform.offline_mode), + ("SECURE_GATEWAY", lambda c: c.platform.secure_gateway), + ("GCP_SERVERLESS", lambda c: c.platform.gcp_serverless), + ("ALLOW_WORKFLOWS_FONTS_DOWNLOAD", lambda c: c.fonts.allow_download), + ("MODEL_CACHE_DIR", lambda c: c.fonts.model_cache_dir), + ("LMM_ENABLED", lambda c: c.models.lmm_enabled), + ("CLIP_VERSION_ID", lambda c: c.models.clip_version_id), + ("CORE_MODEL_SAM2_ENABLED", lambda c: c.models.core_model_sam2_enabled), + ("CORE_MODEL_SAM3_ENABLED", lambda c: c.models.core_model_sam3_enabled), + ("CORE_MODEL_PE_ENABLED", lambda c: c.models.core_model_pe_enabled), + ("CORE_MODEL_GAZE_ENABLED", lambda c: c.models.core_model_gaze_enabled), + ("SAM3_EXEC_MODE", lambda c: c.models.sam3_exec_mode), + ("SAM3_3D_OBJECTS_ENABLED", lambda c: c.models.sam3_3d_objects_enabled), + ("FLORENCE2_ENABLED", lambda c: c.models.florence2_enabled), + ("QWEN_2_5_ENABLED", lambda c: c.models.qwen_2_5_enabled), + ("QWEN_3_ENABLED", lambda c: c.models.qwen_3_enabled), + ("QWEN_3_5_ENABLED", lambda c: c.models.qwen_3_5_enabled), + ("SMOLVLM2_ENABLED", lambda c: c.models.smolvlm2_enabled), + ("MOONDREAM2_ENABLED", lambda c: c.models.moondream2_enabled), + ("DEPTH_ESTIMATION_ENABLED", lambda c: c.models.depth_estimation_enabled), + ("COSMOS3_ENABLED", lambda c: c.models.cosmos3_enabled), + ("GLM_OCR_ENABLED", lambda c: c.models.glm_ocr_enabled), + ("MODAL_TOKEN_ID", lambda c: c.modal.token_id), + ("MODAL_TOKEN_SECRET", lambda c: c.modal.token_secret), + ("MODAL_WORKSPACE_NAME", lambda c: c.modal.workspace_name), + ("MODAL_ALLOW_ANONYMOUS_EXECUTION", lambda c: c.modal.allow_anonymous_execution), + ("MODAL_ANONYMOUS_WORKSPACE_NAME", lambda c: c.modal.anonymous_workspace_name), + ("WEBEXEC_MODAL_APP_NAME", lambda c: c.modal.app_name), + ( + "WEBEXEC_MODAL_EXECUTOR_IDLE_TTL_SECONDS", + lambda c: c.modal.executor_idle_ttl_seconds, + ), + ("WEBEXEC_JPEG_QUALITY", lambda c: c.modal.jpeg_quality), + ("WEBEXEC_TRANSPORT", lambda c: c.modal.transport), + ( + "WEBEXEC_WS_CONNECT_TIMEOUT_SECONDS", + lambda c: c.modal.ws_connect_timeout_seconds, + ), + ("WEBEXEC_WS_READ_TIMEOUT_SECONDS", lambda c: c.modal.ws_read_timeout_seconds), + ("WEBEXEC_WS_CONNECTION_POOL_SIZE", lambda c: c.modal.ws_connection_pool_size), + ("WEBEXEC_WS_FAIL_ON_SESSION_LOSS", lambda c: c.modal.ws_fail_on_session_loss), + ("WEBEXEC_WS_IDLE_RELEASE_SECONDS", lambda c: c.modal.ws_idle_release_seconds), + ("API_KEY", lambda c: c.secrets.api_key), + ( + "ROBOFLOW_INTERNAL_SERVICE_NAME", + lambda c: c.secrets.roboflow_internal_service_name, + ), + ( + "ROBOFLOW_INTERNAL_SERVICE_SECRET", + lambda c: c.secrets.roboflow_internal_service_secret, + ), + ("INFERENCE_DEBUG_OUTPUT_DIR", lambda c: c.debug.output_dir), +] + +ROOTS = { + "inference/core/interfaces/http/http_api.py": 2, + "inference/core/interfaces/stream/inference_pipeline.py": 1, + "inference_cli/lib/workflows/local_image_adapter.py": 1, +} +CONFIGURATION_KEY = "workflows_core.configuration" + + +# -------------------------------------------------------------------------- +# Inventory drift gate +# -------------------------------------------------------------------------- + + +def test_the_field_table_matches_the_facade_exports() -> None: + exported = { + name + for name in vars(workflows_environment) + if name.isupper() and not name.startswith("_") + } + tabled = {name for name, _ in FIELDS} + assert tabled == exported, { + "missing_from_table": sorted(exported - tabled), + "missing_from_facade": sorted(tabled - exported), + } + assert len(tabled) == 67, len(tabled) + + +def test_every_name_workflows_imports_from_the_facade_is_exported() -> None: + # Live AST scan: catches a workflows module importing a name the facade + # does not define, and stays meaningful after the codemod lands. + exported = { + name + for name in vars(workflows_environment) + if name.isupper() and not name.startswith("_") + } + workflows_root = REPO_ROOT / "inference" / "core" / "workflows" + requested = set() + for path in sorted(workflows_root.rglob("*.py")): + if "__pycache__" in str(path): + continue + tree = ast.parse(path.read_bytes().decode("utf-8"), filename=str(path)) + for node in ast.walk(tree): + if ( + isinstance(node, ast.ImportFrom) + and node.module == "inference.core.workflows.environment" + ): + requested.update(alias.name for alias in node.names) + assert requested <= exported, sorted(requested - exported) + + +def test_every_symbol_still_imported_from_env_is_in_the_field_table() -> None: + # Vacuous once Task 5.7 lands; until then it proves nothing is left behind. + workflows_root = REPO_ROOT / "inference" / "core" / "workflows" + deferred = ( + workflows_root / "core_steps" / "sinks" / "roboflow", + workflows_root / "core_steps" / "integrations" / "roboflow", + ) + remaining = set() + for path in sorted(workflows_root.rglob("*.py")): + if "__pycache__" in str(path) or any(d in path.parents for d in deferred): + continue + tree = ast.parse(path.read_bytes().decode("utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module == "inference.core.env": + remaining.update(alias.name for alias in node.names) + assert remaining <= {name for name, _ in FIELDS}, sorted( + remaining - {name for name, _ in FIELDS} + ) + + +# -------------------------------------------------------------------------- +# Field-by-field parity with inference.core.env +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("name, reader", FIELDS, ids=[name for name, _ in FIELDS]) +def test_server_configuration_equals_env_field_by_field(name, reader) -> None: + expected = getattr(env, name) + actual = reader(build_configuration_from_env()) + assert actual == expected, name + assert type(actual) is type(expected), (name, type(actual), type(expected)) + + +def test_the_facade_equals_env_field_by_field() -> None: + # The 151 workflows files read the FACADE, so the facade - not just the + # builder - is what has to agree with env.py in the running server. + for name, _ in FIELDS: + expected = getattr(env, name) + actual = getattr(workflows_environment, name) + assert actual == expected, name + assert type(actual) is type(expected), (name, type(actual), type(expected)) + + +def test_server_workflows_configuration_is_memoised() -> None: + assert server_workflows_configuration() is server_workflows_configuration() + + +def test_install_is_idempotent() -> None: + install_workflows_configuration() + install_workflows_configuration() + from inference.core.workflows.configuration import get_configuration + + assert get_configuration() is server_workflows_configuration() + + +def test_importing_inference_core_installs_before_any_workflows_module_loads() -> None: + # The ordering guarantee this design rests on: `inference/core/__init__.py` + # runs to completion before any `inference.core.workflows.*` body does. + # BOTH flags are pinned - `env.py:1486` ANDs `USE_INFERENCE_MODELS` into + # the tensor flag (round-3 defect 5) - and the child reports env.py's + # EFFECTIVE flag, so a gate-induced False can never pass as "both agree". + script = ( + "import json\n" + "import inference.core.workflows.environment as environment\n" + "from inference.core import env\n" + "print(json.dumps([environment.ENABLE_TENSOR_DATA_REPRESENTATION," + " env.ENABLE_TENSOR_DATA_REPRESENTATION]))\n" + ) + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=REPO_ROOT, + capture_output=True, + text=True, + env={ + **os.environ, + "PYTHONPATH": str(REPO_ROOT / "inference_models"), + "ENABLE_TENSOR_DATA_REPRESENTATION": "True", + "USE_INFERENCE_MODELS": "True", + }, + ) + assert completed.returncode == 0, completed.stderr + facade_value, env_value = json.loads(completed.stdout.strip().splitlines()[-1]) + assert ( + env_value is True + ), "USE_INFERENCE_MODELS is pinned: the effective flag must be on" + assert facade_value is True + assert facade_value == env_value + + +# -------------------------------------------------------------------------- +# Composition-root wiring - AST-precise, not "the string appears somewhere" +# -------------------------------------------------------------------------- + + +def _init_parameter_dicts_reaching_engine_init(source: str): + """For every `ExecutionEngine.init(...)` call, return the set of literal + init-parameter keys its `init_parameters=` argument carries. + + Resolves the argument to a Name, then collects every `{...}` literal + assigned to that name - DIRECTLY, or as the first argument of a helper + call such as Phase 9's `install_workflows_platform_bindings({...})` + (round-3 defect 1: under R-U both HTTP literals are already wrapped) - + and every `name["key"] = ...` / `name.setdefault("key", ...)` in the same + enclosing function. Round-1 defect 7: a substring search anywhere in the + file passes even when one of the two HTTP roots is missing the key, or + when the key only appears in a comment. + """ + tree = ast.parse(source) + parents = {} + for node in ast.walk(tree): + for child in ast.iter_child_nodes(node): + parents[child] = node + + def enclosing_function(node): + while node in parents: + node = parents[node] + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Module)): + return node + return None + + def literal_of(value): + if isinstance(value, ast.Dict): + return value + if ( + isinstance(value, ast.Call) + and value.args + and isinstance(value.args[0], ast.Dict) + ): + return value.args[0] + return None + + results = [] + for node in ast.walk(tree): + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "init" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "ExecutionEngine" + ): + continue + argument = next( + (kw.value for kw in node.keywords if kw.arg == "init_parameters"), None + ) + assert isinstance(argument, ast.Name), ast.dump(node) + scope = enclosing_function(node) + keys = set() + for inner in ast.walk(scope): + if isinstance(inner, ast.Assign): + literal = literal_of(inner.value) + for target in inner.targets: + if ( + isinstance(target, ast.Name) + and target.id == argument.id + and literal is not None + ): + keys.update( + k.value for k in literal.keys if isinstance(k, ast.Constant) + ) + if ( + isinstance(target, ast.Subscript) + and isinstance(target.value, ast.Name) + and target.value.id == argument.id + and isinstance(target.slice, ast.Constant) + ): + keys.add(target.slice.value) + if ( + isinstance(inner, ast.Call) + and isinstance(inner.func, ast.Attribute) + and inner.func.attr == "setdefault" + and isinstance(inner.func.value, ast.Name) + and inner.func.value.id == argument.id + and inner.args + and isinstance(inner.args[0], ast.Constant) + ): + keys.add(inner.args[0].value) + results.append(keys) + return results + + +def test_the_root_scanner_reads_a_wrapped_and_a_plain_literal_alike() -> None: + """Round-3 defect 1: under R-U Phase 9 has already wrapped both HTTP + literals in `install_workflows_platform_bindings({...})` + (DECONTAMINATION.PLAN.PHASE-9.MD Task 9.3 Step 10), and the round-2 scanner + returned `[set(), set()]` for that shape. Five synthetic roots: plain + literal, wrapped literal, subscript assignment, `setdefault`, and a wrapped + literal WITHOUT the key - which must still be reported missing.""" + source = ( + "def plain():\n" + " params = {'workflows_core.configuration': 1}\n" + " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" + "def wrapped():\n" + " params = install_workflows_platform_bindings({\n" + " 'workflows_core.api_key': 1,\n" + " 'workflows_core.configuration': 1,\n" + " })\n" + " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" + "def assigned(params):\n" + " params['workflows_core.configuration'] = 1\n" + " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" + "def defaulted(params):\n" + " params.setdefault('workflows_core.configuration', 1)\n" + " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" + "def missing():\n" + " params = install_workflows_platform_bindings({'workflows_core.api_key': 1})\n" + " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" + ) + per_call = _init_parameter_dicts_reaching_engine_init(source) + assert [CONFIGURATION_KEY in keys for keys in per_call] == [ + True, + True, + True, + True, + False, + ] + + +@pytest.mark.parametrize("relative, expected_calls", sorted(ROOTS.items())) +def test_every_engine_call_at_every_root_carries_the_configuration( + relative, expected_calls +) -> None: + source = (REPO_ROOT / relative).read_text(encoding="utf-8") + per_call_keys = _init_parameter_dicts_reaching_engine_init(source) + assert len(per_call_keys) == expected_calls, relative + for keys in per_call_keys: + assert CONFIGURATION_KEY in keys, (relative, sorted(keys)) + + +def test_http_run_route_binds_the_server_configuration(monkeypatch) -> None: + """Execution proof for HTTP root #1 (`http_api.py:1597`). + + Round-2 defect 2: the AST test alone accepts + `"workflows_core.configuration": None`. This drives the real route with + `_build_plain_interface` + `TestClient` - the harness + `tests/inference/unit_tests/core/interfaces/http/test_http_api.py:1766` + already provides - and asserts the captured object's IDENTITY. + """ + import inference.core.interfaces.http.http_api as http_api + + interface, _ = _build_plain_interface(monkeypatch) + engine = MagicMock() + engine.run.return_value = [] + execution_engine_mock = MagicMock() + execution_engine_mock.init.return_value = engine + monkeypatch.setattr(http_api, "ExecutionEngine", execution_engine_mock) + + with TestClient(interface.app) as client: + response = client.post( + "/workflows/run", + headers={"Authorization": "Bearer header-key"}, + json={"specification": WORKFLOW_SPECIFICATION, "inputs": {}}, + ) + + assert response.status_code == 200, response.text + init_parameters = execution_engine_mock.init.call_args.kwargs["init_parameters"] + assert init_parameters[CONFIGURATION_KEY] is server_workflows_configuration() + + +def test_http_validate_route_binds_the_server_configuration(monkeypatch) -> None: + """Execution proof for HTTP root #2 (`http_api.py:2546`).""" + import inference.core.interfaces.http.http_api as http_api + + interface, _ = _build_plain_interface(monkeypatch) + execution_engine_mock = MagicMock() + monkeypatch.setattr(http_api, "ExecutionEngine", execution_engine_mock) + + with TestClient(interface.app) as client: + response = client.post( + "/workflows/validate?api_key=some-key", json=WORKFLOW_SPECIFICATION + ) + + assert response.status_code == 200, response.text + init_parameters = execution_engine_mock.init.call_args.kwargs["init_parameters"] + assert init_parameters[CONFIGURATION_KEY] is server_workflows_configuration() + + +def test_the_pipeline_binds_the_server_configuration(monkeypatch) -> None: + """Execution proof for the pipeline root (`inference_pipeline.py:751`). + + Round-2 defect 2: the round-1 test patched `inference_pipeline.ExecutionEngine`, + which does not exist - the name is imported INSIDE `init_with_workflow` + (`inference_pipeline.py:704`), so the test died with `AttributeError` + before reaching its assertion. Patching `init` on the DEFINING class works + regardless of where the name is imported. + """ + from inference.core.interfaces.stream.inference_pipeline import InferencePipeline + + execution_engine_init = MagicMock(return_value=MagicMock()) + monkeypatch.setattr(ExecutionEngine, "init", execution_engine_init) + monkeypatch.setattr( + InferencePipeline, "init_with_custom_logic", MagicMock(return_value=MagicMock()) + ) + + InferencePipeline.init_with_workflow( + video_reference="video.mp4", + workflow_specification={"version": "1.0"}, + model_manager=MagicMock(), + ) + + init_parameters = execution_engine_init.call_args.kwargs["init_parameters"] + assert init_parameters[CONFIGURATION_KEY] is server_workflows_configuration() + + +def test_the_pipeline_preserves_a_caller_supplied_configuration(monkeypatch) -> None: + """`setdefault`, not assignment: a caller-supplied object must reach the + engine so the conflict check - not this file - decides whether it is + acceptable.""" + from inference.core.interfaces.stream.inference_pipeline import InferencePipeline + + supplied = dataclasses.replace(server_workflows_configuration()) + execution_engine_init = MagicMock(return_value=MagicMock()) + monkeypatch.setattr(ExecutionEngine, "init", execution_engine_init) + monkeypatch.setattr( + InferencePipeline, "init_with_custom_logic", MagicMock(return_value=MagicMock()) + ) + + InferencePipeline.init_with_workflow( + video_reference="video.mp4", + workflow_specification={"version": "1.0"}, + model_manager=MagicMock(), + workflow_init_parameters={CONFIGURATION_KEY: supplied}, + ) + + init_parameters = execution_engine_init.call_args.kwargs["init_parameters"] + assert init_parameters[CONFIGURATION_KEY] is supplied + + +def _run_cli_root(tmp_path, monkeypatch, init_params=None) -> dict: + from inference_cli.lib.workflows import local_image_adapter + + image_path = str(tmp_path / "frame.png") + assert cv2.imwrite(image_path, np.zeros((8, 12, 3), dtype=np.uint8)) + captured = {} + + class _FakeEngine: + def run(self, runtime_parameters, serialize_results=False): + return [{"ok": True}] + + def _capturing_init(**kwargs): + captured.update(kwargs) + return _FakeEngine() + + monkeypatch.setattr(local_image_adapter.ExecutionEngine, "init", _capturing_init) + local_image_adapter._run_workflow_for_single_image_with_inference( + model_manager=MagicMock(), + image_path=image_path, + workflow_specification=WORKFLOW_SPECIFICATION, + workflow_id=None, + image_input_name="image", + workflow_parameters=None, + api_key="test-key", + thread_pool_executor=MagicMock(), + max_concurrent_workflows_steps=1, + workflows_execution_engine_init_params=init_params, + ) + return captured + + +def test_the_cli_root_binds_the_server_configuration(tmp_path, monkeypatch) -> None: + captured = _run_cli_root(tmp_path, monkeypatch) + assert ( + captured["init_parameters"][CONFIGURATION_KEY] + is server_workflows_configuration() + ) + + +def test_the_cli_root_preserves_a_caller_supplied_configuration( + tmp_path, monkeypatch +) -> None: + supplied = dataclasses.replace(server_workflows_configuration()) + captured = _run_cli_root( + tmp_path, monkeypatch, init_params={CONFIGURATION_KEY: supplied} + ) + assert captured["init_parameters"][CONFIGURATION_KEY] is supplied + + +def _distinct_from(value): + """An override guaranteed to differ from the registered default, whatever + the deployment set it to (round-4 defect 2: a deployment may legitimately + run with ALLOW_WORKFLOW_BLOCKS_ACCESSING_LOCAL_STORAGE=False or + ALLOW_WORKFLOW_BLOCKS_ACCESSING_ENVIRONMENTAL_VARIABLES=False, env.py:1118-1123, + and the loader forwards those resolved values, loader.py:1594-1596).""" + if isinstance(value, bool): # before str/None: bool is the common case + return not value + if value is None: + return "engine-scoped" + if isinstance(value, str): + return value + "-engine-scoped" + raise AssertionError(f"no override rule for {type(value)!r}") + + +def test_named_init_parameters_still_override_per_engine() -> None: + """The supported per-engine channel, exercised with the real resolver. + + D1 makes the CONFIGURATION process-wide; these named parameters are how a + host varies behaviour per engine, and they must keep winning over the + `REGISTERED_INITIALIZERS` defaults. The default is resolved FIRST and the + override derived from it, so the test proves precedence under permissive + and restrictive deployment flags alike. + """ + from inference.core.workflows.execution_engine.introspection.blocks_loader import ( + load_initializers, + ) + from inference.core.workflows.execution_engine.v1.compiler.steps_initialiser import ( + retrieve_init_parameter_values, + ) + + initializers = load_initializers() + for parameter in [ + "api_key", + "disable_sinks", + "allow_access_to_file_system", + "allowed_write_directory", + "allow_access_to_environmental_variables", + ]: + default_value = retrieve_init_parameter_values( + block_name="step", + block_init_parameter=parameter, + block_source="workflows_core", + explicit_init_parameters={}, + initializers=initializers, + ) + override = _distinct_from(default_value) + assert override != default_value, parameter + resolved = retrieve_init_parameter_values( + block_name="step", + block_init_parameter=parameter, + block_source="workflows_core", + explicit_init_parameters={f"workflows_core.{parameter}": override}, + initializers=initializers, + ) + assert resolved == override, parameter + + +def test_step_execution_mode_override_reaches_the_engine() -> None: + from inference.core.workflows.execution_engine.v1 import core as engine_core + from inference.core.workflows.prototypes.block import StepExecutionMode + + resolved = engine_core._retrieve_step_execution_mode( + init_parameters={"workflows_core.step_execution_mode": StepExecutionMode.REMOTE} + ) + assert resolved is StepExecutionMode.REMOTE + fallback = engine_core._retrieve_step_execution_mode(init_parameters={}) + assert fallback == StepExecutionMode(engine_core.WORKFLOWS_STEP_EXECUTION_MODE) diff --git a/tests/workflows/conftest.py b/tests/workflows/conftest.py index a56b2c3cff..d870ce6c34 100644 --- a/tests/workflows/conftest.py +++ b/tests/workflows/conftest.py @@ -1,7 +1,10 @@ +import dataclasses + import pytest from filelock import FileLock from build_scripts.download_fonts import DEFAULT_TARGET_DIR, download_fonts +from inference.core.workflows import configuration as workflows_configuration @pytest.fixture(scope="session") @@ -20,3 +23,35 @@ def bundled_fonts() -> None: exit_code = download_fonts(DEFAULT_TARGET_DIR, only=[]) assert exit_code == 0, "Failed to provision approved font assets for tests" + + +@pytest.fixture +def configured_workflows(): + """Install a WorkflowsConfiguration for one test and restore the previous one. + + Usage: + def test_x(configured_workflows): + configured_workflows(tensor={"representation_enabled": True}) + + This rebinds only the process registry. Module constants bound in + `inference.core.workflows.environment` (and in every module that imported + them) were frozen at import; a test that needs those to change must also + `importlib.reload` the facade and its consumer - see + `tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_v1.py`. + """ + previous = workflows_configuration._CONFIGURATION + + def _install(**group_overrides): + base = workflows_configuration.default_configuration() + groups = { + name: dataclasses.replace(getattr(base, name), **overrides) + for name, overrides in group_overrides.items() + } + configuration = dataclasses.replace(base, **groups) + workflows_configuration.reset_configuration() + workflows_configuration.configure_process(configuration) + return configuration + + yield _install + with workflows_configuration._INSTALL_LOCK: + workflows_configuration._CONFIGURATION = previous From 912069214313a0adb5ab87daa2d15e5ae003a5b1 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:07:20 +0200 Subject: [PATCH 41/86] test(server): count only hop requests in the redirect-cap parity test `requests_mock.Mocker` patches the transport for every `requests` session in the process, so the global `call_count` also sees the usage collector's sender thread when the whole unit suite runs (observed 34 instead of 3). Assert on the three `hop*.example.com` requests in `request_history` instead. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../core/interfaces/test_workflows_image_codec.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_image_codec.py b/tests/inference/unit_tests/core/interfaces/test_workflows_image_codec.py index 7b8ea3fc28..221287466e 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_image_codec.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_image_codec.py @@ -251,8 +251,18 @@ def test_adapter_enforces_the_redirect_hop_cap(requests_mock: Mocker) -> None: CODEC.fetch_url("https://hop0.example.com/image.jpg") # `range(max_redirects + 1)` (url_input.py:366) allows 3 requests before - # raising: hop0, hop1, hop2. - assert requests_mock.call_count == 3 + # raising: hop0, hop1, hop2. Count only the hop requests: `Mocker` patches + # the transport for EVERY `requests` session in the process, so background + # traffic (the usage collector's sender thread) would inflate a global + # `call_count` when the whole suite runs. + hop_requests = [ + request.url + for request in requests_mock.request_history + if request.hostname.startswith("hop") + ] + assert hop_requests == [ + f"https://hop{index}.example.com/image.jpg" for index in range(3) + ] # -------------------------------------------------------------------------- From c8093ccc1495169ef38f24ed8a65866fcb6048bc Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:21:24 +0200 Subject: [PATCH 42/86] refactor(workflows): loader reads the injected configuration, not inference.core.env core_steps/loader.py now imports its 9 flags from inference.core.workflows.environment and registers REGISTERED_INITIALIZERS["configuration"] via get_configuration(), so the whole WorkflowsConfiguration is available to core-sourced blocks without a direct inference.core.env import. Adds an exhaustive before/after registration-identity gate (scripts/verify_loader_registration_parity.py, both tensor modes) and a mode-parity test, and rewrites the one test that reloaded the loader to reconfigure the process instead of monkeypatching inference.core.env. Baseline: 220 -> 219. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/workflows/core_steps/loader.py | 16 ++- scripts/verify_loader_registration_parity.py | 122 ++++++++++++++++++ .../roboflow/action_recognition/test_v1.py | 34 +++-- .../test_loader_tensor_mode_parity.py | 110 ++++++++++++++++ .../unit_tests/decontamination_baseline.txt | 3 +- 5 files changed, 271 insertions(+), 14 deletions(-) create mode 100644 scripts/verify_loader_registration_parity.py create mode 100644 tests/workflows/unit_tests/core_steps/test_loader_tensor_mode_parity.py diff --git a/inference/core/workflows/core_steps/loader.py b/inference/core/workflows/core_steps/loader.py index 83ae92db20..b0765b4f84 100644 --- a/inference/core/workflows/core_steps/loader.py +++ b/inference/core/workflows/core_steps/loader.py @@ -1,6 +1,10 @@ from typing import List, Type -from inference.core.env import ( +from inference.core.workflows.configuration import get_configuration +from inference.core.workflows.core_steps.analytics.data_aggregator.v1 import ( + DataAggregatorBlockV1, +) +from inference.core.workflows.environment import ( ALLOW_WORKFLOW_BLOCKS_ACCESSING_ENVIRONMENTAL_VARIABLES, ALLOW_WORKFLOW_BLOCKS_ACCESSING_LOCAL_STORAGE, API_KEY, @@ -12,9 +16,6 @@ WORKFLOWS_INNER_WORKFLOW_REMOTE_TARGET, WORKFLOWS_STEP_EXECUTION_MODE, ) -from inference.core.workflows.core_steps.analytics.data_aggregator.v1 import ( - DataAggregatorBlockV1, -) if not ENABLE_TENSOR_DATA_REPRESENTATION: from inference.core.workflows.core_steps.analytics.detection_event_log.v1 import ( @@ -1572,6 +1573,13 @@ "allow_access_to_file_system": ALLOW_WORKFLOW_BLOCKS_ACCESSING_LOCAL_STORAGE, "allowed_write_directory": WORKFLOW_BLOCKS_WRITE_DIRECTORY, "allow_access_to_environmental_variables": ALLOW_WORKFLOW_BLOCKS_ACCESSING_ENVIRONMENTAL_VARIABLES, + # The whole configuration, for core blocks that want more than the values + # this dict spells out. It resolves for blocks whose `block_source` is + # `workflows_core` - every core block, plus a plugin that declares + # `BLOCKS_SOURCE = "workflows_core"`. An ordinary plugin must import + # `inference.core.workflows.configuration.get_configuration` instead; + # see `steps_initialiser.retrieve_init_parameter_values`. + "configuration": get_configuration(), } KINDS_SERIALIZERS = { diff --git a/scripts/verify_loader_registration_parity.py b/scripts/verify_loader_registration_parity.py new file mode 100644 index 0000000000..58dc81b5e8 --- /dev/null +++ b/scripts/verify_loader_registration_parity.py @@ -0,0 +1,122 @@ +"""Prove `core_steps/loader.py` registers the same things after a refactor. + +This is a BEFORE/AFTER comparison of the WORKING TREE - record before the edit, +compare after. It does not read git history. + + python scripts/verify_loader_registration_parity.py --record + python scripts/verify_loader_registration_parity.py --compare \ + [--expect-added-initializers configuration] + +One child per tensor mode, with USE_INFERENCE_MODELS pinned True (env.py:1486 ANDs +it into the flag) and every other registration-gating variable pinned so the answer +depends only on the flag. The child also reports the EFFECTIVE +flag it observed, and the parent asserts it matches the mode it asked for - +otherwise a broken configuration hand-off would silently compare a mode against +itself. +""" + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + +PAYLOAD_KEYS = ("blocks", "kinds", "serializers", "deserializers", "initializers") + +CHILD = r""" +import json +from inference.core.workflows.core_steps import loader + +print(json.dumps({ + "flag": loader.ENABLE_TENSOR_DATA_REPRESENTATION, + "blocks": sorted(f"{b.__module__}.{b.__name__}" for b in loader.load_blocks()), + "kinds": sorted(k.name for k in loader.load_kinds()), + "serializers": sorted(loader.KINDS_SERIALIZERS), + "deserializers": sorted(loader.KINDS_DESERIALIZERS), + "initializers": sorted(loader.REGISTERED_INITIALIZERS), +})) +""" + + +def snapshot(tensor_mode: bool) -> dict: + child_env = {**os.environ, "PYTHONPATH": str(REPO_ROOT / "inference_models")} + child_env["ENABLE_TENSOR_DATA_REPRESENTATION"] = "True" if tensor_mode else "False" + # `env.py:1486` ANDs USE_INFERENCE_MODELS into the tensor flag (it defaults + # to False on Windows and may be pinned False elsewhere), so the requested + # mode is only reachable with BOTH pinned - round-3 defect 5. The child + # still reports the EFFECTIVE flag and the parent asserts it below. + child_env["USE_INFERENCE_MODELS"] = "True" + child_env["SAM3_3D_OBJECTS_ENABLED"] = "False" + child_env["WORKFLOW_DISABLED_BLOCK_TYPES"] = "" + child_env["WORKFLOW_DISABLED_BLOCK_PATTERNS"] = "" + child_env.pop("WORKFLOWS_PLUGINS", None) + completed = subprocess.run( + [sys.executable, "-c", CHILD], + cwd=REPO_ROOT, + env=child_env, + capture_output=True, + text=True, + ) + if completed.returncode != 0: + raise SystemExit(completed.stdout + completed.stderr) + payload = json.loads(completed.stdout.strip().splitlines()[-1]) + if payload["flag"] is not tensor_mode: + raise SystemExit( + f"asked for tensor={tensor_mode} but the loader observed " + f"{payload['flag']!r} - the configuration hand-off is broken" + ) + return payload + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--record", metavar="PATH") + parser.add_argument("--compare", metavar="PATH") + parser.add_argument( + "--expect-added-initializers", + nargs="*", + default=[], + help="initializer names this refactor deliberately ADDS", + ) + args = parser.parse_args() + current = {"off": snapshot(False), "on": snapshot(True)} + if args.record: + Path(args.record).write_text(json.dumps(current, indent=1)) + for mode, payload in current.items(): + print( + f"recorded tensor={mode}: {len(payload['blocks'])} blocks, " + f"{len(payload['kinds'])} kinds, " + f"{len(payload['serializers'])} serializers, " + f"{len(payload['initializers'])} initializers " + f"{payload['initializers']}" + ) + return 0 + recorded = json.loads(Path(args.compare).read_text()) + expected_added = set(args.expect_added_initializers) + ok = True + for mode in ("off", "on"): + for key in PAYLOAD_KEYS: + before, after = set(recorded[mode][key]), set(current[mode][key]) + added, removed = after - before, before - after + if key == "initializers": + # The ONE intended change: this task adds `configuration`. + if added != expected_added or removed: + ok = False + print( + f"tensor={mode} initializers: expected exactly " + f"+{sorted(expected_added)}, got +{sorted(added)} " + f"-{sorted(removed)}" + ) + continue + if added or removed: + ok = False + print(f"tensor={mode} {key}: -{sorted(removed)} +{sorted(added)}") + print("IDENTICAL" if ok else "DIFFERENT") + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_v1.py b/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_v1.py index d5547de59d..81e32de12d 100644 --- a/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_v1.py +++ b/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_v1.py @@ -10,7 +10,6 @@ import pytest import torch -import inference.core.env as core_env from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.deserializers import ( deserialize_action_recognition_prediction_kind, @@ -1047,18 +1046,35 @@ def test_tensor_sibling_normalizes_mixed_window_to_rgb_numpy(): @pytest.mark.parametrize("tensor_enabled", [False, True]) -def test_loader_registers_block_kind_and_codecs_for_both_modes( - monkeypatch, tensor_enabled -): +def test_loader_registers_block_kind_and_codecs_for_both_modes(tensor_enabled): + import dataclasses + import importlib + + from inference.core.workflows import configuration as workflows_configuration + from inference.core.workflows import environment as workflows_environment from inference.core.workflows.core_steps import loader - original = core_env.ENABLE_TENSOR_DATA_REPRESENTATION + previous = workflows_configuration.get_configuration() try: - monkeypatch.setattr( - core_env, "ENABLE_TENSOR_DATA_REPRESENTATION", tensor_enabled + workflows_configuration.reset_configuration() + workflows_configuration.configure_process( + dataclasses.replace( + previous, + tensor=dataclasses.replace( + previous.tensor, + representation_enabled=tensor_enabled, + image_tensor_device=( + workflows_configuration.resolve_image_tensor_device( + tensor_enabled + ) + ), + ), + ) ) + importlib.reload(workflows_environment) reloaded_loader = importlib.reload(loader) + assert reloaded_loader.ENABLE_TENSOR_DATA_REPRESENTATION is tensor_enabled assert reloaded_loader.ActionRecognitionModelBlockV1 in ( reloaded_loader.load_blocks() ) @@ -1079,7 +1095,9 @@ def test_loader_registers_block_kind_and_codecs_for_both_modes( is deserialize_action_recognition_prediction_kind ) finally: - monkeypatch.setattr(core_env, "ENABLE_TENSOR_DATA_REPRESENTATION", original) + with workflows_configuration._INSTALL_LOCK: + workflows_configuration._CONFIGURATION = previous + importlib.reload(workflows_environment) importlib.reload(loader) diff --git a/tests/workflows/unit_tests/core_steps/test_loader_tensor_mode_parity.py b/tests/workflows/unit_tests/core_steps/test_loader_tensor_mode_parity.py new file mode 100644 index 0000000000..87ae253772 --- /dev/null +++ b/tests/workflows/unit_tests/core_steps/test_loader_tensor_mode_parity.py @@ -0,0 +1,110 @@ +"""The loader must follow the INSTALLED configuration's tensor flag. + +The exhaustive before/after identity proof is +`scripts/verify_loader_registration_parity.py` (a one-shot migration gate, run +in Task 5.3). What must keep holding forever is the mechanism: the flag the +loader branches on comes from the configuration, and each mode registers its +own variant of a block that has both. +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[4] + +CHILD = r""" +import json +from inference.core.workflows.core_steps import loader + +print(json.dumps({ + "flag": loader.ENABLE_TENSOR_DATA_REPRESENTATION, + "blocks": sorted(f"{b.__module__}.{b.__name__}" for b in loader.load_blocks()), + "serializers": sorted(loader.KINDS_SERIALIZERS), + "initializers": sorted(loader.REGISTERED_INITIALIZERS), +})) +""" + + +def _load(tensor_mode: bool) -> dict: + child_env = {**os.environ, "PYTHONPATH": str(REPO_ROOT / "inference_models")} + child_env["ENABLE_TENSOR_DATA_REPRESENTATION"] = "True" if tensor_mode else "False" + # `env.py:1486` ANDs USE_INFERENCE_MODELS into the tensor flag (it defaults + # to False on Windows and may be pinned False elsewhere), so the requested + # mode is only reachable with BOTH pinned - round-3 defect 5. The child + # still reports the EFFECTIVE flag and the parent asserts it below. + child_env["USE_INFERENCE_MODELS"] = "True" + child_env["SAM3_3D_OBJECTS_ENABLED"] = "False" + child_env["WORKFLOW_DISABLED_BLOCK_TYPES"] = "" + child_env["WORKFLOW_DISABLED_BLOCK_PATTERNS"] = "" + child_env.pop("WORKFLOWS_PLUGINS", None) + completed = subprocess.run( + [sys.executable, "-c", CHILD], + cwd=REPO_ROOT, + env=child_env, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + return json.loads(completed.stdout.strip().splitlines()[-1]) + + +BLUR_NUMPY = ( + "inference.core.workflows.core_steps.classical_cv.image_blur.v1.ImageBlurBlockV1" +) +BLUR_TENSOR = "inference.core.workflows.core_steps.classical_cv.image_blur.v1_tensor.ImageBlurBlockV1" + + +@pytest.mark.parametrize("tensor_mode", [False, True], ids=["numpy", "tensor"]) +def test_loader_follows_the_installed_configuration(tensor_mode: bool) -> None: + payload = _load(tensor_mode) + assert payload["flag"] is tensor_mode + assert (BLUR_TENSOR in payload["blocks"]) is tensor_mode + assert (BLUR_NUMPY in payload["blocks"]) is not tensor_mode + assert len(payload["blocks"]) == len(set(payload["blocks"])), "duplicate blocks" + assert "configuration" in payload["initializers"] + # The tensor-native producers add a classification serialiser the numpy + # path has no counterpart for (`loader.py:1615-1640`). + assert ("classification_prediction" in payload["serializers"]) is tensor_mode + + +def test_configuration_resolves_for_core_sourced_blocks_only() -> None: + """`REGISTERED_INITIALIZERS["configuration"]` is registered as + `workflows_core.configuration` (`blocks_loader.py:372-376`), and resolution + tries `{block_source}.{param}` then the bare name + (`steps_initialiser.py:124-133`). So it resolves for `workflows_core` - + including a plugin that declares `BLOCKS_SOURCE = "workflows_core"` - and + NOT for an ordinary plugin. The plan promises exactly that; this pins it. + """ + from inference.core.workflows.configuration import WorkflowsConfiguration + from inference.core.workflows.errors import BlockInitParameterNotProvidedError + from inference.core.workflows.execution_engine.introspection.blocks_loader import ( + load_initializers, + ) + from inference.core.workflows.execution_engine.v1.compiler.steps_initialiser import ( + retrieve_init_parameter_values, + ) + + initializers = load_initializers() + resolved = retrieve_init_parameter_values( + block_name="step", + block_init_parameter="configuration", + block_source="workflows_core", + explicit_init_parameters={}, + initializers=initializers, + ) + assert isinstance(resolved, WorkflowsConfiguration) + + for foreign_source in ("my_plugin", "dynamic_workflows_blocks"): + with pytest.raises(BlockInitParameterNotProvidedError): + retrieve_init_parameter_values( + block_name="step", + block_init_parameter="configuration", + block_source=foreign_source, + explicit_init_parameters={}, + initializers=initializers, + ) diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 7076c25643..06113960a8 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 220 +# Count: 219 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env @@ -20,7 +20,6 @@ inference/core/workflows/core_steps/formatters/vlm_as_classifier/v2_tensor.py in inference/core/workflows/core_steps/formatters/vlm_as_detector/v1_tensor.py inference.core.env inference/core/workflows/core_steps/formatters/vlm_as_detector/v2_tensor.py inference.core.env inference/core/workflows/core_steps/fusion/detections_consensus/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/loader.py inference.core.env inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py inference.core.env inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py inference.core.env From d2dd0c9e9101fdd0e9aff1bc4acae4167bc92fd6 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:41:13 +0200 Subject: [PATCH 43/86] refactor(workflows): engine reads the injected configuration and refuses a mismatching one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repoint the nine engine-level `inference.core.env` imports at the `inference.core.workflows.environment` façade (name lists unchanged), and add `CONFIGURATION_INIT_PARAMETER_KEY` plus a call to `ensure_process_configuration_matches` in `ExecutionEngineV1.init`, before `compile_workflow` so a warm compilation cache cannot bypass it. Adds the 29-case `test_configuration_injection.py` regression suite. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../execution_engine/entities/base.py | 2 +- .../workflows/execution_engine/v1/core.py | 30 +- .../v1/dynamic_blocks/block_assembler.py | 2 +- .../dynamic_blocks/representation_boundary.py | 8 +- .../execution_engine/v1/executor/core.py | 2 +- .../v1/executor/output_constructor.py | 2 +- .../execution_engine/v1/executor/utils.py | 2 +- .../v1/inner_workflow/compiler_bridge.py | 2 +- inference/core/workflows/offline.py | 6 +- .../unit_tests/decontamination_baseline.txt | 11 +- .../test_configuration_injection.py | 321 ++++++++++++++++++ 11 files changed, 366 insertions(+), 22 deletions(-) create mode 100644 tests/workflows/unit_tests/execution_engine/test_configuration_injection.py diff --git a/inference/core/workflows/execution_engine/entities/base.py b/inference/core/workflows/execution_engine/entities/base.py index 9984a4f943..6d6372913c 100644 --- a/inference/core/workflows/execution_engine/entities/base.py +++ b/inference/core/workflows/execution_engine/entities/base.py @@ -26,7 +26,7 @@ from inference.core.entities.responses.action_recognition import ( ActionRecognitionPrediction, ) -from inference.core.env import ( +from inference.core.workflows.environment import ( ENABLE_TENSOR_DATA_REPRESENTATION, WORKFLOWS_IMAGE_TENSOR_DEVICE, ) diff --git a/inference/core/workflows/execution_engine/v1/core.py b/inference/core/workflows/execution_engine/v1/core.py index 3b571e081d..c244ef6cd7 100644 --- a/inference/core/workflows/execution_engine/v1/core.py +++ b/inference/core/workflows/execution_engine/v1/core.py @@ -8,7 +8,8 @@ from packaging.version import Version -from inference.core.env import WORKFLOWS_STEP_EXECUTION_MODE +from inference.core.workflows.configuration import ensure_process_configuration_matches +from inference.core.workflows.environment import WORKFLOWS_STEP_EXECUTION_MODE from inference.core.workflows.errors import ( RuntimeInputError, WorkflowEnvironmentConfigurationError, @@ -73,6 +74,18 @@ "legacy": legacy_step_error_handler, } +# The ONLY key the process-consistency check looks at. Deliberately NOT +# `_retrieve_init_parameter`, which falls back to the BARE name and invokes +# callables: a plugin's own `configuration` init parameter - bare, or under +# its own namespace such as `my_plugin.configuration` or +# `dynamic_workflows_blocks.configuration` - is a supported, pre-existing path +# (`steps_initialiser.py:124-133`; a plugin picks its BLOCKS_SOURCE freely, +# `blocks_loader.py:297`) and must pass through untouched. Generated dynamic +# blocks request only `api_key`, `workspace_resolver` and `execution_observer` +# (`block_scaffolding.py:481`), so no dynamic-block key is reserved either +# (round-2 defect 1, round-3 defect 4). +CONFIGURATION_INIT_PARAMETER_KEY = "workflows_core.configuration" + PRE_INIT_SUPPORTED_DEPENDENCIES = {DependentResourceType.ROBOFLOW_PLATFORM_MODEL} @@ -399,6 +412,21 @@ def init( # reuses its dictionary across engines never sees, or re-supplies, a # value this engine derived. init_parameters = dict(init_parameters or {}) + # Before compilation, so a warm COMPILATION_CACHE (compiler/core.py:64) + # cannot skip the check with it. The configuration is process-wide; a + # per-engine object that differs anywhere means the process is + # mis-wired, and every value it carries is already frozen into module + # constants, so accepting it would honour nothing. The value is passed + # AS IS: a callable - or an explicit None - is refused by the isinstance + # guard, never invoked or forwarded. PRESENCE is decided here (the key + # is in the dict), VALIDITY there: blocks receive explicit init + # parameters unchanged (steps_initialiser.py:124-125), so a factory + # "validated" by calling it, or a None waved through, would still reach + # every configuration-consuming block (round-3 defect 3, round-4 defect 1). + if CONFIGURATION_INIT_PARAMETER_KEY in init_parameters: + ensure_process_configuration_matches( + init_parameters[CONFIGURATION_INIT_PARAMETER_KEY] + ) if isinstance(step_error_handler, str): if step_error_handler not in REGISTERED_STEP_ERROR_HANDLERS: raise WorkflowEnvironmentConfigurationError( diff --git a/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_assembler.py b/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_assembler.py index 92a99d2ed9..9ae5e5bacf 100644 --- a/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_assembler.py +++ b/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_assembler.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict, Field, create_model -from inference.core.env import ( +from inference.core.workflows.environment import ( ALLOW_CUSTOM_PYTHON_EXECUTION_IN_WORKFLOWS, ENABLE_TENSOR_DATA_REPRESENTATION, WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE, diff --git a/inference/core/workflows/execution_engine/v1/dynamic_blocks/representation_boundary.py b/inference/core/workflows/execution_engine/v1/dynamic_blocks/representation_boundary.py index ab8908a478..4ad103586d 100644 --- a/inference/core/workflows/execution_engine/v1/dynamic_blocks/representation_boundary.py +++ b/inference/core/workflows/execution_engine/v1/dynamic_blocks/representation_boundary.py @@ -29,10 +29,6 @@ import supervision as sv import torch -from inference.core.env import ( - ENABLE_TENSOR_DATA_REPRESENTATION, - WORKFLOWS_IMAGE_TENSOR_DEVICE, -) from inference.core.workflows.core_steps.common.keypoints import ( validate_keypoints_padding, ) @@ -43,6 +39,10 @@ HOST_MIRROR_KEYS, build_native_key_points, ) +from inference.core.workflows.environment import ( + ENABLE_TENSOR_DATA_REPRESENTATION, + WORKFLOWS_IMAGE_TENSOR_DEVICE, +) from inference.core.workflows.errors import DynamicBlockError from inference.core.workflows.execution_engine.constants import ( CLASS_NAME_KEY, diff --git a/inference/core/workflows/execution_engine/v1/executor/core.py b/inference/core/workflows/execution_engine/v1/executor/core.py index ff64792864..b31c8fd0d5 100644 --- a/inference/core/workflows/execution_engine/v1/executor/core.py +++ b/inference/core/workflows/execution_engine/v1/executor/core.py @@ -21,7 +21,7 @@ execution_id = None remote_processing_times = None -from inference.core.env import INFERENCE_DEBUG_OUTPUT_DIR +from inference.core.workflows.environment import INFERENCE_DEBUG_OUTPUT_DIR from inference.core.workflows.errors import ( BlockTraceback, StepExecutionError, diff --git a/inference/core/workflows/execution_engine/v1/executor/output_constructor.py b/inference/core/workflows/execution_engine/v1/executor/output_constructor.py index 1664ab9788..67864a889d 100644 --- a/inference/core/workflows/execution_engine/v1/executor/output_constructor.py +++ b/inference/core/workflows/execution_engine/v1/executor/output_constructor.py @@ -9,13 +9,13 @@ import supervision as sv from networkx import DiGraph -from inference.core.env import ENABLE_TENSOR_DATA_REPRESENTATION from inference.core.workflows.core_steps.common.tensor_native import ( native_detections_to_root_coordinates, ) from inference.core.workflows.core_steps.common.utils import ( sv_detections_to_root_coordinates, ) +from inference.core.workflows.environment import ENABLE_TENSOR_DATA_REPRESENTATION from inference.core.workflows.errors import AssumptionError, ExecutionEngineRuntimeError from inference.core.workflows.execution_engine.constants import ( IMAGE_DIMENSIONS_KEY, diff --git a/inference/core/workflows/execution_engine/v1/executor/utils.py b/inference/core/workflows/execution_engine/v1/executor/utils.py index c93292dcde..2c13d2ccfe 100644 --- a/inference/core/workflows/execution_engine/v1/executor/utils.py +++ b/inference/core/workflows/execution_engine/v1/executor/utils.py @@ -2,7 +2,7 @@ from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError from typing import Any, Callable, Generator, Iterable, List, Optional, TypeVar -from inference.core.env import WORKFLOWS_ASYNC_FUTURE_RESULT_TIMEOUT +from inference.core.workflows.environment import WORKFLOWS_ASYNC_FUTURE_RESULT_TIMEOUT from inference.core.workflows.errors import ExecutionEngineRuntimeError from inference.core.workflows.execution_engine.entities.base import Batch diff --git a/inference/core/workflows/execution_engine/v1/inner_workflow/compiler_bridge.py b/inference/core/workflows/execution_engine/v1/inner_workflow/compiler_bridge.py index d7bfb8e084..6f1d1cd69d 100644 --- a/inference/core/workflows/execution_engine/v1/inner_workflow/compiler_bridge.py +++ b/inference/core/workflows/execution_engine/v1/inner_workflow/compiler_bridge.py @@ -11,7 +11,7 @@ import json from typing import Any, Dict, List, Set, Tuple -from inference.core.env import ( +from inference.core.workflows.environment import ( WORKFLOWS_MAX_INNER_WORKFLOW_COUNT, WORKFLOWS_MAX_INNER_WORKFLOW_DEPTH, ) diff --git a/inference/core/workflows/offline.py b/inference/core/workflows/offline.py index a004310f76..7607780c7e 100644 --- a/inference/core/workflows/offline.py +++ b/inference/core/workflows/offline.py @@ -1,4 +1,8 @@ -from inference.core.env import OFFLINE_MODE, SECURE_GATEWAY, WORKFLOWS_REMOTE_API_TARGET +from inference.core.workflows.environment import ( + OFFLINE_MODE, + SECURE_GATEWAY, + WORKFLOWS_REMOTE_API_TARGET, +) from inference.core.workflows.errors import WorkflowEnvironmentConfigurationError from inference.core.workflows.prototypes.block import StepExecutionMode diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 06113960a8..8bf978d986 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 219 +# Count: 210 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env @@ -208,15 +208,6 @@ inference/core/workflows/core_steps/transformations/stabilize_detections/v1_tens inference/core/workflows/core_steps/visualizations/common/base_tensor.py inference.core.env inference/core/workflows/core_steps/visualizations/common/fonts/__init__.py inference.core.env inference/core/workflows/execution_engine/entities/base.py inference.core.entities.responses.action_recognition -inference/core/workflows/execution_engine/entities/base.py inference.core.env -inference/core/workflows/execution_engine/v1/core.py inference.core.env -inference/core/workflows/execution_engine/v1/dynamic_blocks/block_assembler.py inference.core.env inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.env inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.env (exec'd string) inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py inference.core.env -inference/core/workflows/execution_engine/v1/dynamic_blocks/representation_boundary.py inference.core.env -inference/core/workflows/execution_engine/v1/executor/core.py inference.core.env -inference/core/workflows/execution_engine/v1/executor/output_constructor.py inference.core.env -inference/core/workflows/execution_engine/v1/executor/utils.py inference.core.env -inference/core/workflows/execution_engine/v1/inner_workflow/compiler_bridge.py inference.core.env -inference/core/workflows/offline.py inference.core.env diff --git a/tests/workflows/unit_tests/execution_engine/test_configuration_injection.py b/tests/workflows/unit_tests/execution_engine/test_configuration_injection.py new file mode 100644 index 0000000000..2b74ee6906 --- /dev/null +++ b/tests/workflows/unit_tests/execution_engine/test_configuration_injection.py @@ -0,0 +1,321 @@ +"""The engine refuses a configuration that contradicts the process one. + +`workflows_core.configuration` is a CONSISTENCY ASSERTION (D1): the whole +object is process-wide, so a host that hands one engine a different object is +mis-wired and must be told, not quietly ignored. + +Round-2 defect 1 / round-3 defect 4: the check looks ONLY at +`workflows_core.configuration`. A plugin's bare `configuration` init parameter +and any OTHER namespace (`my_plugin.configuration`, +`dynamic_workflows_blocks.configuration`) are existing supported paths +(`steps_initialiser.py:124-133`) and pass through untouched. + +Round-3 defect 3 / round-4 defect 1: a callable or an explicit None under +the dedicated key is REFUSED, never invoked or forwarded. `retrieve_init_parameter_values` returns an explicit value unchanged +(`steps_initialiser.py:124`), so a factory the engine had "validated" by calling +it would still reach every block as the function itself. + +Round-2 defect 4: every conflicting variant is derived by PERTURBING the +installed value, never hard-coded - the suite is required to run with +`ENABLE_TENSOR_DATA_REPRESENTATION=True USE_INFERENCE_MODELS=True` too, where a +hard-coded `representation_enabled=True` equals the installed value and +`pytest.raises` never fires. +""" + +import dataclasses +from typing import List, Literal + +import pytest + +from inference.core.workflows import configuration as workflows_configuration +from inference.core.workflows import environment as workflows_environment +from inference.core.workflows.configuration import WorkflowsConfiguration +from inference.core.workflows.errors import WorkflowEnvironmentConfigurationError +from inference.core.workflows.execution_engine.core import ExecutionEngine +from inference.core.workflows.execution_engine.entities.base import OutputDefinition +from inference.core.workflows.execution_engine.introspection.blocks_loader import ( + load_initializers, +) +from inference.core.workflows.execution_engine.v1.compiler.entities import ( + BlockSpecification, +) +from inference.core.workflows.execution_engine.v1.compiler.steps_initialiser import ( + initialise_step, + retrieve_init_parameter_values, +) +from inference.core.workflows.prototypes.block import ( + WorkflowBlock, + WorkflowBlockManifest, +) +from tests.workflows.unit_tests.test_configuration import perturb, variant_of + +TRIVIAL_WORKFLOW = { + "version": "1.0", + "inputs": [{"type": "WorkflowParameter", "name": "x"}], + "steps": [], + "outputs": [{"type": "JsonField", "name": "x", "selector": "$inputs.x"}], +} +CONFIGURATION_KEY = "workflows_core.configuration" + +# One field per group, and for each the façade constant that proves an accepted +# override would have changed nothing. +GROUP_FIELDS = [ + ( + "engine", + "allow_custom_python_execution", + "ALLOW_CUSTOM_PYTHON_EXECUTION_IN_WORKFLOWS", + ), + ("tensor", "representation_enabled", "ENABLE_TENSOR_DATA_REPRESENTATION"), + ("remote", "api_target", "WORKFLOWS_REMOTE_API_TARGET"), + ("platform", "offline_mode", "OFFLINE_MODE"), + ("fonts", "allow_download", "ALLOW_WORKFLOWS_FONTS_DOWNLOAD"), + ("models", "sam3_exec_mode", "SAM3_EXEC_MODE"), + ("modal", "transport", "WEBEXEC_TRANSPORT"), + ("secrets", "roboflow_internal_service_name", "ROBOFLOW_INTERNAL_SERVICE_NAME"), + ("debug", "output_dir", "INFERENCE_DEBUG_OUTPUT_DIR"), +] +IDS = [f"{group}.{field}" for group, field, _ in GROUP_FIELDS] + + +class _ConfigurationConsumerManifest(WorkflowBlockManifest): + type: Literal["phase5/configuration_consumer@v1"] + + @classmethod + def describe_outputs(cls) -> List[OutputDefinition]: + return [] + + +class _ConfigurationConsumerBlock(WorkflowBlock): + """A core-sourced block that declares the `configuration` init parameter.""" + + def __init__(self, configuration): + self.configuration = configuration + + @classmethod + def get_init_parameters(cls) -> List[str]: + return ["configuration"] + + @classmethod + def get_manifest(cls): + return _ConfigurationConsumerManifest + + def run(self, *args, **kwargs): + return [] + + +def _initialise_consumer(explicit_init_parameters: dict): + return initialise_step( + step_manifest=_ConfigurationConsumerManifest( + type="phase5/configuration_consumer@v1", name="consumer" + ), + block_specification=BlockSpecification( + block_source="workflows_core", + identifier="workflows_core.ConfigurationConsumer", + block_class=_ConfigurationConsumerBlock, + manifest_class=_ConfigurationConsumerManifest, + ), + explicit_init_parameters=explicit_init_parameters, + initializers=load_initializers(), + ).step + + +def test_engine_accepts_the_installed_configuration() -> None: + engine = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={ + CONFIGURATION_KEY: workflows_configuration.get_configuration() + }, + ) + assert engine is not None + + +def test_engine_accepts_an_equal_but_distinct_configuration() -> None: + engine = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={ + CONFIGURATION_KEY: dataclasses.replace( + workflows_configuration.get_configuration() + ) + }, + ) + assert engine is not None + + +@pytest.mark.parametrize("group, field, _constant", GROUP_FIELDS, ids=IDS) +def test_engine_refuses_a_configuration_that_differs_in_any_group( + group, field, _constant +) -> None: + conflicting = variant_of(workflows_configuration.get_configuration(), group, field) + with pytest.raises(WorkflowEnvironmentConfigurationError) as raised: + ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={CONFIGURATION_KEY: conflicting}, + ) + assert f"{group}.{field}" in raised.value.public_message + + +def test_the_refusal_is_not_bypassed_by_a_warm_compilation_cache() -> None: + # `compiler/core.py:64` caches by definition + engine version, so a second + # init of the same workflow skips compilation entirely. The check has to run + # BEFORE compile_workflow or it would be skipped with it. + installed = workflows_configuration.get_configuration() + ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={CONFIGURATION_KEY: installed}, + ) + with pytest.raises(WorkflowEnvironmentConfigurationError): + ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={ + CONFIGURATION_KEY: variant_of(installed, "remote", "api_target") + }, + ) + + +def test_engine_still_works_without_a_configuration_init_parameter() -> None: + engine = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, init_parameters={} + ) + assert engine is not None + + +def test_a_plugins_bare_configuration_parameter_is_not_intercepted() -> None: + """Round-2 defect 1, reproduced against the real functions. + + `_retrieve_init_parameter` (`v1/core.py:89-96`) falls back from + `workflows_core.configuration` to the BARE name, and a bare explicit + parameter is how a plugin has always supplied its own `configuration` + (`steps_initialiser.py:129`). Using the round-1 wording, a plugin passing + `{"configuration": {"threshold": 0.5}}` reached + `ensure_process_configuration_matches` and died with + `AttributeError: 'dict' object has no attribute 'engine'`. + """ + plugin_value = {"threshold": 0.5} + + engine = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={"configuration": plugin_value}, + ) + assert engine is not None + + resolved = retrieve_init_parameter_values( + block_name="step", + block_init_parameter="configuration", + block_source="my_plugin", + explicit_init_parameters={"configuration": plugin_value}, + initializers=load_initializers(), + ) + assert resolved is plugin_value + + +@pytest.mark.parametrize("namespace", ["my_plugin", "dynamic_workflows_blocks"]) +def test_another_namespaces_configuration_parameter_is_not_intercepted( + namespace, +) -> None: + """Round-3 defect 4: the round-2 check also reserved + `dynamic_workflows_blocks.configuration`. A plugin picks its own + `BLOCKS_SOURCE` (`blocks_loader.py:297`) and may name a parameter + `configuration`; only `workflows_core.configuration` is ours.""" + plugin_value = {"threshold": 0.5} + key = f"{namespace}.configuration" + + engine = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, init_parameters={key: plugin_value} + ) + assert engine is not None + + resolved = retrieve_init_parameter_values( + block_name="step", + block_init_parameter="configuration", + block_source=namespace, + explicit_init_parameters={key: plugin_value}, + initializers=load_initializers(), + ) + assert resolved is plugin_value + + +def test_an_invalid_value_under_the_dedicated_key_is_a_workflows_error() -> None: + with pytest.raises(WorkflowEnvironmentConfigurationError) as raised: + ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={CONFIGURATION_KEY: {"threshold": 0.5}}, + ) + assert "WorkflowsConfiguration" in raised.value.public_message + + +def test_a_callable_under_the_dedicated_key_is_refused_not_invoked() -> None: + """Round-3 defect 3. The real resolver hands an EXPLICIT value to blocks + unchanged (`steps_initialiser.py:124`) - shown first - so an engine that + called the factory to "validate" it would still deliver the function to + every block. The engine therefore refuses callables outright.""" + installed = workflows_configuration.get_configuration() + + def factory(): + return installed + + resolved = retrieve_init_parameter_values( + block_name="step", + block_init_parameter="configuration", + block_source="workflows_core", + explicit_init_parameters={CONFIGURATION_KEY: factory}, + initializers=load_initializers(), + ) + assert resolved is factory, "the resolver does not materialise explicit values" + + with pytest.raises(WorkflowEnvironmentConfigurationError) as raised: + ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={CONFIGURATION_KEY: factory}, + ) + assert "WorkflowsConfiguration" in raised.value.public_message + assert "function" in raised.value.public_message + + +def test_an_explicit_none_under_the_dedicated_key_is_refused() -> None: + """Round-4 defect 1. Presence is decided by the engine (`key in + init_parameters`), so a None under the key is an EXPLICIT None - and the + real resolver would deliver exactly that to a block ahead of the registered + default (shown first). Omission is fine; explicit None is refused.""" + delivered = _initialise_consumer({CONFIGURATION_KEY: None}).configuration + assert delivered is None, "the resolver prefers an explicit value, even None" + + with pytest.raises(WorkflowEnvironmentConfigurationError) as raised: + ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={CONFIGURATION_KEY: None}, + ) + assert "NoneType" in raised.value.public_message + + +def test_a_configuration_consuming_block_receives_the_installed_object() -> None: + """What a block that declares `configuration` actually gets, through the + real `initialise_step`: the very object under `workflows_core.configuration` + when the host supplies it, and the loader's registered default + (`REGISTERED_INITIALIZERS["configuration"]`, Task 5.3) otherwise. In both + cases a `WorkflowsConfiguration` equal to the installed one - never a + function (round-3 defect 3).""" + installed = workflows_configuration.get_configuration() + + explicit = _initialise_consumer({CONFIGURATION_KEY: installed}) + assert explicit.configuration is installed + + defaulted = _initialise_consumer({}) + assert isinstance(defaulted.configuration, WorkflowsConfiguration) + assert defaulted.configuration == installed + assert not callable(defaulted.configuration) + + +@pytest.mark.parametrize("group, field, constant", GROUP_FIELDS, ids=IDS) +def test_a_rejected_value_would_indeed_not_have_been_honoured( + group, field, constant +) -> None: + """The reason the refusal is right, made explicit: every field is read from + a module constant frozen at import time, so an accepted override would have + changed nothing.""" + installed = workflows_configuration.get_configuration() + facade_value = getattr(workflows_environment, constant) + installed_value = getattr(getattr(installed, group), field) + if constant == "WORKFLOW_DISABLED_BLOCK_TYPES": # list vs tuple, not used here + installed_value = list(installed_value) + assert facade_value == installed_value + assert perturb(installed_value) != facade_value From 455409a27d5670517385da35a9882bd53c81dc9c Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:51:53 +0200 Subject: [PATCH 44/86] refactor(workflows): modal executor reads the injected configuration Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../v1/dynamic_blocks/modal_executor.py | 30 ++--- .../unit_tests/decontamination_baseline.txt | 3 +- .../test_modal_configuration_source.py | 103 ++++++++++++++++++ 3 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_modal_configuration_source.py diff --git a/inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py b/inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py index 35dfc9bcc6..fdbd1e8d14 100644 --- a/inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py +++ b/inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py @@ -32,7 +32,15 @@ import numpy as np import requests -from inference.core.env import ( +from inference.core.workflows.core_steps.common.deserializers import ( + deserialize_image_kind, + deserialize_rle_detections_kind, + deserialize_video_metadata_kind, +) +from inference.core.workflows.core_steps.common.serializers import ( + serialize_video_metadata_kind, +) +from inference.core.workflows.environment import ( MODAL_ANONYMOUS_WORKSPACE_NAME, MODAL_TOKEN_ID, MODAL_TOKEN_SECRET, @@ -45,14 +53,6 @@ WEBEXEC_WS_IDLE_RELEASE_SECONDS, WEBEXEC_WS_READ_TIMEOUT_SECONDS, ) -from inference.core.workflows.core_steps.common.deserializers import ( - deserialize_image_kind, - deserialize_rle_detections_kind, - deserialize_video_metadata_kind, -) -from inference.core.workflows.core_steps.common.serializers import ( - serialize_video_metadata_kind, -) from inference.core.workflows.errors import DynamicBlockCodeError, DynamicBlockError from inference.core.workflows.execution_engine.entities.base import ParentOrigin from inference.core.workflows.execution_engine.v1.dynamic_blocks.block_duration import ( @@ -609,7 +609,9 @@ def execute_remote( or workspace == "unauthorized" or workspace == MODAL_ANONYMOUS_WORKSPACE_NAME ): - from inference.core.env import MODAL_ALLOW_ANONYMOUS_EXECUTION + from inference.core.workflows.environment import ( + MODAL_ALLOW_ANONYMOUS_EXECUTION, + ) if not MODAL_ALLOW_ANONYMOUS_EXECUTION: raise DynamicBlockError( @@ -856,10 +858,10 @@ def validate_syntax(): def _serialize_image_for_msgpack(image: Any) -> dict: """Encode a WorkflowImageData as a dict with raw JPEG bytes (no base64).""" - from inference.core.env import WEBEXEC_JPEG_QUALITY from inference.core.workflows.core_steps.common.serializers import ( serialize_video_metadata_kind, ) + from inference.core.workflows.environment import WEBEXEC_JPEG_QUALITY from inference.core.workflows.execution_engine.entities.base import ParentOrigin from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes @@ -1626,7 +1628,9 @@ def execute_remote( "unauthorized", MODAL_ANONYMOUS_WORKSPACE_NAME, ): - from inference.core.env import MODAL_ALLOW_ANONYMOUS_EXECUTION + from inference.core.workflows.environment import ( + MODAL_ALLOW_ANONYMOUS_EXECUTION, + ) if not MODAL_ALLOW_ANONYMOUS_EXECUTION: raise DynamicBlockError( @@ -2324,7 +2328,7 @@ def execute_remote( def get_modal_executor(workspace_id: Optional[str] = None) -> Any: """Returns the right executor based on ``WEBEXEC_TRANSPORT``.""" - from inference.core.env import WEBEXEC_TRANSPORT + from inference.core.workflows.environment import WEBEXEC_TRANSPORT if WEBEXEC_TRANSPORT == "websocket": return PooledWebSocketModalExecutor(workspace_id) diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 8bf978d986..1357bb820f 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 210 +# Count: 209 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env @@ -210,4 +210,3 @@ inference/core/workflows/core_steps/visualizations/common/fonts/__init__.py infe inference/core/workflows/execution_engine/entities/base.py inference.core.entities.responses.action_recognition inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.env inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.env (exec'd string) -inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py inference.core.env diff --git a/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_modal_configuration_source.py b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_modal_configuration_source.py new file mode 100644 index 0000000000..9f101384a5 --- /dev/null +++ b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_modal_configuration_source.py @@ -0,0 +1,103 @@ +"""Modal/WebExec settings come from the injected configuration (D4). + +`modal_executor.py` hides FOUR function-local `inference.core.env` imports +(`:612`, `:859`, `:1629`, `:2327`) on top of its module-level one - the exact +shape the decontamination lint's `ast.walk` was written to catch +(`test_decontamination_lint.py:79-81`). + +Round-1 defect 5: `WEBEXEC_TRANSPORT` is NOT a module attribute of +`modal_executor` - it is imported inside `get_modal_executor`. The behavioural +test therefore calls that function and checks which executor class comes back. +""" + +import ast +from pathlib import Path + +import pytest + +from inference.core.workflows import environment +from inference.core.workflows.execution_engine.v1.dynamic_blocks import modal_executor + +MODULE = Path(modal_executor.__file__) + +MODAL_NAMES = { + "MODAL_TOKEN_ID", + "MODAL_TOKEN_SECRET", + "MODAL_WORKSPACE_NAME", + "MODAL_ALLOW_ANONYMOUS_EXECUTION", + "MODAL_ANONYMOUS_WORKSPACE_NAME", + "WEBEXEC_MODAL_APP_NAME", + "WEBEXEC_JPEG_QUALITY", + "WEBEXEC_TRANSPORT", + "WEBEXEC_WS_CONNECT_TIMEOUT_SECONDS", + "WEBEXEC_WS_READ_TIMEOUT_SECONDS", + "WEBEXEC_WS_CONNECTION_POOL_SIZE", + "WEBEXEC_WS_FAIL_ON_SESSION_LOSS", + "WEBEXEC_WS_IDLE_RELEASE_SECONDS", +} + + +def _import_statements() -> list: + tree = ast.parse(MODULE.read_text(encoding="utf-8")) + # ast.walk, not tree.body: four of the five statements live inside function + # bodies and a top-level scan would miss every one of them. + return [n for n in ast.walk(tree) if isinstance(n, ast.ImportFrom)] + + +def test_no_import_of_the_server_env_module_anywhere_in_the_file() -> None: + assert not [n for n in _import_statements() if n.module == "inference.core.env"] + + +def test_every_modal_name_is_imported_from_the_workflows_facade() -> None: + imported = { + alias.name + for node in _import_statements() + if node.module == "inference.core.workflows.environment" + for alias in node.names + } + assert MODAL_NAMES <= imported, sorted(MODAL_NAMES - imported) + + +def test_the_four_lazy_import_sites_stay_lazy() -> None: + tree = ast.parse(MODULE.read_text(encoding="utf-8")) + local = 0 + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for sub in ast.walk(node): + if ( + isinstance(sub, ast.ImportFrom) + and sub.module == "inference.core.workflows.environment" + ): + local += 1 + assert local == 4, local + + +def test_module_level_modal_availability_follows_the_facade() -> None: + assert modal_executor.MODAL_AVAILABLE is bool( + environment.MODAL_TOKEN_ID and environment.MODAL_TOKEN_SECRET + ) + assert modal_executor.WEBEXEC_JPEG_QUALITY == environment.WEBEXEC_JPEG_QUALITY + assert ( + modal_executor.WEBEXEC_WS_READ_TIMEOUT_SECONDS + == environment.WEBEXEC_WS_READ_TIMEOUT_SECONDS + ) + + +@pytest.mark.parametrize( + "transport, expected_class_name", + [ + ("http", "ModalExecutor"), + ("websocket", "PooledWebSocketModalExecutor"), + ], +) +def test_get_modal_executor_selects_the_transport_from_the_facade( + monkeypatch, transport, expected_class_name +) -> None: + """`get_modal_executor` (`modal_executor.py:2325-2331`) re-imports the + transport on every call, so patching the FACADE - the module it now imports + from - changes the class it returns. This is the behavioural proof that the + lazy site really reads the configuration.""" + monkeypatch.setattr(environment, "WEBEXEC_TRANSPORT", transport) + executor = modal_executor.get_modal_executor(workspace_id="workspace") + assert type(executor).__name__ == expected_class_name From 4a9e55db52bd770dbdf1f4edde88e5ad065cbe70 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:57:37 +0200 Subject: [PATCH 45/86] refactor(workflows): dynamic-block scaffolding reads the injected configuration, including the exec'd import Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../v1/dynamic_blocks/block_scaffolding.py | 8 ++- .../unit_tests/decontamination_baseline.txt | 4 +- .../test_tensor_native_imports_lines.py | 51 +++++++++++++++++++ 3 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_tensor_native_imports_lines.py diff --git a/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py b/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py index 5c2d152139..c0c53b2c6f 100644 --- a/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py +++ b/inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py @@ -10,7 +10,7 @@ from functools import partial from typing import Any, Dict, List, Optional, Type -from inference.core.env import ( +from inference.core.workflows.environment import ( ALLOW_CUSTOM_PYTHON_EXECUTION_IN_WORKFLOWS, ENABLE_TENSOR_DATA_REPRESENTATION, MODAL_ANONYMOUS_WORKSPACE_NAME, @@ -95,9 +95,13 @@ # tensor mode too (user code may mix representations for its own math). # NOTE: `modal/modal_app.py` mirrors this list into the remote sandbox namespace # via a guarded import of this constant — keep it importable and self-contained. +# The device is read from the Workflows configuration facade, not from the +# server's env module: the generated code must be importable wherever the +# workflows package is, including the Modal sandbox, where the sandbox's own +# `inference/core/__init__.py` installs its configuration. TENSOR_NATIVE_IMPORTS_LINES = [ "import torch", - "from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE", + "from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE", "from inference_models.models.base.object_detection import Detections", "from inference_models.models.base.instance_segmentation import InstanceDetections", "from inference_models.models.base.keypoints_detection import KeyPoints", diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 1357bb820f..f8963a2270 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 209 +# Count: 207 inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env @@ -208,5 +208,3 @@ inference/core/workflows/core_steps/transformations/stabilize_detections/v1_tens inference/core/workflows/core_steps/visualizations/common/base_tensor.py inference.core.env inference/core/workflows/core_steps/visualizations/common/fonts/__init__.py inference.core.env inference/core/workflows/execution_engine/entities/base.py inference.core.entities.responses.action_recognition -inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.env -inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py inference.core.env (exec'd string) diff --git a/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_tensor_native_imports_lines.py b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_tensor_native_imports_lines.py new file mode 100644 index 0000000000..07872d3f3d --- /dev/null +++ b/tests/workflows/unit_tests/execution_engine/dynamic_blocs/test_tensor_native_imports_lines.py @@ -0,0 +1,51 @@ +"""The exec'd import string must point at the workflows facade, and must run. + +`block_scaffolding.py:92-100` assembles the namespace of every dynamically +assembled block by `exec`-ing these strings. `ast` sees a string, not an +import, which is why the decontamination lint has a separate textual scan +(`test_decontamination_lint.py:40-53`, `:101`) and why this row is spelled +`inference.core.env (exec'd string)` in the baseline. +""" + +import pytest + +from inference.core.workflows.execution_engine.v1.dynamic_blocks import ( + block_scaffolding, +) + + +def test_no_server_import_is_smuggled_through_the_generated_source() -> None: + for line in ( + block_scaffolding.IMPORTS_LINES + block_scaffolding.TENSOR_NATIVE_IMPORTS_LINES + ): + assert "inference.core.env" not in line, line + assert "from inference.core.utils" not in line, line + + +def test_the_tensor_device_comes_from_the_workflows_facade() -> None: + assert ( + "from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE" + in block_scaffolding.TENSOR_NATIVE_IMPORTS_LINES + ) + # `modal/modal_app.py:607-611` imports this constant into the Modal + # sandbox; it must stay a plain, self-contained list of strings. + assert isinstance(block_scaffolding.TENSOR_NATIVE_IMPORTS_LINES, list) + assert len(block_scaffolding.TENSOR_NATIVE_IMPORTS_LINES) == 7 + assert all( + isinstance(line, str) for line in block_scaffolding.TENSOR_NATIVE_IMPORTS_LINES + ) + + +def test_the_generated_tensor_namespace_actually_executes() -> None: + pytest.importorskip("torch") + namespace = {} + exec( + "\n".join( + block_scaffolding.IMPORTS_LINES + + block_scaffolding.TENSOR_NATIVE_IMPORTS_LINES + ), + namespace, + ) + assert "WORKFLOWS_IMAGE_TENSOR_DEVICE" in namespace + assert "WorkflowImageData" in namespace + assert "Detections" in namespace From d6c22c1ad8834124c4367d3fec4549b95787b370 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:28:33 +0200 Subject: [PATCH 46/86] refactor(workflows): repoint 139 block files at the Workflows configuration facade AST codemod (scripts/repoint_env_imports.py) rewrites `from inference.core.env import ...` to `from inference.core.workflows.environment import ...` across the remaining 139 owned block files (byte-safe, CRLF-preserving, idempotent). Adds tests/workflows/unit_tests/test_no_server_env_imports.py, reusing the decontamination lint's collect_violations() and Task 5.1's environment_reads scanner to assert zero inference.core.env imports outside the Phase 9 skip list and to freeze the permitted direct-environment-read inventory. Regenerates the decontamination baseline (207 -> 68). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../classical_cv/mask_edge_snap/v1_tensor.py | 2 +- .../motion_detection/v1_tensor.py | 2 +- .../template_matching/v1_tensor.py | 2 +- .../core_steps/common/deserializers_tensor.py | 2 +- .../workflows/core_steps/common/openrouter.py | 4 +- .../operations/classification_results/base.py | 2 +- .../operations/detection/base.py | 2 +- .../operations/detections/base.py | 2 +- .../core_steps/common/tensor_native.py | 2 +- .../formatters/vlm_as_classifier/v1_tensor.py | 2 +- .../formatters/vlm_as_classifier/v2_tensor.py | 2 +- .../formatters/vlm_as_detector/v1_tensor.py | 2 +- .../formatters/vlm_as_detector/v2_tensor.py | 2 +- .../fusion/detections_consensus/v1_tensor.py | 2 +- .../models/foundation/anthropic_claude/v1.py | 4 +- .../models/foundation/anthropic_claude/v2.py | 4 +- .../models/foundation/anthropic_claude/v3.py | 4 +- .../models/foundation/anthropic_claude/v4.py | 4 +- .../core_steps/models/foundation/clip/v1.py | 6 +- .../models/foundation/clip/v1_tensor.py | 4 +- .../models/foundation/clip_comparison/v1.py | 14 +- .../foundation/clip_comparison/v1_tensor.py | 12 +- .../models/foundation/clip_comparison/v2.py | 12 +- .../foundation/clip_comparison/v2_tensor.py | 6 +- .../models/foundation/cog_vlm/v1.py | 2 +- .../models/foundation/cosmos3/v1.py | 4 +- .../models/foundation/depth_estimation/v1.py | 4 +- .../foundation/depth_estimation/v1_tensor.py | 4 +- .../models/foundation/easy_ocr/v1.py | 12 +- .../models/foundation/easy_ocr/v1_tensor.py | 12 +- .../models/foundation/florence2/v1.py | 6 +- .../models/foundation/florence2/v1_tensor.py | 12 +- .../core_steps/models/foundation/gaze/v1.py | 2 +- .../models/foundation/glm_ocr/v1.py | 4 +- .../models/foundation/google_gemini/v1.py | 4 +- .../models/foundation/google_gemini/v2.py | 4 +- .../models/foundation/google_gemini/v3.py | 4 +- .../models/foundation/google_gemini/v4.py | 4 +- .../models/foundation/google_gemini/v5.py | 4 +- .../models/foundation/google_gemma/v1.py | 4 +- .../foundation/google_vision_ocr/v1_tensor.py | 2 +- .../models/foundation/kimi_openrouter/v1.py | 4 +- .../models/foundation/llama_vision/v1.py | 4 +- .../core_steps/models/foundation/lmm/v1.py | 12 +- .../models/foundation/lmm_classifier/v1.py | 2 +- .../models/foundation/moondream2/v1.py | 14 +- .../models/foundation/moondream2/v1_tensor.py | 12 +- .../core_steps/models/foundation/ocr/v1.py | 12 +- .../models/foundation/ocr/v1_tensor.py | 12 +- .../core_steps/models/foundation/openai/v1.py | 4 +- .../core_steps/models/foundation/openai/v2.py | 4 +- .../core_steps/models/foundation/openai/v3.py | 4 +- .../core_steps/models/foundation/openai/v4.py | 4 +- .../core_steps/models/foundation/openai/v5.py | 4 +- .../core_steps/models/foundation/openai/v6.py | 4 +- .../foundation/perception_encoder/v1.py | 6 +- .../perception_encoder/v1_tensor.py | 4 +- .../core_steps/models/foundation/pp_ocr/v1.py | 12 +- .../models/foundation/pp_ocr/v1_tensor.py | 12 +- .../core_steps/models/foundation/qwen/v1.py | 4 +- .../foundation/qwen3_5_openrouter/v1.py | 4 +- .../models/foundation/qwen3_5vl/v1.py | 4 +- .../models/foundation/qwen3_5vl/v1_tensor.py | 10 +- .../models/foundation/qwen3_5vl/v2.py | 4 +- .../models/foundation/qwen3_5vl/v2_tensor.py | 12 +- .../foundation/qwen3_6_openrouter/v1.py | 4 +- .../models/foundation/qwen3vl/v1.py | 4 +- .../models/foundation/qwen3vl/v1_tensor.py | 12 +- .../models/foundation/qwen_vlm/v1.py | 12 +- .../models/foundation/qwen_vlm/v2.py | 10 +- .../models/foundation/qwen_vlm/v3.py | 10 +- .../models/foundation/seg_preview/v1.py | 10 +- .../foundation/seg_preview/v1_tensor.py | 10 +- .../models/foundation/segment_anything2/v1.py | 14 +- .../foundation/segment_anything2/v1_tensor.py | 12 +- .../segment_anything2_video/v1_tensor.py | 5 +- .../models/foundation/segment_anything3/v1.py | 16 +- .../foundation/segment_anything3/v1_tensor.py | 10 +- .../models/foundation/segment_anything3/v2.py | 16 +- .../foundation/segment_anything3/v2_tensor.py | 22 +-- .../models/foundation/segment_anything3/v3.py | 12 +- .../foundation/segment_anything3/v3_tensor.py | 22 +-- .../foundation/segment_anything3_3d/v1.py | 4 +- .../segment_anything3_3d/v1_tensor.py | 10 +- .../segment_anything3_interactive/v1.py | 22 +-- .../v1_tensor.py | 22 +-- .../segment_anything3_video/v1_tensor.py | 10 +- .../streaming_video_tensor.py | 2 +- .../models/foundation/smolvlm/v1.py | 4 +- .../models/foundation/smolvlm/v1_tensor.py | 12 +- .../models/foundation/spacexai/v1.py | 4 +- .../models/foundation/spacexai/v2.py | 4 +- .../models/foundation/yolo_world/v1.py | 14 +- .../roboflow/instance_segmentation/v1.py | 16 +- .../instance_segmentation/v1_tensor.py | 14 +- .../roboflow/instance_segmentation/v2.py | 16 +- .../instance_segmentation/v2_tensor.py | 14 +- .../roboflow/instance_segmentation/v3.py | 16 +- .../instance_segmentation/v3_tensor.py | 14 +- .../roboflow/instance_segmentation/v4.py | 16 +- .../instance_segmentation/v4_tensor.py | 14 +- .../models/roboflow/keypoint_detection/v1.py | 16 +- .../roboflow/keypoint_detection/v1_tensor.py | 18 +-- .../models/roboflow/keypoint_detection/v2.py | 16 +- .../roboflow/keypoint_detection/v2_tensor.py | 18 +-- .../models/roboflow/keypoint_detection/v3.py | 16 +- .../roboflow/keypoint_detection/v3_tensor.py | 18 +-- .../roboflow/multi_class_classification/v1.py | 6 +- .../multi_class_classification/v1_tensor.py | 4 +- .../roboflow/multi_class_classification/v2.py | 6 +- .../multi_class_classification/v2_tensor.py | 4 +- .../roboflow/multi_class_classification/v3.py | 6 +- .../multi_class_classification/v3_tensor.py | 4 +- .../roboflow/multi_label_classification/v1.py | 6 +- .../multi_label_classification/v1_tensor.py | 4 +- .../roboflow/multi_label_classification/v2.py | 6 +- .../multi_label_classification/v2_tensor.py | 4 +- .../roboflow/multi_label_classification/v3.py | 6 +- .../multi_label_classification/v3_tensor.py | 4 +- .../models/roboflow/object_detection/v1.py | 16 +- .../roboflow/object_detection/v1_tensor.py | 14 +- .../models/roboflow/object_detection/v2.py | 16 +- .../roboflow/object_detection/v2_tensor.py | 14 +- .../models/roboflow/object_detection/v3.py | 16 +- .../roboflow/object_detection/v3_tensor.py | 14 +- .../roboflow/semantic_segmentation/v1.py | 4 +- .../semantic_segmentation/v1_tensor.py | 10 +- .../roboflow/semantic_segmentation/v2.py | 4 +- .../semantic_segmentation/v2_tensor.py | 10 +- .../barcode_detection/v1_tensor.py | 2 +- .../qr_code_detection/v1_tensor.py | 2 +- .../environment_secrets_store/v1.py | 4 +- .../core_steps/sinks/local_file/v1.py | 4 +- .../detections_combine/v1_tensor.py | 2 +- .../detections_merge/v1_tensor.py | 2 +- .../detections_transformation/v1.py | 2 +- .../stabilize_detections/v1_tensor.py | 2 +- .../visualizations/common/base_tensor.py | 4 +- .../visualizations/common/fonts/__init__.py | 12 +- scripts/repoint_env_imports.py | 96 ++++++++++++ .../unit_tests/decontamination_baseline.txt | 141 +----------------- .../unit_tests/test_no_server_env_imports.py | 94 ++++++++++++ 142 files changed, 769 insertions(+), 663 deletions(-) create mode 100644 scripts/repoint_env_imports.py create mode 100644 tests/workflows/unit_tests/test_no_server_env_imports.py diff --git a/inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py b/inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py index 4814a1d26e..105f7b18b6 100644 --- a/inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py +++ b/inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py @@ -6,11 +6,11 @@ import torch from pydantic import AliasChoices, ConfigDict, Field, field_validator -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, instance_mask_to_numpy, ) +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.execution_engine.constants import DETECTION_ID_KEY from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, diff --git a/inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py b/inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py index 777ae05b5f..47175aa677 100644 --- a/inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py +++ b/inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py @@ -8,10 +8,10 @@ from pydantic import AliasChoices, ConfigDict, Field, PositiveInt from shapely.geometry import Polygon -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, ) +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.execution_engine.constants import DETECTION_ID_KEY from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, diff --git a/inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py b/inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py index a3d9f1a3d7..7d62c44cc4 100644 --- a/inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py +++ b/inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py @@ -7,10 +7,10 @@ import torch from pydantic import AliasChoices, ConfigDict, Field -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, ) +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.execution_engine.constants import DETECTION_ID_KEY from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, diff --git a/inference/core/workflows/core_steps/common/deserializers_tensor.py b/inference/core/workflows/core_steps/common/deserializers_tensor.py index b2e1f116f7..9bf9c552e3 100644 --- a/inference/core/workflows/core_steps/common/deserializers_tensor.py +++ b/inference/core/workflows/core_steps/common/deserializers_tensor.py @@ -8,7 +8,6 @@ import torch -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.core_steps.common.deserializers import ( _parse_optional_parent_metadata, ) @@ -21,6 +20,7 @@ from inference.core.workflows.core_steps.common.tensor_native import ( native_detections_from_inference_predictions, ) +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.errors import RuntimeInputError from inference.core.workflows.execution_engine.constants import ( CLASS_NAME_KEY, diff --git a/inference/core/workflows/core_steps/common/openrouter.py b/inference/core/workflows/core_steps/common/openrouter.py index 69162cef85..5feaafac4c 100644 --- a/inference/core/workflows/core_steps/common/openrouter.py +++ b/inference/core/workflows/core_steps/common/openrouter.py @@ -30,12 +30,14 @@ from openai import APIStatusError, OpenAI from pydantic import ConfigDict, Field -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.token_usage import ( parse_chat_completion_usage, ) from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.types import ( FLOAT_KIND, ROBOFLOW_MANAGED_KEY, diff --git a/inference/core/workflows/core_steps/common/query_language/operations/classification_results/base.py b/inference/core/workflows/core_steps/common/query_language/operations/classification_results/base.py index 65efe56fb8..70ad60761c 100644 --- a/inference/core/workflows/core_steps/common/query_language/operations/classification_results/base.py +++ b/inference/core/workflows/core_steps/common/query_language/operations/classification_results/base.py @@ -1,6 +1,5 @@ from typing import Any, List, Union -from inference.core.env import ENABLE_TENSOR_DATA_REPRESENTATION from inference.core.workflows.core_steps.common.query_language.entities.enums import ( ClassificationProperty, ) @@ -11,6 +10,7 @@ from inference.core.workflows.core_steps.common.query_language.operations.utils import ( safe_stringify, ) +from inference.core.workflows.environment import ENABLE_TENSOR_DATA_REPRESENTATION from inference.core.workflows.execution_engine.constants import CLASS_NAMES_KEY from inference_models import ( ClassificationPrediction, diff --git a/inference/core/workflows/core_steps/common/query_language/operations/detection/base.py b/inference/core/workflows/core_steps/common/query_language/operations/detection/base.py index 763d9e3c94..03735738b3 100644 --- a/inference/core/workflows/core_steps/common/query_language/operations/detection/base.py +++ b/inference/core/workflows/core_steps/common/query_language/operations/detection/base.py @@ -1,6 +1,5 @@ from typing import Any, Optional -from inference.core.env import ENABLE_TENSOR_DATA_REPRESENTATION from inference.core.workflows.core_steps.analytics.line_counter.v2 import ( DETECTIONS_IN_OUT_PARAM, ) @@ -20,6 +19,7 @@ from inference.core.workflows.core_steps.common.query_language.operations.utils import ( safe_stringify, ) +from inference.core.workflows.environment import ENABLE_TENSOR_DATA_REPRESENTATION from inference.core.workflows.execution_engine.constants import ( AREA_CONVERTED_KEY_IN_SV_DETECTIONS, AREA_KEY_IN_SV_DETECTIONS, diff --git a/inference/core/workflows/core_steps/common/query_language/operations/detections/base.py b/inference/core/workflows/core_steps/common/query_language/operations/detections/base.py index d97f9ac2a8..c425015783 100644 --- a/inference/core/workflows/core_steps/common/query_language/operations/detections/base.py +++ b/inference/core/workflows/core_steps/common/query_language/operations/detections/base.py @@ -6,7 +6,6 @@ import torch from supervision import Position -from inference.core.env import ENABLE_TENSOR_DATA_REPRESENTATION from inference.core.workflows.core_steps.common.query_language.entities.enums import ( DetectionsProperty, DetectionsSelectionMode, @@ -32,6 +31,7 @@ from inference.core.workflows.core_steps.common.tensor_native import ( strip_host_mirror_metadata, ) +from inference.core.workflows.environment import ENABLE_TENSOR_DATA_REPRESENTATION from inference.core.workflows.execution_engine.constants import ( CLASS_NAME_KEY, CLASS_NAMES_KEY, diff --git a/inference/core/workflows/core_steps/common/tensor_native.py b/inference/core/workflows/core_steps/common/tensor_native.py index 24253ef1ca..e744a01d4a 100644 --- a/inference/core/workflows/core_steps/common/tensor_native.py +++ b/inference/core/workflows/core_steps/common/tensor_native.py @@ -20,10 +20,10 @@ from pycocotools import mask as mask_utils from supervision.config import ORIENTED_BOX_COORDINATES -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.core_steps.common.keypoints import ( validate_keypoints_padding, ) +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.execution_engine.constants import ( CLASS_ID_KEY, CLASS_NAME_KEY, diff --git a/inference/core/workflows/core_steps/formatters/vlm_as_classifier/v1_tensor.py b/inference/core/workflows/core_steps/formatters/vlm_as_classifier/v1_tensor.py index 3c7d5dd975..4153d6c766 100644 --- a/inference/core/workflows/core_steps/formatters/vlm_as_classifier/v1_tensor.py +++ b/inference/core/workflows/core_steps/formatters/vlm_as_classifier/v1_tensor.py @@ -5,11 +5,11 @@ import torch from pydantic import ConfigDict, Field -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.core_steps.common.vlm_json import ( coerce_classification_payload, extract_json_payload, ) +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, CLASSIFICATION_STYLE_FORMATTER, diff --git a/inference/core/workflows/core_steps/formatters/vlm_as_classifier/v2_tensor.py b/inference/core/workflows/core_steps/formatters/vlm_as_classifier/v2_tensor.py index 0f13b7beee..b2c4a5c527 100644 --- a/inference/core/workflows/core_steps/formatters/vlm_as_classifier/v2_tensor.py +++ b/inference/core/workflows/core_steps/formatters/vlm_as_classifier/v2_tensor.py @@ -5,11 +5,11 @@ import torch from pydantic import ConfigDict, Field -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.core_steps.common.vlm_json import ( coerce_classification_payload, extract_json_payload, ) +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, CLASSIFICATION_STYLE_FORMATTER, diff --git a/inference/core/workflows/core_steps/formatters/vlm_as_detector/v1_tensor.py b/inference/core/workflows/core_steps/formatters/vlm_as_detector/v1_tensor.py index 4309627f58..25db20fc14 100644 --- a/inference/core/workflows/core_steps/formatters/vlm_as_detector/v1_tensor.py +++ b/inference/core/workflows/core_steps/formatters/vlm_as_detector/v1_tensor.py @@ -9,7 +9,6 @@ import torch from pydantic import ConfigDict, Field, model_validator -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.core_steps.common.vlm_json import extract_json_payload from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.core_steps.formatters.vlm_as_detector.gemini_detection_parsing import ( @@ -19,6 +18,7 @@ get_gemini_detection_class_name, scale_confidence, ) +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.execution_engine.constants import ( CLASS_NAME_KEY, CLASS_NAMES_KEY, diff --git a/inference/core/workflows/core_steps/formatters/vlm_as_detector/v2_tensor.py b/inference/core/workflows/core_steps/formatters/vlm_as_detector/v2_tensor.py index e6df757412..7907389a28 100644 --- a/inference/core/workflows/core_steps/formatters/vlm_as_detector/v2_tensor.py +++ b/inference/core/workflows/core_steps/formatters/vlm_as_detector/v2_tensor.py @@ -9,7 +9,6 @@ import torch from pydantic import ConfigDict, Field, model_validator -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.core_steps.common.vlm_json import extract_json_payload from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.core_steps.formatters.vlm_as_detector.gemini_detection_parsing import ( @@ -38,6 +37,7 @@ convert_spacexai_detection_to_pixel_xyxy, extract_spacexai_detection_entries, ) +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.execution_engine.constants import ( CLASS_NAME_KEY, CLASS_NAMES_KEY, diff --git a/inference/core/workflows/core_steps/fusion/detections_consensus/v1_tensor.py b/inference/core/workflows/core_steps/fusion/detections_consensus/v1_tensor.py index 8ed93b2114..cac291c849 100644 --- a/inference/core/workflows/core_steps/fusion/detections_consensus/v1_tensor.py +++ b/inference/core/workflows/core_steps/fusion/detections_consensus/v1_tensor.py @@ -23,11 +23,11 @@ import torch from pydantic import AliasChoices, ConfigDict, Field, PositiveInt -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.core_steps.common.tensor_native import ( instance_mask_to_numpy, take_prediction_by_indices, ) +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, DETECTION_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py index 6568ca9518..faee56ec2e 100644 --- a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py @@ -8,12 +8,14 @@ from anthropic import NOT_GIVEN from pydantic import ConfigDict, Field, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.core_steps.models.foundation.anthropic_claude.model_capabilities import ( resolve_temperature, ) +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py index d5cf69716a..a81e473c2a 100644 --- a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py @@ -7,13 +7,15 @@ from anthropic import NOT_GIVEN from pydantic import ConfigDict, Field, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.core_steps.models.foundation.anthropic_claude.model_capabilities import ( build_thinking_config, resolve_temperature, ) +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py index b86f4c1dbb..0fc1adb2fb 100644 --- a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py @@ -8,13 +8,15 @@ from anthropic import NOT_GIVEN from pydantic import ConfigDict, Field, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.core_steps.models.foundation.anthropic_claude.model_capabilities import ( build_thinking_config, resolve_temperature, ) +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py index b168f13206..3849bde73f 100644 --- a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py +++ b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py @@ -22,7 +22,6 @@ from anthropic import NOT_GIVEN from pydantic import ConfigDict, Field, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.token_usage import ( TOKEN_OUTPUT_DEFINITIONS, parse_responses_api_usage, @@ -36,6 +35,9 @@ build_thinking_config, resolve_temperature, ) +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/clip/v1.py b/inference/core/workflows/core_steps/models/foundation/clip/v1.py index 467b878444..4d08f59d67 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/clip/v1.py @@ -7,14 +7,14 @@ ClipImageEmbeddingRequest, ClipTextEmbeddingRequest, ) -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import load_core_model +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import load_core_model from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, WorkflowImageData, diff --git a/inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py index 4c63ffa73e..ebc55e16fe 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py @@ -4,14 +4,14 @@ import torch from pydantic import ConfigDict, Field -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_IMAGE_TENSOR_DEVICE, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, WorkflowImageData, diff --git a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py index e1b6ff6eea..df0713f1c5 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py @@ -4,7 +4,13 @@ from pydantic import AliasChoices, ConfigDict, Field from inference.core.entities.requests.clip import ClipCompareRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import ( + load_core_model, + remove_unexpected_keys_from_dictionary, + run_in_parallel, +) +from inference.core.workflows.environment import ( CLIP_VERSION_ID, HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, @@ -12,12 +18,6 @@ WORKFLOWS_REMOTE_API_TARGET, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import ( - load_core_model, - remove_unexpected_keys_from_dictionary, - run_in_parallel, -) from inference.core.workflows.execution_engine.constants import ( PARENT_ID_KEY, PREDICTION_TYPE_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py index e6fa1d2824..c6926d841c 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py @@ -5,7 +5,12 @@ import torch.nn.functional as F from pydantic import AliasChoices, ConfigDict, Field -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import ( + remove_unexpected_keys_from_dictionary, + run_in_parallel, +) +from inference.core.workflows.environment import ( CLIP_VERSION_ID, HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, @@ -13,11 +18,6 @@ WORKFLOWS_REMOTE_API_TARGET, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import ( - remove_unexpected_keys_from_dictionary, - run_in_parallel, -) from inference.core.workflows.execution_engine.constants import ( PARENT_ID_KEY, PREDICTION_TYPE_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py index 1b39f97947..f8681fc559 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py @@ -5,18 +5,18 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.clip import ClipCompareRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import ( + load_core_model, + run_in_parallel, +) +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import ( - load_core_model, - run_in_parallel, -) from inference.core.workflows.execution_engine.constants import ( PARENT_ID_KEY, ROOT_PARENT_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py index fc406783f0..66dee1e240 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py @@ -6,15 +6,15 @@ import torch.nn.functional as F from pydantic import ConfigDict, Field -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import run_in_parallel +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, IMAGE_DIMENSIONS_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py b/inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py index 777f0aa5b0..dfc7b918ca 100644 --- a/inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py @@ -3,8 +3,8 @@ from pydantic import ConfigDict, Field -from inference.core.env import LMM_ENABLED from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import LMM_ENABLED from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py b/inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py index 4d41fe6528..3e90a3b2c4 100644 --- a/inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py @@ -3,14 +3,14 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.inference import LMMInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( COSMOS3_ENABLED, HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py b/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py index d3f8c9b247..0eee1cf4d2 100644 --- a/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py @@ -4,14 +4,14 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.inference import DepthEstimationRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( DEPTH_ESTIMATION_ENABLED, HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1_tensor.py index ead68f3844..aa36219724 100644 --- a/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1_tensor.py @@ -5,7 +5,8 @@ import torch from pydantic import ConfigDict, Field -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( DEPTH_ESTIMATION_ENABLED, HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, @@ -13,7 +14,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py index 5fdb057904..695c5666a1 100644 --- a/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py @@ -7,7 +7,12 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.easy_ocr import EasyOCRInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import ( + load_core_model, + post_process_ocr_result, +) +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, @@ -15,11 +20,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import ( - load_core_model, - post_process_ocr_result, -) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py index bbdfcf31a3..40ed18219c 100644 --- a/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py @@ -2,7 +2,12 @@ from pydantic import ConfigDict, Field -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + attach_native_detection_metadata, + native_detections_from_inference_predictions, +) +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_IMAGE_TENSOR_DEVICE, @@ -11,11 +16,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - attach_native_detection_metadata, - native_detections_from_inference_predictions, -) from inference.core.workflows.execution_engine.constants import CLASS_NAME_KEY from inference.core.workflows.execution_engine.entities.base import ( Batch, diff --git a/inference/core/workflows/core_steps/models/foundation/florence2/v1.py b/inference/core/workflows/core_steps/models/foundation/florence2/v1.py index 5f0495ae98..e54a5cf5dd 100644 --- a/inference/core/workflows/core_steps/models/foundation/florence2/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/florence2/v1.py @@ -6,15 +6,15 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.entities.requests.inference import LMMInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( FLORENCE2_ENABLED, HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/florence2/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/florence2/v1_tensor.py index 9443ef7885..6088e20c0c 100644 --- a/inference/core/workflows/core_steps/models/foundation/florence2/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/florence2/v1_tensor.py @@ -10,12 +10,6 @@ import torch -from inference.core.env import ( - HOSTED_CORE_MODEL_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( split_key_point_prediction, @@ -29,6 +23,12 @@ TaskType, _coordinate_to_loc, ) +from inference.core.workflows.environment import ( + HOSTED_CORE_MODEL_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, WorkflowImageData, diff --git a/inference/core/workflows/core_steps/models/foundation/gaze/v1.py b/inference/core/workflows/core_steps/models/foundation/gaze/v1.py index 1dc0ed991d..5399ed8090 100644 --- a/inference/core/workflows/core_steps/models/foundation/gaze/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/gaze/v1.py @@ -2,8 +2,8 @@ from pydantic import ConfigDict, Field -from inference.core.env import CORE_MODEL_GAZE_ENABLED from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import CORE_MODEL_GAZE_ENABLED from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py index eb4e215f74..90b843ac2d 100644 --- a/inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py @@ -4,14 +4,14 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.entities.requests.inference import LMMInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( GLM_OCR_ENABLED, HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py index 82b64896f1..3dd59cce65 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py @@ -8,9 +8,11 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from requests import Response -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py index 8260fc8fb3..09af08fb78 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py @@ -8,9 +8,11 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from requests import Response -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py index e0ecaf1d94..12a5c7b73c 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py @@ -8,9 +8,11 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from requests import Response -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py index 54e4523127..08d911704a 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py @@ -8,9 +8,11 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from requests import Response -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py index 3fe7065cb5..6035cb8e00 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py @@ -8,7 +8,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from requests import Response -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.reasoning import ( attach_reasoning_levels, models_supporting_reasoning, @@ -20,6 +19,9 @@ ) from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py b/inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py index 8e7816213b..65645f0e54 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py @@ -6,9 +6,11 @@ from openai import OpenAI from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1_tensor.py index ee3ddd28cf..02ec3ebdd3 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1_tensor.py @@ -7,10 +7,10 @@ import torch from pydantic import ConfigDict, Field -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, ) +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.execution_engine.constants import ( CLASS_NAME_KEY, DETECTION_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py b/inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py index 1028ae18d4..90dc2caf2e 100644 --- a/inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py @@ -6,9 +6,11 @@ from openai import OpenAI from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py b/inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py index cb7b066d68..1ff311c172 100644 --- a/inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py @@ -6,9 +6,11 @@ from openai import OpenAI from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/lmm/v1.py b/inference/core/workflows/core_steps/models/foundation/lmm/v1.py index 1b6873a76d..52a20e9753 100644 --- a/inference/core/workflows/core_steps/models/foundation/lmm/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/lmm/v1.py @@ -7,17 +7,17 @@ from openai import OpenAI from pydantic import BaseModel, ConfigDict, Field -from inference.core.env import ( - LMM_ENABLED, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_TARGET, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( load_core_model, run_in_parallel, ) +from inference.core.workflows.environment import ( + LMM_ENABLED, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_TARGET, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.constants import ( PARENT_ID_KEY, ROOT_PARENT_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/lmm_classifier/v1.py b/inference/core/workflows/core_steps/models/foundation/lmm_classifier/v1.py index adddbf1372..aaabd2450a 100644 --- a/inference/core/workflows/core_steps/models/foundation/lmm_classifier/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/lmm_classifier/v1.py @@ -2,7 +2,6 @@ from pydantic import ConfigDict, Field -from inference.core.env import LMM_ENABLED from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.models.foundation.lmm.v1 import ( GPT_4V_MODEL_TYPE, @@ -10,6 +9,7 @@ run_gpt_4v_llm_prompting, turn_raw_lmm_output_into_structured, ) +from inference.core.workflows.environment import LMM_ENABLED from inference.core.workflows.execution_engine.constants import ( PARENT_ID_KEY, PREDICTION_TYPE_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/moondream2/v1.py b/inference/core/workflows/core_steps/models/foundation/moondream2/v1.py index 3afda9cd2c..a734ecf59f 100644 --- a/inference/core/workflows/core_steps/models/foundation/moondream2/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/moondream2/v1.py @@ -4,19 +4,19 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.moondream2 import Moondream2InferenceRequest -from inference.core.env import ( - HOSTED_CORE_MODEL_URL, - LOCAL_INFERENCE_API_URL, - MOONDREAM2_ENABLED, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, attach_prediction_type_info_to_sv_detections_batch, convert_inference_detections_batch_to_sv_detections, ) +from inference.core.workflows.environment import ( + HOSTED_CORE_MODEL_URL, + LOCAL_INFERENCE_API_URL, + MOONDREAM2_ENABLED, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/moondream2/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/moondream2/v1_tensor.py index 2c832f7d04..6813cd2c91 100644 --- a/inference/core/workflows/core_steps/models/foundation/moondream2/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/moondream2/v1_tensor.py @@ -2,7 +2,12 @@ from pydantic import ConfigDict, Field -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + attach_native_detection_metadata, + native_detections_from_inference_predictions, +) +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, MOONDREAM2_ENABLED, @@ -10,11 +15,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - attach_native_detection_metadata, - native_detections_from_inference_predictions, -) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/ocr/v1.py index 463d15afd8..bb2b3f0a27 100644 --- a/inference/core/workflows/core_steps/models/foundation/ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/ocr/v1.py @@ -8,7 +8,12 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.doctr import DoctrOCRInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import ( + load_core_model, + post_process_ocr_result, +) +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, @@ -16,11 +21,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import ( - load_core_model, - post_process_ocr_result, -) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py index e7fe611eec..4e7a6f8fcc 100644 --- a/inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py @@ -2,7 +2,12 @@ from pydantic import ConfigDict, Field -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + attach_native_detection_metadata, + native_detections_from_inference_predictions, +) +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_IMAGE_TENSOR_DEVICE, @@ -11,11 +16,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - attach_native_detection_metadata, - native_detections_from_inference_predictions, -) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v1.py b/inference/core/workflows/core_steps/models/foundation/openai/v1.py index 50a649d69e..cdf260d085 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v1.py @@ -7,8 +7,10 @@ from openai import OpenAI from pydantic import BaseModel, ConfigDict, Field -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.constants import ( PARENT_ID_KEY, ROOT_PARENT_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v2.py b/inference/core/workflows/core_steps/models/foundation/openai/v2.py index e8b8aa012d..b80a22db17 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v2.py @@ -7,9 +7,11 @@ from openai._types import NOT_GIVEN from pydantic import ConfigDict, Field, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v3.py b/inference/core/workflows/core_steps/models/foundation/openai/v3.py index 857c8e92c9..70b455daba 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v3.py @@ -8,9 +8,11 @@ from openai._types import NOT_GIVEN from pydantic import ConfigDict, Field, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v4.py b/inference/core/workflows/core_steps/models/foundation/openai/v4.py index c3ed4ca4c7..6ad2da8dfe 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v4.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v4.py @@ -7,9 +7,11 @@ from openai import OpenAI from pydantic import ConfigDict, Field, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v5.py b/inference/core/workflows/core_steps/models/foundation/openai/v5.py index 0cfa23f155..85ba8d2912 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v5.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v5.py @@ -9,13 +9,15 @@ from openai import OpenAI from pydantic import ConfigDict, Field, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import ( DETECTION_MAX_EDGE_PIXELS, run_in_parallel, scale_dimensions_to_max_edge, ) from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v6.py b/inference/core/workflows/core_steps/models/foundation/openai/v6.py index d9b27a9f52..7efef86ff5 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v6.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v6.py @@ -9,7 +9,6 @@ from openai import OpenAI from pydantic import ConfigDict, Field, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.reasoning import ( attach_reasoning_levels, models_supporting_reasoning, @@ -25,6 +24,9 @@ scale_dimensions_to_max_edge, ) from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py b/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py index 940a493155..95eccea2cc 100644 --- a/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py @@ -7,15 +7,15 @@ PerceptionEncoderImageEmbeddingRequest, PerceptionEncoderTextEmbeddingRequest, ) -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import load_core_model +from inference.core.workflows.environment import ( CORE_MODEL_PE_ENABLED, HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import load_core_model from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, WorkflowImageData, diff --git a/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py index a8d3c049c4..f37a313f79 100644 --- a/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py @@ -4,7 +4,8 @@ import torch from pydantic import ConfigDict, Field -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( CORE_MODEL_PE_ENABLED, HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, @@ -12,7 +13,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, WorkflowImageData, diff --git a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py index 8d0b8aa4b3..ded8c1523d 100644 --- a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py @@ -3,7 +3,12 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.entities.requests.pp_ocr import PPOCRInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import ( + load_core_model, + post_process_ocr_result, +) +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, @@ -11,11 +16,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import ( - load_core_model, - post_process_ocr_result, -) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py index 4611112ed8..baa80a2c0a 100644 --- a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py @@ -28,7 +28,12 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.entities.requests.pp_ocr import PPOCRInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + native_detections_from_inference_predictions, +) +from inference.core.workflows.core_steps.common.utils import load_core_model +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_IMAGE_TENSOR_DEVICE, @@ -37,11 +42,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - native_detections_from_inference_predictions, -) -from inference.core.workflows.core_steps.common.utils import load_core_model from inference.core.workflows.execution_engine.constants import CLASS_NAME_KEY from inference.core.workflows.execution_engine.entities.base import ( Batch, diff --git a/inference/core/workflows/core_steps/models/foundation/qwen/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen/v1.py index 687848059b..e63da26b96 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen/v1.py @@ -5,14 +5,14 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.inference import LMMInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, QWEN_2_5_ENABLED, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py index 7ae04a7dbe..ef6bd2ebba 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py @@ -6,9 +6,11 @@ from openai import OpenAI from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py index 3732b56bce..49eff639f0 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py @@ -5,14 +5,14 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.inference import LMMInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, QWEN_3_5_ENABLED, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1_tensor.py index 7882f38289..c25135e760 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1_tensor.py @@ -2,16 +2,16 @@ from typing import List, Optional, Type -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.models.foundation.qwen3_5vl.v1 import ( + BlockManifest, +) +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.models.foundation.qwen3_5vl.v1 import ( - BlockManifest, -) from inference.core.workflows.execution_engine.entities.base import ( Batch, WorkflowImageData, diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py index f5498260de..1ca14885ac 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py @@ -3,14 +3,14 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.inference import LMMInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, QWEN_3_5_ENABLED, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2_tensor.py b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2_tensor.py index f1eb794884..b244be2dda 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2_tensor.py @@ -15,12 +15,6 @@ from typing import List, Optional, Type -from inference.core.env import ( - HOSTED_CORE_MODEL_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.models.foundation.qwen3_5vl.v1_tensor import ( Qwen35VLBlockV1, @@ -28,6 +22,12 @@ from inference.core.workflows.core_steps.models.foundation.qwen3_5vl.v2 import ( BlockManifest, ) +from inference.core.workflows.environment import ( + HOSTED_CORE_MODEL_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, WorkflowImageData, diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py index cfdd1feab7..98eb3486d1 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py @@ -6,9 +6,11 @@ from openai import OpenAI from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py index 51b2dc302e..61460023b1 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py @@ -5,14 +5,14 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.inference import LMMInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, QWEN_3_ENABLED, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1_tensor.py index 05e25f07e4..cbc04b69a7 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1_tensor.py @@ -15,18 +15,18 @@ from typing import List, Optional, Type -from inference.core.env import ( - HOSTED_CORE_MODEL_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode # Unchanged from v1 — verbatim manifest, class name, type literal, outputs. from inference.core.workflows.core_steps.models.foundation.qwen3vl.v1 import ( BlockManifest, ) +from inference.core.workflows.environment import ( + HOSTED_CORE_MODEL_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, WorkflowImageData, diff --git a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py index 0ddc6b6f27..0cb505e016 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py @@ -28,12 +28,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from inference.core.entities.requests.inference import LMMInferenceRequest -from inference.core.env import ( - HOSTED_CORE_MODEL_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.openrouter import ( PRIVACY_LEVEL_LITERAL, @@ -46,6 +40,12 @@ build_prompts_from_images, validate_task_type_required_fields, ) +from inference.core.workflows.environment import ( + HOSTED_CORE_MODEL_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py index f1ace3fd1a..bf4fe4ebae 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py @@ -34,11 +34,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from inference.core.entities.requests.inference import LMMInferenceRequest -from inference.core.env import ( - HOSTED_CORE_MODEL_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_TARGET, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.openrouter import ( PRIVACY_LEVEL_LITERAL, @@ -53,6 +48,11 @@ from inference.core.workflows.core_steps.common.utils import ( scale_dimensions_to_max_edge, ) +from inference.core.workflows.environment import ( + HOSTED_CORE_MODEL_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_TARGET, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py index 0cf0df0586..91f4d9faa3 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py @@ -37,11 +37,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from inference.core.entities.requests.inference import LMMInferenceRequest -from inference.core.env import ( - HOSTED_CORE_MODEL_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_TARGET, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.openrouter import ( PRIVACY_LEVEL_LITERAL, @@ -63,6 +58,11 @@ from inference.core.workflows.core_steps.common.utils import ( scale_dimensions_to_max_edge, ) +from inference.core.workflows.environment import ( + HOSTED_CORE_MODEL_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_TARGET, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py b/inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py index 50819e892a..9a50406cee 100644 --- a/inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py @@ -11,17 +11,17 @@ InstanceSegmentationPrediction, Point, ) -from inference.core.env import ( - API_BASE_URL, - ROBOFLOW_INTERNAL_SERVICE_NAME, - ROBOFLOW_INTERNAL_SERVICE_SECRET, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, attach_prediction_type_info_to_sv_detections_batch, convert_inference_detections_batch_to_sv_detections, ) +from inference.core.workflows.environment import ( + API_BASE_URL, + ROBOFLOW_INTERNAL_SERVICE_NAME, + ROBOFLOW_INTERNAL_SERVICE_SECRET, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py index 0e03aff4ac..e796eaf222 100644 --- a/inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py @@ -24,16 +24,16 @@ from pycocotools import mask as mask_utils from pydantic import ConfigDict, Field -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + build_native_image_metadata, +) +from inference.core.workflows.environment import ( API_BASE_URL, ROBOFLOW_INTERNAL_SERVICE_NAME, ROBOFLOW_INTERNAL_SERVICE_SECRET, WORKFLOWS_IMAGE_TENSOR_DEVICE, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - build_native_image_metadata, -) from inference.core.workflows.execution_engine.constants import ( CLASS_NAME_KEY, DETECTION_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py index ee07da8ceb..a7cf35893f 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py @@ -17,13 +17,6 @@ Point, ) from inference.core.entities.responses.sam2 import Sam2SegmentationPrediction -from inference.core.env import ( - CORE_MODEL_SAM2_ENABLED, - HOSTED_CORE_MODEL_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -31,6 +24,13 @@ convert_inference_detections_batch_to_sv_detections, load_core_model, ) +from inference.core.workflows.environment import ( + CORE_MODEL_SAM2_ENABLED, + HOSTED_CORE_MODEL_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py index 768dbf6e99..4d7c5b542e 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py @@ -5,7 +5,12 @@ from pydantic import ConfigDict, Field from typing_extensions import Literal -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + build_native_image_metadata, + split_key_point_prediction, +) +from inference.core.workflows.environment import ( CORE_MODEL_SAM2_ENABLED, HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, @@ -13,11 +18,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - build_native_image_metadata, - split_key_point_prediction, -) from inference.core.workflows.execution_engine.constants import ( CLASS_NAME_KEY, CLASS_NAMES_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py index d1db5649ce..d482a7ca9c 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py @@ -31,7 +31,6 @@ import numpy as np from pydantic import ConfigDict, Field -from inference.core.env import GCP_SERVERLESS, WORKFLOWS_SAM_VIDEO_MASK_REPRESENTATION from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.models.foundation.segment_anything_common.streaming_video import ( VideoSessionBookkeeping, @@ -42,6 +41,10 @@ extract_box_prompts_tensor, masks_to_instance_detections, ) +from inference.core.workflows.environment import ( + GCP_SERVERLESS, + WORKFLOWS_SAM_VIDEO_MASK_REPRESENTATION, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py index b59c2978a5..e448e48be2 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py @@ -15,7 +15,14 @@ Point, ) from inference.core.entities.responses.sam3 import Sam3SegmentationPrediction -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import ( + attach_parents_coordinates_to_batch_of_sv_detections, + attach_prediction_type_info_to_sv_detections_batch, + convert_inference_detections_batch_to_sv_detections, + load_core_model, +) +from inference.core.workflows.environment import ( API_BASE_URL, CORE_MODEL_SAM3_ENABLED, HOSTED_CORE_MODEL_URL, @@ -26,13 +33,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import ( - attach_parents_coordinates_to_batch_of_sv_detections, - attach_prediction_type_info_to_sv_detections_batch, - convert_inference_detections_batch_to_sv_detections, - load_core_model, -) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py index 9cb36ef825..a0ba2ba475 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py @@ -28,7 +28,11 @@ from pycocotools import mask as mask_utils from pydantic import ConfigDict, Field -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + build_native_image_metadata, +) +from inference.core.workflows.environment import ( API_BASE_URL, CORE_MODEL_SAM3_ENABLED, HOSTED_CORE_MODEL_URL, @@ -40,10 +44,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - build_native_image_metadata, -) from inference.core.workflows.execution_engine.constants import ( CLASS_NAME_KEY, DETECTION_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py index 8673031895..c02179f1e7 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py @@ -15,7 +15,14 @@ Point, ) from inference.core.entities.responses.sam3 import Sam3SegmentationPrediction -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import ( + attach_parents_coordinates_to_batch_of_sv_detections, + attach_prediction_type_info_to_sv_detections_batch, + convert_inference_detections_batch_to_sv_detections, + load_core_model, +) +from inference.core.workflows.environment import ( API_BASE_URL, CORE_MODEL_SAM3_ENABLED, HOSTED_CORE_MODEL_URL, @@ -26,13 +33,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import ( - attach_parents_coordinates_to_batch_of_sv_detections, - attach_prediction_type_info_to_sv_detections_batch, - convert_inference_detections_batch_to_sv_detections, - load_core_model, -) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py index 51a5238ea3..3917f8c77e 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py @@ -29,7 +29,17 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from inference.core.entities.requests.sam3 import Sam3Prompt -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode + +# Reuse the v1_tensor SAM3 conversion machinery verbatim. +from inference.core.workflows.core_steps.models.foundation.segment_anything3.v1_tensor import ( + Item, + _assemble_detections, + _build_instance_detections, + _build_instance_detections_from_polygons, + _normalize_class_names, +) +from inference.core.workflows.environment import ( API_BASE_URL, CORE_MODEL_SAM3_ENABLED, HOSTED_CORE_MODEL_URL, @@ -41,16 +51,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode - -# Reuse the v1_tensor SAM3 conversion machinery verbatim. -from inference.core.workflows.core_steps.models.foundation.segment_anything3.v1_tensor import ( - Item, - _assemble_detections, - _build_instance_detections, - _build_instance_detections_from_polygons, - _normalize_class_names, -) from inference.core.workflows.execution_engine.constants import ( CLASS_NAME_KEY, DETECTION_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py index 9d37c81f92..21928e0a17 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py @@ -15,7 +15,12 @@ InstanceSegmentationRLEPrediction, Point, ) -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import ( + attach_parents_coordinates_to_batch_of_sv_detections, + attach_prediction_type_info_to_sv_detections_batch, +) +from inference.core.workflows.environment import ( API_BASE_URL, CORE_MODEL_SAM3_ENABLED, HOSTED_CORE_MODEL_URL, @@ -26,11 +31,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import ( - attach_parents_coordinates_to_batch_of_sv_detections, - attach_prediction_type_info_to_sv_detections_batch, -) from inference.core.workflows.execution_engine.constants import ( DETECTION_ID_KEY, IMAGE_DIMENSIONS_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py index a17e8dcb89..f311e6f0ae 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py @@ -45,17 +45,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from inference.core.entities.requests.sam3 import Sam3Prompt -from inference.core.env import ( - API_BASE_URL, - CORE_MODEL_SAM3_ENABLED, - HOSTED_CORE_MODEL_URL, - LOCAL_INFERENCE_API_URL, - ROBOFLOW_INTERNAL_SERVICE_NAME, - ROBOFLOW_INTERNAL_SERVICE_SECRET, - SAM3_EXEC_MODE, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode # Reuse the v1_tensor conversion machinery + the v2_tensor per-class/NMS collector. @@ -71,6 +60,17 @@ _min_floor, _per_class_threshold, ) +from inference.core.workflows.environment import ( + API_BASE_URL, + CORE_MODEL_SAM3_ENABLED, + HOSTED_CORE_MODEL_URL, + LOCAL_INFERENCE_API_URL, + ROBOFLOW_INTERNAL_SERVICE_NAME, + ROBOFLOW_INTERNAL_SERVICE_SECRET, + SAM3_EXEC_MODE, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py index c82c8983c5..a2bdc955cf 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py @@ -7,14 +7,14 @@ from inference.core.entities.requests.sam3_3d import Sam3_3D_Objects_InferenceRequest from inference.core.entities.responses.sam3_3d import Sam3_3D_Objects_Response -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, SAM3_3D_OBJECTS_ENABLED, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py index b98f1de2f1..b18ef047be 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py @@ -25,17 +25,17 @@ from inference.core.entities.requests.sam3_3d import Sam3_3D_Objects_InferenceRequest from inference.core.entities.responses.sam3_3d import Sam3_3D_Objects_Response -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + instance_mask_to_numpy, +) +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, SAM3_3D_OBJECTS_ENABLED, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - instance_mask_to_numpy, -) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py index cef5a4f2a8..e7eb774c22 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py @@ -14,17 +14,6 @@ Sam2SegmentationRequest, ) from inference.core.entities.responses.sam2 import Sam2SegmentationPrediction -from inference.core.env import ( - API_BASE_URL, - CORE_MODEL_SAM3_ENABLED, - HOSTED_CORE_MODEL_URL, - LOCAL_INFERENCE_API_URL, - ROBOFLOW_INTERNAL_SERVICE_NAME, - ROBOFLOW_INTERNAL_SERVICE_SECRET, - SAM3_EXEC_MODE, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -39,6 +28,17 @@ SYNTHETIC_POINT_PROMPT_CLASS_NAME, normalise_labeled_points, ) +from inference.core.workflows.environment import ( + API_BASE_URL, + CORE_MODEL_SAM3_ENABLED, + HOSTED_CORE_MODEL_URL, + LOCAL_INFERENCE_API_URL, + ROBOFLOW_INTERNAL_SERVICE_NAME, + ROBOFLOW_INTERNAL_SERVICE_SECRET, + SAM3_EXEC_MODE, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py index 9c8d4ad4e9..42199e6bd4 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py @@ -38,7 +38,17 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.entities.requests.sam2 import Box, Point, Sam2Prompt, Sam2PromptSet -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + build_native_image_metadata, + split_key_point_prediction, +) +from inference.core.workflows.core_steps.models.foundation.segment_anything_common.visual_prompt import ( + SYNTHETIC_POINT_PROMPT_CLASS_ID, + SYNTHETIC_POINT_PROMPT_CLASS_NAME, + normalise_labeled_points, +) +from inference.core.workflows.environment import ( API_BASE_URL, CORE_MODEL_SAM3_ENABLED, HOSTED_CORE_MODEL_URL, @@ -50,16 +60,6 @@ WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - build_native_image_metadata, - split_key_point_prediction, -) -from inference.core.workflows.core_steps.models.foundation.segment_anything_common.visual_prompt import ( - SYNTHETIC_POINT_PROMPT_CLASS_ID, - SYNTHETIC_POINT_PROMPT_CLASS_NAME, - normalise_labeled_points, -) from inference.core.workflows.execution_engine.constants import ( CLASS_NAME_KEY, CLASS_NAMES_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py index a4b44c959e..3a2fc72de3 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py @@ -37,11 +37,6 @@ import torch from pydantic import ConfigDict, Field, model_validator -from inference.core.env import ( - GCP_SERVERLESS, - WORKFLOWS_IMAGE_TENSOR_DEVICE, - WORKFLOWS_SAM_VIDEO_MASK_REPRESENTATION, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, @@ -63,6 +58,11 @@ SYNTHETIC_POINT_PROMPT_CLASS_NAME, normalise_labeled_points, ) +from inference.core.workflows.environment import ( + GCP_SERVERLESS, + WORKFLOWS_IMAGE_TENSOR_DEVICE, + WORKFLOWS_SAM_VIDEO_MASK_REPRESENTATION, +) from inference.core.workflows.execution_engine.constants import ( CLASS_NAME_KEY, DETECTION_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything_common/streaming_video_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything_common/streaming_video_tensor.py index ed75ed7ece..ae11ddb487 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything_common/streaming_video_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything_common/streaming_video_tensor.py @@ -6,7 +6,6 @@ import numpy as np import torch -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, split_key_point_prediction, @@ -14,6 +13,7 @@ from inference.core.workflows.core_steps.models.foundation.segment_anything_common.streaming_video import ( BoxPromptMetadata, ) +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.execution_engine.constants import ( CLASS_NAME_KEY, CLASS_NAMES_KEY, diff --git a/inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py b/inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py index 48e86e7367..b3ed990b05 100644 --- a/inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py @@ -3,14 +3,14 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.inference import LMMInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, SMOLVLM2_ENABLED, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, WORKFLOWS_REMOTE_API_TARGET, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/smolvlm/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/smolvlm/v1_tensor.py index a23c00670e..36ce4bf1f3 100644 --- a/inference/core/workflows/core_steps/models/foundation/smolvlm/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/smolvlm/v1_tensor.py @@ -14,18 +14,18 @@ from typing import List, Optional, Type -from inference.core.env import ( - HOSTED_CORE_MODEL_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode # Unchanged from v1 — verbatim manifest, class name and type literal. from inference.core.workflows.core_steps.models.foundation.smolvlm.v1 import ( BlockManifest, ) +from inference.core.workflows.environment import ( + HOSTED_CORE_MODEL_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, WorkflowImageData, diff --git a/inference/core/workflows/core_steps/models/foundation/spacexai/v1.py b/inference/core/workflows/core_steps/models/foundation/spacexai/v1.py index 85fcd7cca9..7bfcd18521 100644 --- a/inference/core/workflows/core_steps/models/foundation/spacexai/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/spacexai/v1.py @@ -17,9 +17,11 @@ from openai import OpenAI from pydantic import ConfigDict, Field, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/spacexai/v2.py b/inference/core/workflows/core_steps/models/foundation/spacexai/v2.py index de232108de..7ac703aa96 100644 --- a/inference/core/workflows/core_steps/models/foundation/spacexai/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/spacexai/v2.py @@ -17,7 +17,6 @@ from openai import OpenAI from pydantic import ConfigDict, Field, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS from inference.core.workflows.core_steps.common.reasoning import ( attach_reasoning_levels, validate_reasoning_level, @@ -28,6 +27,9 @@ ) from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py b/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py index f460159bce..9543d907be 100644 --- a/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py @@ -3,13 +3,6 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.yolo_world import YOLOWorldInferenceRequest -from inference.core.env import ( - HOSTED_CORE_MODEL_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -17,6 +10,13 @@ convert_inference_detections_batch_to_sv_detections, load_core_model, ) +from inference.core.workflows.environment import ( + HOSTED_CORE_MODEL_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py index 1bdf2a19b5..d226608af4 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py @@ -5,14 +5,6 @@ from inference.core.entities.requests.inference import ( InstanceSegmentationInferenceRequest, ) -from inference.core.env import ( - HOSTED_INSTANCE_SEGMENTATION_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -20,6 +12,14 @@ convert_inference_detections_batch_to_sv_detections, filter_out_unwanted_classes_from_sv_detections_batch, ) +from inference.core.workflows.environment import ( + HOSTED_INSTANCE_SEGMENTATION_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.constants import INFERENCE_ID_KEY from inference.core.workflows.execution_engine.entities.base import ( Batch, diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1_tensor.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1_tensor.py index 87e9d20baa..1ea2d8bae5 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1_tensor.py @@ -41,7 +41,13 @@ import torch from pydantic import ConfigDict, Field, PositiveInt -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + attach_native_detection_metadata, + build_native_image_metadata, + take_prediction_by_mask, +) +from inference.core.workflows.environment import ( HOSTED_INSTANCE_SEGMENTATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_ENFORCE_DENSE_INSTANCE_MASKS, @@ -51,12 +57,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - attach_native_detection_metadata, - build_native_image_metadata, - take_prediction_by_mask, -) from inference.core.workflows.execution_engine.constants import ( CLASS_ID_KEY, CLASS_NAME_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py index e306d2377e..04db74e928 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py @@ -5,14 +5,6 @@ from inference.core.entities.requests.inference import ( InstanceSegmentationInferenceRequest, ) -from inference.core.env import ( - HOSTED_INSTANCE_SEGMENTATION_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -20,6 +12,14 @@ convert_inference_detections_batch_to_sv_detections, filter_out_unwanted_classes_from_sv_detections_batch, ) +from inference.core.workflows.environment import ( + HOSTED_INSTANCE_SEGMENTATION_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.constants import INFERENCE_ID_KEY from inference.core.workflows.execution_engine.entities.base import ( Batch, diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2_tensor.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2_tensor.py index d3853df0c6..0bd5a2c008 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2_tensor.py @@ -41,7 +41,13 @@ import torch from pydantic import ConfigDict, Field, PositiveInt -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + attach_native_detection_metadata, + build_native_image_metadata, + take_prediction_by_mask, +) +from inference.core.workflows.environment import ( HOSTED_INSTANCE_SEGMENTATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_ENFORCE_DENSE_INSTANCE_MASKS, @@ -51,12 +57,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - attach_native_detection_metadata, - build_native_image_metadata, - take_prediction_by_mask, -) from inference.core.workflows.execution_engine.constants import ( CLASS_ID_KEY, CLASS_NAME_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py index fc775ca6da..17c754c08e 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py @@ -13,7 +13,14 @@ InstanceSegmentationInferenceResponseDC, _is_response_dc_to_dict, ) -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import ( + attach_parents_coordinates_to_batch_of_sv_detections, + attach_prediction_type_info_to_sv_detections_batch, + convert_inference_detections_batch_to_sv_detections, + filter_out_unwanted_classes_from_sv_detections_batch, +) +from inference.core.workflows.environment import ( HOSTED_INSTANCE_SEGMENTATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_ASYNC_FUTURE_RESULT_TIMEOUT, @@ -22,13 +29,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import ( - attach_parents_coordinates_to_batch_of_sv_detections, - attach_prediction_type_info_to_sv_detections_batch, - convert_inference_detections_batch_to_sv_detections, - filter_out_unwanted_classes_from_sv_detections_batch, -) from inference.core.workflows.execution_engine.constants import INFERENCE_ID_KEY from inference.core.workflows.execution_engine.entities.base import ( Batch, diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3_tensor.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3_tensor.py index 8fa27c4749..e57031e5cb 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3_tensor.py @@ -41,7 +41,13 @@ import torch from pydantic import ConfigDict, Field, PositiveInt, model_validator -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + attach_native_detection_metadata, + build_native_image_metadata, + take_prediction_by_mask, +) +from inference.core.workflows.environment import ( HOSTED_INSTANCE_SEGMENTATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_ENFORCE_DENSE_INSTANCE_MASKS, @@ -51,12 +57,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - attach_native_detection_metadata, - build_native_image_metadata, - take_prediction_by_mask, -) from inference.core.workflows.execution_engine.constants import ( CLASS_ID_KEY, CLASS_NAME_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py index 4253ea0614..dbc4c1295b 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py @@ -5,14 +5,6 @@ from inference.core.entities.requests.inference import ( InstanceSegmentationInferenceRequest, ) -from inference.core.env import ( - HOSTED_INSTANCE_SEGMENTATION_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -20,6 +12,14 @@ convert_inference_detections_batch_to_sv_detections, filter_out_unwanted_classes_from_sv_detections_batch, ) +from inference.core.workflows.environment import ( + HOSTED_INSTANCE_SEGMENTATION_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.constants import INFERENCE_ID_KEY from inference.core.workflows.execution_engine.entities.base import ( Batch, diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4_tensor.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4_tensor.py index 6bdbe5bd8d..91606d7444 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4_tensor.py @@ -39,7 +39,13 @@ import torch from pydantic import ConfigDict, Field, PositiveInt, model_validator -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + attach_native_detection_metadata, + build_native_image_metadata, + take_prediction_by_mask, +) +from inference.core.workflows.environment import ( HOSTED_INSTANCE_SEGMENTATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_ENFORCE_DENSE_INSTANCE_MASKS, @@ -49,12 +55,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - attach_native_detection_metadata, - build_native_image_metadata, - take_prediction_by_mask, -) from inference.core.workflows.execution_engine.constants import ( CLASS_ID_KEY, CLASS_NAME_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py index 5d93922fc3..ebbdee5641 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py @@ -5,14 +5,6 @@ from inference.core.entities.requests.inference import ( KeypointsDetectionInferenceRequest, ) -from inference.core.env import ( - HOSTED_DETECT_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( add_inference_keypoints_to_sv_detections, @@ -21,6 +13,14 @@ convert_inference_detections_batch_to_sv_detections, filter_out_unwanted_classes_from_sv_detections_batch, ) +from inference.core.workflows.environment import ( + HOSTED_DETECT_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.constants import INFERENCE_ID_KEY from inference.core.workflows.execution_engine.entities.base import ( Batch, diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1_tensor.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1_tensor.py index 95ef0ce8be..bc4260311f 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1_tensor.py @@ -43,15 +43,6 @@ import torch from pydantic import ConfigDict, Field, PositiveInt -from inference.core.env import ( - HOSTED_DETECT_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_IMAGE_TENSOR_DEVICE, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.keypoints import ( validate_keypoints_padding, @@ -61,6 +52,15 @@ native_detections_from_inference_predictions, take_prediction_by_mask, ) +from inference.core.workflows.environment import ( + HOSTED_DETECT_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_IMAGE_TENSOR_DEVICE, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.constants import ( CONFIDENCE_KEY, INFERENCE_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py index b3d33181a9..1c2611f776 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py @@ -5,14 +5,6 @@ from inference.core.entities.requests.inference import ( KeypointsDetectionInferenceRequest, ) -from inference.core.env import ( - HOSTED_DETECT_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( add_inference_keypoints_to_sv_detections, @@ -21,6 +13,14 @@ convert_inference_detections_batch_to_sv_detections, filter_out_unwanted_classes_from_sv_detections_batch, ) +from inference.core.workflows.environment import ( + HOSTED_DETECT_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.constants import INFERENCE_ID_KEY from inference.core.workflows.execution_engine.entities.base import ( Batch, diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2_tensor.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2_tensor.py index 86c340ab47..6fd9ca29a3 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2_tensor.py @@ -44,15 +44,6 @@ import torch from pydantic import ConfigDict, Field, PositiveInt -from inference.core.env import ( - HOSTED_DETECT_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_IMAGE_TENSOR_DEVICE, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.keypoints import ( validate_keypoints_padding, @@ -62,6 +53,15 @@ native_detections_from_inference_predictions, take_prediction_by_mask, ) +from inference.core.workflows.environment import ( + HOSTED_DETECT_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_IMAGE_TENSOR_DEVICE, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.constants import ( CONFIDENCE_KEY, INFERENCE_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py index 67b2c5712b..4eab5414ef 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py @@ -5,14 +5,6 @@ from inference.core.entities.requests.inference import ( KeypointsDetectionInferenceRequest, ) -from inference.core.env import ( - HOSTED_DETECT_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( add_inference_keypoints_to_sv_detections, @@ -21,6 +13,14 @@ convert_inference_detections_batch_to_sv_detections, filter_out_unwanted_classes_from_sv_detections_batch, ) +from inference.core.workflows.environment import ( + HOSTED_DETECT_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.constants import INFERENCE_ID_KEY from inference.core.workflows.execution_engine.entities.base import ( Batch, diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3_tensor.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3_tensor.py index d5da875241..d23f41758b 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3_tensor.py @@ -38,15 +38,6 @@ import torch from pydantic import ConfigDict, Field, PositiveInt, model_validator -from inference.core.env import ( - HOSTED_DETECT_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_IMAGE_TENSOR_DEVICE, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.keypoints import ( validate_keypoints_padding, @@ -56,6 +47,15 @@ native_detections_from_inference_predictions, take_prediction_by_mask, ) +from inference.core.workflows.environment import ( + HOSTED_DETECT_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_IMAGE_TENSOR_DEVICE, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.constants import ( CONFIDENCE_KEY, INFERENCE_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py index 751464922d..a767bb1876 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py @@ -3,7 +3,9 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.inference import ClassificationInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info +from inference.core.workflows.environment import ( HOSTED_CLASSIFICATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, @@ -11,8 +13,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.execution_engine.constants import ( INFERENCE_ID_KEY, PARENT_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1_tensor.py b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1_tensor.py index 8ebc917d7e..31b1202c7c 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1_tensor.py @@ -31,7 +31,8 @@ import torch from pydantic import ConfigDict, Field -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( HOSTED_CLASSIFICATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_IMAGE_TENSOR_DEVICE, @@ -40,7 +41,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, CLASSIFICATION_STYLE_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py index 2991b57f0f..b45f4b0d6a 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py @@ -3,7 +3,9 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.inference import ClassificationInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info +from inference.core.workflows.environment import ( HOSTED_CLASSIFICATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, @@ -11,8 +13,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.execution_engine.constants import ( INFERENCE_ID_KEY, PARENT_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2_tensor.py b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2_tensor.py index d7afe1d66d..2c218ef3d2 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2_tensor.py @@ -31,7 +31,8 @@ import torch from pydantic import ConfigDict, Field -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( HOSTED_CLASSIFICATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_IMAGE_TENSOR_DEVICE, @@ -40,7 +41,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, CLASSIFICATION_STYLE_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py index 7a6d0d3cb2..d3521285bf 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py @@ -3,7 +3,9 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.entities.requests.inference import ClassificationInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info +from inference.core.workflows.environment import ( HOSTED_CLASSIFICATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, @@ -11,8 +13,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.execution_engine.constants import ( INFERENCE_ID_KEY, PARENT_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3_tensor.py b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3_tensor.py index 0ae747e63a..7731afe241 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3_tensor.py @@ -25,7 +25,8 @@ import torch from pydantic import ConfigDict, Field, model_validator -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( HOSTED_CLASSIFICATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_IMAGE_TENSOR_DEVICE, @@ -34,7 +35,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, CLASSIFICATION_STYLE_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py index 06a3956d70..f385dde9fa 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py @@ -3,7 +3,9 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.inference import ClassificationInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info +from inference.core.workflows.environment import ( HOSTED_CLASSIFICATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, @@ -11,8 +13,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.execution_engine.constants import ( INFERENCE_ID_KEY, PARENT_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1_tensor.py b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1_tensor.py index 79ebc7bb5f..d4cd2183da 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1_tensor.py @@ -34,7 +34,8 @@ import torch from pydantic import ConfigDict, Field -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( HOSTED_CLASSIFICATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_IMAGE_TENSOR_DEVICE, @@ -43,7 +44,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, CLASSIFICATION_STYLE_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py index 8990daffbd..196ff09eea 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py @@ -3,7 +3,9 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.inference import ClassificationInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info +from inference.core.workflows.environment import ( HOSTED_CLASSIFICATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, @@ -11,8 +13,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.execution_engine.constants import ( INFERENCE_ID_KEY, PARENT_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2_tensor.py b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2_tensor.py index f4d8b54655..34b300bdd0 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2_tensor.py @@ -34,7 +34,8 @@ import torch from pydantic import ConfigDict, Field -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( HOSTED_CLASSIFICATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_IMAGE_TENSOR_DEVICE, @@ -43,7 +44,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, CLASSIFICATION_STYLE_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py index 0903debc24..49856428c7 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py @@ -3,7 +3,9 @@ from pydantic import ConfigDict, Field, model_validator from inference.core.entities.requests.inference import ClassificationInferenceRequest -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info +from inference.core.workflows.environment import ( HOSTED_CLASSIFICATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, @@ -11,8 +13,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.execution_engine.constants import ( INFERENCE_ID_KEY, PARENT_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3_tensor.py b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3_tensor.py index e306baa57c..10b2f8b41f 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3_tensor.py @@ -28,7 +28,8 @@ import torch from pydantic import ConfigDict, Field, model_validator -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( HOSTED_CLASSIFICATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_IMAGE_TENSOR_DEVICE, @@ -37,7 +38,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, CLASSIFICATION_STYLE_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py b/inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py index 5692c72ac9..1a563cc79a 100644 --- a/inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py @@ -3,14 +3,6 @@ from pydantic import ConfigDict, Field, PositiveInt from inference.core.entities.requests.inference import ObjectDetectionInferenceRequest -from inference.core.env import ( - HOSTED_DETECT_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -18,6 +10,14 @@ convert_inference_detections_batch_to_sv_detections, filter_out_unwanted_classes_from_sv_detections_batch, ) +from inference.core.workflows.environment import ( + HOSTED_DETECT_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.constants import INFERENCE_ID_KEY from inference.core.workflows.execution_engine.entities.base import ( Batch, diff --git a/inference/core/workflows/core_steps/models/roboflow/object_detection/v1_tensor.py b/inference/core/workflows/core_steps/models/roboflow/object_detection/v1_tensor.py index 694df311c7..9a6b95f2d6 100644 --- a/inference/core/workflows/core_steps/models/roboflow/object_detection/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/object_detection/v1_tensor.py @@ -24,7 +24,13 @@ import torch from pydantic import ConfigDict, Field, PositiveInt -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + attach_native_detection_metadata, + native_detections_from_inference_predictions, + take_prediction_by_mask, +) +from inference.core.workflows.environment import ( HOSTED_DETECT_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_IMAGE_TENSOR_DEVICE, @@ -33,12 +39,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - attach_native_detection_metadata, - native_detections_from_inference_predictions, - take_prediction_by_mask, -) from inference.core.workflows.execution_engine.constants import INFERENCE_ID_KEY from inference.core.workflows.execution_engine.entities.base import ( Batch, diff --git a/inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py b/inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py index 679f3f3716..9b035327db 100644 --- a/inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py @@ -3,14 +3,6 @@ from pydantic import ConfigDict, Field, PositiveInt from inference.core.entities.requests.inference import ObjectDetectionInferenceRequest -from inference.core.env import ( - HOSTED_DETECT_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -18,6 +10,14 @@ convert_inference_detections_batch_to_sv_detections, filter_out_unwanted_classes_from_sv_detections_batch, ) +from inference.core.workflows.environment import ( + HOSTED_DETECT_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.constants import INFERENCE_ID_KEY from inference.core.workflows.execution_engine.entities.base import ( Batch, diff --git a/inference/core/workflows/core_steps/models/roboflow/object_detection/v2_tensor.py b/inference/core/workflows/core_steps/models/roboflow/object_detection/v2_tensor.py index f104458df4..f79689ec30 100644 --- a/inference/core/workflows/core_steps/models/roboflow/object_detection/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/object_detection/v2_tensor.py @@ -23,7 +23,13 @@ import torch from pydantic import ConfigDict, Field, PositiveInt -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + attach_native_detection_metadata, + native_detections_from_inference_predictions, + take_prediction_by_mask, +) +from inference.core.workflows.environment import ( HOSTED_DETECT_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_IMAGE_TENSOR_DEVICE, @@ -32,12 +38,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - attach_native_detection_metadata, - native_detections_from_inference_predictions, - take_prediction_by_mask, -) from inference.core.workflows.execution_engine.constants import INFERENCE_ID_KEY from inference.core.workflows.execution_engine.entities.base import ( Batch, diff --git a/inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py b/inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py index d87e878aff..bd5db14566 100644 --- a/inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py @@ -3,14 +3,6 @@ from pydantic import ConfigDict, Field, PositiveInt, model_validator from inference.core.entities.requests.inference import ObjectDetectionInferenceRequest -from inference.core.env import ( - HOSTED_DETECT_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_KEY_TRANSPORT, - WORKFLOWS_REMOTE_API_TARGET, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, - WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -18,6 +10,14 @@ convert_inference_detections_batch_to_sv_detections, filter_out_unwanted_classes_from_sv_detections_batch, ) +from inference.core.workflows.environment import ( + HOSTED_DETECT_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_KEY_TRANSPORT, + WORKFLOWS_REMOTE_API_TARGET, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.constants import INFERENCE_ID_KEY from inference.core.workflows.execution_engine.entities.base import ( Batch, diff --git a/inference/core/workflows/core_steps/models/roboflow/object_detection/v3_tensor.py b/inference/core/workflows/core_steps/models/roboflow/object_detection/v3_tensor.py index 7f381cb5b9..062c468dca 100644 --- a/inference/core/workflows/core_steps/models/roboflow/object_detection/v3_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/object_detection/v3_tensor.py @@ -19,7 +19,13 @@ import torch from pydantic import ConfigDict, Field, PositiveInt, model_validator -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + attach_native_detection_metadata, + native_detections_from_inference_predictions, + take_prediction_by_mask, +) +from inference.core.workflows.environment import ( HOSTED_DETECT_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_IMAGE_TENSOR_DEVICE, @@ -28,12 +34,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - attach_native_detection_metadata, - native_detections_from_inference_predictions, - take_prediction_by_mask, -) from inference.core.workflows.execution_engine.constants import INFERENCE_ID_KEY from inference.core.workflows.execution_engine.entities.base import ( Batch, diff --git a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py index 28786caad6..2f89f71512 100644 --- a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py @@ -11,7 +11,8 @@ from inference.core.entities.requests.inference import ( SemanticSegmentationInferenceRequest, ) -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( HOSTED_SEMANTIC_SEGMENTATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, @@ -19,7 +20,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.constants import ( DETECTION_ID_KEY, IMAGE_DIMENSIONS_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1_tensor.py b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1_tensor.py index c536b7299d..37952bd809 100644 --- a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1_tensor.py @@ -87,7 +87,11 @@ import torch from pydantic import ConfigDict -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + build_native_image_metadata, +) +from inference.core.workflows.environment import ( HOSTED_SEMANTIC_SEGMENTATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_IMAGE_TENSOR_DEVICE, @@ -96,10 +100,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - build_native_image_metadata, -) from inference.core.workflows.execution_engine.constants import ( CLASS_NAME_KEY, DETECTION_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py index 349d24eb57..760961d4a3 100644 --- a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py @@ -11,7 +11,8 @@ from inference.core.entities.requests.inference import ( SemanticSegmentationInferenceRequest, ) -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.environment import ( HOSTED_SEMANTIC_SEGMENTATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_REMOTE_API_KEY_TRANSPORT, @@ -19,7 +20,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.constants import ( DETECTION_ID_KEY, IMAGE_DIMENSIONS_KEY, diff --git a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2_tensor.py b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2_tensor.py index aa6b106c87..5445b08cb4 100644 --- a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2_tensor.py @@ -81,7 +81,11 @@ import torch from pydantic import ConfigDict, Field, model_validator -from inference.core.env import ( +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + build_native_image_metadata, +) +from inference.core.workflows.environment import ( HOSTED_SEMANTIC_SEGMENTATION_URL, LOCAL_INFERENCE_API_URL, WORKFLOWS_IMAGE_TENSOR_DEVICE, @@ -90,10 +94,6 @@ WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.tensor_native import ( - build_native_image_metadata, -) from inference.core.workflows.execution_engine.constants import ( CLASS_NAME_KEY, DETECTION_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/third_party/barcode_detection/v1_tensor.py b/inference/core/workflows/core_steps/models/third_party/barcode_detection/v1_tensor.py index 0329afa597..65df88e211 100644 --- a/inference/core/workflows/core_steps/models/third_party/barcode_detection/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/third_party/barcode_detection/v1_tensor.py @@ -17,10 +17,10 @@ import zxingcpp from pydantic import ConfigDict -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, ) +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.execution_engine.constants import ( DETECTED_CODE_KEY, DETECTION_ID_KEY, diff --git a/inference/core/workflows/core_steps/models/third_party/qr_code_detection/v1_tensor.py b/inference/core/workflows/core_steps/models/third_party/qr_code_detection/v1_tensor.py index 6e1718b52b..4ffec80c35 100644 --- a/inference/core/workflows/core_steps/models/third_party/qr_code_detection/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/third_party/qr_code_detection/v1_tensor.py @@ -17,10 +17,10 @@ import torch from pydantic import ConfigDict -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, ) +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.execution_engine.constants import ( DETECTED_CODE_KEY, DETECTION_ID_KEY, diff --git a/inference/core/workflows/core_steps/secrets_providers/environment_secrets_store/v1.py b/inference/core/workflows/core_steps/secrets_providers/environment_secrets_store/v1.py index 989ea59685..046a3d688e 100644 --- a/inference/core/workflows/core_steps/secrets_providers/environment_secrets_store/v1.py +++ b/inference/core/workflows/core_steps/secrets_providers/environment_secrets_store/v1.py @@ -3,7 +3,9 @@ from pydantic import ConfigDict, Field -from inference.core.env import ALLOW_WORKFLOW_BLOCKS_ACCESSING_ENVIRONMENTAL_VARIABLES +from inference.core.workflows.environment import ( + ALLOW_WORKFLOW_BLOCKS_ACCESSING_ENVIRONMENTAL_VARIABLES, +) from inference.core.workflows.execution_engine.entities.base import OutputDefinition from inference.core.workflows.execution_engine.entities.types import SECRET_KIND from inference.core.workflows.prototypes.block import ( diff --git a/inference/core/workflows/core_steps/sinks/local_file/v1.py b/inference/core/workflows/core_steps/sinks/local_file/v1.py index e0ec0c8399..d9f1eaf112 100644 --- a/inference/core/workflows/core_steps/sinks/local_file/v1.py +++ b/inference/core/workflows/core_steps/sinks/local_file/v1.py @@ -7,8 +7,10 @@ from pydantic import ConfigDict, Field, field_validator -from inference.core.env import ALLOW_WORKFLOW_BLOCKS_ACCESSING_LOCAL_STORAGE from inference.core.workflows.core_steps.sinks.noop import disabled_sink_response +from inference.core.workflows.environment import ( + ALLOW_WORKFLOW_BLOCKS_ACCESSING_LOCAL_STORAGE, +) from inference.core.workflows.execution_engine.entities.base import OutputDefinition from inference.core.workflows.execution_engine.entities.types import ( BOOLEAN_KIND, diff --git a/inference/core/workflows/core_steps/transformations/detections_combine/v1_tensor.py b/inference/core/workflows/core_steps/transformations/detections_combine/v1_tensor.py index 03e9ba709d..be64af628d 100644 --- a/inference/core/workflows/core_steps/transformations/detections_combine/v1_tensor.py +++ b/inference/core/workflows/core_steps/transformations/detections_combine/v1_tensor.py @@ -5,10 +5,10 @@ import torch from pydantic import ConfigDict, Field -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.core_steps.common.tensor_native import ( instance_mask_to_numpy, ) +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, DETECTION_ID_KEY, diff --git a/inference/core/workflows/core_steps/transformations/detections_merge/v1_tensor.py b/inference/core/workflows/core_steps/transformations/detections_merge/v1_tensor.py index 027ec777d8..634af9e05a 100644 --- a/inference/core/workflows/core_steps/transformations/detections_merge/v1_tensor.py +++ b/inference/core/workflows/core_steps/transformations/detections_merge/v1_tensor.py @@ -4,7 +4,7 @@ import torch from pydantic import ConfigDict, Field -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, DETECTION_ID_KEY, diff --git a/inference/core/workflows/core_steps/transformations/detections_transformation/v1.py b/inference/core/workflows/core_steps/transformations/detections_transformation/v1.py index 1d655bb87a..d340015918 100644 --- a/inference/core/workflows/core_steps/transformations/detections_transformation/v1.py +++ b/inference/core/workflows/core_steps/transformations/detections_transformation/v1.py @@ -4,7 +4,6 @@ import supervision as sv from pydantic import ConfigDict, Field -from inference.core.env import ENABLE_TENSOR_DATA_REPRESENTATION from inference.core.workflows.core_steps.common.query_language.entities.operations import ( DEFAULT_OPERAND_NAME, AllOperationsType, @@ -17,6 +16,7 @@ grab_batch_parameters, grab_non_batch_parameters, ) +from inference.core.workflows.environment import ENABLE_TENSOR_DATA_REPRESENTATION from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, diff --git a/inference/core/workflows/core_steps/transformations/stabilize_detections/v1_tensor.py b/inference/core/workflows/core_steps/transformations/stabilize_detections/v1_tensor.py index 0c238091ef..56d80a42e9 100644 --- a/inference/core/workflows/core_steps/transformations/stabilize_detections/v1_tensor.py +++ b/inference/core/workflows/core_steps/transformations/stabilize_detections/v1_tensor.py @@ -5,11 +5,11 @@ import torch from pydantic import ConfigDict, Field -from inference.core.env import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.core_steps.common.tensor_native import ( strip_host_mirror_metadata, take_detections_by_indices, ) +from inference.core.workflows.environment import WORKFLOWS_IMAGE_TENSOR_DEVICE from inference.core.workflows.execution_engine.constants import CLASS_NAMES_KEY from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, diff --git a/inference/core/workflows/core_steps/visualizations/common/base_tensor.py b/inference/core/workflows/core_steps/visualizations/common/base_tensor.py index b3152c6624..95feec0d16 100644 --- a/inference/core/workflows/core_steps/visualizations/common/base_tensor.py +++ b/inference/core/workflows/core_steps/visualizations/common/base_tensor.py @@ -7,7 +7,6 @@ from pydantic import AliasChoices, ConfigDict, Field from supervision.detection.compact_mask import CompactMask -from inference.core.env import WORKFLOWS_TENSOR_VISUALISATION_VALIDATE_OWNERS from inference.core.workflows.core_steps.common.rle_compact import ( instances_rle_to_compact_mask, ) @@ -18,6 +17,9 @@ read_host_mirror, split_key_point_prediction, ) +from inference.core.workflows.environment import ( + WORKFLOWS_TENSOR_VISUALISATION_VALIDATE_OWNERS, +) from inference.core.workflows.execution_engine.constants import ( CLASS_NAMES_KEY, DETECTION_ID_KEY, diff --git a/inference/core/workflows/core_steps/visualizations/common/fonts/__init__.py b/inference/core/workflows/core_steps/visualizations/common/fonts/__init__.py index c9bc2e12bb..68433a380b 100644 --- a/inference/core/workflows/core_steps/visualizations/common/fonts/__init__.py +++ b/inference/core/workflows/core_steps/visualizations/common/fonts/__init__.py @@ -26,12 +26,6 @@ from filelock import FileLock -from inference.core.env import ( - ALLOW_WORKFLOWS_FONTS_DOWNLOAD, - MODEL_CACHE_DIR, - OFFLINE_MODE, - SECURE_GATEWAY, -) from inference.core.workflows.core_steps.visualizations.common.fonts.downloader import ( FontDownloadError, compute_file_sha256, @@ -45,6 +39,12 @@ from inference.core.workflows.core_steps.visualizations.common.fonts.schema import ( font_family_to_identifier, ) +from inference.core.workflows.environment import ( + ALLOW_WORKFLOWS_FONTS_DOWNLOAD, + MODEL_CACHE_DIR, + OFFLINE_MODE, + SECURE_GATEWAY, +) ASSETS_DIR = Path(__file__).parent / "assets" FONTS_CACHE_DIR = Path(MODEL_CACHE_DIR) / "workflows" / "fonts" diff --git a/scripts/repoint_env_imports.py b/scripts/repoint_env_imports.py new file mode 100644 index 0000000000..8e394e550e --- /dev/null +++ b/scripts/repoint_env_imports.py @@ -0,0 +1,96 @@ +"""Repoint `from inference.core.env import ...` at the Workflows configuration facade. + +AST-located, byte-safe, idempotent. Only the module token on the statement's +FIRST physical line is rewritten - no name list, no expression and no body is +touched - so the codemod is order-independent with respect to Phases 9, 10 and +11, which rewrite different import statements in many of the same files. + + python scripts/repoint_env_imports.py # rewrite + python scripts/repoint_env_imports.py --check # exit 1 if anything remains + python scripts/repoint_env_imports.py --list # print the files it would touch +""" + +import argparse +import ast +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS_ROOT = REPO_ROOT / "inference" / "core" / "workflows" +OLD_MODULE = "inference.core.env" +NEW_MODULE = "inference.core.workflows.environment" + +# Phase 9 relocates these two trees wholesale (controller ruling R-S); they +# keep their `env` imports until then, and cease to exist afterwards. +SKIP_PREFIXES = ( + WORKFLOWS_ROOT / "core_steps" / "sinks" / "roboflow", + WORKFLOWS_ROOT / "core_steps" / "integrations" / "roboflow", +) + + +def _skipped(path: Path) -> bool: + return any(prefix in path.parents for prefix in SKIP_PREFIXES) + + +def _statement_lines(source: str, path: Path) -> list: + tree = ast.parse(source, filename=str(path)) + # ast.walk, not tree.body: `modal_executor.py` has four function-local + # statements on top of its module-level one. + return sorted( + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + and node.module == OLD_MODULE + and node.level == 0 + ) + + +def targets() -> list: + found = [] + for path in sorted(WORKFLOWS_ROOT.rglob("*.py")): + if _skipped(path) or "__pycache__" in str(path): + continue + source = path.read_bytes().decode("utf-8") + lines = _statement_lines(source, path) + if lines: + found.append((path, lines)) + return found + + +def rewrite(path: Path, linenos: list) -> None: + # Bytes + keepends: `core_steps/models/foundation/llama_vision/v1.py` is + # CRLF (663 CRLF endings), and text mode with default newline translation + # would rewrite every line ending in the file. + raw = path.read_bytes() + lines = raw.decode("utf-8").splitlines(keepends=True) + for lineno in linenos: + index = lineno - 1 + line = lines[index] + if OLD_MODULE not in line: + raise SystemExit( + f"{path}:{lineno}: AST reported an `{OLD_MODULE}` import but the " + f"first physical line does not contain the token: {line!r}" + ) + lines[index] = line.replace(OLD_MODULE, NEW_MODULE, 1) + path.write_bytes("".join(lines).encode("utf-8")) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true") + parser.add_argument("--list", action="store_true") + args = parser.parse_args() + found = targets() + if args.list or args.check: + for path, linenos in found: + print(f"{path.relative_to(REPO_ROOT)}: {linenos}") + print(f"{len(found)} files, {sum(len(l) for _, l in found)} statements") + return 1 if (args.check and found) else 0 + for path, linenos in found: + rewrite(path, linenos) + print(f"rewrote {len(found)} files, {sum(len(l) for _, l in found)} statements") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index f8963a2270..0fd270109e 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,210 +1,71 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 207 -inference/core/workflows/core_steps/classical_cv/mask_edge_snap/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/classical_cv/motion_detection/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/classical_cv/template_matching/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/common/deserializers_tensor.py inference.core.env -inference/core/workflows/core_steps/common/openrouter.py inference.core.env -inference/core/workflows/core_steps/common/query_language/operations/classification_results/base.py inference.core.env -inference/core/workflows/core_steps/common/query_language/operations/detection/base.py inference.core.env -inference/core/workflows/core_steps/common/query_language/operations/detections/base.py inference.core.env -inference/core/workflows/core_steps/common/tensor_native.py inference.core.env +# Count: 68 inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.clip inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.doctr inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.easy_ocr inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.yolo_world -inference/core/workflows/core_steps/formatters/vlm_as_classifier/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/formatters/vlm_as_classifier/v2_tensor.py inference.core.env -inference/core/workflows/core_steps/formatters/vlm_as_detector/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/formatters/vlm_as_detector/v2_tensor.py inference.core.env -inference/core/workflows/core_steps/fusion/detections_consensus/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/anthropic_claude/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/anthropic_claude/v2.py inference.core.env -inference/core/workflows/core_steps/models/foundation/anthropic_claude/v3.py inference.core.env -inference/core/workflows/core_steps/models/foundation/anthropic_claude/v4.py inference.core.env inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.entities.requests.clip -inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/clip/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py inference.core.entities.requests.clip -inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/clip_comparison/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py inference.core.entities.requests.clip -inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py inference.core.env -inference/core/workflows/core_steps/models/foundation/clip_comparison/v2_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/cog_vlm/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/depth_estimation/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py inference.core.entities.requests.easy_ocr -inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/easy_ocr/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/florence2/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/florence2/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/florence2/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/gaze/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_gemini/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_gemini/v2.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_gemini/v3.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_gemini/v4.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_gemini/v5.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_gemma/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/google_vision_ocr/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/kimi_openrouter/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/llama_vision/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/lmm/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/lmm_classifier/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/moondream2/v1.py inference.core.entities.requests.moondream2 -inference/core/workflows/core_steps/models/foundation/moondream2/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/moondream2/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/ocr/v1.py inference.core.entities.requests.doctr -inference/core/workflows/core_steps/models/foundation/ocr/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/ocr/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/openai/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/openai/v2.py inference.core.env -inference/core/workflows/core_steps/models/foundation/openai/v3.py inference.core.env -inference/core/workflows/core_steps/models/foundation/openai/v4.py inference.core.env -inference/core/workflows/core_steps/models/foundation/openai/v5.py inference.core.env -inference/core/workflows/core_steps/models/foundation/openai/v6.py inference.core.env inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py inference.core.entities.requests.perception_encoder -inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/perception_encoder/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py inference.core.entities.requests.pp_ocr -inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py inference.core.entities.requests.pp_ocr -inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/qwen/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/qwen/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/qwen3_5_openrouter/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py inference.core.env -inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/qwen3_6_openrouter/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/qwen3vl/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py inference.core.env inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py inference.core.env inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py inference.core.entities.responses.inference -inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/seg_preview/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.responses.sam2 -inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything2/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.responses.sam3 -inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything3/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.responses.sam3 -inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.env inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py inference.core.entities.requests.sam3 -inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.entities.responses.inference -inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.env inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py inference.core.entities.requests.sam3 -inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py inference.core.entities.requests.sam3_3d inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py inference.core.entities.responses.sam3_3d -inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py inference.core.entities.requests.sam3_3d inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py inference.core.entities.responses.sam3_3d -inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.entities.responses.sam2 -inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.env inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.entities.requests.sam2 -inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/segment_anything_common/streaming_video_tensor.py inference.core.env inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/smolvlm/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/foundation/spacexai/v1.py inference.core.env -inference/core/workflows/core_steps/models/foundation/spacexai/v2.py inference.core.env inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.entities.requests.yolo_world -inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.env inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py inference.core.entities.responses.inference -inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/object_detection/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/object_detection/v2_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/object_detection/v3_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1_tensor.py inference.core.env inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py inference.core.env -inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2_tensor.py inference.core.env -inference/core/workflows/core_steps/models/third_party/barcode_detection/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/models/third_party/qr_code_detection/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/secrets_providers/environment_secrets_store/v1.py inference.core.env -inference/core/workflows/core_steps/sinks/local_file/v1.py inference.core.env -inference/core/workflows/core_steps/transformations/detections_combine/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/transformations/detections_merge/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/transformations/detections_transformation/v1.py inference.core.env -inference/core/workflows/core_steps/transformations/stabilize_detections/v1_tensor.py inference.core.env -inference/core/workflows/core_steps/visualizations/common/base_tensor.py inference.core.env -inference/core/workflows/core_steps/visualizations/common/fonts/__init__.py inference.core.env inference/core/workflows/execution_engine/entities/base.py inference.core.entities.responses.action_recognition diff --git a/tests/workflows/unit_tests/test_no_server_env_imports.py b/tests/workflows/unit_tests/test_no_server_env_imports.py new file mode 100644 index 0000000000..46bf0c568e --- /dev/null +++ b/tests/workflows/unit_tests/test_no_server_env_imports.py @@ -0,0 +1,94 @@ +"""No module Phase 5 owns may import `inference.core.env`. + +This is narrower and louder than the decontamination lint, whose baseline +tolerates whatever is listed. It reuses the lint's own `collect_violations()` +so relative imports (`from ...core.env import X`), function-local imports and +the exec'd-string form are all handled by the one implementation that already +gets them right - and so a mere docstring mention of the module name is NOT a +violation. + +It also freezes the inventory of direct environment reads left inside +workflows, so a new `os.getenv` / `os.environ[...]` cannot appear silently. +The scanner is `environment_reads` from Task 5.1's test module - ONE rule for +the whole phase (round-3 defect 7: the round-2 copy here started with +`if not isinstance(node, ast.Call): continue` and missed every subscript). +""" + +import ast + +from tests.workflows.unit_tests.test_configuration import environment_reads +from tests.workflows.unit_tests.test_decontamination_lint import ( + REPO_ROOT, + WORKFLOWS_ROOT, + collect_violations, +) + +# Owned by Phase 9 (controller ruling R-S): these trees move to +# `inference/roboflow_workflows_plugin/`, where importing the server's env +# module is legitimate. Under R-U Phase 9 has landed, the paths do not exist +# and this tuple matches nothing; if Phase 5 runs first it skips them. +PHASE_9_PREFIXES = ( + "inference/core/workflows/core_steps/sinks/roboflow/", + "inference/core/workflows/core_steps/integrations/roboflow/", +) + +# Every direct environment read that remains inside `inference/core/workflows` +# after Phase 5, with its owner. Phase 5 removes `inference.core.env` IMPORTS; +# it does not touch these. Adding a row here is a deliberate act. +PERMITTED_ENVIRONMENT_READS = { + ("inference/core/workflows/execution_engine/introspection/blocks_loader.py", 1), + ("inference/core/workflows/execution_engine/v1/core.py", 1), + ("inference/core/workflows/execution_engine/v1/debugger/core.py", 2), + ( + "inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py", + 3, + ), + ( + "inference/core/workflows/core_steps/secrets_providers/environment_secrets_store/v1.py", + 1, + ), + # Phase 9 relocates these two files; if Phase 5 runs first their reads stay. + ("inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1.py", 1), + ( + "inference/core/workflows/core_steps/sinks/roboflow/vision_events/v1_tensor.py", + 1, + ), +} + + +def test_no_owned_module_imports_inference_core_env() -> None: + offenders = sorted( + (path, module) + for path, module in collect_violations() + if module.startswith("inference.core.env") + and not path.startswith(PHASE_9_PREFIXES) + ) + assert not offenders, offenders + + +def test_the_environment_read_inventory_is_frozen() -> None: + found = {} + for path in sorted(WORKFLOWS_ROOT.rglob("*.py")): + if "__pycache__" in str(path): + continue + tree = ast.parse(path.read_bytes().decode("utf-8"), filename=str(path)) + reads = len(environment_reads(tree)) + if reads: + found[path.relative_to(REPO_ROOT).as_posix()] = reads + expected = {path: count for path, count in PERMITTED_ENVIRONMENT_READS} + # Phase 9 may already have relocated its two files. + expected = { + path: count for path, count in expected.items() if (REPO_ROOT / path).exists() + } + assert found == expected, { + "unexpected": {k: v for k, v in found.items() if expected.get(k) != v}, + "missing": {k: v for k, v in expected.items() if found.get(k) != v}, + } + + +def test_the_inventory_scanner_counts_a_subscript_read() -> None: + # Round-3 defect 7: `os.environ["X"]` is a read the import lint cannot see + # either (it scans imports and generated import strings, `lint:91`). + assert environment_reads( + ast.parse('import os\nX = os.environ["SOME_CONFIG"]\n') + ) == [2] From 8a25a647e5a29b94753b2d10ac7910fc00b173be Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:38:20 +0200 Subject: [PATCH 47/86] test(workflows): the isolation probe installs a standalone WorkflowsConfiguration Task 5.8: the child now constructs a WorkflowsConfiguration itself (configure_process/default_configuration/resolve_image_tensor_device) right after the import blocker, in place of six pinned env vars; _child_env pops those six (plus MODEL_CACHE_DIR) so an inherited value cannot make the probe pass or fail for a reason the configuration does not explain. Test parametrisation extended to cover all six. Baseline unchanged (Count: 68). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- scripts/workflows_isolation_probe.py | 54 ++++++++++++++++--- .../isolation/test_isolation_probe.py | 18 +++++-- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/scripts/workflows_isolation_probe.py b/scripts/workflows_isolation_probe.py index 32a02ca533..f05b98ef94 100644 --- a/scripts/workflows_isolation_probe.py +++ b/scripts/workflows_isolation_probe.py @@ -77,6 +77,39 @@ def find_spec(self, fullname, path=None, target=None): sys.meta_path.insert(0, ServerImportBlocker()) +# The isolated tree's `inference/__init__.py` and `inference/core/__init__.py` +# are EMPTY stubs, so nothing installs a WorkflowsConfiguration. In isolation +# the probe IS the host: it builds one explicitly. This must happen before the +# first workflows import, because `core_steps/loader.py` branches on the tensor +# flag at import time and `environment.py` freezes every constant at its own +# import. +import dataclasses as _dc +from inference.core.workflows.configuration import ( + configure_process, + default_configuration, + resolve_image_tensor_device, +) + +_BASE = default_configuration() +configure_process(_dc.replace( + _BASE, + tensor=_dc.replace( + _BASE.tensor, + representation_enabled=TENSOR_MODE, + image_tensor_device=resolve_image_tensor_device(TENSOR_MODE), + ), + engine=_dc.replace( + _BASE.engine, + allow_custom_python_execution=True, + custom_python_execution_mode="local", + ), + fonts=_dc.replace( + _BASE.fonts, + allow_download=False, + model_cache_dir=os.path.join(T, "cache"), + ), +)) + def check(name, fn): try: @@ -383,18 +416,23 @@ def _child_env(tree: Path, tensor_mode: bool) -> dict: # checkout's `inference_models` (an allowed dependency that the venv # would otherwise resolve from a different checkout). "PYTHONPATH": str(tree) + os.pathsep + str(REPO_ROOT / "inference_models"), - "MODEL_CACHE_DIR": str(tree / "cache"), # no previously cached fonts - # Standalone configuration. Phase 5 decides how the workflows-local - # default reads these; until then they are the env names the module - # consumes today. Adjust here if Phase 5 moves them. - "ENABLE_TENSOR_DATA_REPRESENTATION": "True" if tensor_mode else "False", - "ALLOW_CUSTOM_PYTHON_EXECUTION_IN_WORKFLOWS": "True", - "WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE": "local", - "ALLOW_WORKFLOWS_FONTS_DOWNLOAD": "False", # The checks verify with `assert`; an inherited PYTHONOPTIMIZE would # strip every one of them and turn the probe green by deleting it. "PYTHONOPTIMIZE": "0", } + # Since Phase 5 the module takes these from a `WorkflowsConfiguration` the + # child installs itself (see CHILD). They used to be pinned here; they are + # now REMOVED so an inherited value cannot make the probe pass or fail for + # a reason the configuration does not explain. + for variable in ( + "ENABLE_TENSOR_DATA_REPRESENTATION", + "WORKFLOWS_IMAGE_TENSOR_DEVICE", + "ALLOW_CUSTOM_PYTHON_EXECUTION_IN_WORKFLOWS", + "WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE", + "ALLOW_WORKFLOWS_FONTS_DOWNLOAD", + "MODEL_CACHE_DIR", + ): + env.pop(variable, None) # Never inherit the server's plugin list (Task 7.1 expands the enterprise # plugin into it); the probe loads core blocks only. env.pop("WORKFLOWS_PLUGINS", None) diff --git a/tests/workflows/isolation/test_isolation_probe.py b/tests/workflows/isolation/test_isolation_probe.py index 6271edda2c..c474188afb 100644 --- a/tests/workflows/isolation/test_isolation_probe.py +++ b/tests/workflows/isolation/test_isolation_probe.py @@ -81,21 +81,31 @@ def test_main_reports_failure_when_the_child_returns_nothing(monkeypatch) -> Non [ ("WORKFLOWS_PLUGINS", "inference.enterprise.workflows.enterprise_blocks"), ("DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER", "extended_roboflow_errors"), + # Since Phase 5 these six reach the child through a WorkflowsConfiguration + # the child installs itself. An inherited value must not reach it. + ("ENABLE_TENSOR_DATA_REPRESENTATION", "True"), + ("WORKFLOWS_IMAGE_TENSOR_DEVICE", "cuda"), + ("ALLOW_CUSTOM_PYTHON_EXECUTION_IN_WORKFLOWS", "False"), + ("WORKFLOWS_CUSTOM_PYTHON_EXECUTION_MODE", "modal"), + ("ALLOW_WORKFLOWS_FONTS_DOWNLOAD", "True"), + ("MODEL_CACHE_DIR", "/somewhere/else"), ], ) def test_child_env_drops_server_only_configuration( tmp_path, monkeypatch, variable, value ) -> None: - # Both are server settings the child must not inherit: the plugin list - # would load enterprise blocks, and the step-error handler names a handler - # the standalone engine does not register - which would fail - # `ExecutionEngine.init` for a reason unrelated to contamination. + # Server settings the child must not inherit: the plugin list would load + # enterprise blocks, the step-error handler names a handler the standalone + # engine does not register, and the rest are configuration the child now + # installs explicitly. probe = _load_probe() monkeypatch.setenv(variable, value) env = probe._child_env(tmp_path, tensor_mode=False) assert variable not in env assert "WORKFLOWS_PLUGINS" not in env assert "DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER" not in env + assert "ENABLE_TENSOR_DATA_REPRESENTATION" not in env + assert "MODEL_CACHE_DIR" not in env def test_child_refuses_to_run_with_assertions_stripped(tmp_path, monkeypatch) -> None: From a8be95132d7bc04a62dc2fba1a6d809311af3f5c Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:51:03 +0200 Subject: [PATCH 48/86] docs(server): state the exact install-ordering invariant for the Workflows configuration The comments claimed inference/core/__init__.py completes before any workflows module executes. The bootstrap chain env -> utils/environment -> exceptions -> workflows/prototypes/platform_errors, and the builder's own import of workflows/configuration, already enter workflows earlier; they are configuration-independent and must stay so. Narrow the claim to what holds: installation precedes every import of the constants facade. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/__init__.py | 22 ++++++++++++------- .../interfaces/workflows_configuration.py | 14 ++++++++---- .../test_workflows_configuration.py | 5 ++++- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/inference/core/__init__.py b/inference/core/__init__.py index d58552817b..d29ab803ab 100644 --- a/inference/core/__init__.py +++ b/inference/core/__init__.py @@ -6,14 +6,20 @@ from inference.core.env import DISABLE_VERSION_CHECK, VERSION_CHECK_MODE -# Hand the Workflows module its configuration before anything can import it. -# This is the ONE place the ordering is guaranteed: importing any -# `inference.core.workflows.*` module runs this file to completion first, so -# `core_steps/loader.py`'s import-time tensor branches and every module-level -# constant in `inference/core/workflows/environment.py` see the server's -# values. `inference.core.interfaces.workflows_configuration` imports only -# `inference.core.env` (already fully imported above) and the dependency-free -# `inference.core.workflows.configuration`, so this adds no import weight. +# Hand the Workflows module its configuration before anything can READ it. +# The invariant: `install_workflows_configuration()` runs before any import +# of `inference.core.workflows.environment` (the constants facade) or any +# other configuration-consuming workflows module, so `core_steps/loader.py`'s +# import-time tensor branches and every facade constant see the server's +# values. A few configuration-independent workflows modules are already on +# the bootstrap path above this point (`inference.core.env` -> +# `utils/environment.py` -> `core/exceptions.py` -> +# `workflows/prototypes/platform_errors.py`, and the builder's own import of +# `workflows/configuration.py`); they must stay configuration-independent - +# none of them may import the facade. `inference.core.interfaces +# .workflows_configuration` imports only `inference.core.env` (already fully +# imported above) and `inference.core.workflows.configuration`, so this adds +# no import weight. from inference.core.interfaces.workflows_configuration import ( install_workflows_configuration, ) diff --git a/inference/core/interfaces/workflows_configuration.py b/inference/core/interfaces/workflows_configuration.py index 135c941904..649d0ea807 100644 --- a/inference/core/interfaces/workflows_configuration.py +++ b/inference/core/interfaces/workflows_configuration.py @@ -9,10 +9,16 @@ that logic is duplicated and none of it can drift. `install_workflows_configuration()` is called from `inference/core/__init__.py`. -That is an import-system guarantee, not a convention: importing any -`inference.core.workflows.X` executes `inference/core/__init__.py` to -completion first, so the configuration is installed before any workflows -module body - including `core_steps/loader.py`'s tensor-mode branches - runs. +The invariant is narrower than "before any workflows module": installation +precedes every import of the constants facade +(`inference.core.workflows.environment`) and of every configuration-consuming +workflows module, including `core_steps/loader.py`'s tensor-mode branches. +A handful of configuration-independent workflows modules are imported earlier, +on the bootstrap path (`inference.core.env` -> `utils/environment.py` -> +`core/exceptions.py` -> `workflows/prototypes/platform_errors.py`, plus +`workflows/configuration.py` itself, imported by this module); they never read +the facade and must stay that way, or they would freeze standalone defaults +into it before the server's configuration is installed. """ from typing import Optional diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py b/tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py index d841950a75..f97cba3421 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py @@ -264,7 +264,10 @@ def test_install_is_idempotent() -> None: def test_importing_inference_core_installs_before_any_workflows_module_loads() -> None: # The ordering guarantee this design rests on: `inference/core/__init__.py` - # runs to completion before any `inference.core.workflows.*` body does. + # installs the configuration before the constants facade + # (`inference.core.workflows.environment`) is first imported - the + # configuration-independent bootstrap modules (`prototypes/platform_errors`, + # `workflows/configuration`) may load earlier, the facade may not. # BOTH flags are pinned - `env.py:1486` ANDs `USE_INFERENCE_MODELS` into # the tensor flag (round-3 defect 5) - and the child reports env.py's # EFFECTIVE flag, so a gate-induced False can never pass as "both agree". From 58e794215e1a34c7d456a638ade952a5a98d98f0 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:04:22 +0200 Subject: [PATCH 49/86] refactor(workflows): first-class model metadata + stream-pipeline port methods; drop __getitem__ Add get_keypoints_classes, model_supports_stream_pipeline, get_model_pipeline_depth, flush_model_stream_pipeline and shutdown_model_stream_pipeline to the ModelsProvider port, ModelManager and ModelManagerDecorator; repoint the seven raw __getitem__ sites (three keypoint tensor blocks, instance_segmentation/v3) and delete __getitem__ from the port. Adds the Phase 11 annotation-gate script and the additive port-edit script; baseline unchanged (Count 68). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/managers/base.py | 46 ++++++ inference/core/managers/decorators/base.py | 15 ++ .../roboflow/instance_segmentation/v3.py | 23 ++- .../roboflow/keypoint_detection/v1_tensor.py | 6 +- .../roboflow/keypoint_detection/v2_tensor.py | 6 +- .../roboflow/keypoint_detection/v3_tensor.py | 6 +- .../workflows/prototypes/models_provider.py | 27 ++-- scripts/phase11_annotation_scan.py | 50 +++++++ scripts/phase11_port_edit.py | 134 ++++++++++++++++++ .../unit_tests/core/managers/test_base.py | 109 ++++++++++++++ .../test_v3_stream_pipeline.py | 55 +++++++ .../prototypes/test_models_provider.py | 29 +++- 12 files changed, 471 insertions(+), 35 deletions(-) create mode 100644 scripts/phase11_annotation_scan.py create mode 100644 scripts/phase11_port_edit.py create mode 100644 tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py diff --git a/inference/core/managers/base.py b/inference/core/managers/base.py index bbc59ac59b..ee0aa06d2e 100644 --- a/inference/core/managers/base.py +++ b/inference/core/managers/base.py @@ -599,6 +599,52 @@ def get_class_names(self, model_id): model = self._get_model_reference(model_id=model_id) return model.class_names + def get_keypoints_classes(self, model_id: str) -> List[List[str]]: + """Per-object-class keypoint class names, indexed by object class id. + + Only the `inference_models` adapters expose this; the workflow keypoint + blocks read it to label the keypoints they emit. + """ + model = self._get_model_reference(model_id=model_id) + return model.key_points_classes + + def model_supports_stream_pipeline(self, model_id: str) -> bool: + """True when the loaded model runs a depth>1 async inference pipeline.""" + if model_id not in self: + return False + model = self._get_model_reference(model_id=model_id) + return ( + callable(getattr(model, "flush", None)) + and getattr(model, "_pipeline_depth", 1) > 1 + ) + + def get_model_pipeline_depth(self, model_id: str) -> int: + """The model's async pipeline depth; 1 when it has none or is not loaded.""" + if model_id not in self: + return 1 + model = self._get_model_reference(model_id=model_id) + return int(getattr(model, "_pipeline_depth", 1)) + + def flush_model_stream_pipeline(self, model_id: str) -> Optional[List[Any]]: + """Drain the model's in-flight pipeline, or None when it has none.""" + if model_id not in self: + return None + model = self._get_model_reference(model_id=model_id) + flush_fn = getattr(model, "flush", None) + if not callable(flush_fn): + return None + return flush_fn() + + def shutdown_model_stream_pipeline(self, model_id: str) -> None: + """Stop the model's pipeline workers. A no-op when it has none.""" + if model_id not in self: + return None + model = self._get_model_reference(model_id=model_id) + shutdown_fn = getattr(model, "shutdown_pipeline", None) + if callable(shutdown_fn): + shutdown_fn() + return None + def get_task_type(self, model_id: str, api_key: str = None) -> str: """Retrieves the task type for a given model. diff --git a/inference/core/managers/decorators/base.py b/inference/core/managers/decorators/base.py index 1996cd2308..d17384a400 100644 --- a/inference/core/managers/decorators/base.py +++ b/inference/core/managers/decorators/base.py @@ -190,6 +190,21 @@ def get_class_names(self, model_id): """ return self.model_manager.get_class_names(model_id) + def get_keypoints_classes(self, model_id: str) -> List[List[str]]: + return self.model_manager.get_keypoints_classes(model_id) + + def model_supports_stream_pipeline(self, model_id: str) -> bool: + return self.model_manager.model_supports_stream_pipeline(model_id) + + def get_model_pipeline_depth(self, model_id: str) -> int: + return self.model_manager.get_model_pipeline_depth(model_id) + + def flush_model_stream_pipeline(self, model_id: str) -> Optional[List[Any]]: + return self.model_manager.flush_model_stream_pipeline(model_id) + + def shutdown_model_stream_pipeline(self, model_id: str) -> None: + return self.model_manager.shutdown_model_stream_pipeline(model_id) + def remove(self, model_id: str, delete_from_disk: bool = True) -> Model: """Removes a model from the manager. diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py index 17c754c08e..6d3869ed86 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py @@ -568,11 +568,7 @@ def is_stream_pipelined(self) -> bool: or self._last_model_id not in self._model_manager ): return False - model = self._model_manager[self._last_model_id] - return ( - callable(getattr(model, "flush", None)) - and getattr(model, "_pipeline_depth", 1) > 1 - ) + return self._model_manager.model_supports_stream_pipeline(self._last_model_id) def can_activate_stream_pipeline(self) -> bool: return ( @@ -583,8 +579,8 @@ def can_activate_stream_pipeline(self) -> bool: def stream_pipeline_depth(self) -> int: if not self.is_stream_pipelined(): return 0 - model = self._model_manager[self._last_model_id] - return max(0, int(getattr(model, "_pipeline_depth", 1)) - 1) + depth = self._model_manager.get_model_pipeline_depth(self._last_model_id) + return max(0, int(depth) - 1) def flush_stream_pipeline_outputs( self, @@ -595,12 +591,12 @@ def flush_stream_pipeline_outputs( ): self._pending_stream_prediction_contexts.clear() return [] - model = self._model_manager[self._last_model_id] - flush_fn = getattr(model, "flush", None) - if not callable(flush_fn): + predictions = self._model_manager.flush_model_stream_pipeline( + self._last_model_id + ) + if predictions is None: self._pending_stream_prediction_contexts.clear() return [] - predictions = flush_fn() if not isinstance(predictions, list): predictions = [predictions] @@ -646,10 +642,7 @@ def close_stream_pipeline(self) -> None: or self._last_model_id not in self._model_manager ): return None - model = self._model_manager[self._last_model_id] - shutdown_fn = getattr(model, "shutdown_pipeline", None) - if callable(shutdown_fn): - shutdown_fn() + self._model_manager.shutdown_model_stream_pipeline(self._last_model_id) def _build_stream_context_id(self, images: Batch[WorkflowImageData]) -> str: self._stream_context_generation += 1 diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1_tensor.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1_tensor.py index bc4260311f..9e6e05189f 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1_tensor.py @@ -366,9 +366,9 @@ def run_locally( ) class_names = _class_names_map(self._model_manager.get_class_names(model_id)) # `key_points_classes` (List[List[str]], indexed by *object* class id) is only - # exposed on the inference_models adapter - reach the adapter directly through - # the manager's item access (the same handle that backs `get_class_names`). - key_points_classes = self._model_manager[model_id].key_points_classes + # exposed on the inference_models adapter - reach it through the provider's + # first-class accessor (the same handle that backs `get_class_names`). + key_points_classes = self._model_manager.get_keypoints_classes(model_id) results: List[dict] = [] for image, key_points, detections in zip( images, keypoints_batch, detections_batch diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2_tensor.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2_tensor.py index 6fd9ca29a3..fda6692e8d 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2_tensor.py @@ -364,9 +364,9 @@ def run_locally( ) class_names = _class_names_map(self._model_manager.get_class_names(model_id)) # `key_points_classes` (List[List[str]], indexed by *object* class id) is only - # exposed on the inference_models adapter - reach the adapter directly through - # the manager's item access (the same handle that backs `get_class_names`). - key_points_classes = self._model_manager[model_id].key_points_classes + # exposed on the inference_models adapter - reach it through the provider's + # first-class accessor (the same handle that backs `get_class_names`). + key_points_classes = self._model_manager.get_keypoints_classes(model_id) results: List[dict] = [] for image, key_points, detections in zip( images, keypoints_batch, detections_batch diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3_tensor.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3_tensor.py index d23f41758b..d14df28c47 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3_tensor.py @@ -397,9 +397,9 @@ def run_locally( ) class_names = _class_names_map(self._model_manager.get_class_names(model_id)) # `key_points_classes` (List[List[str]], indexed by *object* class id) is only - # exposed on the inference_models adapter - reach the adapter directly through - # the manager's item access (the same handle that backs `get_class_names`). - key_points_classes = self._model_manager[model_id].key_points_classes + # exposed on the inference_models adapter - reach it through the provider's + # first-class accessor (the same handle that backs `get_class_names`). + key_points_classes = self._model_manager.get_keypoints_classes(model_id) results: List[dict] = [] for image, key_points, detections in zip( images, keypoints_batch, detections_batch diff --git a/inference/core/workflows/prototypes/models_provider.py b/inference/core/workflows/prototypes/models_provider.py index a21df5baf8..e756707129 100644 --- a/inference/core/workflows/prototypes/models_provider.py +++ b/inference/core/workflows/prototypes/models_provider.py @@ -1,4 +1,4 @@ -from typing import Any, List, Optional, Protocol +from typing import Any, Dict, List, Optional, Protocol, Union class ModelsProvider(Protocol): @@ -19,13 +19,14 @@ class ModelsProvider(Protocol): to avoid importing the server's ``ModelEndpointType`` enum; the server coerces it back as needed. - PROVISIONAL MEMBERS. ``infer_from_request_sync`` takes a pydantic request + The stream-pipeline members are prefixed because ``flush_stream_pipeline``, + ``stream_pipeline_depth``, ``close_stream_pipeline`` and + ``is_stream_pipelined`` are already a *block*-level duck-typed protocol that + the executor and the server's stream handler call on step instances. + + PROVISIONAL MEMBER. ``infer_from_request_sync`` takes a pydantic request object built by the caller from ``inference.core.entities`` - it is the - method Phase 11 option 2 removes entirely. ``__getitem__`` returns a raw - model object that seven call sites introspect (``key_points_classes``, - ``flush()``, ``_pipeline_depth``, ``shutdown_pipeline()``); it exists so - Phase 2 stays a mechanical swap, and Phase 11 must replace it with - first-class methods. Do not build new code against either. + method Phase 11 removes entirely. Do not build new code against it. """ content_addressed_artifact_cache: Any @@ -50,9 +51,17 @@ def run_tensor_native_inference(self, model_id: str, **kwargs: Any) -> Any: ... def get_class_names(self, model_id: str) -> List[str]: ... - def __contains__(self, model_id: str) -> bool: ... + def get_keypoints_classes(self, model_id: str) -> List[List[str]]: ... + + def model_supports_stream_pipeline(self, model_id: str) -> bool: ... + + def get_model_pipeline_depth(self, model_id: str) -> int: ... - def __getitem__(self, key: str) -> Any: ... + def flush_model_stream_pipeline(self, model_id: str) -> Optional[List[Any]]: ... + + def shutdown_model_stream_pipeline(self, model_id: str) -> None: ... + + def __contains__(self, model_id: str) -> bool: ... # The `endpoint_type` value every core-model block registers with. It is the diff --git a/scripts/phase11_annotation_scan.py b/scripts/phase11_annotation_scan.py new file mode 100644 index 0000000000..e971f47e61 --- /dev/null +++ b/scripts/phase11_annotation_scan.py @@ -0,0 +1,50 @@ +"""Lists every annotation in a file that references one of the given names. + +`compileall` does not evaluate annotations, so a deleted import that survives +only in a `-> List[Point]` return type compiles and then fails at import time. + +Run: python scripts/phase11_annotation_scan.py Name1,Name2,... +Exit code 1 (and a printed table) when any annotation still references one. +""" + +import ast +import sys + + +def scan(path: str, names: set) -> list: + tree = ast.parse(open(path, encoding="utf-8").read()) + hits = [] + for node in ast.walk(tree): + annotations = [] + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if node.returns is not None: + annotations.append(("return", node.returns)) + arguments = ( + list(node.args.posonlyargs) + + list(node.args.args) + + list(node.args.kwonlyargs) + ) + for argument in arguments: + if argument.annotation is not None: + annotations.append((f"arg {argument.arg}", argument.annotation)) + elif isinstance(node, ast.AnnAssign): + annotations.append(("annotated assignment", node.annotation)) + for label, annotation in annotations: + referenced = sorted( + { + inner.id + for inner in ast.walk(annotation) + if isinstance(inner, ast.Name) and inner.id in names + } + ) + if referenced: + hits.append((node.lineno, label, referenced, ast.unparse(annotation))) + return hits + + +if __name__ == "__main__": + path, names = sys.argv[1], set(sys.argv[2].split(",")) + hits = scan(path, names) + for lineno, label, referenced, text in hits: + print(f"{path}:{lineno}: {label}: {text} -> {referenced}") + sys.exit(1 if hits else 0) diff --git a/scripts/phase11_port_edit.py b/scripts/phase11_port_edit.py new file mode 100644 index 0000000000..af5e5c6b71 --- /dev/null +++ b/scripts/phase11_port_edit.py @@ -0,0 +1,134 @@ +"""Additive edit of `inference/core/workflows/prototypes/models_provider.py` (Task 11.1). + +Anchor-based, never a whole-file replacement, so whatever another phase has +already appended to the module survives - Phase 9's `CORE_MODEL_ENDPOINT_TYPE` +constant (Task 9.2) and its `load_action_recognition_model` declaration +(Task 9.9) in particular. It: + + 1. rewrites the `from typing import ...` line to carry Dict and Union as well + (Tasks 11.8/11.9 add annotations that need them); + 2. deletes the `__getitem__` declaration; + 3. inserts the five first-class model-access declarations before + `def __contains__`; + 4. replaces the "PROVISIONAL MEMBERS." docstring paragraph with the + single-member form Task 11.15 later deletes. + +Re-parses before writing and verifies the post-state. Idempotent: a second run +reports SKIP and changes nothing. + +Run: python scripts/phase11_port_edit.py inference/core/workflows/prototypes/models_provider.py +""" + +import ast +import re +import sys +from pathlib import Path + +TYPING_NAMES = ("Any", "Dict", "List", "Optional", "Protocol", "Union") + +GETITEM_DECL = " def __getitem__(self, key: str) -> Any: ...\n" + +NEW_MEMBERS = """ def get_keypoints_classes(self, model_id: str) -> List[List[str]]: ... + + def model_supports_stream_pipeline(self, model_id: str) -> bool: ... + + def get_model_pipeline_depth(self, model_id: str) -> int: ... + + def flush_model_stream_pipeline(self, model_id: str) -> Optional[List[Any]]: ... + + def shutdown_model_stream_pipeline(self, model_id: str) -> None: ... + +""" + +CONTAINS_ANCHOR = " def __contains__(self, model_id: str) -> bool: ...\n" + +NEW_PARAGRAPH = """ The stream-pipeline members are prefixed because ``flush_stream_pipeline``, + ``stream_pipeline_depth``, ``close_stream_pipeline`` and + ``is_stream_pipelined`` are already a *block*-level duck-typed protocol that + the executor and the server's stream handler call on step instances. + + PROVISIONAL MEMBER. ``infer_from_request_sync`` takes a pydantic request + object built by the caller from ``inference.core.entities`` - it is the + method Phase 11 removes entirely. Do not build new code against it. +""" + + +def edit(source: str) -> str: + if "def get_keypoints_classes" in source and "__getitem__" not in source: + return source # already applied + # 1. typing import: union of what is there and what Tasks 11.8/11.9 need. + match = re.search(r"^from typing import ([^\n]+)\n", source, re.MULTILINE) + if match is None: + raise SystemExit("no `from typing import` line found") + present = {name.strip() for name in match.group(1).split(",")} + names = sorted(present | set(TYPING_NAMES)) + source = source.replace( + match.group(0), f"from typing import {', '.join(names)}\n", 1 + ) + # 2. drop __getitem__. + if source.count(GETITEM_DECL) != 1: + raise SystemExit("expected exactly one __getitem__ declaration") + source = source.replace(GETITEM_DECL, "", 1) + # 3. insert the five members before __contains__. + if source.count(CONTAINS_ANCHOR) != 1: + raise SystemExit("expected exactly one __contains__ declaration") + source = source.replace(CONTAINS_ANCHOR, NEW_MEMBERS + CONTAINS_ANCHOR, 1) + # 4. the docstring paragraph: from " PROVISIONAL MEMBERS." to the closing quotes. + paragraph = re.search( + r" PROVISIONAL MEMBERS\..*?(?=\n \"\"\"\n)", source, re.DOTALL + ) + if paragraph is None: + raise SystemExit("PROVISIONAL MEMBERS paragraph not found") + source = ( + source[: paragraph.start()] + + NEW_PARAGRAPH.rstrip("\n") + + source[paragraph.end() :] + ) + source = re.sub(r"\n{3,}( def __contains__)", r"\n\n\1", source) + return source + + +def verify(source: str, original: str) -> None: + tree = ast.parse(source) + declared = { + node.name + for cls in tree.body + if isinstance(cls, ast.ClassDef) and cls.name == "ModelsProvider" + for node in cls.body + if isinstance(node, ast.FunctionDef) + } + for name in ( + "get_keypoints_classes", + "model_supports_stream_pipeline", + "get_model_pipeline_depth", + "flush_model_stream_pipeline", + "shutdown_model_stream_pipeline", + "__contains__", + "add_model", + "run_tensor_native_inference", + "get_class_names", + "infer_from_request_sync", + ): + assert name in declared, f"missing {name}" + assert "__getitem__" not in declared + # Whatever other phases put here must survive untouched. + for preserved in ("CORE_MODEL_ENDPOINT_TYPE", "load_action_recognition_model"): + assert (preserved in source) == (preserved in original), preserved + assert "PROVISIONAL MEMBERS" not in source and "PROVISIONAL MEMBER." in source + + +def main(paths): + for raw in paths: + path = Path(raw) + original = path.read_text(encoding="utf-8") + updated = edit(original) + if updated == original: + print(f"SKIP (already applied): {path}") + continue + verify(updated, original) + path.write_text(updated, encoding="utf-8") + print(f"edited: {path}") + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/tests/inference/unit_tests/core/managers/test_base.py b/tests/inference/unit_tests/core/managers/test_base.py index 8322b4e159..920af621ba 100644 --- a/tests/inference/unit_tests/core/managers/test_base.py +++ b/tests/inference/unit_tests/core/managers/test_base.py @@ -782,3 +782,112 @@ def test_add_model_records_all_model_ids() -> None: # then assert ids.get_ids() == {"warm/1", "cold/1"} + + +class _FakeStreamModel: + def __init__(self, pipeline_depth=None, has_flush=False, has_shutdown=False): + self.key_points_classes = [["nose", "eye"], ["tip"]] + if pipeline_depth is not None: + self._pipeline_depth = pipeline_depth + if has_flush: + self.flush = lambda: ["flushed"] + if has_shutdown: + self.shutdown_called = False + self.shutdown_pipeline = self._shutdown + + def _shutdown(self): + self.shutdown_called = True + + +def _manager_with(model_id: str, model: object) -> ModelManager: + manager = ModelManager(model_registry=MagicMock()) + manager._models[model_id] = model + return manager + + +def test_get_keypoints_classes_returns_model_attribute() -> None: + assert _manager_with("m/1", _FakeStreamModel()).get_keypoints_classes("m/1") == [ + ["nose", "eye"], + ["tip"], + ] + + +def test_model_supports_stream_pipeline_requires_flush_and_depth_above_one() -> None: + assert _manager_with( + "m/1", _FakeStreamModel(pipeline_depth=3, has_flush=True) + ).model_supports_stream_pipeline("m/1") + assert not _manager_with( + "m/1", _FakeStreamModel(pipeline_depth=1, has_flush=True) + ).model_supports_stream_pipeline("m/1") + assert not _manager_with( + "m/1", _FakeStreamModel(pipeline_depth=3) + ).model_supports_stream_pipeline("m/1") + assert not _manager_with( + "other", _FakeStreamModel(pipeline_depth=3, has_flush=True) + ).model_supports_stream_pipeline("m/1") + + +def test_get_model_pipeline_depth_defaults_to_one() -> None: + assert ( + _manager_with( + "m/1", _FakeStreamModel(pipeline_depth=4) + ).get_model_pipeline_depth("m/1") + == 4 + ) + assert _manager_with("m/1", _FakeStreamModel()).get_model_pipeline_depth("m/1") == 1 + assert ( + _manager_with("m/1", _FakeStreamModel()).get_model_pipeline_depth("absent") == 1 + ) + + +def test_flush_model_stream_pipeline_returns_none_when_unavailable() -> None: + assert _manager_with( + "m/1", _FakeStreamModel(pipeline_depth=3, has_flush=True) + ).flush_model_stream_pipeline("m/1") == ["flushed"] + assert ( + _manager_with("m/1", _FakeStreamModel()).flush_model_stream_pipeline("m/1") + is None + ) + assert ( + _manager_with("m/1", _FakeStreamModel()).flush_model_stream_pipeline("absent") + is None + ) + + +def test_shutdown_model_stream_pipeline_is_a_noop_when_unsupported() -> None: + model = _FakeStreamModel(has_shutdown=True) + manager = _manager_with("m/1", model) + manager.shutdown_model_stream_pipeline("m/1") + assert model.shutdown_called is True + manager.shutdown_model_stream_pipeline("absent") + _manager_with("m/2", _FakeStreamModel()).shutdown_model_stream_pipeline("m/2") + + +def test_decorator_forwards_the_new_members() -> None: + from inference.core.managers.decorators.base import ModelManagerDecorator + + inner = MagicMock() + decorator = ModelManagerDecorator(inner) + assert ( + decorator.get_keypoints_classes("m/1") + is inner.get_keypoints_classes.return_value + ) + inner.get_keypoints_classes.assert_called_once_with("m/1") + assert ( + decorator.model_supports_stream_pipeline("m/1") + is inner.model_supports_stream_pipeline.return_value + ) + assert ( + decorator.get_model_pipeline_depth("m/1") + is inner.get_model_pipeline_depth.return_value + ) + assert ( + decorator.flush_model_stream_pipeline("m/1") + is inner.flush_model_stream_pipeline.return_value + ) + decorator.shutdown_model_stream_pipeline("m/1") + inner.shutdown_model_stream_pipeline.assert_called_once_with("m/1") + decorator.load_action_recognition_model(model_id="ar/1", api_key="k") + inner.load_action_recognition_model.assert_called_once_with( + model_id="ar/1", api_key="k" + ) diff --git a/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py b/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py new file mode 100644 index 0000000000..27c82e3ddc --- /dev/null +++ b/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py @@ -0,0 +1,55 @@ +from unittest.mock import MagicMock + +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.models.roboflow.instance_segmentation.v3 import ( + RoboflowInstanceSegmentationModelBlockV3, +) + + +def _block(model_manager) -> RoboflowInstanceSegmentationModelBlockV3: + block = RoboflowInstanceSegmentationModelBlockV3( + model_manager=model_manager, + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + block._last_model_id = "m/1" + return block + + +def _manager() -> MagicMock: + manager = MagicMock() + manager.__contains__.return_value = True # MagicMock's default is False + return manager + + +def test_is_stream_pipelined_uses_the_port_not_item_access() -> None: + manager = _manager() + manager.model_supports_stream_pipeline.return_value = True + assert _block(manager).is_stream_pipelined() is True + manager.model_supports_stream_pipeline.assert_called_once_with("m/1") + manager.__getitem__.assert_not_called() + + +def test_stream_pipeline_depth_subtracts_one() -> None: + manager = _manager() + manager.model_supports_stream_pipeline.return_value = True + manager.get_model_pipeline_depth.return_value = 4 + assert _block(manager).stream_pipeline_depth() == 3 + manager.__getitem__.assert_not_called() + + +def test_flush_stream_pipeline_outputs_clears_contexts_when_flush_unavailable() -> None: + manager = _manager() + manager.flush_model_stream_pipeline.return_value = None + block = _block(manager) + block._pending_stream_prediction_contexts.append(object()) + assert block.flush_stream_pipeline_outputs() == [] + assert len(block._pending_stream_prediction_contexts) == 0 + manager.__getitem__.assert_not_called() + + +def test_close_stream_pipeline_delegates_shutdown() -> None: + manager = _manager() + _block(manager).close_stream_pipeline() + manager.shutdown_model_stream_pipeline.assert_called_once_with("m/1") + manager.__getitem__.assert_not_called() diff --git a/tests/workflows/unit_tests/prototypes/test_models_provider.py b/tests/workflows/unit_tests/prototypes/test_models_provider.py index 7713fb738d..8a50f4e964 100644 --- a/tests/workflows/unit_tests/prototypes/test_models_provider.py +++ b/tests/workflows/unit_tests/prototypes/test_models_provider.py @@ -11,8 +11,12 @@ "infer_from_request_sync", "run_tensor_native_inference", "get_class_names", + "get_keypoints_classes", + "model_supports_stream_pipeline", + "get_model_pipeline_depth", + "flush_model_stream_pipeline", + "shutdown_model_stream_pipeline", "__contains__", - "__getitem__", ] @@ -49,7 +53,7 @@ def test_model_manager_is_structurally_compatible_with_the_port() -> None: # anything that binds by keyword (a mock, a wrapper, a future refactor) # would break. Compare ordered parameter NAMES only; annotations differ # deliberately (the port returns `Any`, ModelManager returns `Model`). - for member in ("__getitem__", "__contains__"): + for member in ("__contains__",): port_names = [ name for name in inspect.signature(getattr(ModelsProvider, member)).parameters @@ -75,3 +79,24 @@ def test_model_manager_is_structurally_compatible_with_the_port() -> None: ) ) assert assigns_content_addressed_artifact_cache + + +def test_port_dropped_getitem_and_declares_first_class_model_access() -> None: + from inference.core.managers.decorators.base import ModelManagerDecorator + + assert "__getitem__" not in ModelsProvider.__dict__ + for name in ( + "get_keypoints_classes", + "model_supports_stream_pipeline", + "get_model_pipeline_depth", + "flush_model_stream_pipeline", + "shutdown_model_stream_pipeline", + "load_action_recognition_model", + ): + assert name in ModelManager.__dict__, f"{name} must be declared on ModelManager" + # A decorator that inherits instead of forwarding hits the `_models` + # property that raises (decorators/base.py:35-37), so the override must + # exist in the decorator's own __dict__. + assert ( + name in ModelManagerDecorator.__dict__ + ), f"{name} must be forwarded by ModelManagerDecorator" From 3fa14a879e3ed9ab15e698170bffa91788c7e1f7 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:11:37 +0200 Subject: [PATCH 50/86] refactor(workflows): duck-type response normalisation instead of importing _is_response_dc_to_dict Add public to_dict() method to InstanceSegmentationInferenceResponseDC and update instance_segmentation/v3.py to use duck-typing instead of importing private symbols. Removes 1 baseline row (68 -> 67). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../core/entities/responses/inference.py | 9 +++ .../roboflow/instance_segmentation/v3.py | 14 ++--- .../entities/test_inference_response_dc.py | 5 ++ .../test_v3_stream_pipeline.py | 59 +++++++++++++++++++ .../unit_tests/decontamination_baseline.txt | 3 +- 5 files changed, 79 insertions(+), 11 deletions(-) diff --git a/inference/core/entities/responses/inference.py b/inference/core/entities/responses/inference.py index d975d15492..fffacfbc87 100644 --- a/inference/core/entities/responses/inference.py +++ b/inference/core/entities/responses/inference.py @@ -366,6 +366,15 @@ class InstanceSegmentationInferenceResponseDC: _async_response_future: object = None _async_response_context_id: object = None + def to_dict(self) -> dict: + """Public form of `_is_response_dc_to_dict`. + + The workflow instance-segmentation block and the workflows models + provider duck-type this so they do not import a private symbol across + the package boundary. + """ + return _is_response_dc_to_dict(self) + def _is_pred_dc_to_dict(p: InstanceSegmentationPredictionDC) -> dict: """Bit-equivalent to `InstanceSegmentationPrediction(...).model_dump(by_alias=True, exclude_none=True)`.""" diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py index 6d3869ed86..9b229c1e1c 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py @@ -9,10 +9,6 @@ from inference.core.entities.requests.inference import ( InstanceSegmentationInferenceRequest, ) -from inference.core.entities.responses.inference import ( - InstanceSegmentationInferenceResponseDC, - _is_response_dc_to_dict, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -502,13 +498,13 @@ def _finalize_prediction_responses( stream_context: _StreamPredictionContext, ) -> BlockResult: # The adapter returns dataclass responses when source="workflow-execution" - # (cheaper construct + dict-walk than pydantic). Any other response type - # (e.g. if a non-rfdetr backend is bound to the same block) falls back - # to `model_dump`. + # (cheaper construct + dict-walk than pydantic); those expose `to_dict()`. + # Any other response type (e.g. if a non-rfdetr backend is bound to the + # same block) falls back to `model_dump`. predictions = [ ( - _is_response_dc_to_dict(e) - if isinstance(e, InstanceSegmentationInferenceResponseDC) + e.to_dict() + if callable(getattr(e, "to_dict", None)) else e.model_dump(by_alias=True, exclude_none=True) ) for e in predictions diff --git a/tests/inference/unit_tests/core/entities/test_inference_response_dc.py b/tests/inference/unit_tests/core/entities/test_inference_response_dc.py index 5f01e04ec0..54ff25a69d 100644 --- a/tests/inference/unit_tests/core/entities/test_inference_response_dc.py +++ b/tests/inference/unit_tests/core/entities/test_inference_response_dc.py @@ -178,3 +178,8 @@ def test_instance_segmentation_dc_can_carry_async_response_context() -> None: assert get_async_response_future(response) is response_future assert get_async_response_context_id(response) == "context-1" + + +def test_response_dc_to_dict_method_matches_the_module_function() -> None: + dc_response = _dc_response(predictions=[]) + assert dc_response.to_dict() == _is_response_dc_to_dict(dc_response) diff --git a/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py b/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py index 27c82e3ddc..f21eeaa3cd 100644 --- a/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py +++ b/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py @@ -53,3 +53,62 @@ def test_close_stream_pipeline_delegates_shutdown() -> None: _block(manager).close_stream_pipeline() manager.shutdown_model_stream_pipeline.assert_called_once_with("m/1") manager.__getitem__.assert_not_called() + + +def test_dc_and_pydantic_responses_normalise_to_the_same_dict() -> None: + from inference.core.entities.responses.inference import ( + InferenceResponseImage, + InferenceResponseImageDC, + InstanceSegmentationInferenceResponse, + InstanceSegmentationInferenceResponseDC, + InstanceSegmentationPrediction, + InstanceSegmentationPredictionDC, + Point, + PointDC, + ) + + dc = InstanceSegmentationInferenceResponseDC( + image=InferenceResponseImageDC(width=10, height=20), + predictions=[ + InstanceSegmentationPredictionDC( + x=1.0, + y=2.0, + width=3.0, + height=4.0, + confidence=0.5, + class_name="a", + class_id=0, + points=[ + PointDC(x=0.0, y=0.0), + PointDC(x=1.0, y=1.0), + PointDC(x=2.0, y=0.0), + ], + detection_id="fixed-id", + ) + ], + ) + pydantic_equivalent = InstanceSegmentationInferenceResponse( + image=InferenceResponseImage(width=10, height=20), + predictions=[ + InstanceSegmentationPrediction( + **{ + "x": 1.0, + "y": 2.0, + "width": 3.0, + "height": 4.0, + "confidence": 0.5, + "class": "a", + "class_id": 0, + "detection_id": "fixed-id", + "points": [ + Point(x=0.0, y=0.0), + Point(x=1.0, y=1.0), + Point(x=2.0, y=0.0), + ], + } + ) + ], + ) + assert dc.to_dict() == pydantic_equivalent.model_dump( + by_alias=True, exclude_none=True + ) diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 0fd270109e..929d766c76 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 68 +# Count: 67 inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.clip inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.doctr inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.easy_ocr @@ -52,7 +52,6 @@ inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py inference.core.entities.requests.inference From b464920ee5509010ec50e71b891e1a405ffd1464 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:20:21 +0200 Subject: [PATCH 51/86] refactor(workflows): plain-argument load_core_model; drop annotation-only entity imports load_core_model now takes model_manager, core_model, version_id and api_key directly instead of a request object, dropping the five inference.core.entities.requests imports it needed only for that Union. The 12 call sites pass version_id/api_key read off the same request attributes as before (pure refactor, no observable change). Five SAM blocks rewrite an annotation-only entities.responses import to List[Any] (with a comment naming the real type) since only the fields already accessed are read. Baseline 67 -> 57. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../core/workflows/core_steps/common/utils.py | 27 +++++------- .../core_steps/models/foundation/clip/v1.py | 6 ++- .../models/foundation/clip_comparison/v1.py | 3 +- .../models/foundation/clip_comparison/v2.py | 3 +- .../models/foundation/easy_ocr/v1.py | 3 +- .../core_steps/models/foundation/ocr/v1.py | 3 +- .../foundation/perception_encoder/v1.py | 6 ++- .../core_steps/models/foundation/pp_ocr/v1.py | 3 +- .../models/foundation/pp_ocr/v1_tensor.py | 3 +- .../models/foundation/segment_anything2/v1.py | 10 +++-- .../models/foundation/segment_anything3/v1.py | 7 +-- .../models/foundation/segment_anything3/v2.py | 7 +-- .../foundation/segment_anything3_3d/v1.py | 7 ++- .../segment_anything3_3d/v1_tensor.py | 7 ++- .../models/foundation/yolo_world/v1.py | 3 +- .../core_steps/common/test_load_core_model.py | 44 +++++++++++++++++++ .../unit_tests/decontamination_baseline.txt | 12 +---- 17 files changed, 97 insertions(+), 57 deletions(-) create mode 100644 tests/workflows/unit_tests/core_steps/common/test_load_core_model.py diff --git a/inference/core/workflows/core_steps/common/utils.py b/inference/core/workflows/core_steps/common/utils.py index dcbee5eec1..9f04c9806f 100644 --- a/inference/core/workflows/core_steps/common/utils.py +++ b/inference/core/workflows/core_steps/common/utils.py @@ -21,11 +21,6 @@ import supervision as sv from supervision.config import CLASS_NAME_DATA_FIELD, ORIENTED_BOX_COORDINATES -from inference.core.entities.requests.clip import ClipCompareRequest -from inference.core.entities.requests.doctr import DoctrOCRInferenceRequest -from inference.core.entities.requests.easy_ocr import EasyOCRInferenceRequest -from inference.core.entities.requests.sam2 import Sam2InferenceRequest -from inference.core.entities.requests.yolo_world import YOLOWorldInferenceRequest from inference.core.workflows.core_steps.common.keypoints import ( KEYPOINT_PADDING_CLASS_NAME, validate_keypoints_padding, @@ -79,22 +74,20 @@ def load_core_model( model_manager: ModelsProvider, - inference_request: Union[ - DoctrOCRInferenceRequest, - EasyOCRInferenceRequest, - ClipCompareRequest, - YOLOWorldInferenceRequest, - Sam2InferenceRequest, - ], core_model: str, + version_id: Optional[str], + api_key: Optional[str], ) -> str: - version_id_field = f"{core_model}_version_id" - core_model_id = ( - f"{core_model}/{inference_request.__getattribute__(version_id_field)}" - ) + """Register a Roboflow core model and return the id it was registered under. + + Takes the version id and api key directly rather than a request object, so + `inference.core.workflows` does not import the server's HTTP request + classes just to read two attributes off them. + """ + core_model_id = f"{core_model}/{version_id}" model_manager.add_model( core_model_id, - inference_request.api_key, + api_key, endpoint_type=CORE_MODEL_ENDPOINT_TYPE, ) return core_model_id diff --git a/inference/core/workflows/core_steps/models/foundation/clip/v1.py b/inference/core/workflows/core_steps/models/foundation/clip/v1.py index 4d08f59d67..30f8d0eebe 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/clip/v1.py @@ -196,7 +196,8 @@ def run_locally( ) clip_model_id = load_core_model( model_manager=self._model_manager, - inference_request=inference_request, + version_id=inference_request.clip_version_id, + api_key=self._api_key, core_model="clip", ) predictions = self._model_manager.infer_from_request_sync( @@ -214,7 +215,8 @@ def run_locally( ) clip_model_id = load_core_model( model_manager=self._model_manager, - inference_request=inference_request, + version_id=inference_request.clip_version_id, + api_key=self._api_key, core_model="clip", ) predictions = self._model_manager.infer_from_request_sync( diff --git a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py index df0713f1c5..96845e46ac 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py @@ -177,7 +177,8 @@ def run_locally( ) clip_model_id = load_core_model( model_manager=self._model_manager, - inference_request=inference_request, + version_id=inference_request.clip_version_id, + api_key=self._api_key, core_model="clip", ) prediction = self._model_manager.infer_from_request_sync( diff --git a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py index f8681fc559..cbad8d0eca 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py @@ -213,7 +213,8 @@ def run_locally( ) clip_model_id = load_core_model( model_manager=self._model_manager, - inference_request=inference_request, + version_id=inference_request.clip_version_id, + api_key=self._api_key, core_model="clip", ) prediction = self._model_manager.infer_from_request_sync( diff --git a/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py index 695c5666a1..507248ad63 100644 --- a/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py @@ -246,7 +246,8 @@ def run_locally( ) model_id = load_core_model( model_manager=self._model_manager, - inference_request=inference_request, + version_id=inference_request.easy_ocr_version_id, + api_key=self._api_key, core_model="easy_ocr", ) result = self._model_manager.infer_from_request_sync( diff --git a/inference/core/workflows/core_steps/models/foundation/ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/ocr/v1.py index bb2b3f0a27..47ce43eab9 100644 --- a/inference/core/workflows/core_steps/models/foundation/ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/ocr/v1.py @@ -176,7 +176,8 @@ def run_locally( ) doctr_model_id = load_core_model( model_manager=self._model_manager, - inference_request=inference_request, + version_id=inference_request.doctr_version_id, + api_key=self._api_key, core_model="doctr", ) result = self._model_manager.infer_from_request_sync( diff --git a/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py b/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py index 95eccea2cc..808984ea09 100644 --- a/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py @@ -204,7 +204,8 @@ def run_locally( ) pe_model_id = load_core_model( model_manager=self._model_manager, - inference_request=inference_request, + version_id=inference_request.perception_encoder_version_id, + api_key=self._api_key, core_model="perception_encoder", ) predictions = self._model_manager.infer_from_request_sync( @@ -220,7 +221,8 @@ def run_locally( ) pe_model_id = load_core_model( model_manager=self._model_manager, - inference_request=inference_request, + version_id=inference_request.perception_encoder_version_id, + api_key=self._api_key, core_model="perception_encoder", ) predictions = self._model_manager.infer_from_request_sync( diff --git a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py index ded8c1523d..758884856e 100644 --- a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py @@ -215,7 +215,8 @@ def run_locally( ) model_id = load_core_model( model_manager=self._model_manager, - inference_request=inference_request, + version_id=inference_request.pp_ocr_version_id, + api_key=self._api_key, core_model="pp_ocr", ) result = self._model_manager.infer_from_request_sync( diff --git a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py index baa80a2c0a..182f937f0e 100644 --- a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py @@ -244,7 +244,8 @@ def run_locally( ) model_id = load_core_model( model_manager=self._model_manager, - inference_request=inference_request, + version_id=inference_request.pp_ocr_version_id, + api_key=self._api_key, core_model="pp_ocr", ) result = self._model_manager.infer_from_request_sync( diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py index a7cf35893f..118f0e7412 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional, Type, TypeVar, Union +from typing import Any, List, Literal, Optional, Type, TypeVar, Union import numpy as np import supervision as sv @@ -16,7 +16,6 @@ InstanceSegmentationPrediction, Point, ) -from inference.core.entities.responses.sam2 import Sam2SegmentationPrediction from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -460,7 +459,8 @@ def run_locally( ) sam_model_id = load_core_model( model_manager=self._model_manager, - inference_request=inference_request, + version_id=inference_request.sam2_version_id, + api_key=self._api_key, core_model="sam2", ) @@ -504,7 +504,9 @@ def _post_process_result( def convert_sam2_segmentation_response_to_inference_instances_seg_response( - sam2_segmentation_predictions: List[Sam2SegmentationPrediction], + # Items are the server's Sam2SegmentationPrediction; only .masks and + # .confidence are read. + sam2_segmentation_predictions: List[Any], image: WorkflowImageData, prompt_class_ids: List[Optional[int]], prompt_class_names: List[Optional[str]], diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py index e448e48be2..75412171fe 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py @@ -1,6 +1,6 @@ import logging from types import SimpleNamespace -from typing import List, Literal, Optional, Type, Union +from typing import Any, List, Literal, Optional, Type, Union import numpy as np import requests @@ -14,7 +14,6 @@ InstanceSegmentationPrediction, Point, ) -from inference.core.entities.responses.sam3 import Sam3SegmentationPrediction from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -523,7 +522,9 @@ def _post_process_result( def convert_sam3_segmentation_response_to_inference_instances_seg_response( - sam3_segmentation_predictions: List[Sam3SegmentationPrediction], + # Items are the server's Sam3SegmentationPrediction; only .masks and + # .confidence are read. + sam3_segmentation_predictions: List[Any], image: WorkflowImageData, prompt_class_ids: List[Optional[int]], prompt_class_names: List[Optional[str]], diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py index c02179f1e7..a410052429 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py @@ -1,6 +1,6 @@ import logging from types import SimpleNamespace -from typing import List, Literal, Optional, Type, Union +from typing import Any, List, Literal, Optional, Type, Union import numpy as np import requests @@ -14,7 +14,6 @@ InstanceSegmentationPrediction, Point, ) -from inference.core.entities.responses.sam3 import Sam3SegmentationPrediction from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -612,7 +611,9 @@ def _post_process_result( def convert_sam3_segmentation_response_to_inference_instances_seg_response( - sam3_segmentation_predictions: List[Sam3SegmentationPrediction], + # Items are the server's Sam3SegmentationPrediction; only .masks and + # .confidence are read. + sam3_segmentation_predictions: List[Any], image: WorkflowImageData, prompt_class_ids: List[Optional[int]], prompt_class_names: List[Optional[str]], diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py index a2bdc955cf..c2bd8cbe29 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py @@ -6,7 +6,6 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.sam3_3d import Sam3_3D_Objects_InferenceRequest -from inference.core.entities.responses.sam3_3d import Sam3_3D_Objects_Response from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, @@ -263,8 +262,8 @@ def run_locally( model_id=model_id, ) - response: Sam3_3D_Objects_Response = ( - self._model_manager.infer_from_request_sync(model_id, inference_request) + response = self._model_manager.infer_from_request_sync( + model_id, inference_request ) results.append(_format_response(response)) @@ -283,7 +282,7 @@ def extract_masks_from_input(mask_input: Any) -> Any: return mask_input -def _format_response(response: Sam3_3D_Objects_Response) -> dict: +def _format_response(response: Any) -> dict: """Format response with base64 encoded outputs.""" def encode(data): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py index b18ef047be..7864c970d2 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py @@ -24,7 +24,6 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.sam3_3d import Sam3_3D_Objects_InferenceRequest -from inference.core.entities.responses.sam3_3d import Sam3_3D_Objects_Response from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( instance_mask_to_numpy, @@ -263,8 +262,8 @@ def run_locally( api_key=self._api_key, model_id=model_id, ) - response: Sam3_3D_Objects_Response = ( - self._model_manager.infer_from_request_sync(model_id, inference_request) + response = self._model_manager.infer_from_request_sync( + model_id, inference_request ) results.append(_format_response(response)) return results @@ -288,7 +287,7 @@ def extract_masks_from_input(mask_input: Any) -> Any: return mask_input -def _format_response(response: Sam3_3D_Objects_Response) -> dict: +def _format_response(response: Any) -> dict: """Format response with base64 encoded outputs.""" def encode(data): diff --git a/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py b/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py index 9543d907be..14cd359485 100644 --- a/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py @@ -225,7 +225,8 @@ def run_locally( ) yolo_world_model_id = load_core_model( model_manager=self._model_manager, - inference_request=inference_request, + version_id=inference_request.yolo_world_version_id, + api_key=self._api_key, core_model="yolo_world", ) prediction = self._model_manager.infer_from_request_sync( diff --git a/tests/workflows/unit_tests/core_steps/common/test_load_core_model.py b/tests/workflows/unit_tests/core_steps/common/test_load_core_model.py new file mode 100644 index 0000000000..21d6e1f441 --- /dev/null +++ b/tests/workflows/unit_tests/core_steps/common/test_load_core_model.py @@ -0,0 +1,44 @@ +import ast +from pathlib import Path +from unittest.mock import MagicMock + +from inference.core.roboflow_api import ModelEndpointType +from inference.core.workflows.core_steps.common.utils import load_core_model + +# tests/workflows/unit_tests/core_steps/common/ -> parents[5] is the repo root +UTILS = ( + Path(__file__).resolve().parents[5] + / "inference/core/workflows/core_steps/common/utils.py" +) + + +def test_utils_does_not_import_server_entities() -> None: + assert UTILS.is_file(), UTILS + tree = ast.parse(UTILS.read_text(encoding="utf-8")) + offenders = [ + node.module + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + and node.module + and node.module.startswith("inference.core.entities") + ] + assert offenders == [] + + +def test_load_core_model_registers_the_derived_core_model_id() -> None: + model_manager = MagicMock() + assert ( + load_core_model( + model_manager=model_manager, + core_model="sam2", + version_id="hiera_large", + api_key="key", + ) + == "sam2/hiera_large" + ) + args, kwargs = model_manager.add_model.call_args + assert args == ("sam2/hiera_large", "key") + # Order-independent: Phase 9's string constant (Task 9.2) and the enum a + # pre-Phase-9 tree passes both coerce to the CORE_MODEL member. + assert set(kwargs) == {"endpoint_type"} + assert ModelEndpointType(kwargs["endpoint_type"]) is ModelEndpointType.CORE_MODEL diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 929d766c76..bb2f6dfbfe 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,11 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 67 -inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.clip -inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.doctr -inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.easy_ocr -inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.sam2 -inference/core/workflows/core_steps/common/utils.py inference.core.entities.requests.yolo_world +# Count: 57 inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py inference.core.entities.requests.clip @@ -29,21 +24,16 @@ inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py inference.c inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.responses.inference -inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.responses.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.responses.inference -inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.responses.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.responses.inference -inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.responses.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py inference.core.entities.requests.sam3_3d -inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py inference.core.entities.responses.sam3_3d inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py inference.core.entities.requests.sam3_3d -inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py inference.core.entities.responses.sam3_3d inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.entities.responses.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.entities.requests.sam2 From e343023744d6d967743e07cbd9d3aed255e67181 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:54:46 +0200 Subject: [PATCH 52/86] refactor(workflows): own the segmentation prediction classes (server re-exports); response-level DTOs as local dataclasses Move Point, InstanceSegmentationBasePrediction, InstanceSegmentationPrediction, InstanceSegmentationRLEPrediction (from responses/inference.py) and Sam2SegmentationPrediction (from responses/sam2.py) into core_steps/common/segmentation_entities.py verbatim; both server modules re-export them (one class object per name), so pydantic validation/coercion built from remote responses is unchanged. InferenceResponseImage and InstanceSegmentationInferenceResponse are only ever built from locals, so they become workflows-local dataclasses (inference_response_dc.py) whose to_dict() is bit-equivalent to model_dump(by_alias=True, exclude_none=True). Repoints the six SAM/seg-preview blocks and fixes the three sv.Detections.from_inference call sites in segment_anything3/v3.py to pass to_dict() (from_inference subscripts its argument unless it exposes .dict()/.json()). Decontamination baseline 57 -> 51. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../core/entities/responses/inference.py | 66 +-- inference/core/entities/responses/sam2.py | 24 +- .../common/inference_response_dc.py | 69 +++ .../common/segmentation_entities.py | 98 ++++ .../models/foundation/seg_preview/v1.py | 24 +- .../models/foundation/segment_anything2/v1.py | 30 +- .../models/foundation/segment_anything3/v1.py | 40 +- .../models/foundation/segment_anything3/v2.py | 40 +- .../models/foundation/segment_anything3/v3.py | 34 +- .../segment_anything3_interactive/v1.py | 16 +- scripts/phase11_move_segmentation_entities.py | 149 ++++++ .../common/test_segmentation_entities.py | 457 ++++++++++++++++++ .../foundation/test_segment_anything2.py | 60 +++ .../foundation/test_segment_anything3_v3.py | 159 ++++++ .../unit_tests/decontamination_baseline.txt | 8 +- 15 files changed, 1094 insertions(+), 180 deletions(-) create mode 100644 inference/core/workflows/core_steps/common/inference_response_dc.py create mode 100644 inference/core/workflows/core_steps/common/segmentation_entities.py create mode 100644 scripts/phase11_move_segmentation_entities.py create mode 100644 tests/workflows/unit_tests/core_steps/common/test_segmentation_entities.py diff --git a/inference/core/entities/responses/inference.py b/inference/core/entities/responses/inference.py index fffacfbc87..47e23bacf4 100644 --- a/inference/core/entities/responses/inference.py +++ b/inference/core/entities/responses/inference.py @@ -5,6 +5,13 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_serializer +from inference.core.workflows.core_steps.common.segmentation_entities import ( # noqa: F401 + InstanceSegmentationBasePrediction, + InstanceSegmentationPrediction, + InstanceSegmentationRLEPrediction, + Point, +) + class ObjectDetectionPrediction(BaseModel): """Object Detection prediction. @@ -51,18 +58,6 @@ class ObjectDetectionPrediction(BaseModel): ) -class Point(BaseModel): - """Point coordinates. - - Attributes: - x (float): The x-axis pixel coordinate of the point. - y (float): The y-axis pixel coordinate of the point. - """ - - x: float = Field(description="The x-axis pixel coordinate of the point") - y: float = Field(description="The y-axis pixel coordinate of the point") - - class Point3D(Point): """3D Point coordinates. @@ -73,53 +68,6 @@ class Point3D(Point): z: float = Field(description="The z-axis pixel coordinate of the point") -class InstanceSegmentationBasePrediction(BaseModel): - x: float = Field(description="The center x-axis pixel coordinate of the prediction") - y: float = Field(description="The center y-axis pixel coordinate of the prediction") - width: float = Field( - description="The width of the prediction bounding box in number of pixels" - ) - height: float = Field( - description="The height of the prediction bounding box in number of pixels" - ) - confidence: float = Field( - description="The detection confidence as a fraction between 0 and 1" - ) - class_name: str = Field(alias="class", description="The predicted class label") - class_id: int = Field(description="The class id of the prediction") - detection_id: str = Field( - description="Unique identifier of detection", - default_factory=lambda: str(uuid4()), - ) - parent_id: Optional[str] = Field( - description="Identifier of parent image region", - default=None, - ) - - -class InstanceSegmentationPrediction(InstanceSegmentationBasePrediction): - class_confidence: Union[float, None] = Field( - None, description="The class label confidence as a fraction between 0 and 1" - ) - points: List[Point] = Field( - description="The list of points that make up the instance polygon" - ) - mask_format: Literal["polygon"] = Field( - default="polygon", - description="Type of mask format", - ) - - -class InstanceSegmentationRLEPrediction(InstanceSegmentationBasePrediction): - rle: dict = Field( - description="RLE-encoded mask in COCO format: {'size': [H, W], 'counts': '...'}" - ) - mask_format: Literal["rle"] = Field( - default="rle", - description="Type of mask format", - ) - - def _mask_to_base64_png(mask: Any) -> str: """Encodes a uint8 numpy mask exactly like the model-side eager encoding.""" import io diff --git a/inference/core/entities/responses/sam2.py b/inference/core/entities/responses/sam2.py index c0da06c1f1..04730851ae 100644 --- a/inference/core/entities/responses/sam2.py +++ b/inference/core/entities/responses/sam2.py @@ -1,7 +1,11 @@ -from typing import Any, Dict, List, Optional, Union +from typing import List from pydantic import BaseModel, Field +from inference.core.workflows.core_steps.common.segmentation_entities import ( # noqa: F401 + Sam2SegmentationPrediction, +) + class Sam2EmbeddingResponse(BaseModel): """SAM embedding response. @@ -17,24 +21,6 @@ class Sam2EmbeddingResponse(BaseModel): ) -class Sam2SegmentationPrediction(BaseModel): - """SAM segmentation prediction. - - Attributes: - masks (Union[List[List[List[int]]], Dict[str, Any], Any]): Mask data - either polygon coordinates or RLE encoding. - confidence (float): Masks confidences. - format (Optional[str]): Format of the mask data: 'polygon' or 'rle'. - """ - - masks: Union[List[List[List[int]]], Dict[str, Any]] = Field( - description="If polygon format, masks is a list of polygons, where each polygon is a list of points, where each point is a tuple containing the x,y pixel coordinates of the point. If rle format, masks is a dictionary with the keys 'size' and 'counts' containing the size and counts of the RLE encoding." - ) - confidence: float = Field(description="Masks confidences") - format: Optional[str] = Field( - default="polygon", description="Format of the mask data: 'polygon' or 'rle'" - ) - - class Sam2SegmentationResponse(BaseModel): predictions: List[Sam2SegmentationPrediction] = Field() time: float = Field( diff --git a/inference/core/workflows/core_steps/common/inference_response_dc.py b/inference/core/workflows/core_steps/common/inference_response_dc.py new file mode 100644 index 0000000000..ffe2fd5ebd --- /dev/null +++ b/inference/core/workflows/core_steps/common/inference_response_dc.py @@ -0,0 +1,69 @@ +"""Workflows-local twins of the two RESPONSE-level instance-segmentation DTOs. + +`InferenceResponseImage` and `InstanceSegmentationInferenceResponse` are only +ever built from locals - `width`/`height` from `numpy_image.shape` and a list +of prediction objects the block has already built or validated (sweep: every +constructor call site, `## Entity-class validation-parity sweep`). The +prediction-level classes are NOT twinned: `Point`, +`InstanceSegmentationPrediction`, `InstanceSegmentationRLEPrediction` and +`Sam2SegmentationPrediction` are built from remote responses too, so they keep +their pydantic validation and live in `segmentation_entities.py` (moved from the +server, which re-exports them). + +NOTE for callers: `sv.Detections.from_inference` subscripts its argument (it +only unwraps objects exposing `.dict()`/`.json()`), so pass `to_dict()`, never +the dataclass. + +`tests/workflows/unit_tests/core_steps/common/test_segmentation_entities.py` +pins every `to_dict()` to the corresponding pydantic dump and pins the +supervision conversion. +""" + +from dataclasses import dataclass, field +from typing import Any, List, Optional, Union + +from inference.core.workflows.core_steps.common.segmentation_entities import ( + InstanceSegmentationPrediction, + InstanceSegmentationRLEPrediction, +) + + +@dataclass(slots=True) +class InferenceResponseImageDC: + width: int + height: int + + def to_dict(self) -> dict: + return {"width": self.width, "height": self.height} + + +@dataclass(slots=True) +class InstanceSegmentationInferenceResponseDC: + image: InferenceResponseImageDC + predictions: List[ + Union[InstanceSegmentationPrediction, InstanceSegmentationRLEPrediction] + ] = field(default_factory=list) + inference_id: Optional[str] = None + frame_id: Optional[int] = None + time: Optional[float] = None + visualization: Optional[Any] = None + + def to_dict(self) -> dict: + """The exact dict `InstanceSegmentationInferenceResponse.model_dump( + by_alias=True, exclude_none=True)` produces for the same content.""" + result = { + "image": self.image.to_dict(), + "predictions": [ + prediction.model_dump(by_alias=True, exclude_none=True) + for prediction in self.predictions + ], + } + if self.inference_id is not None: + result["inference_id"] = self.inference_id + if self.frame_id is not None: + result["frame_id"] = self.frame_id + if self.time is not None: + result["time"] = self.time + if self.visualization is not None: + result["visualization"] = self.visualization + return result diff --git a/inference/core/workflows/core_steps/common/segmentation_entities.py b/inference/core/workflows/core_steps/common/segmentation_entities.py new file mode 100644 index 0000000000..780b84009b --- /dev/null +++ b/inference/core/workflows/core_steps/common/segmentation_entities.py @@ -0,0 +1,98 @@ +"""Segmentation prediction entities, owned by Workflows. + +`Point`, `InstanceSegmentationBasePrediction`, `InstanceSegmentationPrediction` +and `InstanceSegmentationRLEPrediction` (from `inference/core/entities/ +responses/inference.py`) and `Sam2SegmentationPrediction` (from +`responses/sam2.py`) were MOVED here verbatim; both server modules re-export +them, so there is exactly ONE class object per name. The blocks build these +from REMOTE responses as well as from their own arithmetic - the SAM 3 +interactive parser, the SAM2 remote converter, the SAM3 v1/v2/v3 and +seg-preview remote/proxy paths - so they must keep pydantic validation and +coercion (`"0.9"` -> 0.9, nested `Point` and mask validation, alias `class`). + +Pinned by `tests/workflows/unit_tests/core_steps/common/test_segmentation_entities.py` +(identity through the re-export, the remote-parser coercion/rejection matrix +through both remote branches, frozen validation tables). +""" + +from typing import Any, Dict, List, Literal, Optional, Union +from uuid import uuid4 + +from pydantic import BaseModel, Field + + +class Point(BaseModel): + """Point coordinates. + + Attributes: + x (float): The x-axis pixel coordinate of the point. + y (float): The y-axis pixel coordinate of the point. + """ + + x: float = Field(description="The x-axis pixel coordinate of the point") + y: float = Field(description="The y-axis pixel coordinate of the point") + + +class InstanceSegmentationBasePrediction(BaseModel): + x: float = Field(description="The center x-axis pixel coordinate of the prediction") + y: float = Field(description="The center y-axis pixel coordinate of the prediction") + width: float = Field( + description="The width of the prediction bounding box in number of pixels" + ) + height: float = Field( + description="The height of the prediction bounding box in number of pixels" + ) + confidence: float = Field( + description="The detection confidence as a fraction between 0 and 1" + ) + class_name: str = Field(alias="class", description="The predicted class label") + class_id: int = Field(description="The class id of the prediction") + detection_id: str = Field( + description="Unique identifier of detection", + default_factory=lambda: str(uuid4()), + ) + parent_id: Optional[str] = Field( + description="Identifier of parent image region", + default=None, + ) + + +class InstanceSegmentationPrediction(InstanceSegmentationBasePrediction): + class_confidence: Union[float, None] = Field( + None, description="The class label confidence as a fraction between 0 and 1" + ) + points: List[Point] = Field( + description="The list of points that make up the instance polygon" + ) + mask_format: Literal["polygon"] = Field( + default="polygon", + description="Type of mask format", + ) + + +class InstanceSegmentationRLEPrediction(InstanceSegmentationBasePrediction): + rle: dict = Field( + description="RLE-encoded mask in COCO format: {'size': [H, W], 'counts': '...'}" + ) + mask_format: Literal["rle"] = Field( + default="rle", + description="Type of mask format", + ) + + +class Sam2SegmentationPrediction(BaseModel): + """SAM segmentation prediction. + + Attributes: + masks (Union[List[List[List[int]]], Dict[str, Any], Any]): Mask data - either polygon coordinates or RLE encoding. + confidence (float): Masks confidences. + format (Optional[str]): Format of the mask data: 'polygon' or 'rle'. + """ + + masks: Union[List[List[List[int]]], Dict[str, Any]] = Field( + description="If polygon format, masks is a list of polygons, where each polygon is a list of points, where each point is a tuple containing the x,y pixel coordinates of the point. If rle format, masks is a dictionary with the keys 'size' and 'counts' containing the size and counts of the RLE encoding." + ) + confidence: float = Field(description="Masks confidences") + format: Optional[str] = Field( + default="polygon", description="Format of the mask data: 'polygon' or 'rle'" + ) diff --git a/inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py b/inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py index 9a50406cee..a6188d61d0 100644 --- a/inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py @@ -5,13 +5,15 @@ import requests from pydantic import ConfigDict, Field -from inference.core.entities.responses.inference import ( - InferenceResponseImage, - InstanceSegmentationInferenceResponse, +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.inference_response_dc import ( + InferenceResponseImageDC, + InstanceSegmentationInferenceResponseDC, +) +from inference.core.workflows.core_steps.common.segmentation_entities import ( InstanceSegmentationPrediction, Point, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, attach_prediction_type_info_to_sv_detections_batch, @@ -260,15 +262,13 @@ def run_via_request( image_width = single_image.numpy_image.shape[1] image_height = single_image.numpy_image.shape[0] - final_inference_prediction = InstanceSegmentationInferenceResponse( + final_inference_prediction = InstanceSegmentationInferenceResponseDC( predictions=class_predictions, - image=InferenceResponseImage(width=image_width, height=image_height), + image=InferenceResponseImageDC(width=image_width, height=image_height), ) predictions.append(final_inference_prediction) - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] + predictions = [e.to_dict() for e in predictions] return self._post_process_result( images=images, predictions=predictions, @@ -300,7 +300,7 @@ def convert_segmentation_response_to_inference_instances_seg_response( threshold: float, text_prompt: Optional[str] = None, specific_class_id: Optional[int] = None, -) -> InstanceSegmentationInferenceResponse: +) -> InstanceSegmentationInferenceResponseDC: image_width = image.numpy_image.shape[1] image_height = image.numpy_image.shape[0] predictions = [] @@ -350,7 +350,7 @@ def convert_segmentation_response_to_inference_instances_seg_response( } ) ) - return InstanceSegmentationInferenceResponse( + return InstanceSegmentationInferenceResponseDC( predictions=predictions, - image=InferenceResponseImage(width=image_width, height=image_height), + image=InferenceResponseImageDC(width=image_width, height=image_height), ) diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py index 118f0e7412..244d37c0e7 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py @@ -10,13 +10,15 @@ Sam2PromptSet, Sam2SegmentationRequest, ) -from inference.core.entities.responses.inference import ( - InferenceResponseImage, - InstanceSegmentationInferenceResponse, +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.inference_response_dc import ( + InferenceResponseImageDC, + InstanceSegmentationInferenceResponseDC, +) +from inference.core.workflows.core_steps.common.segmentation_entities import ( InstanceSegmentationPrediction, Point, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, attach_prediction_type_info_to_sv_detections_batch, @@ -331,9 +333,7 @@ def run_remotely( ) predictions.append(prediction) - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] + predictions = [e.to_dict() for e in predictions] return self._post_process_result( images=images, predictions=predictions, @@ -347,7 +347,7 @@ def _convert_remote_response_to_inference_response( prompt_class_names: List[Optional[str]], prompt_detection_ids: List[Optional[str]], threshold: float, - ) -> InstanceSegmentationInferenceResponse: + ) -> InstanceSegmentationInferenceResponseDC: """Convert remote SAM2 response to InstanceSegmentationInferenceResponse.""" image_width = image.numpy_image.shape[1] image_height = image.numpy_image.shape[0] @@ -405,9 +405,9 @@ def _convert_remote_response_to_inference_response( ) ) - return InstanceSegmentationInferenceResponse( + return InstanceSegmentationInferenceResponseDC( predictions=predictions, - image=InferenceResponseImage(width=image_width, height=image_height), + image=InferenceResponseImageDC(width=image_width, height=image_height), ) def run_locally( @@ -478,9 +478,7 @@ def run_locally( ) predictions.append(prediction) - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] + predictions = [e.to_dict() for e in predictions] return self._post_process_result( images=images, predictions=predictions, @@ -512,7 +510,7 @@ def convert_sam2_segmentation_response_to_inference_instances_seg_response( prompt_class_names: List[Optional[str]], prompt_detection_ids: List[Optional[str]], threshold: float, -) -> InstanceSegmentationInferenceResponse: +) -> InstanceSegmentationInferenceResponseDC: image_width = image.numpy_image.shape[1] image_height = image.numpy_image.shape[0] predictions = [] @@ -558,7 +556,7 @@ def convert_sam2_segmentation_response_to_inference_instances_seg_response( } ) ) - return InstanceSegmentationInferenceResponse( + return InstanceSegmentationInferenceResponseDC( predictions=predictions, - image=InferenceResponseImage(width=image_width, height=image_height), + image=InferenceResponseImageDC(width=image_width, height=image_height), ) diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py index 75412171fe..559f0433bd 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py @@ -8,13 +8,15 @@ from pydantic import ConfigDict, Field from inference.core.entities.requests.sam3 import Sam3Prompt, Sam3SegmentationRequest -from inference.core.entities.responses.inference import ( - InferenceResponseImage, - InstanceSegmentationInferenceResponse, +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.inference_response_dc import ( + InferenceResponseImageDC, + InstanceSegmentationInferenceResponseDC, +) +from inference.core.workflows.core_steps.common.segmentation_entities import ( InstanceSegmentationPrediction, Point, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, attach_prediction_type_info_to_sv_detections_batch, @@ -315,15 +317,13 @@ def run_locally( image_width = single_image.numpy_image.shape[1] image_height = single_image.numpy_image.shape[0] - final_inference_prediction = InstanceSegmentationInferenceResponse( + final_inference_prediction = InstanceSegmentationInferenceResponseDC( predictions=class_predictions, - image=InferenceResponseImage(width=image_width, height=image_height), + image=InferenceResponseImageDC(width=image_width, height=image_height), ) predictions.append(final_inference_prediction) - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] + predictions = [e.to_dict() for e in predictions] return self._post_process_result( images=images, predictions=predictions, @@ -394,15 +394,13 @@ def run_remotely( image_width = single_image.numpy_image.shape[1] image_height = single_image.numpy_image.shape[0] - final_inference_prediction = InstanceSegmentationInferenceResponse( + final_inference_prediction = InstanceSegmentationInferenceResponseDC( predictions=class_predictions, - image=InferenceResponseImage(width=image_width, height=image_height), + image=InferenceResponseImageDC(width=image_width, height=image_height), ) predictions.append(final_inference_prediction) - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] + predictions = [e.to_dict() for e in predictions] return self._post_process_result( images=images, predictions=predictions, @@ -490,15 +488,13 @@ def run_via_request( image_width = single_image.numpy_image.shape[1] image_height = single_image.numpy_image.shape[0] - final_inference_prediction = InstanceSegmentationInferenceResponse( + final_inference_prediction = InstanceSegmentationInferenceResponseDC( predictions=class_predictions, - image=InferenceResponseImage(width=image_width, height=image_height), + image=InferenceResponseImageDC(width=image_width, height=image_height), ) predictions.append(final_inference_prediction) - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] + predictions = [e.to_dict() for e in predictions] return self._post_process_result( images=images, predictions=predictions, @@ -532,7 +528,7 @@ def convert_sam3_segmentation_response_to_inference_instances_seg_response( threshold: float, text_prompt: Optional[str] = None, specific_class_id: Optional[int] = None, -) -> InstanceSegmentationInferenceResponse: +) -> InstanceSegmentationInferenceResponseDC: image_width = image.numpy_image.shape[1] image_height = image.numpy_image.shape[0] predictions = [] @@ -582,7 +578,7 @@ def convert_sam3_segmentation_response_to_inference_instances_seg_response( } ) ) - return InstanceSegmentationInferenceResponse( + return InstanceSegmentationInferenceResponseDC( predictions=predictions, - image=InferenceResponseImage(width=image_width, height=image_height), + image=InferenceResponseImageDC(width=image_width, height=image_height), ) diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py index a410052429..7921ba8609 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py @@ -8,13 +8,15 @@ from pydantic import ConfigDict, Field, model_validator, validator from inference.core.entities.requests.sam3 import Sam3Prompt, Sam3SegmentationRequest -from inference.core.entities.responses.inference import ( - InferenceResponseImage, - InstanceSegmentationInferenceResponse, +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.inference_response_dc import ( + InferenceResponseImageDC, + InstanceSegmentationInferenceResponseDC, +) +from inference.core.workflows.core_steps.common.segmentation_entities import ( InstanceSegmentationPrediction, Point, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, attach_prediction_type_info_to_sv_detections_batch, @@ -390,15 +392,13 @@ def run_locally( image_width = single_image.numpy_image.shape[1] image_height = single_image.numpy_image.shape[0] - final_inference_prediction = InstanceSegmentationInferenceResponse( + final_inference_prediction = InstanceSegmentationInferenceResponseDC( predictions=class_predictions, - image=InferenceResponseImage(width=image_width, height=image_height), + image=InferenceResponseImageDC(width=image_width, height=image_height), ) predictions.append(final_inference_prediction) - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] + predictions = [e.to_dict() for e in predictions] return self._post_process_result( images=images, predictions=predictions, @@ -476,15 +476,13 @@ def run_remotely( image_width = single_image.numpy_image.shape[1] image_height = single_image.numpy_image.shape[0] - final_inference_prediction = InstanceSegmentationInferenceResponse( + final_inference_prediction = InstanceSegmentationInferenceResponseDC( predictions=class_predictions, - image=InferenceResponseImage(width=image_width, height=image_height), + image=InferenceResponseImageDC(width=image_width, height=image_height), ) predictions.append(final_inference_prediction) - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] + predictions = [e.to_dict() for e in predictions] return self._post_process_result( images=images, predictions=predictions, @@ -579,15 +577,13 @@ def run_via_request( image_width = single_image.numpy_image.shape[1] image_height = single_image.numpy_image.shape[0] - final_inference_prediction = InstanceSegmentationInferenceResponse( + final_inference_prediction = InstanceSegmentationInferenceResponseDC( predictions=class_predictions, - image=InferenceResponseImage(width=image_width, height=image_height), + image=InferenceResponseImageDC(width=image_width, height=image_height), ) predictions.append(final_inference_prediction) - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] + predictions = [e.to_dict() for e in predictions] return self._post_process_result( images=images, predictions=predictions, @@ -621,7 +617,7 @@ def convert_sam3_segmentation_response_to_inference_instances_seg_response( confidence: float, text_prompt: Optional[str] = None, specific_class_id: Optional[int] = None, -) -> InstanceSegmentationInferenceResponse: +) -> InstanceSegmentationInferenceResponseDC: image_width = image.numpy_image.shape[1] image_height = image.numpy_image.shape[0] predictions = [] @@ -671,7 +667,7 @@ def convert_sam3_segmentation_response_to_inference_instances_seg_response( } ) ) - return InstanceSegmentationInferenceResponse( + return InstanceSegmentationInferenceResponseDC( predictions=predictions, - image=InferenceResponseImage(width=image_width, height=image_height), + image=InferenceResponseImageDC(width=image_width, height=image_height), ) diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py index 21928e0a17..9a1bfba0d1 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py @@ -8,14 +8,16 @@ from pydantic import ConfigDict, Field, model_validator, validator from inference.core.entities.requests.sam3 import Sam3Prompt, Sam3SegmentationRequest -from inference.core.entities.responses.inference import ( - InferenceResponseImage, - InstanceSegmentationInferenceResponse, +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.inference_response_dc import ( + InferenceResponseImageDC, + InstanceSegmentationInferenceResponseDC, +) +from inference.core.workflows.core_steps.common.segmentation_entities import ( InstanceSegmentationPrediction, InstanceSegmentationRLEPrediction, Point, ) -from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, attach_prediction_type_info_to_sv_detections_batch, @@ -428,7 +430,9 @@ def run_locally( image_height=image_height, image_width=image_width, ) - detections = sv.Detections.from_inference(inference_response) + # `from_inference` subscripts its argument unless it exposes + # `.dict()`/`.json()`; the local dataclass exposes `to_dict()`. + detections = sv.Detections.from_inference(inference_response.to_dict()) detections[DETECTION_ID_KEY] = np.array( [p.detection_id for p in inference_response.predictions] ) @@ -518,7 +522,9 @@ def run_remotely( image_width=image_width, ) ) - detections = sv.Detections.from_inference(inference_response) + # `from_inference` subscripts its argument unless it exposes + # `.dict()`/`.json()`; the local dataclass exposes `to_dict()`. + detections = sv.Detections.from_inference(inference_response.to_dict()) detections[DETECTION_ID_KEY] = np.array( [p.detection_id for p in inference_response.predictions] ) @@ -625,7 +631,9 @@ def run_via_request( image_width=image_width, ) ) - detections = sv.Detections.from_inference(inference_response) + # `from_inference` subscripts its argument unless it exposes + # `.dict()`/`.json()`; the local dataclass exposes `to_dict()`. + detections = sv.Detections.from_inference(inference_response.to_dict()) detections[DETECTION_ID_KEY] = np.array( [p.detection_id for p in inference_response.predictions] ) @@ -806,7 +814,7 @@ def _convert_polygon_response_to_inference_format( confidence: float, image_height: int, image_width: int, - ) -> InstanceSegmentationInferenceResponse: + ) -> InstanceSegmentationInferenceResponseDC: predictions: List[InstanceSegmentationPrediction] = [] for prompt_result in sam3_response.prompt_results: @@ -826,9 +834,9 @@ def _convert_polygon_response_to_inference_format( if pred: predictions.append(pred) - return InstanceSegmentationInferenceResponse( + return InstanceSegmentationInferenceResponseDC( predictions=predictions, - image=InferenceResponseImage(width=image_width, height=image_height), + image=InferenceResponseImageDC(width=image_width, height=image_height), ) def _convert_polygon_json_response_to_inference_format( @@ -838,7 +846,7 @@ def _convert_polygon_json_response_to_inference_format( confidence: float, image_height: int, image_width: int, - ) -> InstanceSegmentationInferenceResponse: + ) -> InstanceSegmentationInferenceResponseDC: predictions: List[InstanceSegmentationPrediction] = [] for prompt_result in resp_json.get("prompt_results", []): @@ -859,9 +867,9 @@ def _convert_polygon_json_response_to_inference_format( if pred: predictions.append(pred) - return InstanceSegmentationInferenceResponse( + return InstanceSegmentationInferenceResponseDC( predictions=predictions, - image=InferenceResponseImage(width=image_width, height=image_height), + image=InferenceResponseImageDC(width=image_width, height=image_height), ) @staticmethod diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py index e7eb774c22..8492c2b9b7 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py @@ -13,8 +13,10 @@ Sam2PromptSet, Sam2SegmentationRequest, ) -from inference.core.entities.responses.sam2 import Sam2SegmentationPrediction from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.segmentation_entities import ( + Sam2SegmentationPrediction, +) from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, attach_prediction_type_info_to_sv_detections_batch, @@ -358,9 +360,7 @@ def run_locally( ) predictions.append(prediction) - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] + predictions = [e.to_dict() for e in predictions] return self._post_process_result( images=images, predictions=predictions, @@ -428,9 +428,7 @@ def run_remotely( ) predictions.append(prediction) - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] + predictions = [e.to_dict() for e in predictions] return self._post_process_result( images=images, predictions=predictions, @@ -515,9 +513,7 @@ def run_via_request( ) predictions.append(prediction) - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] + predictions = [e.to_dict() for e in predictions] return self._post_process_result( images=images, predictions=predictions, diff --git a/scripts/phase11_move_segmentation_entities.py b/scripts/phase11_move_segmentation_entities.py new file mode 100644 index 0000000000..abb70719da --- /dev/null +++ b/scripts/phase11_move_segmentation_entities.py @@ -0,0 +1,149 @@ +"""Move the segmentation prediction classes into workflows; the server re-exports them (Task 11.4). + +Creates `inference/core/workflows/core_steps/common/segmentation_entities.py` from +three spans copied character for character: + - `class Point` .. (up to `class Point3D`) and + `class InstanceSegmentationBasePrediction` .. (up to `def _mask_to_base64_png`) + of `inference/core/entities/responses/inference.py`; + - `class Sam2SegmentationPrediction` .. (up to `class Sam2SegmentationResponse`) + of `inference/core/entities/responses/sam2.py`; +then replaces each span in its server module with a re-export import placed right +after the module's `from pydantic import ...` line (so `Point3D(Point)`, +`Keypoint(Point)` and the response classes still see the names), and trims the +typing names only the moved span used (`responses/sam2.py`: `Any`, `Dict`, +`Optional`, `Union`; `responses/inference.py` keeps every name - the rest of the +module still uses them). Content-anchored, re-parses every output before +writing, idempotent (second run prints SKIP), aborts on a half-applied state. + +Run: python scripts/phase11_move_segmentation_entities.py [] +""" + +import ast +import sys +from pathlib import Path + +ROOT = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".") +TARGET = ROOT / "inference/core/workflows/core_steps/common/segmentation_entities.py" +INFERENCE = ROOT / "inference/core/entities/responses/inference.py" +SAM2 = ROOT / "inference/core/entities/responses/sam2.py" +MODULE = "inference.core.workflows.core_steps.common.segmentation_entities" +INFERENCE_IMPORT = ( + f"from {MODULE} import ( # noqa: F401\n" + " InstanceSegmentationBasePrediction,\n" + " InstanceSegmentationPrediction,\n" + " InstanceSegmentationRLEPrediction,\n" + " Point,\n" + ")\n" +) +SAM2_IMPORT = ( + f"from {MODULE} import ( # noqa: F401\n Sam2SegmentationPrediction,\n)\n" +) +INFERENCE_ANCHOR = "from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_serializer\n" +SAM2_ANCHOR = "from pydantic import BaseModel, Field\n" +HEADER = '''"""Segmentation prediction entities, owned by Workflows. + +`Point`, `InstanceSegmentationBasePrediction`, `InstanceSegmentationPrediction` +and `InstanceSegmentationRLEPrediction` (from `inference/core/entities/ +responses/inference.py`) and `Sam2SegmentationPrediction` (from +`responses/sam2.py`) were MOVED here verbatim; both server modules re-export +them, so there is exactly ONE class object per name. The blocks build these +from REMOTE responses as well as from their own arithmetic - the SAM 3 +interactive parser, the SAM2 remote converter, the SAM3 v1/v2/v3 and +seg-preview remote/proxy paths - so they must keep pydantic validation and +coercion (`"0.9"` -> 0.9, nested `Point` and mask validation, alias `class`). + +Pinned by `tests/workflows/unit_tests/core_steps/common/test_segmentation_entities.py` +(identity through the re-export, the remote-parser coercion/rejection matrix +through both remote branches, frozen validation tables). +""" + +from typing import Any, Dict, List, Literal, Optional, Union +from uuid import uuid4 + +from pydantic import BaseModel, Field + + +''' + + +def _span(source: str, start: str, end: str) -> str: + first, last = source.index(start), source.index(end) + assert first < last, (start, end) + return source[first:last] + + +def _drop_names(source: str, prefix: str, names: list) -> str: + lines = source.split("\n") + matches = [index for index, line in enumerate(lines) if line.startswith(prefix)] + assert len(matches) == 1, (prefix, matches) + present = [name.strip() for name in lines[matches[0]][len(prefix) :].split(",")] + for name in names: + assert name in present, (prefix, name) + kept = [name for name in present if name not in names] + lines[matches[0]] = prefix + ", ".join(kept) + return "\n".join(lines) + + +def main() -> int: + inference = INFERENCE.read_text(encoding="utf-8") + sam2 = SAM2.read_text(encoding="utf-8") + applied = (INFERENCE_IMPORT in inference, SAM2_IMPORT in sam2, TARGET.exists()) + if all(applied): + print("SKIP (already applied)") + return 0 + if any(applied): + raise SystemExit( + f"half-applied state {applied}; restore the three files from git first" + ) + point_span = _span(inference, "class Point(BaseModel):", "class Point3D(Point):") + base_span = _span( + inference, + "class InstanceSegmentationBasePrediction(BaseModel):", + "def _mask_to_base64_png(", + ) + sam2_span = _span( + sam2, + "class Sam2SegmentationPrediction(BaseModel):", + "class Sam2SegmentationResponse(", + ) + module = ( + HEADER + + point_span.rstrip() + + "\n\n\n" + + base_span.rstrip() + + "\n\n\n" + + sam2_span.rstrip() + + "\n" + ) + ast.parse(module) + new_inference = inference.replace(point_span, "", 1).replace(base_span, "", 1) + assert new_inference.count(INFERENCE_ANCHOR) == 1 + new_inference = new_inference.replace( + INFERENCE_ANCHOR, INFERENCE_ANCHOR + INFERENCE_IMPORT, 1 + ) + new_sam2 = sam2.replace(sam2_span, "", 1) + assert new_sam2.count(SAM2_ANCHOR) == 1 + new_sam2 = new_sam2.replace(SAM2_ANCHOR, SAM2_ANCHOR + SAM2_IMPORT, 1) + new_sam2 = _drop_names( + new_sam2, "from typing import ", ["Any", "Dict", "Optional", "Union"] + ) + for text in (new_inference, new_sam2): + ast.parse(text) + for name in ( + "class Point(", + "class InstanceSegmentationBasePrediction(", + "class InstanceSegmentationPrediction(", + "class InstanceSegmentationRLEPrediction(", + ): + assert name not in new_inference, name + assert "class Sam2SegmentationPrediction(" not in new_sam2 + TARGET.write_text(module, encoding="utf-8") + INFERENCE.write_text(new_inference, encoding="utf-8") + SAM2.write_text(new_sam2, encoding="utf-8") + for path in (TARGET, INFERENCE, SAM2): + print(f"{path}: {len(path.read_text(encoding='utf-8').splitlines())} lines") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/workflows/unit_tests/core_steps/common/test_segmentation_entities.py b/tests/workflows/unit_tests/core_steps/common/test_segmentation_entities.py new file mode 100644 index 0000000000..fee41c2d13 --- /dev/null +++ b/tests/workflows/unit_tests/core_steps/common/test_segmentation_entities.py @@ -0,0 +1,457 @@ +"""The segmentation prediction classes live in workflows; the server re-exports them. + +`Point`, `InstanceSegmentationBasePrediction`, `InstanceSegmentationPrediction`, +`InstanceSegmentationRLEPrediction` (server: `responses/inference.py`) and +`Sam2SegmentationPrediction` (server: `responses/sam2.py`) are ONE class object +per name. The blocks build them from remote responses as well as from their own +arithmetic, so they keep pydantic coercion and validation - the interactive +parser's string confidence becomes a float, a malformed mask is a +`ValidationError` (round-6 defect 1). The two response-level DTOs are plain +dataclasses (`inference_response_dc.py`), because every constructor call site +passes locals; their `to_dict()` is pinned to the pydantic dump here. The +tables below are frozen from the classes as they stood before the move. +""" + +import ast +from pathlib import Path +from typing import List, Union +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest +import supervision as sv +from pydantic import ValidationError + +from inference.core.entities.responses import inference as server_inference +from inference.core.entities.responses import sam2 as server_sam2 +from inference.core.workflows.core_steps.common import segmentation_entities as local +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.inference_response_dc import ( + InferenceResponseImageDC, + InstanceSegmentationInferenceResponseDC, +) +from inference.core.workflows.core_steps.models.foundation.segment_anything3_interactive import ( + v1 as interactive_v1, +) +from inference.core.workflows.execution_engine.entities.base import ( + Batch, + ImageParentMetadata, + WorkflowImageData, +) + +# tests/workflows/unit_tests/core_steps/common/ -> parents[5] is the repo root +ENTITIES_MODULE = ( + Path(__file__).resolve().parents[5] + / "inference/core/workflows/core_steps/common/segmentation_entities.py" +) +_POINTS = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] +PAIRS = [ + (local.Point, server_inference.Point), + ( + local.InstanceSegmentationBasePrediction, + server_inference.InstanceSegmentationBasePrediction, + ), + ( + local.InstanceSegmentationPrediction, + server_inference.InstanceSegmentationPrediction, + ), + ( + local.InstanceSegmentationRLEPrediction, + server_inference.InstanceSegmentationRLEPrediction, + ), + (local.Sam2SegmentationPrediction, server_sam2.Sam2SegmentationPrediction), +] + + +@pytest.mark.parametrize( + "local_cls,server_cls", PAIRS, ids=[pair[1].__name__ for pair in PAIRS] +) +def test_server_module_re_exports_the_workflows_class(local_cls, server_cls) -> None: + assert local_cls is server_cls + + +def test_server_classes_still_derive_from_and_hold_the_workflows_classes() -> None: + assert issubclass(server_inference.Point3D, local.Point) + assert issubclass(server_inference.Keypoint, local.Point) + assert ( + server_inference.InstanceSegmentationInferenceResponse.model_fields[ + "predictions" + ].annotation + == List[ + Union[ + local.InstanceSegmentationPrediction, + local.InstanceSegmentationRLEPrediction, + ] + ] + ) + assert ( + server_sam2.Sam2SegmentationResponse.model_fields["predictions"].annotation + == List[local.Sam2SegmentationPrediction] + ) + assert interactive_v1.Sam2SegmentationPrediction is local.Sam2SegmentationPrediction + + +def test_entities_module_imports_nothing_from_the_server() -> None: + assert ENTITIES_MODULE.is_file(), ENTITIES_MODULE + tree = ast.parse(ENTITIES_MODULE.read_text(encoding="utf-8")) + imported = sorted( + node.module + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module + ) + assert imported == ["pydantic", "typing", "uuid"], imported + + +# --- the remote parser, through the function and through BOTH remote branches --- + +PARSER_TABLE = [ + ( + "str confidence", + {"predictions": [{"masks": [[[0, 0], [4, 0], [4, 4]]], "confidence": "0.9"}]}, + ("ok", [(0.9, "float", [[[0, 0], [4, 0], [4, 4]]])]), + ), + ( + "int confidence", + {"predictions": [{"masks": [[[0, 0], [4, 0], [4, 4]]], "confidence": 1}]}, + ("ok", [(1.0, "float", [[[0, 0], [4, 0], [4, 4]]])]), + ), + ( + "missing confidence", + {"predictions": [{"masks": [[[0, 0], [4, 0], [4, 4]]]}]}, + ("ok", [(0.0, "float", [[[0, 0], [4, 0], [4, 4]]])]), + ), + ( + "missing masks", + {"predictions": [{"confidence": 0.5}]}, + ("ok", [(0.5, "float", [])]), + ), + ( + "rle masks", + { + "predictions": [ + {"masks": {"size": [2, 2], "counts": "abc"}, "confidence": 0.5} + ] + }, + ("ok", [(0.5, "float", {"size": [2, 2], "counts": "abc"})]), + ), + ( + "flat list response", + [{"masks": [[[0, 0], [4, 0], [4, 4]]], "confidence": "0.25"}], + ("ok", [(0.25, "float", [[[0, 0], [4, 0], [4, 4]]])]), + ), + ( + "masks not a list", + {"predictions": [{"masks": "oops", "confidence": 0.9}]}, + ( + "error", + "2 validation errors for Sam2SegmentationPrediction", + [ + (("masks", "list[list[list[int]]]"), "list_type"), + (("masks", "dict[str,any]"), "dict_type"), + ], + ), + ), + ( + "non-numeric points", + {"predictions": [{"masks": [[["a", "b"]]], "confidence": 0.9}]}, + ( + "error", + "3 validation errors for Sam2SegmentationPrediction", + [ + (("masks", "list[list[list[int]]]", 0, 0, 0), "int_parsing"), + (("masks", "list[list[list[int]]]", 0, 0, 1), "int_parsing"), + (("masks", "dict[str,any]"), "dict_type"), + ], + ), + ), + ( + "non-numeric confidence", + {"predictions": [{"masks": [[[0, 0], [4, 0], [4, 4]]], "confidence": "high"}]}, + ( + "error", + "1 validation error for Sam2SegmentationPrediction", + [(("confidence",), "float_parsing")], + ), + ), +] + + +def _outcome(build): + try: + value = build() + except ValidationError as error: + return ( + "error", + str(error).splitlines()[0], + [(e["loc"], e["type"]) for e in error.errors()], + ) + return ("ok", [(p.confidence, type(p.confidence).__name__, p.masks) for p in value]) + + +@pytest.mark.parametrize( + "payload,expected", [c[1:] for c in PARSER_TABLE], ids=[c[0] for c in PARSER_TABLE] +) +def test_parse_segmentation_predictions_coerces_and_rejects_as_before_the_move( + payload, expected +) -> None: + assert ( + _outcome(lambda: interactive_v1._parse_segmentation_predictions(payload)) + == expected + ) + + +def _one_image_batch(): + return Batch( + content=[ + WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="p"), + numpy_image=np.zeros((20, 10, 3), dtype=np.uint8), + ) + ], + indices=[(0,)], + ) + + +def _run_remote_branch(branch, payload): + """`sdk` = run_remotely (InferenceHTTPClient.sam3_visual_segment, v1.py:411); + `proxy` = run_via_request (requests.post, v1.py:492).""" + if branch == "sdk": + with patch.object(interactive_v1, "InferenceHTTPClient") as client_cls: + client_cls.return_value.sam3_visual_segment.return_value = payload + block = interactive_v1.SegmentAnything3InteractiveBlockV1( + model_manager=MagicMock(), + api_key="k", + step_execution_mode=StepExecutionMode.REMOTE, + ) + return block.run_remotely( + images=_one_image_batch(), + points=[{"x": 1, "y": 1}], + boxes=None, + threshold=0.5, + multimask_output=True, + ) + response = MagicMock() + response.json.return_value = payload + with patch.object(interactive_v1.requests, "post", return_value=response): + block = interactive_v1.SegmentAnything3InteractiveBlockV1( + model_manager=MagicMock(), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + return block.run_via_request( + images=_one_image_batch(), + points=[{"x": 1, "y": 1}], + boxes=None, + threshold=0.5, + multimask_output=True, + ) + + +@pytest.mark.parametrize("branch", ["sdk", "proxy"]) +def test_remote_branch_coerces_a_string_confidence_into_a_detection(branch) -> None: + """A dataclass carrier left "0.9" a str and `prediction.confidence < threshold` + raised TypeError in the SAM2 converter (round-6 defect 1).""" + result = _run_remote_branch( + branch, + {"predictions": [{"masks": [[[0, 0], [4, 0], [4, 4]]], "confidence": "0.9"}]}, + ) + detections = result[0]["predictions"] + assert len(detections) == 1 + assert detections.confidence.tolist() == [pytest.approx(0.9)] + + +@pytest.mark.parametrize("branch", ["sdk", "proxy"]) +def test_remote_branch_rejects_malformed_masks_with_the_pydantic_error(branch) -> None: + with pytest.raises(ValidationError) as error: + _run_remote_branch( + branch, {"predictions": [{"masks": "oops", "confidence": 0.9}]} + ) + assert str(error.value).startswith( + "2 validation errors for Sam2SegmentationPrediction\nmasks.list[list[list[int]]]\n" + ) + + +# --- the moved prediction classes: frozen coercion / rejection --- + +CLASS_TABLE = [ + ( + "Point str coords", + local.Point, + {"x": "1", "y": "2.5"}, + ("ok", {"x": 1.0, "y": 2.5}), + ), + ("Point missing y", local.Point, {"x": 1}, ("error", [(("y",), "missing")])), + ( + "prediction alias + str confidence", + local.InstanceSegmentationPrediction, + { + "x": 1, + "y": 2, + "width": 3, + "height": 4, + "confidence": "0.5", + "class": "cat", + "class_id": 0, + "points": [{"x": 0, "y": 0}], + "detection_id": "d", + }, + ( + "ok", + { + "x": 1.0, + "y": 2.0, + "width": 3.0, + "height": 4.0, + "confidence": 0.5, + "class": "cat", + "class_id": 0, + "detection_id": "d", + "points": [{"x": 0.0, "y": 0.0}], + "mask_format": "polygon", + }, + ), + ), + ( + "prediction missing points", + local.InstanceSegmentationPrediction, + { + "x": 1, + "y": 2, + "width": 3, + "height": 4, + "confidence": 0.5, + "class": "cat", + "class_id": 0, + }, + ("error", [(("points",), "missing")]), + ), + ( + "rle prediction rle not a dict", + local.InstanceSegmentationRLEPrediction, + { + "x": 1, + "y": 2, + "width": 3, + "height": 4, + "confidence": 0.5, + "class": "cat", + "class_id": 0, + "rle": "x", + }, + ("error", [(("rle",), "dict_type")]), + ), +] + + +@pytest.mark.parametrize( + "cls,kwargs,expected", [c[1:] for c in CLASS_TABLE], ids=[c[0] for c in CLASS_TABLE] +) +def test_moved_prediction_classes_validate_as_before_the_move( + cls, kwargs, expected +) -> None: + try: + outcome = ("ok", cls(**kwargs).model_dump(by_alias=True, exclude_none=True)) + except ValidationError as error: + outcome = ("error", [(e["loc"], e["type"]) for e in error.errors()]) + assert outcome == expected + + +# --- the two response-level dataclasses --- + + +def _prediction(): + return local.InstanceSegmentationPrediction( + **{ + "x": 5.0, + "y": 5.0, + "width": 10.0, + "height": 10.0, + "confidence": 0.9, + "class": "cat", + "class_id": 3, + "points": [local.Point(x=px, y=py) for px, py in _POINTS], + "detection_id": "fixed", + } + ) + + +def _rle_prediction(): + return local.InstanceSegmentationRLEPrediction( + **{ + "x": 5.0, + "y": 5.0, + "width": 10.0, + "height": 10.0, + "confidence": 0.9, + "class": "cat", + "class_id": 3, + "rle": {"size": [10, 10], "counts": "abc"}, + "detection_id": "fixed", + } + ) + + +def test_image_dict_matches_pydantic() -> None: + assert InferenceResponseImageDC(width=640, height=480).to_dict() == ( + server_inference.InferenceResponseImage(width=640, height=480).model_dump( + by_alias=True, exclude_none=True + ) + ) + + +@pytest.mark.parametrize( + "predictions", + [[], [_prediction()], [_rle_prediction()], [_prediction(), _rle_prediction()]], + ids=["empty", "polygon", "rle", "mixed"], +) +def test_response_dict_matches_pydantic(predictions) -> None: + dc = InstanceSegmentationInferenceResponseDC( + image=InferenceResponseImageDC(width=640, height=480), predictions=predictions + ) + pydantic = server_inference.InstanceSegmentationInferenceResponse( + image=server_inference.InferenceResponseImage(width=640, height=480), + predictions=predictions, + ) + assert dc.to_dict() == pydantic.model_dump(by_alias=True, exclude_none=True) + + +def test_response_with_optionals_matches_pydantic() -> None: + dc = InstanceSegmentationInferenceResponseDC( + image=InferenceResponseImageDC(width=1, height=1), + predictions=[_prediction()], + inference_id="i", + frame_id=3, + time=0.5, + ) + pydantic = server_inference.InstanceSegmentationInferenceResponse( + image=server_inference.InferenceResponseImage(width=1, height=1), + predictions=[_prediction()], + inference_id="i", + frame_id=3, + time=0.5, + ) + assert dc.to_dict() == pydantic.model_dump(by_alias=True, exclude_none=True) + + +@pytest.mark.parametrize("predictions", [[], [_prediction()]], ids=["empty", "polygon"]) +def test_response_converts_through_supervision_identically(predictions) -> None: + """`sv.Detections.from_inference` subscripts its argument after trying + `.dict()`/`.json()`; the dataclass must be passed as `to_dict()`.""" + from_local = sv.Detections.from_inference( + InstanceSegmentationInferenceResponseDC( + image=InferenceResponseImageDC(width=640, height=480), + predictions=predictions, + ).to_dict() + ) + from_pydantic = sv.Detections.from_inference( + server_inference.InstanceSegmentationInferenceResponse( + image=server_inference.InferenceResponseImage(width=640, height=480), + predictions=predictions, + ) + ) + assert len(from_local) == len(from_pydantic) + assert np.array_equal(from_local.xyxy, from_pydantic.xyxy) + assert (from_local.mask is None) == (from_pydantic.mask is None) + if from_local.mask is not None: + assert np.array_equal(from_local.mask, from_pydantic.mask) + assert sorted(from_local.data) == sorted(from_pydantic.data) diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything2.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything2.py index d60a3e7b91..dc17b911f5 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything2.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything2.py @@ -132,3 +132,63 @@ def test_run_remotely_with_prompts( # Verify prompts were passed call_args = mock_client.sam2_segment_image.call_args assert call_args.kwargs.get("prompts") is not None + + +def test_convert_sam2_response_produces_the_same_dict_as_the_pydantic_form() -> None: + import numpy as np + + from inference.core.entities.responses.inference import ( + InferenceResponseImage, + InstanceSegmentationInferenceResponse, + InstanceSegmentationPrediction, + Point, + ) + from inference.core.workflows.core_steps.common.segmentation_entities import ( + Sam2SegmentationPrediction, + ) + from inference.core.workflows.core_steps.models.foundation.segment_anything2.v1 import ( + convert_sam2_segmentation_response_to_inference_instances_seg_response, + ) + from inference.core.workflows.execution_engine.entities.base import ( + ImageParentMetadata, + WorkflowImageData, + ) + + image = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="p"), + numpy_image=np.zeros((20, 10, 3), dtype=np.uint8), + ) + result = convert_sam2_segmentation_response_to_inference_instances_seg_response( + sam2_segmentation_predictions=[ + # the parser's own input shape: a str confidence the pydantic class coerces + Sam2SegmentationPrediction( + masks=[[[0, 0], [4, 0], [4, 4]]], confidence="0.9" + ) + ], + image=image, + prompt_class_ids=[1], + prompt_class_names=["cat"], + prompt_detection_ids=["d1"], + threshold=0.1, + ) + produced = result.to_dict() + expected = InstanceSegmentationInferenceResponse( + image=InferenceResponseImage(width=10, height=20), + predictions=[ + InstanceSegmentationPrediction( + **{ + "x": 2.0, + "y": 2.0, + "width": 4.0, + "height": 4.0, + "confidence": 0.9, + "class": "cat", + "class_id": 1, + "parent_id": "d1", + "detection_id": produced["predictions"][0]["detection_id"], + "points": [Point(x=0, y=0), Point(x=4, y=0), Point(x=4, y=4)], + } + ) + ], + ).model_dump(by_alias=True, exclude_none=True) + assert produced == expected diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_v3.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_v3.py index 783b67bb3b..0171fee295 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_v3.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_v3.py @@ -195,3 +195,162 @@ def test_run_with_partial_class_mapping(mock_run_locally, mock_workflow_image_da "dog", "bird", ] + + +def _sam3_polygon_response(): + """One text prompt, one polygon; the shapes + `_convert_polygon_response_to_inference_format` reads off the response.""" + prediction = MagicMock() + prediction.confidence = 0.9 + prediction.masks = [[[0, 0], [8, 0], [8, 6], [0, 6]]] + prompt_result = MagicMock() + prompt_result.prompt_index = 0 + prompt_result.predictions = [prediction] + response = MagicMock() + response.prompt_results = [prompt_result] + response.predictions = [prediction] + return response + + +_SAM3_POLYGON_JSON = { + "prompt_results": [ + { + "prompt_index": 0, + "predictions": [ + {"confidence": 0.9, "masks": [[[0, 0], [8, 0], [8, 6], [0, 6]]]} + ], + } + ] +} + + +def _one_polygon_image_batch(): + from inference.core.workflows.execution_engine.entities.base import Batch + + return Batch( + content=[ + WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="p"), + numpy_image=np.zeros((10, 20, 3), dtype=np.uint8), + ) + ], + indices=[(0,)], + ) + + +_POLYGON_RUN_KWARGS = dict( + class_names=["cat"], + confidence=0.5, + per_class_confidence=None, + apply_nms=False, + nms_iou_threshold=0.9, + output_format="polygons", +) + + +def test_v3_local_polygon_path_converts_through_supervision() -> None: + """Drives `run_locally` with a stubbed provider so the changed + `_convert_polygon_response_to_inference_format(...).to_dict()` -> + `sv.Detections.from_inference` path actually executes. Before Task 11.4 + Step 8 this raised `TypeError: … object is not subscriptable`.""" + model_manager = MagicMock() + model_manager.infer_from_request_sync.return_value = _sam3_polygon_response() + block = SegmentAnything3BlockV3( + model_manager=model_manager, + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + result = block.run_locally( + images=_one_polygon_image_batch(), + model_id="sam3/sam3_final", + **_POLYGON_RUN_KWARGS + ) + detections = result[0]["predictions"] + assert len(detections) == 1 + assert detections.xyxy.tolist() == [[0.0, 0.0, 8.0, 6.0]] + model_manager.infer_from_request_sync.assert_called_once() + + +def test_v3_remote_polygon_path_converts_through_supervision() -> None: + """The REMOTE branch converts `_convert_polygon_json_response_to_inference_format(...)` + through the same `to_dict()` seam (`v3.py:517`).""" + import inference.core.workflows.core_steps.models.foundation.segment_anything3.v3 as v3_module + + with patch.object(v3_module, "InferenceHTTPClient") as client_cls: + client_cls.return_value.sam3_concept_segment.return_value = _SAM3_POLYGON_JSON + block = SegmentAnything3BlockV3( + model_manager=MagicMock(), + api_key="k", + step_execution_mode=StepExecutionMode.REMOTE, + ) + result = block.run_remotely( + images=_one_polygon_image_batch(), + model_id="sam3/sam3_final", + **_POLYGON_RUN_KWARGS + ) + assert len(result[0]["predictions"]) == 1 + assert result[0]["predictions"].xyxy.tolist() == [[0.0, 0.0, 8.0, 6.0]] + + +def test_v3_proxy_polygon_path_converts_through_supervision() -> None: + """The inference-proxy branch (`run_via_request`, `v3.py:622`) - the third + changed call site.""" + import inference.core.workflows.core_steps.models.foundation.segment_anything3.v3 as v3_module + + response = MagicMock() + response.json.return_value = _SAM3_POLYGON_JSON + with patch.object(v3_module.requests, "post", return_value=response): + block = SegmentAnything3BlockV3( + model_manager=MagicMock(), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + result = block.run_via_request( + images=_one_polygon_image_batch(), **_POLYGON_RUN_KWARGS + ) + assert len(result[0]["predictions"]) == 1 + assert result[0]["predictions"].xyxy.tolist() == [[0.0, 0.0, 8.0, 6.0]] + + +def test_v3_polygon_dataclass_matches_the_pydantic_form_through_supervision() -> None: + """Explains the contract the block tests rely on: the response dataclass, + passed as a dict, produces the same Detections the pydantic response did.""" + from inference.core.entities.responses.inference import ( + InferenceResponseImage, + InstanceSegmentationInferenceResponse, + InstanceSegmentationPrediction, + Point, + ) + from inference.core.workflows.core_steps.common.inference_response_dc import ( + InferenceResponseImageDC, + InstanceSegmentationInferenceResponseDC, + ) + + polygon = [(0.0, 0.0), (8.0, 0.0), (8.0, 6.0), (0.0, 6.0)] + prediction = InstanceSegmentationPrediction( + **{ + "x": 4.0, + "y": 3.0, + "width": 8.0, + "height": 6.0, + "confidence": 0.75, + "class": "cat", + "class_id": 2, + "detection_id": "fixed", + "points": [Point(x=px, y=py) for px, py in polygon], + } + ) + local = InstanceSegmentationInferenceResponseDC( + image=InferenceResponseImageDC(width=20, height=10), predictions=[prediction] + ) + pydantic = InstanceSegmentationInferenceResponse( + image=InferenceResponseImage(width=20, height=10), predictions=[prediction] + ) + from_local = sv.Detections.from_inference(local.to_dict()) + from_pydantic = sv.Detections.from_inference(pydantic) + assert np.array_equal(from_local.xyxy, from_pydantic.xyxy) + assert np.array_equal(from_local.class_id, from_pydantic.class_id) + assert np.array_equal(from_local.confidence, from_pydantic.confidence) + assert (from_local.mask is None) == (from_pydantic.mask is None) + if from_local.mask is not None: + assert np.array_equal(from_local.mask, from_pydantic.mask) diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index bb2f6dfbfe..a73ababf7c 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 57 +# Count: 51 inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py inference.core.entities.requests.clip @@ -21,21 +21,15 @@ inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py inference.co inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/seg_preview/v1.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.requests.sam2 -inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.requests.sam3 -inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.requests.sam3 -inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.entities.requests.sam3 -inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.entities.responses.inference inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py inference.core.entities.requests.sam3_3d inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py inference.core.entities.requests.sam3_3d inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.entities.requests.sam2 -inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.entities.responses.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.entities.requests.yolo_world From f7841390e0845795afe6c08df8fa64a5b57701cf Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:59:58 +0200 Subject: [PATCH 53/86] docs(workflows): name the dropped Sam3 3D response type at _format_response Task 11.3 review nit: the two 3D SAM blocks lost their annotation-only Sam3_3D_Objects_Response import without a comment naming the real type, unlike the other three SAM blocks. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../core_steps/models/foundation/segment_anything3_3d/v1.py | 2 ++ .../models/foundation/segment_anything3_3d/v1_tensor.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py index c2bd8cbe29..c5a74ff70e 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py @@ -282,6 +282,8 @@ def extract_masks_from_input(mask_input: Any) -> Any: return mask_input +# `response` is the server's Sam3_3D_Objects_Response; only the fields read +# below are accessed, so the annotation is dropped (decontamination). def _format_response(response: Any) -> dict: """Format response with base64 encoded outputs.""" diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py index 7864c970d2..5b842bdc30 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py @@ -287,6 +287,8 @@ def extract_masks_from_input(mask_input: Any) -> Any: return mask_input +# `response` is the server's Sam3_3D_Objects_Response; only the fields read +# below are accessed, so the annotation is dropped (decontamination). def _format_response(response: Any) -> dict: """Format response with base64 encoded outputs.""" From 31964e9cb170b1631bcce0b016facf4d826e3b4f Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:59:58 +0200 Subject: [PATCH 54/86] test(stream): teach the fake model manager the stream-pipeline port methods Task 11.1 repointed instance_segmentation/v3.py from model_manager[...] to the four stream-pipeline methods on the ModelsProvider port; the fake manager in the InferencePipeline workflow tests still only exposed __getitem__, so four tests failed with AttributeError. The fake now mirrors ModelManager's bodies over its single fake model. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../core/interfaces/stream/test_workflows.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/inference/unit_tests/core/interfaces/stream/test_workflows.py b/tests/inference/unit_tests/core/interfaces/stream/test_workflows.py index 9a41763e99..c5fa0dd82d 100644 --- a/tests/inference/unit_tests/core/interfaces/stream/test_workflows.py +++ b/tests/inference/unit_tests/core/interfaces/stream/test_workflows.py @@ -315,6 +315,21 @@ def __getitem__(self, model_id: str): assert model_id == "model" return self.model + # The stream-pipeline port methods (Phase 11 Task 11.1) mirror + # `ModelManager`'s bodies over the single fake model. + def model_supports_stream_pipeline(self, model_id: str) -> bool: + return model_id in self and self.model._pipeline_depth > 1 + + def get_model_pipeline_depth(self, model_id: str) -> int: + return self.model._pipeline_depth if model_id in self else 1 + + def flush_model_stream_pipeline(self, model_id: str): + return self.model.flush() if model_id in self else None + + def shutdown_model_stream_pipeline(self, model_id: str) -> None: + if model_id in self: + self.model.shutdown_pipeline() + class _ContextAwareModelManager(_FakeModelManager): def __init__(self, mode: str) -> None: From cc4120298ae53dd0c2be29cd5694512c226b1576 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:30:20 +0200 Subject: [PATCH 55/86] refactor(workflows): own ActionRecognitionPrediction, server re-exports it Move ActionRecognitionPrediction from inference.core.entities.responses.action_recognition into inference.core.workflows.core_steps.models.roboflow.action_recognition.entities. The server module now re-exports the same class object so isinstance checks in the serializers and the HTTP response model keep working. Decontamination baseline: 51 -> 50. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../entities/responses/action_recognition.py | 25 +------ .../roboflow/action_recognition/entities.py | 29 +++++++ .../execution_engine/entities/base.py | 2 +- .../test_action_recognition_entities.py | 75 +++++++++++++++++++ .../unit_tests/decontamination_baseline.txt | 3 +- 5 files changed, 110 insertions(+), 24 deletions(-) create mode 100644 inference/core/workflows/core_steps/models/roboflow/action_recognition/entities.py create mode 100644 tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_action_recognition_entities.py diff --git a/inference/core/entities/responses/action_recognition.py b/inference/core/entities/responses/action_recognition.py index 55aff79f4a..709123274f 100644 --- a/inference/core/entities/responses/action_recognition.py +++ b/inference/core/entities/responses/action_recognition.py @@ -1,27 +1,10 @@ from typing import List -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field - -class ActionRecognitionPrediction(BaseModel): - """One classified frame range of a video. - - The HTTP response and the workflow kind carry the same shape, so it is - declared once here and imported by both. A field added on one transport - would otherwise be silently missing from the other. - """ - - model_config = ConfigDict(populate_by_name=True) - - start_frame_idx: int = Field(description="First frame of the range") - end_frame_idx: int = Field(description="Last frame of the range") - class_name: str = Field(alias="class", description="The class name") - class_id: int = Field( - description=( - "The class position in the model's own class list. A model without " - "a class list reports -1." - ) - ) +from inference.core.workflows.core_steps.models.roboflow.action_recognition.entities import ( # noqa: F401 + ActionRecognitionPrediction, +) class ActionRecognitionInferenceResponse(BaseModel): diff --git a/inference/core/workflows/core_steps/models/roboflow/action_recognition/entities.py b/inference/core/workflows/core_steps/models/roboflow/action_recognition/entities.py new file mode 100644 index 0000000000..88adb3e933 --- /dev/null +++ b/inference/core/workflows/core_steps/models/roboflow/action_recognition/entities.py @@ -0,0 +1,29 @@ +"""Action-recognition payload type, owned by Workflows. + +Moved here from `inference.core.entities.responses.action_recognition`, which +now re-exports it: the workflow kind, the serializers' `isinstance` dispatch and +the HTTP `ActionRecognitionInferenceResponse` must all see ONE class object. +""" + +from pydantic import BaseModel, ConfigDict, Field + + +class ActionRecognitionPrediction(BaseModel): + """One classified frame range of a video. + + The HTTP response and the workflow kind carry the same shape, so it is + declared once here and imported by both. A field added on one transport + would otherwise be silently missing from the other. + """ + + model_config = ConfigDict(populate_by_name=True) + + start_frame_idx: int = Field(description="First frame of the range") + end_frame_idx: int = Field(description="Last frame of the range") + class_name: str = Field(alias="class", description="The class name") + class_id: int = Field( + description=( + "The class position in the model's own class list. A model without " + "a class list reports -1." + ) + ) diff --git a/inference/core/workflows/execution_engine/entities/base.py b/inference/core/workflows/execution_engine/entities/base.py index 6d6372913c..6f852d96e2 100644 --- a/inference/core/workflows/execution_engine/entities/base.py +++ b/inference/core/workflows/execution_engine/entities/base.py @@ -23,7 +23,7 @@ from torchvision.io import ImageReadMode, decode_image, read_file from typing_extensions import Annotated, Literal -from inference.core.entities.responses.action_recognition import ( +from inference.core.workflows.core_steps.models.roboflow.action_recognition.entities import ( ActionRecognitionPrediction, ) from inference.core.workflows.environment import ( diff --git a/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_action_recognition_entities.py b/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_action_recognition_entities.py new file mode 100644 index 0000000000..9604accc00 --- /dev/null +++ b/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_action_recognition_entities.py @@ -0,0 +1,75 @@ +import ast +from pathlib import Path + +# tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/ +# -> parents[7] is the repo root +REPO_ROOT = Path(__file__).resolve().parents[7] +BASE = REPO_ROOT / "inference/core/workflows/execution_engine/entities/base.py" + + +def test_base_does_not_import_server_action_recognition_entities() -> None: + assert BASE.is_file(), BASE + tree = ast.parse(BASE.read_text(encoding="utf-8")) + offenders = [ + node.module + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + and node.module + and node.module.startswith("inference.core.entities") + ] + assert offenders == [] + + +def test_there_is_exactly_one_action_recognition_prediction_class() -> None: + from inference.core.entities.responses.action_recognition import ( + ActionRecognitionPrediction as ServerName, + ) + from inference.core.workflows.core_steps.models.roboflow.action_recognition.entities import ( + ActionRecognitionPrediction, + ) + from inference.core.workflows.execution_engine.entities.base import ( + ActionRecognitionPrediction as ReExported, + ) + + assert ServerName is ActionRecognitionPrediction + assert ReExported is ActionRecognitionPrediction + + +def test_isinstance_holds_for_objects_the_server_helper_builds() -> None: + """`serialize_wildcard_kind` dispatches on isinstance; the timeline entries + are built by `inference.core.models.action_recognition.merge_window_segments`, + which stays server-side (Phase 9's Task 9.9 copies it into workflows; the + server function keeps existing).""" + from inference.core.models.action_recognition import merge_window_segments + from inference.core.workflows.core_steps.common.serializers import ( + serialize_wildcard_kind, + ) + from inference_models.models.base.action_recognition import ( + ActionRecognitionPrediction as ModelSegment, + ) + + timeline = [] + merge_window_segments( + timeline=timeline, + frame_numbers=[10, 20, 30], + segments=[ModelSegment(start_frame_idx=0, end_frame_idx=1, class_name="wave")], + id_vocabulary=["wave"], + stride=1.0, + ) + assert len(timeline) == 1 + assert serialize_wildcard_kind(value=timeline[0]) == { + "start_frame_idx": 10, + "end_frame_idx": 20, + "class": "wave", + "class_id": 0, + } + + +def test_schema_description_survives_the_move() -> None: + from inference.core.workflows.core_steps.models.roboflow.action_recognition.entities import ( + ActionRecognitionPrediction, + ) + + schema = ActionRecognitionPrediction.model_json_schema() + assert "One classified frame range of a video." in schema["description"] + assert schema["properties"]["class"]["title"] == "Class" diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index a73ababf7c..50e2ff748a 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 51 +# Count: 50 inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py inference.core.entities.requests.clip @@ -51,4 +51,3 @@ inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py infer inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py inference.core.entities.requests.inference -inference/core/workflows/execution_engine/entities/base.py inference.core.entities.responses.action_recognition From 35618e1e0b8e43884f23157af58fb1357c99c7a1 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:30:47 +0200 Subject: [PATCH 56/86] test(http): count only the image fetch in the bound-codec allow-list test The process-global usage collector flushes its own POSTs through the same requests_mock transport, so requests_mock.call_count is order-dependent (same class as the hop-cap fix in test_workflows_image_codec.py). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../unit_tests/core/interfaces/test_image_codec_binding.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py b/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py index 688fcdcb92..2446a6a1c6 100644 --- a/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py +++ b/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py @@ -372,8 +372,10 @@ def test_http_run_route_fetches_an_allow_listed_url_input_through_the_bound_code ) assert response.status_code == 200, response.text - assert requests_mock.call_count == 1 - assert requests_mock.request_history[0].url == url + # Count only the image fetch: the process-global usage collector may flush + # its own POSTs through the same mocked transport (order-dependent). + image_fetches = [r for r in requests_mock.request_history if r.url == url] + assert len(image_fetches) == 1 _assert_one_object_on_both_paths(forwarded_engine_init, GUARDED_IMAGE_CODEC) blurred = _decode_serialised_image(response.json()["outputs"][0]["blurred"]) assert blurred.shape == (16, 24, 3) From ee62f94d1e12036cd8e739239f9567a61caea300 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:13:10 +0200 Subject: [PATCH 57/86] feat(server): ModelsProvider adapter bound at the four workflow composition roots Adds ModelManagerModelsProvider (inference/core/interfaces/workflows_models_provider.py), the server-side implementation of the Workflows ModelsProvider port, forwarding every port member to a wrapped ModelManager. Binds it at the four composition roots (http_api.py x2, inference_pipeline.py, local_image_adapter.py) via scripts/phase11_bind_models_provider.py, an AST-located script that substitutes only the value bound to "workflows_core.model_manager". Repoints the port's structural test and gives the integration test fixtures a raw_model_manager/model_manager pair so tests can still inspect the underlying manager while workflows receive the adapter. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/interfaces/http/http_api.py | 11 +- .../interfaces/stream/inference_pipeline.py | 7 +- .../interfaces/workflows_models_provider.py | 142 +++++++++++++ .../lib/workflows/local_image_adapter.py | 5 +- scripts/phase11_bind_models_provider.py | 88 ++++++++ .../test_workflows_composition_roots.py | 131 ++++++++++++ .../test_workflows_models_provider.py | 188 ++++++++++++++++++ tests/workflows/integration_tests/conftest.py | 17 +- ...test_rfdetr_sliced_workflow_main_parity.py | 7 +- ...low_with_control_flow_with_side_effects.py | 5 +- .../test_workflow_dependencies_pre_loading.py | 6 +- .../execution/test_workflow_with_sahi.py | 9 +- .../prototypes/test_models_provider.py | 5 + 13 files changed, 609 insertions(+), 12 deletions(-) create mode 100644 inference/core/interfaces/workflows_models_provider.py create mode 100644 scripts/phase11_bind_models_provider.py create mode 100644 tests/inference/unit_tests/core/interfaces/test_workflows_composition_roots.py create mode 100644 tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py diff --git a/inference/core/interfaces/http/http_api.py b/inference/core/interfaces/http/http_api.py index 773b989175..5cead0018b 100644 --- a/inference/core/interfaces/http/http_api.py +++ b/inference/core/interfaces/http/http_api.py @@ -396,6 +396,9 @@ import time +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) from inference.core.roboflow_api import ModelEndpointType from inference.core.version import __version__ from inference_sdk.http.entities import Confidence @@ -1608,7 +1611,9 @@ def process_workflow_inference_request( workflow_init_parameters = install_workflows_platform_bindings( { - "workflows_core.model_manager": model_manager, + "workflows_core.model_manager": ModelManagerModelsProvider( + model_manager + ), "workflows_core.api_key": workflow_request.api_key, "workflows_core.background_tasks": background_tasks, "workflows_core.disable_sinks": workflow_request.disable_sinks, @@ -2575,7 +2580,9 @@ def validate_workflow( step_execution_mode = StepExecutionMode(WORKFLOWS_STEP_EXECUTION_MODE) workflow_init_parameters = install_workflows_platform_bindings( { - "workflows_core.model_manager": model_manager, + "workflows_core.model_manager": ModelManagerModelsProvider( + model_manager + ), "workflows_core.api_key": api_key, "workflows_core.background_tasks": None, "workflows_core.step_execution_mode": step_execution_mode, diff --git a/inference/core/interfaces/stream/inference_pipeline.py b/inference/core/interfaces/stream/inference_pipeline.py index 5d9238931d..bb0f68104e 100644 --- a/inference/core/interfaces/stream/inference_pipeline.py +++ b/inference/core/interfaces/stream/inference_pipeline.py @@ -68,6 +68,9 @@ NullPipelineWatchdog, PipelineWatchDog, ) +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) from inference.core.managers.active_learning import BackgroundTaskActiveLearningManager from inference.core.managers.base import ModelManager from inference.core.managers.decorators.fixed_size_cache import WithFixedSizeCache @@ -752,7 +755,9 @@ def init_with_workflow( execution_engine_thread_pool_executor = ThreadPoolExecutor( max_workers=execution_engine_thread_pool_workers ) - workflow_init_parameters["workflows_core.model_manager"] = model_manager + workflow_init_parameters["workflows_core.model_manager"] = ( + ModelManagerModelsProvider(model_manager) + ) workflow_init_parameters["workflows_core.api_key"] = api_key workflow_init_parameters["workflows_core.thread_pool_executor"] = ( thread_pool_executor diff --git a/inference/core/interfaces/workflows_models_provider.py b/inference/core/interfaces/workflows_models_provider.py new file mode 100644 index 0000000000..6b63d46ee3 --- /dev/null +++ b/inference/core/interfaces/workflows_models_provider.py @@ -0,0 +1,142 @@ +"""Server-side implementation of the Workflows `ModelsProvider` port. + +Workflow blocks describe an inference call with plain arguments; this adapter +turns those into the server's pydantic request objects and runs them through +`ModelManager`, so `inference.core.workflows` never imports +`inference.core.entities`. Every `run_*` method reproduces, argument for +argument, the request a block used to build inline - see +`tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py`, +which pins each one against the pydantic class. + +MODEL REGISTRATION IS NOT DONE HERE, with two exceptions. `add_model` / +`load_core_model` stay in the blocks: `core_steps/models/roboflow/ +instance_segmentation/v3.py` registers the model, then reads its pipeline depth +to decide whether to queue a stream frame context, and only then infers. +Registering inside the inference call would make that check report "no +pipeline" on a cold model and the first delayed response would find no pending +context. The exceptions are `run_clip_comparison` and `run_pp_ocr`, whose core +model id exists only on the validated request; they register in the position +the blocks used (build -> register -> infer). + +Bound at the four composition roots as +`init_parameters["workflows_core.model_manager"]`, and by the test fixtures +that used to inject a raw manager (Task 11.7 Step 6b). + +Phase 12 note: a second implementation backed by `inference_sdk` +(`InferenceHTTPClientModelsProvider`) would satisfy the same port for REMOTE +step execution. Keep the argument names aligned with +`inference_sdk.http.entities.InferenceConfiguration`. +""" + +from typing import Any, Dict, List, Optional, Union + +from inference.core.managers.base import ModelManager + +_WORKFLOW_SOURCE = "workflow-execution" + + +class ModelManagerModelsProvider: + """Implements `inference.core.workflows.prototypes.models_provider.ModelsProvider`.""" + + def __init__(self, model_manager: ModelManager): + self._model_manager = model_manager + + # -- forwarded members ------------------------------------------------- + + @property + def content_addressed_artifact_cache(self) -> Any: + return self._model_manager.content_addressed_artifact_cache + + def add_model( + self, + model_id: str, + api_key: str, + model_id_alias: Optional[str] = None, + **kwargs: Any, + ) -> None: + # Forwarded as the block made the call (keywords, no synthesised + # `model_id_alias=None`), so a class-level test patch on + # `ModelManager.add_model` observes the same call it observes today. + if model_id_alias is not None: + kwargs["model_id_alias"] = model_id_alias + return self._model_manager.add_model( + model_id=model_id, api_key=api_key, **kwargs + ) + + def infer_from_request_sync( + self, model_id: str, request: Any, **kwargs: Any + ) -> Any: + return self._model_manager.infer_from_request_sync( + model_id=model_id, request=request, **kwargs + ) + + def run_tensor_native_inference(self, model_id: str, **kwargs: Any) -> Any: + return self._model_manager.run_tensor_native_inference( + model_id=model_id, **kwargs + ) + + def get_class_names(self, model_id: str) -> List[str]: + return self._model_manager.get_class_names(model_id) + + def load_action_recognition_model( + self, model_id: str, api_key: Optional[str] = None, **kwargs: Any + ) -> Any: + # Phase 9's loader (Task 9.9); the action block calls it with keywords. + return self._model_manager.load_action_recognition_model( + model_id=model_id, api_key=api_key, **kwargs + ) + + def get_keypoints_classes(self, model_id: str) -> List[List[str]]: + return self._model_manager.get_keypoints_classes(model_id) + + def model_supports_stream_pipeline(self, model_id: str) -> bool: + return self._model_manager.model_supports_stream_pipeline(model_id) + + def get_model_pipeline_depth(self, model_id: str) -> int: + return self._model_manager.get_model_pipeline_depth(model_id) + + def flush_model_stream_pipeline(self, model_id: str) -> Optional[List[Any]]: + return self._model_manager.flush_model_stream_pipeline(model_id) + + def shutdown_model_stream_pipeline(self, model_id: str) -> None: + return self._model_manager.shutdown_model_stream_pipeline(model_id) + + def __contains__(self, model_id: str) -> bool: + return model_id in self._model_manager + + # -- shared helpers used by the run_* methods -------------------------- + + def _infer(self, model_id: str, request: Any, **kwargs: Any) -> List[Any]: + """Run one request and normalise the result to a list. + + `kwargs` are forwarded to `ModelManager.infer_from_request_sync`, which + passes them on to `model_infer_sync` - two multi-label blocks send an + extra `confidence` that way today. + + The caller has already registered `model_id` unless this adapter owns + that registration (`run_clip_comparison`, `run_pp_ocr`). + """ + responses = self._model_manager.infer_from_request_sync( + model_id=model_id, request=request, **kwargs + ) + if not isinstance(responses, list): + responses = [responses] + return responses + + @staticmethod + def _dump(responses: List[Any]) -> List[dict]: + """The dict form every block's `_post_process_result` consumes. + + Identical to the `e.model_dump(by_alias=True, exclude_none=True)` the + blocks ran inline, with the `to_dict()` fast path the rfdetr adapter's + dataclass responses expose (see + `inference/core/entities/responses/inference.py`). + """ + return [ + ( + response.to_dict() + if callable(getattr(response, "to_dict", None)) + else response.model_dump(by_alias=True, exclude_none=True) + ) + for response in responses + ] diff --git a/inference_cli/lib/workflows/local_image_adapter.py b/inference_cli/lib/workflows/local_image_adapter.py index 1956ecabfe..003f7c39af 100644 --- a/inference_cli/lib/workflows/local_image_adapter.py +++ b/inference_cli/lib/workflows/local_image_adapter.py @@ -19,6 +19,9 @@ UsageTrackingExecutionObserver, ) from inference.core.interfaces.workflows_image_codec import bind_image_codec +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) from inference.core.interfaces.workflows_step_error_handlers import ( resolve_step_error_handler, ) @@ -457,7 +460,7 @@ def _run_workflow_for_single_image_with_inference( workflows_execution_engine_init_params: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: workflow_init_parameters = { - "workflows_core.model_manager": model_manager, + "workflows_core.model_manager": ModelManagerModelsProvider(model_manager), "workflows_core.api_key": api_key, "workflows_core.thread_pool_executor": thread_pool_executor, "workflows_core.execution_observer": UsageTrackingExecutionObserver(), diff --git a/scripts/phase11_bind_models_provider.py b/scripts/phase11_bind_models_provider.py new file mode 100644 index 0000000000..2c5880a72d --- /dev/null +++ b/scripts/phase11_bind_models_provider.py @@ -0,0 +1,88 @@ +"""Bind `ModelManagerModelsProvider(model_manager)` at the four composition roots (Task 11.7). + +AST-located, one value substitution per root: every expression bound to the +`"workflows_core.model_manager"` key - a dict-literal entry or a +`params["workflows_core.model_manager"] = ...` assignment - whose value is the +bare name `model_manager` becomes `ModelManagerModelsProvider(model_manager)`, +and the import is added once after the last top-level import. Nothing else on +the line changes, so the edit composes with whatever wrapper another phase +put around the dictionary (Phase 9's `install_workflows_platform_bindings({...})`, +Phase 5/6/10's extra keys). Re-parses before writing; idempotent (a file whose +bindings are already wrapped reports 0 and is left alone). + +Run: python scripts/phase11_bind_models_provider.py [ ...] +""" + +import ast +import sys +from pathlib import Path + +KEY = "workflows_core.model_manager" +IMPORT = ( + "from inference.core.interfaces.workflows_models_provider import (\n" + " ModelManagerModelsProvider,\n" + ")\n" +) + + +def _bound_values(tree): + for node in ast.walk(tree): + if isinstance(node, ast.Dict): + for key, value in zip(node.keys, node.values): + if isinstance(key, ast.Constant) and key.value == KEY: + yield value + elif isinstance(node, ast.Assign): + for target in node.targets: + if ( + isinstance(target, ast.Subscript) + and isinstance(target.slice, ast.Constant) + and target.slice.value == KEY + ): + yield node.value + + +def rewrite(path: Path) -> int: + source = path.read_text(encoding="utf-8") + newline = "\r\n" if "\r\n" in source else "\n" + lines = source.split(newline) + tree = ast.parse(source) + targets = [ + v + for v in _bound_values(tree) + if isinstance(v, ast.Name) and v.id == "model_manager" + ] + if not targets: + print(f"{path}: 0 bindings rewritten (already wrapped or absent)") + return 0 + for value in sorted(targets, key=lambda n: (n.lineno, n.col_offset), reverse=True): + assert value.lineno == value.end_lineno + line = lines[value.lineno - 1] + assert line[value.col_offset : value.end_col_offset] == "model_manager" + lines[value.lineno - 1] = ( + line[: value.col_offset] + + "ModelManagerModelsProvider(model_manager)" + + line[value.end_col_offset :] + ) + if "ModelManagerModelsProvider" not in source: + last_import = max( + n.end_lineno + for n in tree.body + if isinstance(n, (ast.Import, ast.ImportFrom)) + ) + lines[last_import:last_import] = IMPORT.rstrip("\n").split("\n") + updated = newline.join(lines) + ast.parse(updated) + remaining = [ + v + for v in _bound_values(ast.parse(updated)) + if isinstance(v, ast.Name) and v.id == "model_manager" + ] + assert not remaining, "post-state: a raw binding survived" + path.write_text(updated, encoding="utf-8") + print(f"{path}: {len(targets)} bindings rewritten") + return len(targets) + + +if __name__ == "__main__": + total = sum(rewrite(Path(p)) for p in sys.argv[1:]) + print(f"TOTAL {total}") diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_composition_roots.py b/tests/inference/unit_tests/core/interfaces/test_workflows_composition_roots.py new file mode 100644 index 0000000000..daa0c49296 --- /dev/null +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_composition_roots.py @@ -0,0 +1,131 @@ +"""Every composition root must bind the adapter, not the raw manager. + +Round-1 defect 8: a name-occurrence check passes on an unused import while all +four raw-manager assignments stay unchanged. +""" + +import ast +from pathlib import Path + +# tests/inference/unit_tests/core/interfaces/ -> parents[5] is the repo root +REPO_ROOT = Path(__file__).resolve().parents[5] + +from inference.core.workflows.core_steps.common.entities import StepExecutionMode + +ROOTS = { + "inference/core/interfaces/http/http_api.py": 2, + "inference/core/interfaces/stream/inference_pipeline.py": 1, + "inference_cli/lib/workflows/local_image_adapter.py": 1, +} +KEY = "workflows_core.model_manager" +ADAPTER = "ModelManagerModelsProvider" + + +def _bound_values(tree: ast.AST) -> list: + """Every expression bound to the `workflows_core.model_manager` key. + + Covers both shapes in the tree: a dict literal entry (bare or wrapped in + `install_workflows_platform_bindings({...})`), and + `params["workflows_core.model_manager"] = `. + """ + values = [] + for node in ast.walk(tree): + if isinstance(node, ast.Dict): + for key, value in zip(node.keys, node.values): + if isinstance(key, ast.Constant) and key.value == KEY: + values.append(value) + elif isinstance(node, ast.Assign): + for target in node.targets: + if ( + isinstance(target, ast.Subscript) + and isinstance(target.slice, ast.Constant) + and target.slice.value == KEY + ): + values.append(node.value) + return values + + +def _is_adapter_call(value: ast.AST) -> bool: + return ( + isinstance(value, ast.Call) + and isinstance(value.func, ast.Name) + and value.func.id == ADAPTER + and len(value.args) == 1 + and isinstance(value.args[0], ast.Name) + and value.args[0].id == "model_manager" + ) + + +def test_every_binding_wraps_the_manager_in_the_adapter() -> None: + total = 0 + for relative, expected_engine_inits in ROOTS.items(): + path = REPO_ROOT / relative + assert path.is_file(), path + source = path.read_text(encoding="utf-8") + tree = ast.parse(source) + values = _bound_values(tree) + assert values, f"{relative}: no `{KEY}` binding found" + for value in values: + assert _is_adapter_call(value), ( + f"{relative}: `{KEY}` is bound to " + f"{ast.dump(value)[:120]}, not {ADAPTER}(model_manager)" + ) + engine_inits = sum( + 1 + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "init" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "ExecutionEngine" + ) + assert engine_inits == expected_engine_inits, (relative, engine_inits) + assert len(values) == engine_inits, (relative, len(values), engine_inits) + total += len(values) + assert total == 4, total + + +def test_a_step_constructed_through_the_engine_receives_the_adapter() -> None: + """Round-1 defect 8 / round-2: stopping at `retrieve_init_parameters_values` + proves the lookup, not the construction. This goes through + `initialise_step` (`steps_initialiser.py:53-86`), which is what the compiler + calls, and asserts the *constructed block instance* holds the adapter.""" + from unittest.mock import MagicMock + + from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, + ) + from inference.core.workflows.core_steps.models.roboflow.object_detection.v1 import ( + BlockManifest, + RoboflowObjectDetectionModelBlockV1, + ) + from inference.core.workflows.execution_engine.v1.compiler.entities import ( + BlockSpecification, + ) + from inference.core.workflows.execution_engine.v1.compiler.steps_initialiser import ( + initialise_step, + ) + + manager = MagicMock() + provider = ModelManagerModelsProvider(manager) + manifest = BlockManifest.model_construct( + name="detection", type="roboflow_core/roboflow_object_detection_model@v1" + ) + specification = BlockSpecification( + block_source="workflows_core", + identifier="roboflow_core/roboflow_object_detection_model@v1", + block_class=RoboflowObjectDetectionModelBlockV1, + manifest_class=BlockManifest, + ) + initialised = initialise_step( + step_manifest=manifest, + block_specification=specification, + explicit_init_parameters={ + "workflows_core.model_manager": provider, + "workflows_core.api_key": "key", + "workflows_core.step_execution_mode": StepExecutionMode.LOCAL, + }, + initializers={}, + ) + assert initialised.step._model_manager is provider + assert initialised.step._model_manager._model_manager is manager diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py new file mode 100644 index 0000000000..568194148b --- /dev/null +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py @@ -0,0 +1,188 @@ +import ast +import inspect +from unittest.mock import MagicMock + +import pytest + +import inference.core.interfaces.workflows_models_provider as adapter_module +from inference.core.entities.responses.inference import ( + InferenceResponseImage, + ObjectDetectionInferenceResponse, +) +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) +from inference.core.workflows.prototypes.models_provider import ModelsProvider + +# A payload `InferenceRequestImage` accepts. A bare string does not validate. +IMAGE = {"type": "base64", "value": "aGVsbG8="} +IMAGES = [IMAGE] + + +def empty_detection_response() -> ObjectDetectionInferenceResponse: + return ObjectDetectionInferenceResponse( + image=InferenceResponseImage(width=10, height=20), predictions=[] + ) + + +def manager_returning(value) -> MagicMock: + manager = MagicMock() + manager.infer_from_request_sync.return_value = value + manager.__contains__.return_value = True # MagicMock's default is False + return manager + + +def captured_request(manager): + assert manager.infer_from_request_sync.call_count == 1 + call = manager.infer_from_request_sync.call_args + return call.kwargs["request"] if "request" in call.kwargs else call.args[1] + + +def test_adapter_implements_every_port_member() -> None: + for name, member in vars(ModelsProvider).items(): + if not callable(member): + continue + if name.startswith("_") and name != "__contains__": + continue + assert hasattr(ModelManagerModelsProvider, name), name + assert isinstance( + vars(ModelManagerModelsProvider)["content_addressed_artifact_cache"], property + ) + + +def test_adapter_forwards_each_member_with_its_own_valid_arguments() -> None: + manager = MagicMock() + manager.__contains__.return_value = True + provider = ModelManagerModelsProvider(manager) + + # `add_model` is forwarded exactly as the block called it, so a class-level + # test patch on `ModelManager.add_model` sees the call it sees today. + provider.add_model(model_id="m/1", api_key="key") + manager.add_model.assert_called_once_with(model_id="m/1", api_key="key") + manager.add_model.reset_mock() + provider.add_model("m/2", "key", model_id_alias="alias", endpoint_type="core_model") + manager.add_model.assert_called_once_with( + model_id="m/2", + api_key="key", + model_id_alias="alias", + endpoint_type="core_model", + ) + + sentinel = object() + provider.infer_from_request_sync(model_id="m/1", request=sentinel) + manager.infer_from_request_sync.assert_called_once_with( + model_id="m/1", request=sentinel + ) + + provider.run_tensor_native_inference("m/1", images=[1]) + manager.run_tensor_native_inference.assert_called_once_with( + model_id="m/1", images=[1] + ) + + for name in ( + "get_class_names", + "get_keypoints_classes", + "model_supports_stream_pipeline", + "get_model_pipeline_depth", + "flush_model_stream_pipeline", + "shutdown_model_stream_pipeline", + ): + getattr(provider, name)("m/1") + getattr(manager, name).assert_called_once_with("m/1") + + # Phase 9's loader (Task 9.9), forwarded with the block's keyword shape. + assert ( + provider.load_action_recognition_model(model_id="ar/1", api_key="k") + is manager.load_action_recognition_model.return_value + ) + manager.load_action_recognition_model.assert_called_once_with( + model_id="ar/1", api_key="k" + ) + + assert "m/1" in provider + manager.__contains__.assert_called_once_with("m/1") + assert ( + provider.content_addressed_artifact_cache + is manager.content_addressed_artifact_cache + ) + assert provider._model_manager is manager + + +def test_adapter_signatures_match_the_port() -> None: + for name, member in vars(ModelsProvider).items(): + if not callable(member): + continue + if name.startswith("_") and name != "__contains__": + continue + port_parameters = inspect.signature(member).parameters + adapter_parameters = inspect.signature( + getattr(ModelManagerModelsProvider, name) + ).parameters + for key, port_parameter in port_parameters.items(): + if key in ("self", "kwargs"): + continue + assert key in adapter_parameters, f"{name}.{key}" + assert ( + port_parameter.default == adapter_parameters[key].default + ), f"{name}.{key}" + + +def test_adapter_registers_only_for_the_validator_derived_core_models() -> None: + """Registration stays in the blocks (instance_segmentation/v3.py reads the + loaded model's pipeline depth between registering and inferring). The two + exceptions are the core models whose id only exists on the VALIDATED + request - CLIP comparison (pydantic default version) and PP-OCR (validator- + derived id) - and there the order is build -> register -> infer.""" + tree = ast.parse(inspect.getsource(adapter_module)) + registering = {} + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef) or not node.name.startswith("run_"): + continue + events = [] + for inner in ast.walk(node): + if not isinstance(inner, ast.Call): + continue + if isinstance(inner.func, ast.Attribute) and inner.func.attr in ( + "add_model", + "_infer", + ): + events.append((inner.func.attr, inner.lineno)) + elif isinstance(inner.func, ast.Name) and inner.func.id.endswith("Request"): + events.append(("build", inner.lineno)) + if any(kind == "add_model" for kind, _ in events): + registering[node.name] = [ + kind for kind, _ in sorted(events, key=lambda event: event[1]) + ] + assert set(registering) <= {"run_clip_comparison", "run_pp_ocr"}, registering + for name, order in registering.items(): + assert ( + order.index("build") < order.index("add_model") < order.index("_infer") + ), ( + name, + order, + ) + + +def test_action_recognition_block_loads_through_the_adapter() -> None: + """Round-3 defect 4: the injected wrapper must carry Phase 9's loader all + the way to the block. Executes Phase 9's block path through the adapter.""" + from inference.core.workflows.core_steps.common.entities import StepExecutionMode + from inference.core.workflows.core_steps.models.roboflow.action_recognition.v1 import ( + ActionRecognitionModelBlockV1, + ) + + if "model_manager" not in ActionRecognitionModelBlockV1.get_init_parameters(): + pytest.skip("Phase 9 Task 9.9 (block takes model_manager) has not landed") + manager = MagicMock() + block = ActionRecognitionModelBlockV1( + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + model_manager=ModelManagerModelsProvider(manager), + ) + assert ( + block._get_model("cosmos-3-edge") + is manager.load_action_recognition_model.return_value + ) + manager.load_action_recognition_model.assert_called_once_with( + model_id="cosmos-3-edge", api_key="k" + ) diff --git a/tests/workflows/integration_tests/conftest.py b/tests/workflows/integration_tests/conftest.py index 6faeb5bfa6..8c75125e2f 100644 --- a/tests/workflows/integration_tests/conftest.py +++ b/tests/workflows/integration_tests/conftest.py @@ -3,6 +3,9 @@ import pytest from inference.core.env import MAX_ACTIVE_MODELS +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) from inference.core.managers.base import ModelManager from inference.core.managers.decorators.fixed_size_cache import WithFixedSizeCache from inference.core.registries.roboflow import RoboflowModelRegistry @@ -20,7 +23,19 @@ def deep_lab_v3_api_key() -> str: @pytest.fixture(scope="function") -def model_manager() -> ModelManager: +def raw_model_manager() -> ModelManager: + # The decorated server manager - what this fixture returned before Phase 11. + # For INSPECTION only (`raw_model_manager.models()`); never bound to a workflow. model_registry = RoboflowModelRegistry(ROBOFLOW_MODEL_TYPES) model_manager = ModelManager(model_registry=model_registry) return WithFixedSizeCache(model_manager, max_size=MAX_ACTIVE_MODELS) + + +@pytest.fixture(scope="function") +def model_manager(raw_model_manager) -> ModelManagerModelsProvider: + # What the composition roots inject: the adapter over that SAME manager, so a + # test can run a workflow with `model_manager` and inspect + # `raw_model_manager.models()` afterwards. Class-level patches on + # `ModelManager.infer_from_request_sync` / `add_model` still intercept - + # the adapter forwards to the wrapped instance. + return ModelManagerModelsProvider(raw_model_manager) diff --git a/tests/workflows/integration_tests/execution/control_flow_with_side_effects/test_rfdetr_sliced_workflow_main_parity.py b/tests/workflows/integration_tests/execution/control_flow_with_side_effects/test_rfdetr_sliced_workflow_main_parity.py index 179de7e699..bbc6bfe233 100644 --- a/tests/workflows/integration_tests/execution/control_flow_with_side_effects/test_rfdetr_sliced_workflow_main_parity.py +++ b/tests/workflows/integration_tests/execution/control_flow_with_side_effects/test_rfdetr_sliced_workflow_main_parity.py @@ -706,6 +706,9 @@ def do_run( model_id, cleanup_model_bundle = _prepare_local_workflow_model_bundle(repo_path) try: case = WORKFLOW_PARITY_CASES[workflow_case] + from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, + ) from inference.core.managers.base import ModelManager from inference.core.registries.roboflow import RoboflowModelRegistry from inference.core.workflows.core_steps.common.entities import ( @@ -724,7 +727,9 @@ def do_run( execution_engine = ExecutionEngine.init( workflow_definition=workflow_definition, init_parameters={ - "workflows_core.model_manager": model_manager, + "workflows_core.model_manager": ModelManagerModelsProvider( + model_manager + ), "workflows_core.api_key": None, "workflows_core.step_execution_mode": StepExecutionMode.LOCAL, }, diff --git a/tests/workflows/integration_tests/execution/control_flow_with_side_effects/test_workflow_with_control_flow_with_side_effects.py b/tests/workflows/integration_tests/execution/control_flow_with_side_effects/test_workflow_with_control_flow_with_side_effects.py index cb9aafc65b..a6fa52db87 100644 --- a/tests/workflows/integration_tests/execution/control_flow_with_side_effects/test_workflow_with_control_flow_with_side_effects.py +++ b/tests/workflows/integration_tests/execution/control_flow_with_side_effects/test_workflow_with_control_flow_with_side_effects.py @@ -21,6 +21,9 @@ ObjectDetectionPrediction, ) from inference.core.env import ENABLE_TENSOR_DATA_REPRESENTATION +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.fusion.detections_stitch.v1 import ( @@ -214,7 +217,7 @@ def model_manager() -> ModelManager: registry = RoboflowModelRegistry(ROBOFLOW_MODEL_TYPES) manager = ModelManager(model_registry=registry) - return manager + return ModelManagerModelsProvider(manager) def _run_workflow( diff --git a/tests/workflows/integration_tests/execution/test_workflow_dependencies_pre_loading.py b/tests/workflows/integration_tests/execution/test_workflow_dependencies_pre_loading.py index b3535e430d..7580c97d29 100644 --- a/tests/workflows/integration_tests/execution/test_workflow_dependencies_pre_loading.py +++ b/tests/workflows/integration_tests/execution/test_workflow_dependencies_pre_loading.py @@ -39,6 +39,7 @@ def test_pre_loading_of_dependencies_when_static_model_id_used( model_manager: ModelManager, + raw_model_manager: ModelManager, dogs_image: np.ndarray, roboflow_api_key: str, ) -> None: @@ -62,7 +63,7 @@ def test_pre_loading_of_dependencies_when_static_model_id_used( "yolov8n-640" in model_manager ), "Expected declared model to be pre-loaded into model manager at init" assert ( - len(model_manager.models()) == 1 + len(raw_model_manager.models()) == 1 ), "Expected exactly the declared model to be registered at init" # when @@ -110,6 +111,7 @@ def test_pre_loading_of_dependencies_when_static_model_id_used( def test_pre_loading_of_dependencies_when_model_id_fed_by_input_parameter( model_manager: ModelManager, + raw_model_manager: ModelManager, dogs_image: np.ndarray, roboflow_api_key: str, ) -> None: @@ -130,7 +132,7 @@ def test_pre_loading_of_dependencies_when_model_id_fed_by_input_parameter( # then - nothing can be pre-loaded at init, dependency awaits first run assert ( - len(model_manager.models()) == 0 + len(raw_model_manager.models()) == 0 ), "Expected no model to be registered at init when model id is input-fed" assert [ dependency.metadata.model_id diff --git a/tests/workflows/integration_tests/execution/test_workflow_with_sahi.py b/tests/workflows/integration_tests/execution/test_workflow_with_sahi.py index 33d484ea90..ff13787e89 100644 --- a/tests/workflows/integration_tests/execution/test_workflow_with_sahi.py +++ b/tests/workflows/integration_tests/execution/test_workflow_with_sahi.py @@ -641,6 +641,7 @@ def test_sahi_workflow_with_serialization( @_NUMPY_ONLY def test_sahi_workflow_provides_the_same_result_as_sahi_applied_directly( model_manager: ModelManager, + raw_model_manager: ModelManager, crowd_image: np.ndarray, ) -> None: """ @@ -666,7 +667,7 @@ def test_sahi_workflow_provides_the_same_result_as_sahi_applied_directly( model_id="yolov8n-640", api_key=None, ) - model = model_manager.models()["yolov8n-640"] + model = raw_model_manager.models()["yolov8n-640"] def slicer_callback(image_slice: np.ndarray): inference_image = {"type": "numpy_object", "value": image_slice} @@ -739,6 +740,7 @@ def slicer_callback(image_slice: np.ndarray): @_TENSOR_ONLY def test_sahi_workflow_provides_the_same_result_as_sahi_applied_directly_tensor_native( model_manager: ModelManager, + raw_model_manager: ModelManager, crowd_image: np.ndarray, image_as_workflow_input, ) -> None: @@ -759,7 +761,7 @@ def test_sahi_workflow_provides_the_same_result_as_sahi_applied_directly_tensor_ model_id="yolov8n-640", api_key=None, ) - model = model_manager.models()["yolov8n-640"] + model = raw_model_manager.models()["yolov8n-640"] def slicer_callback(image_slice: np.ndarray): inference_image = {"type": "numpy_object", "value": image_slice} @@ -849,6 +851,7 @@ def slicer_callback(image_slice: np.ndarray): @_TENSOR_ONLY def test_sahi_workflow_provides_the_same_result_as_sahi_applied_directly_with_tensor_input( model_manager: ModelManager, + raw_model_manager: ModelManager, crowd_image: np.ndarray, ) -> None: """Same as test_sahi_workflow_provides_the_same_result_as_sahi_applied_directly_tensor_native, @@ -870,7 +873,7 @@ def test_sahi_workflow_provides_the_same_result_as_sahi_applied_directly_with_te model_id="yolov8n-640", api_key=None, ) - model = model_manager.models()["yolov8n-640"] + model = raw_model_manager.models()["yolov8n-640"] def slicer_callback(image_slice: np.ndarray): inference_image = {"type": "numpy_object", "value": image_slice} diff --git a/tests/workflows/unit_tests/prototypes/test_models_provider.py b/tests/workflows/unit_tests/prototypes/test_models_provider.py index 8a50f4e964..6efb46082e 100644 --- a/tests/workflows/unit_tests/prototypes/test_models_provider.py +++ b/tests/workflows/unit_tests/prototypes/test_models_provider.py @@ -31,8 +31,13 @@ def test_model_manager_is_structurally_compatible_with_the_port() -> None: # the wrong signature. Check that ModelManager can actually be CALLED the # way the port declares, so a signature drift in the server surfaces here # rather than at runtime inside a block. + from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, + ) + for member in REQUIRED_METHODS: assert hasattr(ModelManager, member), member + assert hasattr(ModelManagerModelsProvider, member), member port_add = inspect.signature(ModelsProvider.add_model) real_add = inspect.signature(ModelManager.add_model) for name in ("model_id", "api_key", "model_id_alias"): From 26cb50192f88abcc26f5269489b9cf62b09078e9 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:19:53 +0200 Subject: [PATCH 58/86] test: GET-only request assertions in codec-binding tests; entities purity guard The two codec-binding tests asserted requests_mock.call_count, which the process-global usage collector's POSTs make order-dependent (unmasked once the stream-pipeline tests run to completion); assert the list of GET urls instead. Adds an AST guard that action_recognition/entities.py imports nothing from `inference.` (Task 11.5 review minor). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../interfaces/test_image_codec_binding.py | 18 +++++++++++------- .../test_action_recognition_entities.py | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py b/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py index 2446a6a1c6..96cce2fa0b 100644 --- a/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py +++ b/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py @@ -133,6 +133,10 @@ def _decode_serialised_image(payload: dict) -> np.ndarray: ) +def _get_urls(requests_mock: Mocker) -> list: + return [r.url for r in requests_mock.request_history if r.method == "GET"] + + def _assert_one_object_on_both_paths(captured: dict, expected) -> None: assert len(captured["init_parameters"]) == 1, "expected exactly one engine init" # Path A: the SAME dict object the root handed to the engine carries the codec... @@ -372,10 +376,9 @@ def test_http_run_route_fetches_an_allow_listed_url_input_through_the_bound_code ) assert response.status_code == 200, response.text - # Count only the image fetch: the process-global usage collector may flush - # its own POSTs through the same mocked transport (order-dependent). - image_fetches = [r for r in requests_mock.request_history if r.url == url] - assert len(image_fetches) == 1 + # GETs only: the process-global usage collector may POST through the same + # mocked transport (order-dependent). + assert _get_urls(requests_mock) == [url] _assert_one_object_on_both_paths(forwarded_engine_init, GUARDED_IMAGE_CODEC) blurred = _decode_serialised_image(response.json()["outputs"][0]["blurred"]) assert blurred.shape == (16, 24, 3) @@ -567,7 +570,9 @@ def load_image(self, value, disable_preproc_auto_orient=False): assert isinstance(image, WorkflowImageData) assert image.numpy_image.shape == (16, 24, 3) # cached pixels, no reload assert recorder.calls == [("fetch_url", url)] - assert requests_mock.call_count == 1 + # GETs only: the process-global usage collector may POST through the same + # mocked transport (order-dependent). + assert _get_urls(requests_mock) == [url] # Stage 2 - Path B: the downstream block re-loads THAT image from its # inference-format dict through the process registry - the same object. @@ -591,8 +596,7 @@ def load_image(self, value, disable_preproc_auto_orient=False): "image": {"width": 24, "height": 16}, } assert recorder.calls == [("fetch_url", url), ("load_image", payload)] - assert requests_mock.call_count == 2 - assert {request.url for request in requests_mock.request_history} == {url} + assert _get_urls(requests_mock) == [url, url] client.chat.completions.create.assert_called_once() diff --git a/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_action_recognition_entities.py b/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_action_recognition_entities.py index 9604accc00..dc6adc2e6d 100644 --- a/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_action_recognition_entities.py +++ b/tests/workflows/unit_tests/core_steps/models/roboflow/action_recognition/test_action_recognition_entities.py @@ -5,6 +5,21 @@ # -> parents[7] is the repo root REPO_ROOT = Path(__file__).resolve().parents[7] BASE = REPO_ROOT / "inference/core/workflows/execution_engine/entities/base.py" +ENTITIES = ( + REPO_ROOT + / "inference/core/workflows/core_steps/models/roboflow/action_recognition/entities.py" +) + + +def test_entities_module_imports_nothing_from_the_server() -> None: + tree = ast.parse(ENTITIES.read_text(encoding="utf-8")) + imported = [ + node.module if isinstance(node, ast.ImportFrom) else alias.name + for node in ast.walk(tree) + if isinstance(node, (ast.Import, ast.ImportFrom)) + for alias in (node.names if isinstance(node, ast.Import) else [None]) + ] + assert not [m for m in imported if m and m.startswith("inference.")], imported def test_base_does_not_import_server_action_recognition_entities() -> None: From 7cb4843d774748bfdb8894f9791ae4a59a9a170b Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:54:11 +0200 Subject: [PATCH 59/86] refactor(workflows): detection/classification/keypoints/semantic-seg run through the plain-argument port Adds run_object_detection, run_classification, run_keypoints_detection and run_semantic_segmentation to the ModelsProvider port and its ModelManager adapter, and switches the 14 object-detection / classification / keypoint / semantic-segmentation blocks to call them instead of building inference.core.entities.requests objects inline. Introduces the UNSET sentinel (and the adapter's _passed helper) so an argument a block never sets is omitted from the request while an explicit None is still forwarded and still raises ValidationError where it does today. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../interfaces/workflows_models_provider.py | 123 +++++++++++ .../models/roboflow/keypoint_detection/v1.py | 28 +-- .../models/roboflow/keypoint_detection/v2.py | 28 +-- .../models/roboflow/keypoint_detection/v3.py | 28 +-- .../roboflow/multi_class_classification/v1.py | 23 +- .../roboflow/multi_class_classification/v2.py | 23 +- .../roboflow/multi_class_classification/v3.py | 23 +- .../roboflow/multi_label_classification/v1.py | 23 +- .../roboflow/multi_label_classification/v2.py | 24 +-- .../roboflow/multi_label_classification/v3.py | 24 +-- .../models/roboflow/object_detection/v1.py | 26 +-- .../models/roboflow/object_detection/v2.py | 26 +-- .../models/roboflow/object_detection/v3.py | 26 +-- .../roboflow/semantic_segmentation/v1.py | 24 +-- .../roboflow/semantic_segmentation/v2.py | 24 +-- .../workflows/prototypes/models_provider.py | 72 +++++++ .../test_workflows_models_provider.py | 198 ++++++++++++++++++ .../test_v1_local_execution.py | 71 +++++++ .../unit_tests/decontamination_baseline.txt | 16 +- 19 files changed, 563 insertions(+), 267 deletions(-) create mode 100644 tests/workflows/unit_tests/core_steps/models/roboflow/object_detection/test_v1_local_execution.py diff --git a/inference/core/interfaces/workflows_models_provider.py b/inference/core/interfaces/workflows_models_provider.py index 6b63d46ee3..0fccfbc74c 100644 --- a/inference/core/interfaces/workflows_models_provider.py +++ b/inference/core/interfaces/workflows_models_provider.py @@ -30,11 +30,27 @@ from typing import Any, Dict, List, Optional, Union +from inference.core.entities.requests.inference import ( + ClassificationInferenceRequest, + KeypointsDetectionInferenceRequest, + ObjectDetectionInferenceRequest, + SemanticSegmentationInferenceRequest, +) from inference.core.managers.base import ModelManager +from inference.core.workflows.prototypes.models_provider import UNSET, _Unset _WORKFLOW_SOURCE = "workflow-execution" +def _passed(**arguments: Any) -> Dict[str, Any]: + """The request keywords the caller actually passed: drops UNSET, keeps None.""" + return { + name: value + for name, value in arguments.items() + if not isinstance(value, _Unset) + } + + class ModelManagerModelsProvider: """Implements `inference.core.workflows.prototypes.models_provider.ModelsProvider`.""" @@ -70,6 +86,113 @@ def infer_from_request_sync( model_id=model_id, request=request, **kwargs ) + def run_object_detection( + self, + model_id: str, + images: List[Any], + api_key: Optional[str] = None, + class_agnostic_nms: Optional[bool] = None, + class_filter: Optional[List[str]] = None, + confidence: Optional[Union[float, str]] = None, + iou_threshold: Optional[float] = None, + max_detections: Optional[int] = None, + max_candidates: Optional[int] = None, + disable_active_learning: Optional[bool] = None, + active_learning_target_dataset: Optional[str] = None, + ) -> List[dict]: + request = ObjectDetectionInferenceRequest( + api_key=api_key, + model_id=model_id, + image=images, + disable_active_learning=disable_active_learning, + active_learning_target_dataset=active_learning_target_dataset, + class_agnostic_nms=class_agnostic_nms, + class_filter=class_filter, + confidence=confidence, + iou_threshold=iou_threshold, + max_detections=max_detections, + max_candidates=max_candidates, + source=_WORKFLOW_SOURCE, + ) + return self._dump(self._infer(model_id=model_id, request=request)) + + def run_classification( + self, + model_id: str, + images: List[Any], + api_key: Optional[str] = None, + confidence: Optional[Union[float, str]] = None, + disable_active_learning: Optional[bool] = None, + active_learning_target_dataset: Optional[str] = None, + inference_kwargs: Optional[Dict[str, Any]] = None, + ) -> List[dict]: + request = ClassificationInferenceRequest( + api_key=api_key, + model_id=model_id, + image=images, + confidence=confidence, + disable_active_learning=disable_active_learning, + source=_WORKFLOW_SOURCE, + active_learning_target_dataset=active_learning_target_dataset, + ) + return self._dump( + self._infer(model_id=model_id, request=request, **(inference_kwargs or {})) + ) + + def run_keypoints_detection( + self, + model_id: str, + images: List[Any], + api_key: Optional[str] = None, + class_agnostic_nms: Optional[bool] = None, + class_filter: Optional[List[str]] = None, + confidence: Optional[Union[float, str]] = None, + iou_threshold: Optional[float] = None, + max_detections: Optional[int] = None, + max_candidates: Optional[int] = None, + keypoint_confidence: Optional[float] = None, + disable_active_learning: Optional[bool] = None, + active_learning_target_dataset: Optional[str] = None, + ) -> List[dict]: + request = KeypointsDetectionInferenceRequest( + api_key=api_key, + model_id=model_id, + image=images, + disable_active_learning=disable_active_learning, + active_learning_target_dataset=active_learning_target_dataset, + class_agnostic_nms=class_agnostic_nms, + class_filter=class_filter, + confidence=confidence, + iou_threshold=iou_threshold, + max_detections=max_detections, + max_candidates=max_candidates, + keypoint_confidence=keypoint_confidence, + source=_WORKFLOW_SOURCE, + ) + return self._dump(self._infer(model_id=model_id, request=request)) + + def run_semantic_segmentation( + self, + model_id: str, + images: List[Any], + api_key: Optional[str] = None, + confidence: Union[float, str, None, _Unset] = UNSET, + response_mask_format: str = "base64_png", + ) -> List[dict]: + # UNSET means "v1, which never sets confidence" -> omitted, so the + # pydantic default applies. An explicit None means "v2 resolved its + # manifest to None" -> forwarded, so the ValidationError it raises today + # still happens. Collapsing the two would be a silent behaviour change. + request = SemanticSegmentationInferenceRequest( + api_key=api_key, + model_id=model_id, + image=images, + response_mask_format=response_mask_format, + source=_WORKFLOW_SOURCE, + **_passed(confidence=confidence), + ) + return self._dump(self._infer(model_id=model_id, request=request)) + def run_tensor_native_inference(self, model_id: str, **kwargs: Any) -> Any: return self._model_manager.run_tensor_native_inference( model_id=model_id, **kwargs diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py index ebbdee5641..e96ae03ce7 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py @@ -2,9 +2,6 @@ from pydantic import ConfigDict, Field, PositiveInt -from inference.core.entities.requests.inference import ( - KeypointsDetectionInferenceRequest, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( add_inference_keypoints_to_sv_detections, @@ -268,12 +265,14 @@ def run_locally( active_learning_target_dataset: Optional[str], ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = KeypointsDetectionInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_keypoints_detection( model_id=model_id, - image=inference_images, - disable_active_learning=disable_active_learning, - active_learning_target_dataset=active_learning_target_dataset, + images=inference_images, + api_key=self._api_key, class_agnostic_nms=class_agnostic_nms, class_filter=class_filter, confidence=confidence, @@ -281,20 +280,9 @@ def run_locally( max_detections=max_detections, max_candidates=max_candidates, keypoint_confidence=keypoint_confidence, - source="workflow-execution", - ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request + disable_active_learning=disable_active_learning, + active_learning_target_dataset=active_learning_target_dataset, ) - if not isinstance(predictions, list): - predictions = [predictions] - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] return self._post_process_result( images=images, predictions=predictions, diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py index 1c2611f776..e35ba6e6ac 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py @@ -2,9 +2,6 @@ from pydantic import ConfigDict, Field, PositiveInt -from inference.core.entities.requests.inference import ( - KeypointsDetectionInferenceRequest, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( add_inference_keypoints_to_sv_detections, @@ -265,12 +262,14 @@ def run_locally( active_learning_target_dataset: Optional[str], ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = KeypointsDetectionInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_keypoints_detection( model_id=model_id, - image=inference_images, - disable_active_learning=disable_active_learning, - active_learning_target_dataset=active_learning_target_dataset, + images=inference_images, + api_key=self._api_key, class_agnostic_nms=class_agnostic_nms, class_filter=class_filter, confidence=confidence, @@ -278,20 +277,9 @@ def run_locally( max_detections=max_detections, max_candidates=max_candidates, keypoint_confidence=keypoint_confidence, - source="workflow-execution", - ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request + disable_active_learning=disable_active_learning, + active_learning_target_dataset=active_learning_target_dataset, ) - if not isinstance(predictions, list): - predictions = [predictions] - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] return self._post_process_result( images=images, predictions=predictions, diff --git a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py index 4eab5414ef..3f70b11994 100644 --- a/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py @@ -2,9 +2,6 @@ from pydantic import ConfigDict, Field, PositiveInt, model_validator -from inference.core.entities.requests.inference import ( - KeypointsDetectionInferenceRequest, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( add_inference_keypoints_to_sv_detections, @@ -304,12 +301,14 @@ def run_locally( active_learning_target_dataset: Optional[str], ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = KeypointsDetectionInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_keypoints_detection( model_id=model_id, - image=inference_images, - disable_active_learning=disable_active_learning, - active_learning_target_dataset=active_learning_target_dataset, + images=inference_images, + api_key=self._api_key, class_agnostic_nms=class_agnostic_nms, class_filter=class_filter, confidence=confidence, @@ -317,20 +316,9 @@ def run_locally( max_detections=max_detections, max_candidates=max_candidates, keypoint_confidence=keypoint_confidence, - source="workflow-execution", - ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request + disable_active_learning=disable_active_learning, + active_learning_target_dataset=active_learning_target_dataset, ) - if not isinstance(predictions, list): - predictions = [predictions] - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] return self._post_process_result( images=images, predictions=predictions, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py index a767bb1876..84ad8cff2d 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py @@ -2,7 +2,6 @@ from pydantic import ConfigDict, Field -from inference.core.entities.requests.inference import ClassificationInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.environment import ( @@ -191,28 +190,18 @@ def run_locally( active_learning_target_dataset: Optional[str], ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = ClassificationInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_classification( model_id=model_id, - image=inference_images, + images=inference_images, + api_key=self._api_key, confidence=confidence, disable_active_learning=disable_active_learning, - source="workflow-execution", active_learning_target_dataset=active_learning_target_dataset, ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request - ) - if isinstance(predictions, list): - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] - else: - predictions = [predictions.model_dump(by_alias=True, exclude_none=True)] return self._post_process_result( predictions=predictions, images=images, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py index b45f4b0d6a..f6228945c4 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py @@ -2,7 +2,6 @@ from pydantic import ConfigDict, Field -from inference.core.entities.requests.inference import ClassificationInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.environment import ( @@ -195,28 +194,18 @@ def run_locally( active_learning_target_dataset: Optional[str], ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = ClassificationInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_classification( model_id=model_id, - image=inference_images, + images=inference_images, + api_key=self._api_key, confidence=confidence, disable_active_learning=disable_active_learning, - source="workflow-execution", active_learning_target_dataset=active_learning_target_dataset, ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request - ) - if isinstance(predictions, list): - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] - else: - predictions = [predictions.model_dump(by_alias=True, exclude_none=True)] return self._post_process_result( predictions=predictions, images=images, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py index d3521285bf..9baeeb1857 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py @@ -2,7 +2,6 @@ from pydantic import ConfigDict, Field, model_validator -from inference.core.entities.requests.inference import ClassificationInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.environment import ( @@ -239,28 +238,18 @@ def run_locally( active_learning_target_dataset: Optional[str], ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = ClassificationInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_classification( model_id=model_id, - image=inference_images, + images=inference_images, + api_key=self._api_key, confidence=confidence, disable_active_learning=disable_active_learning, - source="workflow-execution", active_learning_target_dataset=active_learning_target_dataset, ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request - ) - if isinstance(predictions, list): - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] - else: - predictions = [predictions.model_dump(by_alias=True, exclude_none=True)] return self._post_process_result( predictions=predictions, images=images, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py index f385dde9fa..bfa52a7b54 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py @@ -2,7 +2,6 @@ from pydantic import ConfigDict, Field -from inference.core.entities.requests.inference import ClassificationInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.environment import ( @@ -197,28 +196,18 @@ def run_locally( active_learning_target_dataset: Optional[str], ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = ClassificationInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_classification( model_id=model_id, - image=inference_images, + images=inference_images, + api_key=self._api_key, confidence=confidence, disable_active_learning=disable_active_learning, - source="workflow-execution", active_learning_target_dataset=active_learning_target_dataset, ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request - ) - if isinstance(predictions, list): - predictions = [ - e.dict(by_alias=True, exclude_none=True) for e in predictions - ] - else: - predictions = [predictions.dict(by_alias=True, exclude_none=True)] return self._post_process_result( predictions=predictions, images=images, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py index 196ff09eea..7dc727eda8 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py @@ -2,7 +2,6 @@ from pydantic import ConfigDict, Field -from inference.core.entities.requests.inference import ClassificationInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.environment import ( @@ -194,28 +193,19 @@ def run_locally( active_learning_target_dataset: Optional[str], ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = ClassificationInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_classification( model_id=model_id, - image=inference_images, + images=inference_images, + api_key=self._api_key, confidence=confidence, disable_active_learning=disable_active_learning, - source="workflow-execution", active_learning_target_dataset=active_learning_target_dataset, + inference_kwargs={"confidence": confidence}, ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request, confidence=confidence - ) - if isinstance(predictions, list): - predictions = [ - e.dict(by_alias=True, exclude_none=True) for e in predictions - ] - else: - predictions = [predictions.dict(by_alias=True, exclude_none=True)] return self._post_process_result( predictions=predictions, images=images, diff --git a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py index 49856428c7..7d304843b1 100644 --- a/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py @@ -2,7 +2,6 @@ from pydantic import ConfigDict, Field, model_validator -from inference.core.entities.requests.inference import ClassificationInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import attach_prediction_type_info from inference.core.workflows.environment import ( @@ -239,28 +238,19 @@ def run_locally( active_learning_target_dataset: Optional[str], ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = ClassificationInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_classification( model_id=model_id, - image=inference_images, + images=inference_images, + api_key=self._api_key, confidence=confidence, disable_active_learning=disable_active_learning, - source="workflow-execution", active_learning_target_dataset=active_learning_target_dataset, + inference_kwargs={"confidence": confidence}, ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request, confidence=confidence - ) - if isinstance(predictions, list): - predictions = [ - e.dict(by_alias=True, exclude_none=True) for e in predictions - ] - else: - predictions = [predictions.dict(by_alias=True, exclude_none=True)] return self._post_process_result( predictions=predictions, images=images, diff --git a/inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py b/inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py index 1a563cc79a..3f7de4cfd8 100644 --- a/inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py @@ -2,7 +2,6 @@ from pydantic import ConfigDict, Field, PositiveInt -from inference.core.entities.requests.inference import ObjectDetectionInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -254,32 +253,23 @@ def run_locally( active_learning_target_dataset: Optional[str], ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = ObjectDetectionInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_object_detection( model_id=model_id, - image=inference_images, - disable_active_learning=disable_active_learning, - active_learning_target_dataset=active_learning_target_dataset, + images=inference_images, + api_key=self._api_key, class_agnostic_nms=class_agnostic_nms, class_filter=class_filter, confidence=confidence, iou_threshold=iou_threshold, max_detections=max_detections, max_candidates=max_candidates, - source="workflow-execution", - ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request + disable_active_learning=disable_active_learning, + active_learning_target_dataset=active_learning_target_dataset, ) - if not isinstance(predictions, list): - predictions = [predictions] - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] return self._post_process_result( images=images, predictions=predictions, diff --git a/inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py b/inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py index 9b035327db..c64e4789ce 100644 --- a/inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py @@ -2,7 +2,6 @@ from pydantic import ConfigDict, Field, PositiveInt -from inference.core.entities.requests.inference import ObjectDetectionInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -251,32 +250,23 @@ def run_locally( active_learning_target_dataset: Optional[str], ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = ObjectDetectionInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_object_detection( model_id=model_id, - image=inference_images, - disable_active_learning=disable_active_learning, - active_learning_target_dataset=active_learning_target_dataset, + images=inference_images, + api_key=self._api_key, class_agnostic_nms=class_agnostic_nms, class_filter=class_filter, confidence=confidence, iou_threshold=iou_threshold, max_detections=max_detections, max_candidates=max_candidates, - source="workflow-execution", - ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request + disable_active_learning=disable_active_learning, + active_learning_target_dataset=active_learning_target_dataset, ) - if not isinstance(predictions, list): - predictions = [predictions] - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] return self._post_process_result( images=images, predictions=predictions, diff --git a/inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py b/inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py index bd5db14566..250dae7537 100644 --- a/inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py @@ -2,7 +2,6 @@ from pydantic import ConfigDict, Field, PositiveInt, model_validator -from inference.core.entities.requests.inference import ObjectDetectionInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -293,32 +292,23 @@ def run_locally( active_learning_target_dataset: Optional[str], ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = ObjectDetectionInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_object_detection( model_id=model_id, - image=inference_images, - disable_active_learning=disable_active_learning, - active_learning_target_dataset=active_learning_target_dataset, + images=inference_images, + api_key=self._api_key, class_agnostic_nms=class_agnostic_nms, class_filter=class_filter, confidence=confidence, iou_threshold=iou_threshold, max_detections=max_detections, max_candidates=max_candidates, - source="workflow-execution", - ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request + disable_active_learning=disable_active_learning, + active_learning_target_dataset=active_learning_target_dataset, ) - if not isinstance(predictions, list): - predictions = [predictions] - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] return self._post_process_result( images=images, predictions=predictions, diff --git a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py index 2f89f71512..e40916327c 100644 --- a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py @@ -8,9 +8,6 @@ import supervision as sv from pydantic import ConfigDict -from inference.core.entities.requests.inference import ( - SemanticSegmentationInferenceRequest, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.environment import ( HOSTED_SEMANTIC_SEGMENTATION_URL, @@ -155,27 +152,18 @@ def run_locally( model_id: str, ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = SemanticSegmentationInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_semantic_segmentation( model_id=model_id, - image=inference_images, + images=inference_images, + api_key=self._api_key, # In-process call: raw numpy masks skip a full-resolution PNG # encode/decode round-trip between the model and this block. response_mask_format="numpy", - source="workflow-execution", - ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request ) - if not isinstance(predictions, list): - predictions = [predictions] - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] return self._post_process_result(predictions=predictions, model_id=model_id) def run_remotely( diff --git a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py index 760961d4a3..ee21e1ab08 100644 --- a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py @@ -8,9 +8,6 @@ import supervision as sv from pydantic import ConfigDict, Field, model_validator -from inference.core.entities.requests.inference import ( - SemanticSegmentationInferenceRequest, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.environment import ( HOSTED_SEMANTIC_SEGMENTATION_URL, @@ -213,28 +210,19 @@ def run_locally( confidence: Union[None, float, Literal["best", "default"]], ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = SemanticSegmentationInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_semantic_segmentation( model_id=model_id, - image=inference_images, + images=inference_images, + api_key=self._api_key, confidence=confidence, # In-process call: raw numpy masks skip a full-resolution PNG # encode/decode round-trip between the model and this block. response_mask_format="numpy", - source="workflow-execution", - ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request ) - if not isinstance(predictions, list): - predictions = [predictions] - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] return self._post_process_result(predictions=predictions, model_id=model_id) def run_remotely( diff --git a/inference/core/workflows/prototypes/models_provider.py b/inference/core/workflows/prototypes/models_provider.py index e756707129..5f47cdeea4 100644 --- a/inference/core/workflows/prototypes/models_provider.py +++ b/inference/core/workflows/prototypes/models_provider.py @@ -1,6 +1,27 @@ from typing import Any, Dict, List, Optional, Protocol, Union +class _Unset: + """Sentinel type: 'the caller did not pass this argument at all'. + + Distinct from `None`: the adapter forwards `None` into the request exactly + as the block used to, so a request field that rejects `None` + (`multimask_output`, `enable_thinking`, `confidence`) or treats it + differently from its default (`sam2_version_id`, `output_prob_thresh`, + `enforce_dense_masks_in_inference_models`, `clip_version_id`) behaves as + before. Every optional request argument of a `run_*` method that a block + passes only sometimes defaults to UNSET. + """ + + __slots__ = () + + def __repr__(self) -> str: # pragma: no cover - debugging aid + return "UNSET" + + +UNSET = _Unset() + + class ModelsProvider(Protocol): """The port through which Workflows reach models. @@ -47,6 +68,57 @@ def infer_from_request_sync( self, model_id: str, request: Any, **kwargs: Any ) -> Any: ... + def run_object_detection( + self, + model_id: str, + images: List[Any], + api_key: Optional[str] = None, + class_agnostic_nms: Optional[bool] = None, + class_filter: Optional[List[str]] = None, + confidence: Optional[Union[float, str]] = None, + iou_threshold: Optional[float] = None, + max_detections: Optional[int] = None, + max_candidates: Optional[int] = None, + disable_active_learning: Optional[bool] = None, + active_learning_target_dataset: Optional[str] = None, + ) -> List[dict]: ... + + def run_classification( + self, + model_id: str, + images: List[Any], + api_key: Optional[str] = None, + confidence: Optional[Union[float, str]] = None, + disable_active_learning: Optional[bool] = None, + active_learning_target_dataset: Optional[str] = None, + inference_kwargs: Optional[Dict[str, Any]] = None, + ) -> List[dict]: ... + + def run_keypoints_detection( + self, + model_id: str, + images: List[Any], + api_key: Optional[str] = None, + class_agnostic_nms: Optional[bool] = None, + class_filter: Optional[List[str]] = None, + confidence: Optional[Union[float, str]] = None, + iou_threshold: Optional[float] = None, + max_detections: Optional[int] = None, + max_candidates: Optional[int] = None, + keypoint_confidence: Optional[float] = None, + disable_active_learning: Optional[bool] = None, + active_learning_target_dataset: Optional[str] = None, + ) -> List[dict]: ... + + def run_semantic_segmentation( + self, + model_id: str, + images: List[Any], + api_key: Optional[str] = None, + confidence: Union[float, str, None, _Unset] = UNSET, + response_mask_format: str = "base64_png", + ) -> List[dict]: ... + def run_tensor_native_inference(self, model_id: str, **kwargs: Any) -> Any: ... def get_class_names(self, model_id: str) -> List[str]: ... diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py index 568194148b..eaddde0cde 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py @@ -186,3 +186,201 @@ def test_action_recognition_block_loads_through_the_adapter() -> None: manager.load_action_recognition_model.assert_called_once_with( model_id="cosmos-3-edge", api_key="k" ) + + +from inference.core.entities.requests.inference import ( + ClassificationInferenceRequest, + KeypointsDetectionInferenceRequest, + ObjectDetectionInferenceRequest, + SemanticSegmentationInferenceRequest, +) + + +def test_run_object_detection_builds_the_request_the_block_used_to_build() -> None: + response = empty_detection_response() + manager = manager_returning(response) + provider = ModelManagerModelsProvider(manager) + + result = provider.run_object_detection( + model_id="m/1", + images=IMAGES, + api_key="k", + class_agnostic_nms=True, + class_filter=["cat"], + confidence=0.6, + iou_threshold=0.4, + max_detections=10, + max_candidates=100, + disable_active_learning=True, + active_learning_target_dataset="ds", + ) + + manager.add_model.assert_not_called() # registration stays in the block + request = captured_request(manager) + expected = ObjectDetectionInferenceRequest( + api_key="k", + model_id="m/1", + image=IMAGES, + disable_active_learning=True, + active_learning_target_dataset="ds", + class_agnostic_nms=True, + class_filter=["cat"], + confidence=0.6, + iou_threshold=0.4, + max_detections=10, + max_candidates=100, + source="workflow-execution", + ) + assert isinstance(request, ObjectDetectionInferenceRequest) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert result == [response.model_dump(by_alias=True, exclude_none=True)] + + +def test_run_object_detection_accepts_a_symbolic_confidence() -> None: + manager = manager_returning(empty_detection_response()) + ModelManagerModelsProvider(manager).run_object_detection( + model_id="m/1", images=IMAGES, api_key="k", confidence="best" + ) + assert captured_request(manager).confidence == "best" + + +def test_run_classification_builds_the_request_the_block_used_to_build() -> None: + from inference.core.entities.responses.inference import ( + ClassificationInferenceResponse, + ) + + response = ClassificationInferenceResponse( + image=InferenceResponseImage(width=10, height=20), + predictions=[], + top="cat", + confidence=0.9, + ) + manager = manager_returning(response) + ModelManagerModelsProvider(manager).run_classification( + model_id="m/1", + images=IMAGES, + api_key="k", + confidence=0.7, + disable_active_learning=False, + active_learning_target_dataset=None, + ) + request = captured_request(manager) + expected = ClassificationInferenceRequest( + api_key="k", + model_id="m/1", + image=IMAGES, + confidence=0.7, + disable_active_learning=False, + source="workflow-execution", + active_learning_target_dataset=None, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_run_keypoints_detection_builds_the_request_the_block_used_to_build() -> None: + manager = manager_returning(empty_detection_response()) + ModelManagerModelsProvider(manager).run_keypoints_detection( + model_id="m/1", + images=IMAGES, + api_key="k", + class_agnostic_nms=False, + class_filter=None, + confidence=0.4, + iou_threshold=0.3, + max_detections=300, + max_candidates=3000, + keypoint_confidence=0.5, + disable_active_learning=False, + active_learning_target_dataset=None, + ) + request = captured_request(manager) + expected = KeypointsDetectionInferenceRequest( + api_key="k", + model_id="m/1", + image=IMAGES, + disable_active_learning=False, + active_learning_target_dataset=None, + class_agnostic_nms=False, + class_filter=None, + confidence=0.4, + iou_threshold=0.3, + max_detections=300, + max_candidates=3000, + keypoint_confidence=0.5, + source="workflow-execution", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_run_semantic_segmentation_keeps_the_numpy_fast_path() -> None: + manager = manager_returning(empty_detection_response()) + ModelManagerModelsProvider(manager).run_semantic_segmentation( + model_id="m/1", images=IMAGES, api_key="k", response_mask_format="numpy" + ) + request = captured_request(manager) + assert isinstance(request, SemanticSegmentationInferenceRequest) + assert request.response_mask_format == "numpy" + assert request.source == "workflow-execution" + + +def test_run_semantic_segmentation_omits_confidence_when_never_passed() -> None: + manager = manager_returning(empty_detection_response()) + ModelManagerModelsProvider(manager).run_semantic_segmentation( + model_id="m/1", images=IMAGES, api_key="k" + ) + request = captured_request(manager) + # v1 never sets it; the pydantic default must survive, not become None. + assert ( + request.confidence + == SemanticSegmentationInferenceRequest( + api_key="k", model_id="m/1", image=IMAGES + ).confidence + ) + + +def test_run_semantic_segmentation_still_rejects_an_explicit_none_confidence() -> None: + """Round-2 defect 5: v2 forwards its manifest value, which can be None, and + that raises today. An UNSET sentinel keeps 'not passed' and 'passed None' + distinct.""" + import pytest + from pydantic import ValidationError + + manager = manager_returning(empty_detection_response()) + with pytest.raises(ValidationError): + ModelManagerModelsProvider(manager).run_semantic_segmentation( + model_id="m/1", images=IMAGES, api_key="k", confidence=None + ) + manager.infer_from_request_sync.assert_not_called() + + +def test_run_classification_forwards_extra_inference_kwargs() -> None: + """multi_label v2/v3 pass `confidence` to the model call as well as into the + request; the port carries it as `inference_kwargs`.""" + from inference.core.entities.responses.inference import ( + ClassificationInferenceResponse, + ) + + manager = manager_returning( + ClassificationInferenceResponse( + image=InferenceResponseImage(width=10, height=20), + predictions=[], + top="cat", + confidence=0.9, + ) + ) + ModelManagerModelsProvider(manager).run_classification( + model_id="m/1", + images=IMAGES, + api_key="k", + confidence=0.7, + inference_kwargs={"confidence": 0.7}, + ) + assert manager.infer_from_request_sync.call_args.kwargs["confidence"] == 0.7 + + +def test_run_methods_normalise_a_single_response_to_a_list() -> None: + response = empty_detection_response() + manager = manager_returning(response) + assert ModelManagerModelsProvider(manager).run_object_detection( + model_id="m/1", images=IMAGES, confidence=0.4 + ) == [response.model_dump(by_alias=True, exclude_none=True)] diff --git a/tests/workflows/unit_tests/core_steps/models/roboflow/object_detection/test_v1_local_execution.py b/tests/workflows/unit_tests/core_steps/models/roboflow/object_detection/test_v1_local_execution.py new file mode 100644 index 0000000000..3215541066 --- /dev/null +++ b/tests/workflows/unit_tests/core_steps/models/roboflow/object_detection/test_v1_local_execution.py @@ -0,0 +1,71 @@ +from unittest.mock import MagicMock + +import numpy as np + +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.models.roboflow.object_detection.v1 import ( + RoboflowObjectDetectionModelBlockV1, +) +from inference.core.workflows.execution_engine.entities.base import ( + Batch, + ImageParentMetadata, + WorkflowImageData, +) + +RAW_PREDICTION = { + "inference_id": "inf-1", + "image": {"width": 100, "height": 200}, + "predictions": [ + { + "x": 50.0, + "y": 100.0, + "width": 20.0, + "height": 40.0, + "confidence": 0.9, + "class": "cat", + "class_id": 1, + "detection_id": "d1", + "parent_id": "p", + } + ], +} + + +def test_object_detection_v1_local_registers_then_infers_and_post_processes() -> None: + model_manager = MagicMock() + model_manager.run_object_detection.return_value = [RAW_PREDICTION] + block = RoboflowObjectDetectionModelBlockV1( + model_manager=model_manager, + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + images = Batch( + content=[ + WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="p"), + numpy_image=np.zeros((200, 100, 3), dtype=np.uint8), + ) + ], + indices=[(0,)], + ) + result = block.run( + images=images, + model_id="m/1", + class_agnostic_nms=False, + class_filter=None, + confidence=0.4, + iou_threshold=0.3, + max_detections=300, + max_candidates=3000, + disable_active_learning=False, + active_learning_target_dataset=None, + ) + model_manager.add_model.assert_called_once_with(model_id="m/1", api_key="k") + call = model_manager.run_object_detection.call_args.kwargs + assert call["model_id"] == "m/1" and call["api_key"] == "k" + assert call["confidence"] == 0.4 and call["iou_threshold"] == 0.3 + assert result[0]["inference_id"] == "inf-1" + detections = result[0]["predictions"] + assert len(detections) == 1 + assert detections.xyxy.tolist() == [[40.0, 80.0, 60.0, 120.0]] + assert detections.confidence.tolist() == [0.9] diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 50e2ff748a..83f4984c6e 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 50 +# Count: 36 inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py inference.core.entities.requests.clip @@ -37,17 +37,3 @@ inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v2.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/keypoint_detection/v3.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v2.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/multi_class_classification/v3.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v2.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/multi_label_classification/v3.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/object_detection/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/object_detection/v2.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/object_detection/v3.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v2.py inference.core.entities.requests.inference From 4f52dcc98ba9a1d0c058317bd536949e562dbb74 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:07:41 +0200 Subject: [PATCH 60/86] =?UTF-8?q?test(workflows):=20block=E2=86=92adapter?= =?UTF-8?q?=20requests=20match=20the=20pre-port=20construction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../test_port_requests_match_legacy.py | 357 ++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 tests/workflows/unit_tests/core_steps/models/roboflow/test_port_requests_match_legacy.py diff --git a/tests/workflows/unit_tests/core_steps/models/roboflow/test_port_requests_match_legacy.py b/tests/workflows/unit_tests/core_steps/models/roboflow/test_port_requests_match_legacy.py new file mode 100644 index 0000000000..131fd4a2b2 --- /dev/null +++ b/tests/workflows/unit_tests/core_steps/models/roboflow/test_port_requests_match_legacy.py @@ -0,0 +1,357 @@ +"""Block -> adapter -> request differential tests for Task 11.8's six block +families. + +Each test drives a block's real `run_locally` through a real +`ModelManagerModelsProvider` wrapping a `MagicMock` `ModelManager`, then +compares the pydantic request the adapter built against the exact request +construction the block used to run inline before Task 11.8 (copied from +`git show 93b644ca7:...//v1.py`, the commit immediately before that +task). `_post_process_result` is stubbed out because these tests only care +about the request that reaches `infer_from_request_sync`, not about parsing +a mocked response into detections/predictions. +""" + +from unittest.mock import MagicMock + +import numpy as np + +from inference.core.entities.requests.inference import ( + ClassificationInferenceRequest, + KeypointsDetectionInferenceRequest, + ObjectDetectionInferenceRequest, + SemanticSegmentationInferenceRequest, +) +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.models.roboflow.keypoint_detection.v1 import ( + RoboflowKeypointDetectionModelBlockV1, +) +from inference.core.workflows.core_steps.models.roboflow.multi_class_classification.v1 import ( + RoboflowClassificationModelBlockV1, +) +from inference.core.workflows.core_steps.models.roboflow.multi_label_classification.v1 import ( + RoboflowMultiLabelClassificationModelBlockV1, +) +from inference.core.workflows.core_steps.models.roboflow.object_detection.v1 import ( + RoboflowObjectDetectionModelBlockV1, +) +from inference.core.workflows.core_steps.models.roboflow.object_detection.v3 import ( + RoboflowObjectDetectionModelBlockV3, +) +from inference.core.workflows.core_steps.models.roboflow.semantic_segmentation.v1 import ( + RoboflowSemanticSegmentationModelBlockV1, +) +from inference.core.workflows.core_steps.models.roboflow.semantic_segmentation.v2 import ( + RoboflowSemanticSegmentationModelBlockV2, +) +from inference.core.workflows.execution_engine.entities.base import ( + Batch, + ImageParentMetadata, + WorkflowImageData, +) + + +def _make_images() -> Batch: + # numpy_preferred=True (what every one of these blocks calls) turns this + # into {"type": "numpy_object", "value": }. Building + # the "expected" request from the SAME Batch/WorkflowImageData instance + # the block runs against keeps `value` the identical object both times + # (WorkflowImageData caches `numpy_image`), so dict/list equality on the + # dumped requests hits Python's identity fast path instead of trying an + # element-wise ndarray `==` (which raises on a >1-element array). + image = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="p"), + numpy_image=np.zeros((20, 10, 3), dtype=np.uint8), + ) + return Batch(content=[image], indices=[(0,)]) + + +def _manager() -> MagicMock: + return MagicMock() + + +def _captured_request(manager: MagicMock): + assert manager.infer_from_request_sync.call_count == 1 + call = manager.infer_from_request_sync.call_args + return call.kwargs["request"] if "request" in call.kwargs else call.args[1] + + +def _assert_registers_before_inferring( + manager: MagicMock, model_id: str, api_key: str +) -> None: + names = [call[0] for call in manager.method_calls] + assert "add_model" in names and "infer_from_request_sync" in names + assert names.index("add_model") < names.index("infer_from_request_sync"), names + manager.add_model.assert_called_once_with(model_id=model_id, api_key=api_key) + + +def test_object_detection_v1_request_matches_the_pre_port_construction() -> None: + manager = _manager() + images = _make_images() + block = RoboflowObjectDetectionModelBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + block._post_process_result = lambda **_: None + + block.run_locally( + images=images, + model_id="m/1", + class_agnostic_nms=True, + class_filter=["cat"], + confidence=0.6, + iou_threshold=0.4, + max_detections=10, + max_candidates=100, + disable_active_learning=True, + active_learning_target_dataset="ds", + ) + + _assert_registers_before_inferring(manager, model_id="m/1", api_key="k") + request = _captured_request(manager) + + # Copied verbatim from `git show 93b644ca7:.../object_detection/v1.py`. + inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] + expected = ObjectDetectionInferenceRequest( + api_key="k", + model_id="m/1", + image=inference_images, + disable_active_learning=True, + active_learning_target_dataset="ds", + class_agnostic_nms=True, + class_filter=["cat"], + confidence=0.6, + iou_threshold=0.4, + max_detections=10, + max_candidates=100, + source="workflow-execution", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_object_detection_v3_request_matches_the_pre_port_construction() -> None: + manager = _manager() + images = _make_images() + block = RoboflowObjectDetectionModelBlockV3( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + block._post_process_result = lambda **_: None + + block.run_locally( + images=images, + model_id="m/1", + class_agnostic_nms=True, + class_filter=["cat"], + confidence=0.6, + iou_threshold=0.4, + max_detections=10, + max_candidates=100, + disable_active_learning=True, + active_learning_target_dataset="ds", + ) + + _assert_registers_before_inferring(manager, model_id="m/1", api_key="k") + request = _captured_request(manager) + + # Copied verbatim from `git show 93b644ca7:.../object_detection/v3.py` + # (identical request construction to v1). + inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] + expected = ObjectDetectionInferenceRequest( + api_key="k", + model_id="m/1", + image=inference_images, + disable_active_learning=True, + active_learning_target_dataset="ds", + class_agnostic_nms=True, + class_filter=["cat"], + confidence=0.6, + iou_threshold=0.4, + max_detections=10, + max_candidates=100, + source="workflow-execution", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_multi_class_classification_v1_request_matches_the_pre_port_construction() -> ( + None +): + manager = _manager() + images = _make_images() + block = RoboflowClassificationModelBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + block._post_process_result = lambda **_: None + + block.run_locally( + images=images, + model_id="m/1", + confidence=0.7, + disable_active_learning=False, + active_learning_target_dataset=None, + ) + + _assert_registers_before_inferring(manager, model_id="m/1", api_key="k") + request = _captured_request(manager) + + # Copied verbatim from `git show 93b644ca7:.../multi_class_classification/v1.py`. + inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] + expected = ClassificationInferenceRequest( + api_key="k", + model_id="m/1", + image=inference_images, + confidence=0.7, + disable_active_learning=False, + source="workflow-execution", + active_learning_target_dataset=None, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_multi_label_classification_v1_request_matches_the_pre_port_construction() -> ( + None +): + manager = _manager() + images = _make_images() + block = RoboflowMultiLabelClassificationModelBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + block._post_process_result = lambda **_: None + + block.run_locally( + images=images, + model_id="m/1", + confidence=0.7, + disable_active_learning=False, + active_learning_target_dataset=None, + ) + + _assert_registers_before_inferring(manager, model_id="m/1", api_key="k") + request = _captured_request(manager) + + # Copied verbatim from `git show 93b644ca7:.../multi_label_classification/v1.py`. + inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] + expected = ClassificationInferenceRequest( + api_key="k", + model_id="m/1", + image=inference_images, + confidence=0.7, + disable_active_learning=False, + source="workflow-execution", + active_learning_target_dataset=None, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_keypoint_detection_v1_request_matches_the_pre_port_construction() -> None: + manager = _manager() + images = _make_images() + block = RoboflowKeypointDetectionModelBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + block._post_process_result = lambda **_: None + + block.run_locally( + images=images, + model_id="m/1", + class_agnostic_nms=False, + class_filter=None, + confidence=0.4, + iou_threshold=0.3, + max_detections=300, + max_candidates=3000, + keypoint_confidence=0.5, + disable_active_learning=False, + active_learning_target_dataset=None, + ) + + _assert_registers_before_inferring(manager, model_id="m/1", api_key="k") + request = _captured_request(manager) + + # Copied verbatim from `git show 93b644ca7:.../keypoint_detection/v1.py`. + inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] + expected = KeypointsDetectionInferenceRequest( + api_key="k", + model_id="m/1", + image=inference_images, + disable_active_learning=False, + active_learning_target_dataset=None, + class_agnostic_nms=False, + class_filter=None, + confidence=0.4, + iou_threshold=0.3, + max_detections=300, + max_candidates=3000, + keypoint_confidence=0.5, + source="workflow-execution", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_semantic_segmentation_v1_request_matches_the_pre_port_construction() -> None: + manager = _manager() + images = _make_images() + block = RoboflowSemanticSegmentationModelBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + block._post_process_result = lambda **_: None + + block.run_locally(images=images, model_id="m/1") + + _assert_registers_before_inferring(manager, model_id="m/1", api_key="k") + request = _captured_request(manager) + + # Copied verbatim from `git show 93b644ca7:.../semantic_segmentation/v1.py` + # - v1 never sets `confidence`. + inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] + expected = SemanticSegmentationInferenceRequest( + api_key="k", + model_id="m/1", + image=inference_images, + response_mask_format="numpy", + source="workflow-execution", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_semantic_segmentation_v2_request_matches_the_pre_port_construction() -> None: + # v2's argument set differs from v1 (it resolves and forwards `confidence`), + # so it gets its own case per the fix-round instructions. + manager = _manager() + images = _make_images() + block = RoboflowSemanticSegmentationModelBlockV2( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + block._post_process_result = lambda **_: None + + block.run_locally(images=images, model_id="m/1", confidence=0.4) + + _assert_registers_before_inferring(manager, model_id="m/1", api_key="k") + request = _captured_request(manager) + + # Copied verbatim from `git show 93b644ca7:.../semantic_segmentation/v2.py`. + inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] + expected = SemanticSegmentationInferenceRequest( + api_key="k", + model_id="m/1", + image=inference_images, + confidence=0.4, + response_mask_format="numpy", + source="workflow-execution", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) From c0c5f77518197a957d05a751c2015f5bfb37456f Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:16:52 +0200 Subject: [PATCH 61/86] test(workflows): multi-label v2/v3 forward the separate confidence keyword unchanged Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../test_port_requests_match_legacy.py | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/tests/workflows/unit_tests/core_steps/models/roboflow/test_port_requests_match_legacy.py b/tests/workflows/unit_tests/core_steps/models/roboflow/test_port_requests_match_legacy.py index 131fd4a2b2..72cbecd4c8 100644 --- a/tests/workflows/unit_tests/core_steps/models/roboflow/test_port_requests_match_legacy.py +++ b/tests/workflows/unit_tests/core_steps/models/roboflow/test_port_requests_match_legacy.py @@ -34,6 +34,12 @@ from inference.core.workflows.core_steps.models.roboflow.multi_label_classification.v1 import ( RoboflowMultiLabelClassificationModelBlockV1, ) +from inference.core.workflows.core_steps.models.roboflow.multi_label_classification.v2 import ( + RoboflowMultiLabelClassificationModelBlockV2, +) +from inference.core.workflows.core_steps.models.roboflow.multi_label_classification.v3 import ( + RoboflowMultiLabelClassificationModelBlockV3, +) from inference.core.workflows.core_steps.models.roboflow.object_detection.v1 import ( RoboflowObjectDetectionModelBlockV1, ) @@ -252,6 +258,97 @@ def test_multi_label_classification_v1_request_matches_the_pre_port_construction assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) +def test_multi_label_classification_v2_forwards_the_separate_confidence_keyword() -> ( + None +): + # Round-2 review finding: v2 passes `confidence` to `infer_from_request_sync` + # as a SEPARATE keyword in addition to the request field - the adapter + # carries this as `inference_kwargs`. Assert both the request and that + # extra keyword match the pre-11.8 call. + manager = _manager() + images = _make_images() + block = RoboflowMultiLabelClassificationModelBlockV2( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + block._post_process_result = lambda **_: None + + block.run_locally( + images=images, + model_id="m/1", + confidence=0.7, + disable_active_learning=False, + active_learning_target_dataset=None, + ) + + _assert_registers_before_inferring(manager, model_id="m/1", api_key="k") + request = _captured_request(manager) + + # Copied verbatim from `git show 93b644ca7:.../multi_label_classification/v2.py`. + inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] + expected = ClassificationInferenceRequest( + api_key="k", + model_id="m/1", + image=inference_images, + confidence=0.7, + disable_active_learning=False, + source="workflow-execution", + active_learning_target_dataset=None, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + # Old code: `infer_from_request_sync(model_id=model_id, request=request, + # confidence=confidence)` - the extra `confidence` keyword, unchanged. + call = manager.infer_from_request_sync.call_args + assert call.kwargs["confidence"] == 0.7 + + +def test_multi_label_classification_v3_forwards_the_separate_confidence_keyword() -> ( + None +): + # Same shape as v2 (round-2 review finding); v3 additionally resolves + # `confidence` from `confidence_mode`/`custom_confidence` in `run()`, but + # `run_locally` (called directly here) takes the resolved `confidence`. + manager = _manager() + images = _make_images() + block = RoboflowMultiLabelClassificationModelBlockV3( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + block._post_process_result = lambda **_: None + + block.run_locally( + images=images, + model_id="m/1", + confidence=0.7, + disable_active_learning=False, + active_learning_target_dataset=None, + ) + + _assert_registers_before_inferring(manager, model_id="m/1", api_key="k") + request = _captured_request(manager) + + # Copied verbatim from `git show 93b644ca7:.../multi_label_classification/v3.py`. + inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] + expected = ClassificationInferenceRequest( + api_key="k", + model_id="m/1", + image=inference_images, + confidence=0.7, + disable_active_learning=False, + source="workflow-execution", + active_learning_target_dataset=None, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + # Old code: `infer_from_request_sync(model_id=model_id, request=request, + # confidence=confidence)` - the extra `confidence` keyword, unchanged. + call = manager.infer_from_request_sync.call_args + assert call.kwargs["confidence"] == 0.7 + + def test_keypoint_detection_v1_request_matches_the_pre_port_construction() -> None: manager = _manager() images = _make_images() From 458e0e7a9dfd3062cb963f8a74f33684f52ef57a Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:48:46 +0200 Subject: [PATCH 62/86] refactor(workflows): instance segmentation runs through the plain-argument port Adds `run_instance_segmentation` to the workflows `ModelsProvider` port and the `ModelManagerModelsProvider` adapter (Task 11.9), including the `InferenceResultsDC` result type for the rfdetr cold-model async stream handoff (`return_raw_responses=True`). Switches the four instance_segmentation blocks (v1, v2, v3, v4) to call it instead of building `InstanceSegmentationInferenceRequest` inline and calling `infer_from_request_sync` directly. v3's registration-before-depth-check ordering is preserved unchanged so a cold model still queues its first stream frame correctly. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../interfaces/workflows_models_provider.py | 58 +++++- .../roboflow/instance_segmentation/v1.py | 28 +-- .../roboflow/instance_segmentation/v2.py | 28 +-- .../roboflow/instance_segmentation/v3.py | 26 ++- .../roboflow/instance_segmentation/v4.py | 28 +-- .../workflows/prototypes/models_provider.py | 32 +++ .../core/interfaces/stream/test_workflows.py | 58 ++++-- .../test_workflows_models_provider.py | 52 +++++ .../test_v3_stream_pipeline.py | 187 ++++++++++++++++++ .../unit_tests/decontamination_baseline.txt | 6 +- 10 files changed, 403 insertions(+), 100 deletions(-) diff --git a/inference/core/interfaces/workflows_models_provider.py b/inference/core/interfaces/workflows_models_provider.py index 0fccfbc74c..0abbcd99ab 100644 --- a/inference/core/interfaces/workflows_models_provider.py +++ b/inference/core/interfaces/workflows_models_provider.py @@ -32,12 +32,17 @@ from inference.core.entities.requests.inference import ( ClassificationInferenceRequest, + InstanceSegmentationInferenceRequest, KeypointsDetectionInferenceRequest, ObjectDetectionInferenceRequest, SemanticSegmentationInferenceRequest, ) from inference.core.managers.base import ModelManager -from inference.core.workflows.prototypes.models_provider import UNSET, _Unset +from inference.core.workflows.prototypes.models_provider import ( + UNSET, + InferenceResultsDC, + _Unset, +) _WORKFLOW_SOURCE = "workflow-execution" @@ -193,6 +198,57 @@ def run_semantic_segmentation( ) return self._dump(self._infer(model_id=model_id, request=request)) + def run_instance_segmentation( + self, + model_id: str, + images: List[Any], + api_key: Optional[str] = None, + class_agnostic_nms: Optional[bool] = None, + class_filter: Optional[List[str]] = None, + confidence: Optional[Union[float, str]] = None, + iou_threshold: Optional[float] = None, + max_detections: Optional[int] = None, + max_candidates: Optional[int] = None, + mask_decode_mode: Optional[str] = None, + tradeoff_factor: Optional[float] = None, + response_mask_format: Union[str, None, _Unset] = UNSET, + enforce_dense_masks_in_inference_models: Union[bool, None, _Unset] = UNSET, + stream_pipeline_context_id: Union[str, None, _Unset] = UNSET, + disable_active_learning: Optional[bool] = None, + active_learning_target_dataset: Optional[str] = None, + return_raw_responses: bool = False, + ) -> Union[List[dict], InferenceResultsDC]: + # The three UNSET-defaulted fields are each set by only some of the + # four block versions. Whatever a version passes - None included - is + # forwarded (v1/v2/v3 pass enforce_dense_masks_in_inference_models even + # when a selector resolved it to None, and the request stored that + # None); what it does not pass is left to the pydantic default. + request = InstanceSegmentationInferenceRequest( + api_key=api_key, + model_id=model_id, + image=images, + disable_active_learning=disable_active_learning, + active_learning_target_dataset=active_learning_target_dataset, + class_agnostic_nms=class_agnostic_nms, + class_filter=class_filter, + confidence=confidence, + iou_threshold=iou_threshold, + max_detections=max_detections, + max_candidates=max_candidates, + mask_decode_mode=mask_decode_mode, + tradeoff_factor=tradeoff_factor, + source=_WORKFLOW_SOURCE, + **_passed( + response_mask_format=response_mask_format, + enforce_dense_masks_in_inference_models=enforce_dense_masks_in_inference_models, + stream_pipeline_context_id=stream_pipeline_context_id, + ), + ) + responses = self._infer(model_id=model_id, request=request) + if return_raw_responses: + return InferenceResultsDC(predictions=[], raw_responses=responses) + return self._dump(responses) + def run_tensor_native_inference(self, model_id: str, **kwargs: Any) -> Any: return self._model_manager.run_tensor_native_inference( model_id=model_id, **kwargs diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py index d226608af4..925103edfe 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py @@ -2,9 +2,6 @@ from pydantic import ConfigDict, Field, PositiveInt -from inference.core.entities.requests.inference import ( - InstanceSegmentationInferenceRequest, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -293,12 +290,14 @@ def run_locally( enforce_dense_masks_in_inference_models: bool, ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = InstanceSegmentationInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_instance_segmentation( model_id=model_id, - image=inference_images, - disable_active_learning=disable_active_learning, - active_learning_target_dataset=active_learning_target_dataset, + images=inference_images, + api_key=self._api_key, class_agnostic_nms=class_agnostic_nms, class_filter=class_filter, confidence=confidence, @@ -307,21 +306,10 @@ def run_locally( max_candidates=max_candidates, mask_decode_mode=mask_decode_mode, tradeoff_factor=tradeoff_factor, - source="workflow-execution", enforce_dense_masks_in_inference_models=enforce_dense_masks_in_inference_models, + disable_active_learning=disable_active_learning, + active_learning_target_dataset=active_learning_target_dataset, ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request - ) - if not isinstance(predictions, list): - predictions = [predictions] - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] return self._post_process_result( images=images, predictions=predictions, diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py index 04db74e928..9456e30e49 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py @@ -2,9 +2,6 @@ from pydantic import ConfigDict, Field, PositiveInt -from inference.core.entities.requests.inference import ( - InstanceSegmentationInferenceRequest, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -291,12 +288,14 @@ def run_locally( enforce_dense_masks_in_inference_models: bool, ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = InstanceSegmentationInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_instance_segmentation( model_id=model_id, - image=inference_images, - disable_active_learning=disable_active_learning, - active_learning_target_dataset=active_learning_target_dataset, + images=inference_images, + api_key=self._api_key, class_agnostic_nms=class_agnostic_nms, class_filter=class_filter, confidence=confidence, @@ -305,21 +304,10 @@ def run_locally( max_candidates=max_candidates, mask_decode_mode=mask_decode_mode, tradeoff_factor=tradeoff_factor, - source="workflow-execution", enforce_dense_masks_in_inference_models=enforce_dense_masks_in_inference_models, + disable_active_learning=disable_active_learning, + active_learning_target_dataset=active_learning_target_dataset, ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request - ) - if not isinstance(predictions, list): - predictions = [predictions] - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] return self._post_process_result( images=images, predictions=predictions, diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py index 9b229c1e1c..3fa53c8ec0 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py @@ -6,9 +6,6 @@ from pydantic import ConfigDict, Field, PositiveInt, model_validator -from inference.core.entities.requests.inference import ( - InstanceSegmentationInferenceRequest, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -370,12 +367,10 @@ def run_locally( ) if self.stream_pipeline_depth() > 0 and len(images) == 1: self._pending_stream_prediction_contexts.append(stream_context) - request = InstanceSegmentationInferenceRequest( - api_key=self._api_key, + results = self._model_manager.run_instance_segmentation( model_id=model_id, - image=inference_images, - disable_active_learning=disable_active_learning, - active_learning_target_dataset=active_learning_target_dataset, + images=inference_images, + api_key=self._api_key, class_agnostic_nms=class_agnostic_nms, class_filter=class_filter, confidence=confidence, @@ -384,15 +379,16 @@ def run_locally( max_candidates=max_candidates, mask_decode_mode=mask_decode_mode, tradeoff_factor=tradeoff_factor, - source="workflow-execution", - stream_pipeline_context_id=stream_context.context_id, enforce_dense_masks_in_inference_models=enforce_dense_masks_in_inference_models, + disable_active_learning=disable_active_learning, + active_learning_target_dataset=active_learning_target_dataset, + stream_pipeline_context_id=stream_context.context_id, + # The rfdetr stream path hands back a `Future` on the response + # object; `_extract_async_response_future` needs the object, not + # its dict form. + return_raw_responses=True, ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request - ) - if not isinstance(predictions, list): - predictions = [predictions] + predictions = results.raw_responses async_response_future = self._extract_async_response_future( predictions=predictions ) diff --git a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py index dbc4c1295b..2107bc5e90 100644 --- a/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py +++ b/inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py @@ -2,9 +2,6 @@ from pydantic import ConfigDict, Field, PositiveInt, model_validator -from inference.core.entities.requests.inference import ( - InstanceSegmentationInferenceRequest, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -328,12 +325,14 @@ def run_locally( active_learning_target_dataset: Optional[str], ) -> BlockResult: inference_images = [i.to_inference_format(numpy_preferred=True) for i in images] - request = InstanceSegmentationInferenceRequest( + self._model_manager.add_model( + model_id=model_id, api_key=self._api_key, + ) + predictions = self._model_manager.run_instance_segmentation( model_id=model_id, - image=inference_images, - disable_active_learning=disable_active_learning, - active_learning_target_dataset=active_learning_target_dataset, + images=inference_images, + api_key=self._api_key, class_agnostic_nms=class_agnostic_nms, class_filter=class_filter, confidence=confidence, @@ -342,21 +341,10 @@ def run_locally( max_candidates=max_candidates, mask_decode_mode=mask_decode_mode, tradeoff_factor=tradeoff_factor, - source="workflow-execution", response_mask_format="rle", + disable_active_learning=disable_active_learning, + active_learning_target_dataset=active_learning_target_dataset, ) - self._model_manager.add_model( - model_id=model_id, - api_key=self._api_key, - ) - predictions = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request - ) - if not isinstance(predictions, list): - predictions = [predictions] - predictions = [ - e.model_dump(by_alias=True, exclude_none=True) for e in predictions - ] return self._post_process_result( images=images, predictions=predictions, diff --git a/inference/core/workflows/prototypes/models_provider.py b/inference/core/workflows/prototypes/models_provider.py index 5f47cdeea4..133b8e98ab 100644 --- a/inference/core/workflows/prototypes/models_provider.py +++ b/inference/core/workflows/prototypes/models_provider.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Protocol, Union @@ -22,6 +23,16 @@ def __repr__(self) -> str: # pragma: no cover - debugging aid UNSET = _Unset() +@dataclass(slots=True) +class InferenceResultsDC: + """What an inference call returns when the caller also needs the raw + response objects - today only the rfdetr async stream handoff does + (`run_instance_segmentation(..., return_raw_responses=True)`).""" + + predictions: List[dict] = field(default_factory=list) + raw_responses: List[Any] = field(default_factory=list) + + class ModelsProvider(Protocol): """The port through which Workflows reach models. @@ -119,6 +130,27 @@ def run_semantic_segmentation( response_mask_format: str = "base64_png", ) -> List[dict]: ... + def run_instance_segmentation( + self, + model_id: str, + images: List[Any], + api_key: Optional[str] = None, + class_agnostic_nms: Optional[bool] = None, + class_filter: Optional[List[str]] = None, + confidence: Optional[Union[float, str]] = None, + iou_threshold: Optional[float] = None, + max_detections: Optional[int] = None, + max_candidates: Optional[int] = None, + mask_decode_mode: Optional[str] = None, + tradeoff_factor: Optional[float] = None, + response_mask_format: Union[str, None, _Unset] = UNSET, + enforce_dense_masks_in_inference_models: Union[bool, None, _Unset] = UNSET, + stream_pipeline_context_id: Union[str, None, _Unset] = UNSET, + disable_active_learning: Optional[bool] = None, + active_learning_target_dataset: Optional[str] = None, + return_raw_responses: bool = False, + ) -> Union[List[dict], InferenceResultsDC]: ... + def run_tensor_native_inference(self, model_id: str, **kwargs: Any) -> Any: ... def get_class_names(self, model_id: str) -> List[str]: ... diff --git a/tests/inference/unit_tests/core/interfaces/stream/test_workflows.py b/tests/inference/unit_tests/core/interfaces/stream/test_workflows.py index c5fa0dd82d..772aa899bf 100644 --- a/tests/inference/unit_tests/core/interfaces/stream/test_workflows.py +++ b/tests/inference/unit_tests/core/interfaces/stream/test_workflows.py @@ -24,6 +24,7 @@ WorkflowBatchInput, WorkflowParameter, ) +from inference.core.workflows.prototypes.models_provider import InferenceResultsDC from inference_models.models.base.async_handoff import attach_async_response_future @@ -304,9 +305,18 @@ def __init__(self, inference_results) -> None: def add_model(self, model_id: str, api_key: str) -> None: self.add_model_calls.append((model_id, api_key)) - def infer_from_request_sync(self, model_id: str, request): + def run_instance_segmentation( + self, + model_id: str, + images, + return_raw_responses: bool = False, + **kwargs, + ): self.infer_calls += 1 - return self._inference_results.pop(0) + responses = self._inference_results.pop(0) + if return_raw_responses: + return InferenceResultsDC(predictions=[], raw_responses=responses) + return responses def __contains__(self, model_id: str) -> bool: return model_id == "model" @@ -337,14 +347,20 @@ def __init__(self, mode: str) -> None: self.mode = mode self.stream_pipeline_context_ids = [] - def infer_from_request_sync(self, model_id: str, request): + def run_instance_segmentation( + self, + model_id: str, + images, + stream_pipeline_context_id=None, + return_raw_responses: bool = False, + **kwargs, + ): self.infer_calls += 1 - assert request.source_info is None - self.stream_pipeline_context_ids.append(request.stream_pipeline_context_id) + self.stream_pipeline_context_ids.append(stream_pipeline_context_id) if self.infer_calls == 1: - return [_FakeResponse("priming", width=8, height=8)] - if self.mode == "previous": - return [ + responses = [_FakeResponse("priming", width=8, height=8)] + elif self.mode == "previous": + responses = [ _make_async_placeholder( "first-final", context_id=self.stream_pipeline_context_ids[0], @@ -352,23 +368,27 @@ def infer_from_request_sync(self, model_id: str, request): response_height=8, ) ] - if self.mode == "current-with-old-size": - return [ + elif self.mode == "current-with-old-size": + responses = [ _make_async_placeholder( "first-final", - context_id=request.stream_pipeline_context_id, + context_id=stream_pipeline_context_id, response_width=8, response_height=8, ) ] - return [ - _make_async_placeholder( - "first-final", - context_id="missing-context", - response_width=8, - response_height=8, - ) - ] + else: + responses = [ + _make_async_placeholder( + "first-final", + context_id="missing-context", + response_width=8, + response_height=8, + ) + ] + if return_raw_responses: + return InferenceResultsDC(predictions=[], raw_responses=responses) + return responses def _make_async_placeholder( diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py index eaddde0cde..76160f93e4 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py @@ -384,3 +384,55 @@ def test_run_methods_normalise_a_single_response_to_a_list() -> None: assert ModelManagerModelsProvider(manager).run_object_detection( model_id="m/1", images=IMAGES, confidence=0.4 ) == [response.model_dump(by_alias=True, exclude_none=True)] + + +from inference.core.entities.requests.inference import ( + InstanceSegmentationInferenceRequest, +) +from inference.core.workflows.prototypes.models_provider import InferenceResultsDC + + +def test_run_instance_segmentation_omits_unset_optional_fields() -> None: + manager = manager_returning([empty_detection_response()]) + ModelManagerModelsProvider(manager).run_instance_segmentation( + model_id="m/1", + images=IMAGES, + api_key="k", + class_agnostic_nms=False, + class_filter=None, + confidence=0.4, + iou_threshold=0.3, + max_detections=300, + max_candidates=3000, + mask_decode_mode="accurate", + tradeoff_factor=0.0, + enforce_dense_masks_in_inference_models=True, + disable_active_learning=False, + active_learning_target_dataset=None, + ) + manager.add_model.assert_not_called() + request = captured_request(manager) + assert isinstance(request, InstanceSegmentationInferenceRequest) + assert request.enforce_dense_masks_in_inference_models is True + assert request.stream_pipeline_context_id is None + default = InstanceSegmentationInferenceRequest( + api_key="k", model_id="m/1", image=IMAGES + ) + assert request.response_mask_format == default.response_mask_format + + +def test_run_instance_segmentation_can_return_raw_responses() -> None: + raw = [object(), object()] + manager = manager_returning(raw) + result = ModelManagerModelsProvider(manager).run_instance_segmentation( + model_id="m/1", + images=IMAGES, + api_key="k", + confidence=0.4, + stream_pipeline_context_id="ctx-1", + return_raw_responses=True, + ) + assert isinstance(result, InferenceResultsDC) + assert result.raw_responses == raw + assert result.predictions == [] + assert captured_request(manager).stream_pipeline_context_id == "ctx-1" diff --git a/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py b/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py index f21eeaa3cd..aabfce66e2 100644 --- a/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py +++ b/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py @@ -112,3 +112,190 @@ def test_dc_and_pydantic_responses_normalise_to_the_same_dict() -> None: assert dc.to_dict() == pydantic_equivalent.model_dump( by_alias=True, exclude_none=True ) + + +from concurrent.futures import Future + +import numpy as np +import supervision as sv + +from inference.core.workflows.execution_engine.entities.base import ( + Batch, + ImageParentMetadata, + WorkflowImageData, +) +from inference.core.workflows.prototypes.models_provider import InferenceResultsDC + + +class _StreamResponse: + """Shaped like the rfdetr adapter's workflow-execution responses: `to_dict()` + plus, when the adapter attached a handoff, the two private attributes + `attach_async_response_future` sets + (inference_models/models/base/async_handoff.py:106-114).""" + + def __init__(self, image, predictions=(), future=None, context_id=None): + self._payload = { + "inference_id": "inf", + "image": image, + "predictions": list(predictions), + } + if future is not None: + self._async_response_future = future + if context_id is not None: + self._async_response_context_id = context_id + + def to_dict(self): + return self._payload + + +IMAGE_10x20 = {"width": 10, "height": 20} +ONE_PREDICTION = [ + { + "x": 5.0, + "y": 10.0, + "width": 4.0, + "height": 6.0, + "confidence": 0.9, + "class": "a", + "class_id": 0, + "detection_id": "d0", + "points": [{"x": 3.0, "y": 7.0}, {"x": 7.0, "y": 7.0}, {"x": 7.0, "y": 13.0}], + } +] + + +def _one_image_batch(parent_id): + return Batch( + content=[ + WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id=parent_id), + numpy_image=np.zeros((20, 10, 3), dtype=np.uint8), + ) + ], + indices=[(0,)], + ) + + +def _run_locally(block, images): + return block.run_locally( + images=images, + model_id="m/1", + class_agnostic_nms=False, + class_filter=None, + confidence=0.4, + iou_threshold=0.3, + max_detections=300, + max_candidates=3000, + mask_decode_mode="accurate", + tradeoff_factor=0.0, + disable_active_learning=False, + active_learning_target_dataset=None, + enforce_dense_masks_in_inference_models=False, + ) + + +def test_cold_model_first_frame_is_queued_before_inference_and_the_pipeline_pairs_and_flushes() -> ( + None +): + """Round-1 defect 1 + round-3 defect 5. Registration must happen BEFORE + the depth check, so the FIRST frame - on a cold model - is queued by the + time the provider is called. A later frame's response then carries the + first frame's future and context id, which pairs with (and removes) that + queued context; the frame that carried it stays queued until flush.""" + loaded = {"value": False} + registered = [] + at_provider_call = [] # (context id passed, pending ids at that moment) + + manager = MagicMock() + manager.__contains__.side_effect = lambda model_id: loaded["value"] + + def _add_model(**kwargs): + registered.append(kwargs) + loaded["value"] = True + + manager.add_model.side_effect = _add_model + manager.model_supports_stream_pipeline.side_effect = lambda _: loaded["value"] + manager.get_model_pipeline_depth.side_effect = lambda _: 3 if loaded["value"] else 1 + + frame_0_future = Future() + + def _run_instance_segmentation(**kwargs): + context_id = kwargs["stream_pipeline_context_id"] + assert kwargs["return_raw_responses"] is True + at_provider_call.append( + ( + context_id, + [c.context_id for c in block._pending_stream_prediction_contexts], + ) + ) + if len(at_provider_call) == 1: + # Cold pipeline: the adapter has no finished response yet, so it + # hands back an empty response with NO future attached. + response = _StreamResponse(IMAGE_10x20) + else: + # Steady state: frame N's call returns the finished result of an + # OLDER frame - here frame 0's - as a future carrying frame 0's id. + response = _StreamResponse( + IMAGE_10x20, future=frame_0_future, context_id=at_provider_call[0][0] + ) + return InferenceResultsDC(predictions=[], raw_responses=[response]) + + manager.run_instance_segmentation.side_effect = _run_instance_segmentation + + block = RoboflowInstanceSegmentationModelBlockV3( + model_manager=manager, + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + try: + first = _run_locally(block, _one_image_batch("p0")) + assert registered == [{"model_id": "m/1", "api_key": "k"}] + c0 = at_provider_call[0][0] + # Registration preceded the depth check: the COLD first frame was + # already queued when the provider was called. + assert at_provider_call[0][1] == [c0] + # No handoff on the cold response -> finalised immediately, c0 stays queued. + assert [c.context_id for c in block._pending_stream_prediction_contexts] == [c0] + assert len(first) == 1 and len(first[0]["predictions"]) == 0 + + second = _run_locally(block, _one_image_batch("p1")) + c1 = at_provider_call[1][0] + assert at_provider_call[1][1] == [c0, c1] + # Frame 1's call returned frame 0's future: c0 moved to deferred + # processing, c1 is the genuinely outstanding context. + assert [c.context_id for c in block._pending_stream_prediction_contexts] == [c1] + assert isinstance(second[0]["predictions"], Future) + assert second[0]["model_id"] == "m/1" + + # Resolve frame 0's delayed result: it must be finalised against frame + # 0's image (parent "p0"), not the frame that carried it. + frame_0_future.set_result( + [_StreamResponse(IMAGE_10x20, predictions=ONE_PREDICTION)] + ) + resolved = second[0]["predictions"].result(timeout=5) + assert isinstance(resolved, sv.Detections) and len(resolved) == 1 + assert resolved["parent_id"].tolist() == ["p0"] + + # Flush drains exactly the outstanding context (c1) and pairs it with frame 1. + manager.flush_model_stream_pipeline.return_value = [ + _StreamResponse(IMAGE_10x20, predictions=ONE_PREDICTION) + ] + flushed = block.flush_stream_pipeline_outputs() + assert len(flushed) == 1 + indices, outputs = flushed[0] + assert indices == [(0,)] + assert outputs[0]["predictions"]["parent_id"].tolist() == ["p1"] + assert len(block._pending_stream_prediction_contexts) == 0 + finally: + # Round-4 defect 7: if an assertion above fails before `set_result`, the + # worker is still blocked in `_finalize_async_prediction_value` + # (`v3.py:763`, `future.result(timeout=WORKFLOWS_ASYNC_FUTURE_RESULT_TIMEOUT)`, + # 60 s by default, env.py:1240). Cancel the source future first - the + # waiter gets CancelledError immediately - then drain the executor with + # a blocking shutdown, then close the pipeline. + if not frame_0_future.done(): + frame_0_future.cancel() + executor = block._stream_response_executor + if executor is not None: + executor.shutdown(wait=True) + block.close_stream_pipeline() diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 83f4984c6e..312c746442 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,6 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 36 +# Count: 32 inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py inference.core.entities.requests.clip @@ -33,7 +33,3 @@ inference/core/workflows/core_steps/models/foundation/segment_anything3_interact inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.entities.requests.yolo_world -inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v2.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v3.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/roboflow/instance_segmentation/v4.py inference.core.entities.requests.inference From fcb7ed10055bc62ef942ad32b012b2e63d998aa9 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:09:22 +0200 Subject: [PATCH 63/86] test: stream-pipeline tests drive v3 through the real ModelsProvider adapter Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../core/interfaces/stream/test_workflows.py | 73 ++++++-------- .../test_v3_stream_pipeline.py | 99 +++++++++++++++++++ 2 files changed, 128 insertions(+), 44 deletions(-) diff --git a/tests/inference/unit_tests/core/interfaces/stream/test_workflows.py b/tests/inference/unit_tests/core/interfaces/stream/test_workflows.py index 772aa899bf..b216b8f424 100644 --- a/tests/inference/unit_tests/core/interfaces/stream/test_workflows.py +++ b/tests/inference/unit_tests/core/interfaces/stream/test_workflows.py @@ -14,6 +14,9 @@ _index_list_parameters_by_frame_id, wrap_workflow_runner_for_stream_pipeline, ) +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.models.roboflow.instance_segmentation.v3 import ( RoboflowInstanceSegmentationModelBlockV3, @@ -24,7 +27,6 @@ WorkflowBatchInput, WorkflowParameter, ) -from inference.core.workflows.prototypes.models_provider import InferenceResultsDC from inference_models.models.base.async_handoff import attach_async_response_future @@ -305,18 +307,9 @@ def __init__(self, inference_results) -> None: def add_model(self, model_id: str, api_key: str) -> None: self.add_model_calls.append((model_id, api_key)) - def run_instance_segmentation( - self, - model_id: str, - images, - return_raw_responses: bool = False, - **kwargs, - ): + def infer_from_request_sync(self, model_id: str, request): self.infer_calls += 1 - responses = self._inference_results.pop(0) - if return_raw_responses: - return InferenceResultsDC(predictions=[], raw_responses=responses) - return responses + return self._inference_results.pop(0) def __contains__(self, model_id: str) -> bool: return model_id == "model" @@ -347,20 +340,14 @@ def __init__(self, mode: str) -> None: self.mode = mode self.stream_pipeline_context_ids = [] - def run_instance_segmentation( - self, - model_id: str, - images, - stream_pipeline_context_id=None, - return_raw_responses: bool = False, - **kwargs, - ): + def infer_from_request_sync(self, model_id: str, request): self.infer_calls += 1 - self.stream_pipeline_context_ids.append(stream_pipeline_context_id) + assert request.source_info is None + self.stream_pipeline_context_ids.append(request.stream_pipeline_context_id) if self.infer_calls == 1: - responses = [_FakeResponse("priming", width=8, height=8)] - elif self.mode == "previous": - responses = [ + return [_FakeResponse("priming", width=8, height=8)] + if self.mode == "previous": + return [ _make_async_placeholder( "first-final", context_id=self.stream_pipeline_context_ids[0], @@ -368,27 +355,23 @@ def run_instance_segmentation( response_height=8, ) ] - elif self.mode == "current-with-old-size": - responses = [ + if self.mode == "current-with-old-size": + return [ _make_async_placeholder( "first-final", - context_id=stream_pipeline_context_id, + context_id=request.stream_pipeline_context_id, response_width=8, response_height=8, ) ] - else: - responses = [ - _make_async_placeholder( - "first-final", - context_id="missing-context", - response_width=8, - response_height=8, - ) - ] - if return_raw_responses: - return InferenceResultsDC(predictions=[], raw_responses=responses) - return responses + return [ + _make_async_placeholder( + "first-final", + context_id="missing-context", + response_width=8, + response_height=8, + ) + ] def _make_async_placeholder( @@ -752,7 +735,9 @@ def test_instance_segmentation_stream_pipeline_activation_requires_depth_above_o monkeypatch, ) -> None: block = RoboflowInstanceSegmentationModelBlockV3( - model_manager=_FakeModelManager(inference_results=[]), + model_manager=ModelManagerModelsProvider( + _FakeModelManager(inference_results=[]) + ), api_key="api-key", step_execution_mode=StepExecutionMode.LOCAL, ) @@ -780,7 +765,7 @@ def test_instance_segmentation_stream_flush_drains_model_without_rerunning_workf ] ) block = RoboflowInstanceSegmentationModelBlockV3( - model_manager=manager, + model_manager=ModelManagerModelsProvider(manager), api_key="api-key", step_execution_mode=StepExecutionMode.LOCAL, ) @@ -851,7 +836,7 @@ def test_instance_segmentation_stream_flush_drains_model_without_rerunning_workf def test_instance_segmentation_stream_pipeline_uses_response_context_id() -> None: manager = _ContextAwareModelManager(mode="previous") block = RoboflowInstanceSegmentationModelBlockV3( - model_manager=manager, + model_manager=ModelManagerModelsProvider(manager), api_key="api-key", step_execution_mode=StepExecutionMode.LOCAL, ) @@ -905,7 +890,7 @@ def test_instance_segmentation_stream_pipeline_uses_response_context_id() -> Non def test_instance_segmentation_stream_pipeline_rejects_unknown_context_id() -> None: manager = _ContextAwareModelManager(mode="missing") block = RoboflowInstanceSegmentationModelBlockV3( - model_manager=manager, + model_manager=ModelManagerModelsProvider(manager), api_key="api-key", step_execution_mode=StepExecutionMode.LOCAL, ) @@ -957,7 +942,7 @@ def test_instance_segmentation_stream_pipeline_rejects_image_metadata_mismatch() ): manager = _ContextAwareModelManager(mode="current-with-old-size") block = RoboflowInstanceSegmentationModelBlockV3( - model_manager=manager, + model_manager=ModelManagerModelsProvider(manager), api_key="api-key", step_execution_mode=StepExecutionMode.LOCAL, ) diff --git a/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py b/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py index aabfce66e2..c3b8b64db7 100644 --- a/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py +++ b/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py @@ -299,3 +299,102 @@ def _run_instance_segmentation(**kwargs): if executor is not None: executor.shutdown(wait=True) block.close_stream_pipeline() + + +from inference.core.entities.requests.inference import ( + InstanceSegmentationInferenceRequest, +) +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) + +# A payload `InferenceRequestImage` accepts - distinct from `IMAGE_10x20`, which +# is shaped like a *response* image and would fail request validation. +REQUEST_IMAGE = {"type": "base64", "value": "aGVsbG8="} + + +def test_adapter_run_instance_segmentation_forwards_explicit_none_and_defaults_omitted_fields() -> ( + None +): + """Round-1 review finding: an explicitly-passed `None` (e.g. `class_filter`) + must reach the built request as `None`, while a field that is simply never + passed (the UNSET-defaulted ones) must keep the pydantic default rather than + becoming `None`.""" + manager = MagicMock() + manager.infer_from_request_sync.return_value = [] + provider = ModelManagerModelsProvider(manager) + + provider.run_instance_segmentation( + model_id="m/1", + images=[REQUEST_IMAGE], + api_key="k", + confidence=0.4, + class_filter=None, + ) + + request = manager.infer_from_request_sync.call_args.kwargs["request"] + assert isinstance(request, InstanceSegmentationInferenceRequest) + assert request.class_filter is None + default = InstanceSegmentationInferenceRequest( + api_key="k", model_id="m/1", image=[REQUEST_IMAGE] + ) + assert request.response_mask_format == default.response_mask_format + assert ( + request.enforce_dense_masks_in_inference_models + == default.enforce_dense_masks_in_inference_models + ) + assert request.stream_pipeline_context_id == default.stream_pipeline_context_id + + +def test_adapter_run_instance_segmentation_wraps_a_single_raw_response_like_v3_used_to() -> ( + None +): + """`return_raw_responses=True` over a SINGLE (non-list) manager response must + get the same `[predictions]` wrapping the blocks used to apply inline, with + the same object identity preserved inside.""" + manager = MagicMock() + raw = object() + manager.infer_from_request_sync.return_value = raw # not a list + provider = ModelManagerModelsProvider(manager) + + result = provider.run_instance_segmentation( + model_id="m/1", + images=[REQUEST_IMAGE], + api_key="k", + confidence=0.4, + return_raw_responses=True, + ) + + assert isinstance(result, InferenceResultsDC) + assert result.predictions == [] + assert result.raw_responses == [raw] + assert result.raw_responses[0] is raw + + +def test_adapter_run_instance_segmentation_normalises_a_list_response_like_the_inlined_dump_used_to() -> ( + None +): + """The default (non-raw) output must equal what v1/v2/v3/v4 produced inline + before Task 11.9: `[e.model_dump(by_alias=True, exclude_none=True) for e in + predictions]`.""" + from inference.core.entities.responses.inference import ( + InferenceResponseImage, + InstanceSegmentationInferenceResponse, + ) + + response = InstanceSegmentationInferenceResponse( + image=InferenceResponseImage(width=10, height=20), + predictions=[], + ) + manager = MagicMock() + manager.infer_from_request_sync.return_value = [response] + provider = ModelManagerModelsProvider(manager) + + result = provider.run_instance_segmentation( + model_id="m/1", + images=[REQUEST_IMAGE], + api_key="k", + confidence=0.4, + ) + + assert result == [response.model_dump(by_alias=True, exclude_none=True)] From 0ec4aaf24dcb6047975a94b715d52367e08af8f0 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:13:28 +0200 Subject: [PATCH 64/86] test(workflows): pin explicit-None forwarding on a non-None request default The explicit-None adapter case used class_filter, whose request default is already None, so it could not catch `_passed` dropping None. Use enforce_dense_masks_in_inference_models instead (Task 11.9 re-review minor). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../instance_segmentation/test_v3_stream_pipeline.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py b/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py index c3b8b64db7..d2165a8acb 100644 --- a/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py +++ b/tests/workflows/unit_tests/core_steps/models/roboflow/instance_segmentation/test_v3_stream_pipeline.py @@ -330,6 +330,7 @@ def test_adapter_run_instance_segmentation_forwards_explicit_none_and_defaults_o api_key="k", confidence=0.4, class_filter=None, + enforce_dense_masks_in_inference_models=None, ) request = manager.infer_from_request_sync.call_args.kwargs["request"] @@ -338,11 +339,12 @@ def test_adapter_run_instance_segmentation_forwards_explicit_none_and_defaults_o default = InstanceSegmentationInferenceRequest( api_key="k", model_id="m/1", image=[REQUEST_IMAGE] ) + # An explicit None overrides a non-None request default (the `_passed` + # rule must forward None, not drop it)... + assert default.enforce_dense_masks_in_inference_models is not None + assert request.enforce_dense_masks_in_inference_models is None + # ...while omitted (UNSET) fields keep the request's defaults. assert request.response_mask_format == default.response_mask_format - assert ( - request.enforce_dense_masks_in_inference_models - == default.enforce_dense_masks_in_inference_models - ) assert request.stream_pipeline_context_id == default.stream_pipeline_context_id From 82febf1f4844442868da394d219c5c52dc0e8ead Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:38:19 +0200 Subject: [PATCH 65/86] refactor(workflows): LMM, depth and moondream2 blocks run through the plain-argument port Adds run_lmm, run_depth_estimation and run_moondream2 to the workflows ModelsProvider port and its ModelManager-backed adapter, then swaps the 11 uniform LMMInferenceRequest call sites (cosmos3, florence2, glm_ocr, qwen, qwen3_5vl v1/v2, qwen3vl, qwen_vlm v1/v2/v3, smolvlm) plus depth_estimation and moondream2 onto the new plain-argument calls, so inference.core.workflows no longer builds these pydantic request objects directly. Decontamination baseline 32 -> 19. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../interfaces/workflows_models_provider.py | 52 ++++ .../models/foundation/cosmos3/v1.py | 11 +- .../models/foundation/depth_estimation/v1.py | 13 +- .../models/foundation/florence2/v1.py | 15 +- .../models/foundation/glm_ocr/v1.py | 15 +- .../models/foundation/moondream2/v1.py | 20 +- .../core_steps/models/foundation/qwen/v1.py | 12 +- .../models/foundation/qwen3_5vl/v1.py | 16 +- .../models/foundation/qwen3_5vl/v2.py | 15 +- .../models/foundation/qwen3vl/v1.py | 12 +- .../models/foundation/qwen_vlm/v1.py | 17 +- .../models/foundation/qwen_vlm/v2.py | 17 +- .../models/foundation/qwen_vlm/v3.py | 17 +- .../models/foundation/smolvlm/v1.py | 12 +- .../workflows/prototypes/models_provider.py | 21 ++ scripts/phase11_swap_lmm_call_sites.py | 120 +++++++++ .../test_workflows_models_provider.py | 71 ++++++ .../foundation/test_depth_estimation.py | 14 +- .../test_port_requests_match_legacy_lmm.py | 233 ++++++++++++++++++ .../models/foundation/test_qwen_vlm.py | 19 +- .../models/foundation/test_qwen_vlm_v2.py | 19 +- .../models/foundation/test_qwen_vlm_v3.py | 2 +- .../unit_tests/decontamination_baseline.txt | 15 +- 23 files changed, 577 insertions(+), 181 deletions(-) create mode 100644 scripts/phase11_swap_lmm_call_sites.py create mode 100644 tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_lmm.py diff --git a/inference/core/interfaces/workflows_models_provider.py b/inference/core/interfaces/workflows_models_provider.py index 0abbcd99ab..1402d87084 100644 --- a/inference/core/interfaces/workflows_models_provider.py +++ b/inference/core/interfaces/workflows_models_provider.py @@ -32,11 +32,14 @@ from inference.core.entities.requests.inference import ( ClassificationInferenceRequest, + DepthEstimationRequest, InstanceSegmentationInferenceRequest, KeypointsDetectionInferenceRequest, + LMMInferenceRequest, ObjectDetectionInferenceRequest, SemanticSegmentationInferenceRequest, ) +from inference.core.entities.requests.moondream2 import Moondream2InferenceRequest from inference.core.managers.base import ModelManager from inference.core.workflows.prototypes.models_provider import ( UNSET, @@ -249,6 +252,55 @@ def run_instance_segmentation( return InferenceResultsDC(predictions=[], raw_responses=responses) return self._dump(responses) + def run_lmm( + self, + model_id: str, + image: Any, + prompt: str, + api_key: Optional[str] = None, + enable_thinking: Union[bool, None, _Unset] = UNSET, + max_new_tokens: Optional[int] = None, + ) -> dict: + kwargs: Dict[str, Any] = { + "api_key": api_key, + "model_id": model_id, + "image": image, + "source": _WORKFLOW_SOURCE, + "prompt": prompt, + # Forwarded exactly as the block passed it (five blocks do; the + # request field is a non-optional bool, so a None keeps raising). + **_passed(enable_thinking=enable_thinking), + } + # The blocks only ever added max_new_tokens to the request when it was + # not None (`if max_new_tokens is not None: request_kwargs[...] = ...`); + # mirror that, so the pydantic default applies otherwise. + if max_new_tokens is not None: + kwargs["max_new_tokens"] = max_new_tokens + request = LMMInferenceRequest(**kwargs) + return self._dump(self._infer(model_id=model_id, request=request))[0] + + def run_depth_estimation(self, model_id: str, image: Any) -> Any: + request = DepthEstimationRequest(image=image) + return self._infer(model_id=model_id, request=request)[0].response + + def run_moondream2( + self, + model_id: str, + image: Any, + prompt: str, + text: List[str], + api_key: Optional[str] = None, + ) -> dict: + # `text` is a required list on the request; the block passes `[]`. + request = Moondream2InferenceRequest( + api_key=api_key, + model_id=model_id, + image=image, + text=text, + prompt=prompt, + ) + return self._dump(self._infer(model_id=model_id, request=request))[0] + def run_tensor_native_inference(self, model_id: str, **kwargs: Any) -> Any: return self._model_manager.run_tensor_native_inference( model_id=model_id, **kwargs diff --git a/inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py b/inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py index 3e90a3b2c4..b398ccd2f4 100644 --- a/inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py @@ -2,7 +2,6 @@ from pydantic import ConfigDict, Field -from inference.core.entities.requests.inference import LMMInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.environment import ( COSMOS3_ENABLED, @@ -244,17 +243,13 @@ def run_locally( predictions = [] for image in inference_images: - request = LMMInferenceRequest( - api_key=self._api_key, + prediction = self._model_manager.run_lmm( model_id=model_version, image=image, - source="workflow-execution", prompt=combined_prompt, + api_key=self._api_key, ) - prediction = self._model_manager.infer_from_request_sync( - model_id=model_version, request=request - ) - predictions.append({"output": prediction.response}) + predictions.append({"output": prediction["response"]}) return predictions diff --git a/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py b/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py index 0eee1cf4d2..a1cd50fc65 100644 --- a/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py @@ -3,7 +3,6 @@ import numpy as np from pydantic import ConfigDict, Field -from inference.core.entities.requests.inference import DepthEstimationRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.environment import ( DEPTH_ESTIMATION_ENABLED, @@ -279,16 +278,12 @@ def run_locally( predictions = [] for idx, image in enumerate(inference_images): - # Run inference. - request = DepthEstimationRequest( - image=image, - ) - try: - prediction = self._model_manager.infer_from_request_sync( - model_id=model_version, request=request + predictions.append( + self._model_manager.run_depth_estimation( + model_id=model_version, image=image + ) ) - predictions.append(prediction.response) except Exception as e: raise diff --git a/inference/core/workflows/core_steps/models/foundation/florence2/v1.py b/inference/core/workflows/core_steps/models/foundation/florence2/v1.py index e54a5cf5dd..36828c1055 100644 --- a/inference/core/workflows/core_steps/models/foundation/florence2/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/florence2/v1.py @@ -5,7 +5,6 @@ import supervision as sv from pydantic import ConfigDict, Field, model_validator -from inference.core.entities.requests.inference import LMMInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA from inference.core.workflows.environment import ( @@ -556,22 +555,18 @@ def run_locally( else: prompt = task_type + (single_prompt or "") - request = LMMInferenceRequest( - api_key=self._api_key, + prediction = self._model_manager.run_lmm( model_id=model_version, image=image, - source="workflow-execution", prompt=prompt, - ) - prediction = self._model_manager.infer_from_request_sync( - model_id=model_version, request=request + api_key=self._api_key, ) if is_not_florence_task: - prediction_data = prediction.response[ - list(prediction.response.keys())[0] + prediction_data = prediction["response"][ + list(prediction["response"].keys())[0] ] else: - prediction_data = prediction.response[task_type] + prediction_data = prediction["response"][task_type] if task_type in TASKS_TO_EXTRACT_LABELS_AS_CLASSES: classes = prediction_data.get("labels", []) predictions.append( diff --git a/inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py index 90b843ac2d..01d0dc733f 100644 --- a/inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py @@ -3,7 +3,6 @@ from pydantic import ConfigDict, Field, model_validator -from inference.core.entities.requests.inference import LMMInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.environment import ( GLM_OCR_ENABLED, @@ -395,20 +394,14 @@ def run_locally( predictions = [] for image in inference_images: - request_kwargs = dict( - api_key=self._api_key, + prediction = self._model_manager.run_lmm( model_id=model_version, image=image, - source="workflow-execution", prompt=prompt, + api_key=self._api_key, + max_new_tokens=max_new_tokens, ) - if max_new_tokens is not None: - request_kwargs["max_new_tokens"] = max_new_tokens - request = LMMInferenceRequest(**request_kwargs) - prediction = self._model_manager.infer_from_request_sync( - model_id=model_version, request=request - ) - response_text = prediction.response + response_text = prediction["response"] predictions.append({"parsed_output": response_text}) return predictions diff --git a/inference/core/workflows/core_steps/models/foundation/moondream2/v1.py b/inference/core/workflows/core_steps/models/foundation/moondream2/v1.py index a734ecf59f..7d80b5f667 100644 --- a/inference/core/workflows/core_steps/models/foundation/moondream2/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/moondream2/v1.py @@ -3,7 +3,6 @@ from pydantic import ConfigDict, Field -from inference.core.entities.requests.moondream2 import Moondream2InferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -244,18 +243,15 @@ def run_locally( predictions = [] for image, single_prompt in zip(inference_images, prompts): - request = Moondream2InferenceRequest( - api_key=self._api_key, - model_id=model_version, - image=image, - text=[], - prompt=single_prompt, - ) - # Run inference. - prediction = self._model_manager.infer_from_request_sync( - model_id=model_version, request=request + predictions.append( + self._model_manager.run_moondream2( + model_id=model_version, + image=image, + prompt=single_prompt, + text=[], + api_key=self._api_key, + ) ) - predictions.append(prediction.model_dump(by_alias=True, exclude_none=True)) return self._post_process_result(images=images, predictions=predictions) diff --git a/inference/core/workflows/core_steps/models/foundation/qwen/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen/v1.py index e63da26b96..eae47f5c77 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen/v1.py @@ -4,7 +4,6 @@ import supervision as sv from pydantic import ConfigDict, Field -from inference.core.entities.requests.inference import LMMInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, @@ -264,18 +263,13 @@ def run_locally( predictions = [] for image, single_prompt in zip(inference_images, prompts): # Build an LMMInferenceRequest with both prompt and image. - request = LMMInferenceRequest( - api_key=self._api_key, + prediction = self._model_manager.run_lmm( model_id=model_version, image=image, - source="workflow-execution", prompt=single_prompt, + api_key=self._api_key, ) - # Run inference. - prediction = self._model_manager.infer_from_request_sync( - model_id=model_version, request=request - ) - response_text = prediction.response + response_text = prediction["response"] predictions.append( { "parsed_output": response_text, diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py index 49eff639f0..ae2bb674a0 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py @@ -4,7 +4,6 @@ import supervision as sv from pydantic import ConfigDict, Field -from inference.core.entities.requests.inference import LMMInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, @@ -297,22 +296,15 @@ def run_locally( predictions = [] for image, single_prompt in zip(inference_images, prompts): # Build an LMMInferenceRequest with both prompt and image. - request_kwargs = dict( - api_key=self._api_key, + prediction = self._model_manager.run_lmm( model_id=model_version, image=image, - source="workflow-execution", prompt=single_prompt, + api_key=self._api_key, enable_thinking=enable_thinking, + max_new_tokens=max_new_tokens, ) - if max_new_tokens is not None: - request_kwargs["max_new_tokens"] = max_new_tokens - request = LMMInferenceRequest(**request_kwargs) - # Run inference. - prediction = self._model_manager.infer_from_request_sync( - model_id=model_version, request=request - ) - response_text = prediction.response + response_text = prediction["response"] # When enable_thinking is used and the response contains # thinking data (dict with 'thinking' and 'answer' keys), # extract them separately. diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py index 1ca14885ac..aec7af6370 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py @@ -2,7 +2,6 @@ from pydantic import ConfigDict, Field -from inference.core.entities.requests.inference import LMMInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, @@ -262,20 +261,14 @@ def run_locally( predictions = [] for image, single_prompt in zip(inference_images, prompts): - request_kwargs = dict( - api_key=self._api_key, + prediction = self._model_manager.run_lmm( model_id=model_version, image=image, - source="workflow-execution", prompt=single_prompt, + api_key=self._api_key, enable_thinking=False, + max_new_tokens=max_new_tokens, ) - if max_new_tokens is not None: - request_kwargs["max_new_tokens"] = max_new_tokens - request = LMMInferenceRequest(**request_kwargs) - prediction = self._model_manager.infer_from_request_sync( - model_id=model_version, request=request - ) - response_text = prediction.response + response_text = prediction["response"] predictions.append({"parsed_output": response_text}) return predictions diff --git a/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py index 61460023b1..616364bf47 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py @@ -4,7 +4,6 @@ import supervision as sv from pydantic import ConfigDict, Field -from inference.core.entities.requests.inference import LMMInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, @@ -261,18 +260,13 @@ def run_locally( predictions = [] for image, single_prompt in zip(inference_images, prompts): # Build an LMMInferenceRequest with both prompt and image. - request = LMMInferenceRequest( - api_key=self._api_key, + prediction = self._model_manager.run_lmm( model_id=model_version, image=image, - source="workflow-execution", prompt=single_prompt, + api_key=self._api_key, ) - # Run inference. - prediction = self._model_manager.infer_from_request_sync( - model_id=model_version, request=request - ) - response_text = prediction.response + response_text = prediction["response"] predictions.append( { "parsed_output": response_text, diff --git a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py index 0cb505e016..360d6c4a0d 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py @@ -27,7 +27,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core.entities.requests.inference import LMMInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.openrouter import ( PRIVACY_LEVEL_LITERAL, @@ -276,7 +275,7 @@ def _coerce_native_response(response: Any) -> Tuple[str, str]: - """Normalize a native Qwen prediction.response into (output, thinking). + """Normalize a native Qwen prediction["response"] into (output, thinking). When ``enable_thinking`` is on, some Qwen variants return a ``{"thinking": "...", "answer": "..."}`` dict; split that into the two @@ -947,21 +946,15 @@ def _run_native_locally( self._model_manager.add_model(model_id=model_id, api_key=self._roboflow_api_key) outputs: List[Dict[str, str]] = [] for image in inference_images: - request_kwargs: Dict[str, Any] = dict( - api_key=self._roboflow_api_key, + prediction = self._model_manager.run_lmm( model_id=model_id, image=image, - source="workflow-execution", prompt=combined_prompt, + api_key=self._roboflow_api_key, enable_thinking=enable_thinking, + max_new_tokens=max_new_tokens, ) - if max_new_tokens is not None: - request_kwargs["max_new_tokens"] = max_new_tokens - request = LMMInferenceRequest(**request_kwargs) - prediction = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request - ) - output, thinking = _coerce_native_response(prediction.response) + output, thinking = _coerce_native_response(prediction["response"]) outputs.append({"output": output, "thinking": thinking}) return outputs diff --git a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py index bf4fe4ebae..a758192603 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py @@ -33,7 +33,6 @@ import numpy as np from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core.entities.requests.inference import LMMInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.openrouter import ( PRIVACY_LEVEL_LITERAL, @@ -607,7 +606,7 @@ def build_qwen_openrouter_prompts( def _coerce_native_response(response: Any) -> Tuple[str, str]: - """Normalize a native Qwen prediction.response into (output, thinking). + """Normalize a native Qwen prediction["response"] into (output, thinking). When ``enable_thinking`` is on, some Qwen variants return a ``{"thinking": "...", "answer": "..."}`` dict; split that into the two @@ -1346,21 +1345,15 @@ def _run_native_locally( self._model_manager.add_model(model_id=model_id, api_key=self._roboflow_api_key) outputs: List[Dict[str, str]] = [] for image in inference_images: - request_kwargs: Dict[str, Any] = dict( - api_key=self._roboflow_api_key, + prediction = self._model_manager.run_lmm( model_id=model_id, image=image, - source="workflow-execution", prompt=combined_prompt, + api_key=self._roboflow_api_key, enable_thinking=enable_thinking, + max_new_tokens=max_new_tokens, ) - if max_new_tokens is not None: - request_kwargs["max_new_tokens"] = max_new_tokens - request = LMMInferenceRequest(**request_kwargs) - prediction = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request - ) - output, thinking = _coerce_native_response(prediction.response) + output, thinking = _coerce_native_response(prediction["response"]) outputs.append({"output": output, "thinking": thinking}) return outputs diff --git a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py index 91f4d9faa3..9f779fadbe 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py @@ -36,7 +36,6 @@ import numpy as np from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core.entities.requests.inference import LMMInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.openrouter import ( PRIVACY_LEVEL_LITERAL, @@ -637,7 +636,7 @@ def build_qwen_openrouter_prompts( def _coerce_native_response(response: Any) -> Tuple[str, str]: - """Normalize a native Qwen prediction.response into (output, thinking). + """Normalize a native Qwen prediction["response"] into (output, thinking). When ``enable_thinking`` is on, some Qwen variants return a ``{"thinking": "...", "answer": "..."}`` dict; split that into the two @@ -1402,21 +1401,15 @@ def _run_native_locally( self._model_manager.add_model(model_id=model_id, api_key=self._roboflow_api_key) outputs: List[Dict[str, str]] = [] for image in inference_images: - request_kwargs: Dict[str, Any] = dict( - api_key=self._roboflow_api_key, + prediction = self._model_manager.run_lmm( model_id=model_id, image=image, - source="workflow-execution", prompt=combined_prompt, + api_key=self._roboflow_api_key, enable_thinking=enable_thinking, + max_new_tokens=max_new_tokens, ) - if max_new_tokens is not None: - request_kwargs["max_new_tokens"] = max_new_tokens - request = LMMInferenceRequest(**request_kwargs) - prediction = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request - ) - output, thinking = _coerce_native_response(prediction.response) + output, thinking = _coerce_native_response(prediction["response"]) outputs.append({"output": output, "thinking": thinking}) return outputs diff --git a/inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py b/inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py index b3ed990b05..0c53d47fba 100644 --- a/inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py @@ -2,7 +2,6 @@ from pydantic import ConfigDict, Field -from inference.core.entities.requests.inference import LMMInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, @@ -231,18 +230,13 @@ def run_locally( predictions = [] for image, single_prompt in zip(inference_images, prompts): # Build an LMMInferenceRequest with both prompt and image. - request = LMMInferenceRequest( - api_key=self._api_key, + prediction = self._model_manager.run_lmm( model_id=model_version, image=image, - source="workflow-execution", prompt=single_prompt, + api_key=self._api_key, ) - # Run inference. - prediction = self._model_manager.infer_from_request_sync( - model_id=model_version, request=request - ) - response_text = prediction.response + response_text = prediction["response"] predictions.append( { "parsed_output": response_text, diff --git a/inference/core/workflows/prototypes/models_provider.py b/inference/core/workflows/prototypes/models_provider.py index 133b8e98ab..58309d96e3 100644 --- a/inference/core/workflows/prototypes/models_provider.py +++ b/inference/core/workflows/prototypes/models_provider.py @@ -151,6 +151,27 @@ def run_instance_segmentation( return_raw_responses: bool = False, ) -> Union[List[dict], InferenceResultsDC]: ... + def run_lmm( + self, + model_id: str, + image: Any, + prompt: str, + api_key: Optional[str] = None, + enable_thinking: Union[bool, None, _Unset] = UNSET, + max_new_tokens: Optional[int] = None, + ) -> dict: ... + + def run_depth_estimation(self, model_id: str, image: Any) -> Any: ... + + def run_moondream2( + self, + model_id: str, + image: Any, + prompt: str, + text: List[str], + api_key: Optional[str] = None, + ) -> dict: ... + def run_tensor_native_inference(self, model_id: str, **kwargs: Any) -> Any: ... def get_class_names(self, model_id: str) -> List[str]: ... diff --git a/scripts/phase11_swap_lmm_call_sites.py b/scripts/phase11_swap_lmm_call_sites.py new file mode 100644 index 0000000000..60698026e6 --- /dev/null +++ b/scripts/phase11_swap_lmm_call_sites.py @@ -0,0 +1,120 @@ +"""Rewrites the 11 uniform LMMInferenceRequest call sites onto `run_lmm`. + +Each site is one of two shapes: + + A) request = LMMInferenceRequest(api_key=..., model_id=..., image=..., + source="workflow-execution", prompt=...) + prediction = self._model_manager.infer_from_request_sync( + model_id=..., request=request) + + B) request_kwargs = dict(api_key=..., model_id=..., image=..., + source="workflow-execution", prompt=..., + [enable_thinking=...]) + if max_new_tokens is not None: + request_kwargs["max_new_tokens"] = max_new_tokens + request = LMMInferenceRequest(**request_kwargs) + prediction = self._model_manager.infer_from_request_sync( + model_id=..., request=request) + +Both collapse to one `self._model_manager.run_lmm(...)` carrying the same +argument expressions. The script reads the arguments off the AST, edits with a +`\r?\n`-safe regex, re-parses before writing, and refuses a file whose shape it +does not recognise - so a drifted site is reported, never mangled. It does NOT +touch `add_model` (registration stays in the block) or the `prediction.response` +read that follows; both are handled by hand in the next step. + +Run: python scripts/phase11_swap_lmm_call_sites.py [ ...] +""" + +import ast +import re +import sys +from pathlib import Path + +_SOURCE_LITERAL = "workflow-execution" + + +def _kwargs_of(call: ast.Call, source: str) -> dict: + return { + keyword.arg: ast.get_source_segment(source, keyword.value) + for keyword in call.keywords + if keyword.arg is not None + } + + +def rewrite(path: Path) -> bool: + source = path.read_text(encoding="utf-8") + tree = ast.parse(source) + calls = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "LMMInferenceRequest" + ] + if not calls: + # Already rewritten. An AST check, not a grep: the class name survives + # in comments and docstrings (`qwen/v1.py:265`, `qwen_vlm/v1.py:203`), + # so a text test would make the second run abort instead of skip. + return False + if len(calls) != 1: + raise SystemExit( + f"{path}: expected exactly 1 LMMInferenceRequest, got {len(calls)}" + ) + kwargs = _kwargs_of(calls[0], source) + if not kwargs: # shape B keeps its arguments in a preceding dict(...) + dict_calls = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "dict" + and any( + keyword.arg == "source" + and isinstance(keyword.value, ast.Constant) + and keyword.value.value == _SOURCE_LITERAL + for keyword in node.keywords + ) + ] + if len(dict_calls) != 1: + raise SystemExit(f"{path}: expected exactly one request_kwargs dict()") + kwargs = _kwargs_of(dict_calls[0], source) + for required in ("api_key", "model_id", "image", "prompt"): + if required not in kwargs: + raise SystemExit(f"{path}: missing {required} in the request kwargs") + arguments = [ + f"model_id={kwargs['model_id']}", + f"image={kwargs['image']}", + f"prompt={kwargs['prompt']}", + f"api_key={kwargs['api_key']}", + ] + if "enable_thinking" in kwargs: + arguments.append(f"enable_thinking={kwargs['enable_thinking']}") + if "max_new_tokens" in source: + arguments.append("max_new_tokens=max_new_tokens") + replacement = ( + " prediction = self._model_manager.run_lmm(\n" + + "".join(f" {argument},\n" for argument in arguments) + + " )\n" + ) + pattern = re.compile( + r"[ \t]*(?:request_kwargs[^\n]*=\s*dict\(|request\s*=\s*LMMInferenceRequest\()" + r".*?infer_from_request_sync\(\r?\n.*?\r?\n[ \t]*\)\r?\n", + re.DOTALL, + ) + new_source, count = pattern.subn(replacement, source, count=1) + if count != 1: + raise SystemExit(f"{path}: could not locate the call block to replace") + new_source = re.sub( + r"from inference\.core\.entities\.requests\.inference import LMMInferenceRequest\r?\n", + "", + new_source, + ) + ast.parse(new_source) # refuse to write anything that does not parse + path.write_text(new_source, encoding="utf-8") + return True + + +if __name__ == "__main__": + for argument in sys.argv[1:]: + print(argument, "rewritten" if rewrite(Path(argument)) else "skipped") diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py index 76160f93e4..c831f7d62d 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py @@ -436,3 +436,74 @@ def test_run_instance_segmentation_can_return_raw_responses() -> None: assert result.raw_responses == raw assert result.predictions == [] assert captured_request(manager).stream_pipeline_context_id == "ctx-1" + + +from inference.core.entities.requests.inference import ( + DepthEstimationRequest, + LMMInferenceRequest, +) +from inference.core.entities.requests.moondream2 import Moondream2InferenceRequest + + +class _LMMResponse: + """Minimal stand-in with the two members the adapter and blocks touch.""" + + def __init__(self, response): + self.response = response + + def model_dump(self, **_kwargs): + return {"response": self.response} + + +def test_run_lmm_omits_thinking_and_token_budget_when_unset() -> None: + manager = manager_returning(_LMMResponse("hi")) + provider = ModelManagerModelsProvider(manager) + assert provider.run_lmm(model_id="m/1", image=IMAGE, prompt="p", api_key="k") == { + "response": "hi" + } + request = captured_request(manager) + expected = LMMInferenceRequest( + api_key="k", + model_id="m/1", + image=IMAGE, + source="workflow-execution", + prompt="p", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_run_lmm_passes_thinking_and_token_budget_when_set() -> None: + manager = manager_returning(_LMMResponse("hi")) + ModelManagerModelsProvider(manager).run_lmm( + model_id="m/1", + image=IMAGE, + prompt="p", + api_key="k", + enable_thinking=True, + max_new_tokens=64, + ) + request = captured_request(manager) + assert request.enable_thinking is True and request.max_new_tokens == 64 + + +def test_run_moondream2_builds_the_request_the_block_used_to_build() -> None: + manager = manager_returning(_LMMResponse("cat")) + ModelManagerModelsProvider(manager).run_moondream2( + model_id="md/2", image=IMAGE, prompt="p", text=[], api_key="k" + ) + request = captured_request(manager) + expected = Moondream2InferenceRequest( + api_key="k", model_id="md/2", image=IMAGE, text=[], prompt="p" + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_run_depth_estimation_returns_the_raw_response_field() -> None: + manager = manager_returning(_LMMResponse("depth-map")) + assert ( + ModelManagerModelsProvider(manager).run_depth_estimation( + model_id="d/1", image=IMAGE + ) + == "depth-map" + ) + assert isinstance(captured_request(manager), DepthEstimationRequest) diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_depth_estimation.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_depth_estimation.py index 5199e45a69..8f2777fb6b 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_depth_estimation.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_depth_estimation.py @@ -38,12 +38,10 @@ @pytest.fixture def mock_model_manager(): mock = MagicMock() - mock.infer_from_request_sync.return_value = MagicMock( - response={ - "normalized_depth": np.zeros((480, 640)), - "image": MagicMock(numpy_image=np.zeros((480, 640, 3), dtype=np.uint8)), - } - ) + mock.run_depth_estimation.return_value = { + "normalized_depth": np.zeros((480, 640)), + "image": MagicMock(numpy_image=np.zeros((480, 640, 3), dtype=np.uint8)), + } return mock @@ -92,7 +90,7 @@ def test_run_locally(mock_model_manager, mock_workflow_image_data): assert len(result) == 1 mock_model_manager.add_model.assert_called_once() - mock_model_manager.infer_from_request_sync.assert_called_once() + mock_model_manager.run_depth_estimation.assert_called_once() @patch( @@ -167,7 +165,7 @@ def test_run_locally_with_yolo26_depth_variant( mock_model_manager.add_model.assert_called_once_with( model_id="yolo26n-depth-768", api_key="test_key" ) - mock_model_manager.infer_from_request_sync.assert_called_once() + mock_model_manager.run_depth_estimation.assert_called_once() @pytest.fixture diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_lmm.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_lmm.py new file mode 100644 index 0000000000..94ad65019c --- /dev/null +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_lmm.py @@ -0,0 +1,233 @@ +"""Block -> adapter -> request differential tests for Task 11.10's LMM family, +depth estimation and Moondream2 blocks. + +Each test drives a block's real `run_locally` through a real +`ModelManagerModelsProvider` wrapping a `MagicMock` `ModelManager`, then +compares the pydantic request the adapter built against the exact request +construction the block used to run inline before Task 11.10 (copied from +`git show 723646aed:...//v1.py`, the BASE commit for this task - at +that commit these blocks were unmodified, so the pre-port construction is the +same code visible today on `git show 723646aed`). + +One case per distinct request-building shape the task touches: +`LMMInferenceRequest` with neither optional field set (`cosmos3`), with only +`max_new_tokens` forwarded (`glm_ocr`), and with both `enable_thinking` and +`max_new_tokens` forwarded (`qwen3_5vl` v1); plus `DepthEstimationRequest` +(`depth_estimation`) and `Moondream2InferenceRequest` (`moondream2`). +""" + +from unittest.mock import MagicMock + +import numpy as np + +from inference.core.entities.requests.inference import ( + DepthEstimationRequest, + LMMInferenceRequest, +) +from inference.core.entities.requests.moondream2 import Moondream2InferenceRequest +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.models.foundation.cosmos3.v1 import ( + Cosmos3EdgeBlockV1, +) +from inference.core.workflows.core_steps.models.foundation.depth_estimation.v1 import ( + DepthEstimationBlockV1, +) +from inference.core.workflows.core_steps.models.foundation.glm_ocr.v1 import ( + GLMOCRBlockV1, +) +from inference.core.workflows.core_steps.models.foundation.moondream2.v1 import ( + Moondream2BlockV1, +) +from inference.core.workflows.core_steps.models.foundation.qwen3_5vl.v1 import ( + Qwen35VLBlockV1, +) +from inference.core.workflows.execution_engine.entities.base import ( + Batch, + ImageParentMetadata, + WorkflowImageData, +) + + +def _make_images() -> Batch: + # Every LMM/depth/moondream2 block calls `to_inference_format(numpy_preferred=False)` + # (unlike the roboflow model family, which prefers numpy). Building the + # "expected" request from the SAME Batch/WorkflowImageData instance the + # block runs against keeps `value` the identical object both times + # (WorkflowImageData caches the conversion), so dict/list equality on the + # dumped requests hits Python's identity fast path instead of an + # element-wise ndarray `==` (which raises on a >1-element array). + image = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="p"), + numpy_image=np.zeros((20, 10, 3), dtype=np.uint8), + ) + return Batch(content=[image], indices=[(0,)]) + + +def _manager() -> MagicMock: + return MagicMock() + + +def _captured_request(manager: MagicMock): + assert manager.infer_from_request_sync.call_count == 1 + call = manager.infer_from_request_sync.call_args + return call.kwargs["request"] if "request" in call.kwargs else call.args[1] + + +def test_lmm_request_matches_the_pre_port_construction_without_optional_fields() -> ( + None +): + # Copied verbatim from `git show 723646aed:.../cosmos3/v1.py`: neither + # `enable_thinking` nor `max_new_tokens` is ever set by this block. + manager = _manager() + images = _make_images() + block = Cosmos3EdgeBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally( + images=images, + model_version="m/1", + prompt="custom prompt", + system_prompt=None, + ) + + manager.add_model.assert_called_once_with(model_id="m/1", api_key="k") + request = _captured_request(manager) + + inference_images = [i.to_inference_format(numpy_preferred=False) for i in images] + expected = LMMInferenceRequest( + api_key="k", + model_id="m/1", + image=inference_images[0], + source="workflow-execution", + prompt="custom prompt", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_lmm_request_matches_the_pre_port_construction_with_max_new_tokens_only() -> ( + None +): + # Copied verbatim from `git show 723646aed:.../glm_ocr/v1.py`: forwards + # `max_new_tokens` only when it is not None; never sets `enable_thinking`. + manager = _manager() + images = _make_images() + block = GLMOCRBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally( + images=images, + model_version="m/1", + prompt="p", + max_new_tokens=64, + ) + + manager.add_model.assert_called_once_with(model_id="m/1", api_key="k") + request = _captured_request(manager) + + inference_images = [i.to_inference_format(numpy_preferred=False) for i in images] + expected = LMMInferenceRequest( + api_key="k", + model_id="m/1", + image=inference_images[0], + source="workflow-execution", + prompt="p", + max_new_tokens=64, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_lmm_request_matches_the_pre_port_construction_with_thinking_and_tokens() -> ( + None +): + # Copied verbatim from `git show 723646aed:.../qwen3_5vl/v1.py`: always + # sets `enable_thinking`, forwards `max_new_tokens` only when not None. + manager = _manager() + images = _make_images() + block = Qwen35VLBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally( + images=images, + model_version="m/1", + prompt="Hi", + system_prompt="Sys", + enable_thinking=True, + max_new_tokens=100, + ) + + manager.add_model.assert_called_once_with(model_id="m/1", api_key="k") + request = _captured_request(manager) + + inference_images = [i.to_inference_format(numpy_preferred=False) for i in images] + expected = LMMInferenceRequest( + api_key="k", + model_id="m/1", + image=inference_images[0], + source="workflow-execution", + prompt="HiSys", + enable_thinking=True, + max_new_tokens=100, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_depth_estimation_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show 723646aed:.../depth_estimation/v1.py`: + # `DepthEstimationRequest(image=image)` - no `model_id` on the request + # itself (it only goes to `infer_from_request_sync`'s own `model_id` kwarg). + manager = _manager() + images = _make_images() + block = DepthEstimationBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally(images=images, model_version="m/1") + + manager.add_model.assert_called_once_with(model_id="m/1", api_key="k") + request = _captured_request(manager) + assert manager.infer_from_request_sync.call_args.kwargs["model_id"] == "m/1" + + inference_images = [i.to_inference_format(numpy_preferred=False) for i in images] + expected = DepthEstimationRequest(image=inference_images[0]) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_moondream2_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show 723646aed:.../moondream2/v1.py`. + manager = _manager() + images = _make_images() + block = Moondream2BlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + block._post_process_result = lambda **_: None + + block.run_locally(images=images, model_version="m/1", prompt="p") + + manager.add_model.assert_called_once_with(model_id="m/1", api_key="k") + request = _captured_request(manager) + + inference_images = [i.to_inference_format(numpy_preferred=False) for i in images] + expected = Moondream2InferenceRequest( + api_key="k", + model_id="m/1", + image=inference_images[0], + text=[], + prompt="p", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm.py index cdba7d4dcb..6ca476b0a1 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm.py @@ -291,7 +291,7 @@ def test_run_dispatches_to_local_native_when_step_mode_local(): model_manager = MagicMock() fake_prediction = MagicMock() fake_prediction.response = "native local answer" - model_manager.infer_from_request_sync.return_value = fake_prediction + model_manager.run_lmm.return_value = {"response": fake_prediction.response} block = QwenVlmBlockV1( model_manager=model_manager, @@ -306,9 +306,8 @@ def test_run_dispatches_to_local_native_when_step_mode_local(): model_manager.add_model.assert_called_once_with( model_id="qwen3_5-2b", api_key="ws-key" ) - assert model_manager.infer_from_request_sync.called - request = model_manager.infer_from_request_sync.call_args.kwargs["request"] - assert request.enable_thinking is False + assert model_manager.run_lmm.called + assert model_manager.run_lmm.call_args.kwargs["enable_thinking"] is False def test_run_local_native_with_enable_thinking_splits_response(): @@ -317,7 +316,7 @@ def test_run_local_native_with_enable_thinking_splits_response(): model_manager = MagicMock() fake_prediction = MagicMock() fake_prediction.response = {"thinking": "reasoning...", "answer": "42"} - model_manager.infer_from_request_sync.return_value = fake_prediction + model_manager.run_lmm.return_value = {"response": fake_prediction.response} block = QwenVlmBlockV1( model_manager=model_manager, @@ -333,8 +332,7 @@ def test_run_local_native_with_enable_thinking_splits_response(): ) ) assert result == [{"output": "42", "classes": None, "thinking": "reasoning..."}] - request = model_manager.infer_from_request_sync.call_args.kwargs["request"] - assert request.enable_thinking is True + assert model_manager.run_lmm.call_args.kwargs["enable_thinking"] is True def test_run_local_native_enable_thinking_silently_ignored_on_unsupported_model(): @@ -344,7 +342,7 @@ def test_run_local_native_enable_thinking_silently_ignored_on_unsupported_model( model_manager = MagicMock() fake_prediction = MagicMock() fake_prediction.response = "ok" - model_manager.infer_from_request_sync.return_value = fake_prediction + model_manager.run_lmm.return_value = {"response": fake_prediction.response} block = QwenVlmBlockV1( model_manager=model_manager, @@ -360,8 +358,7 @@ def test_run_local_native_enable_thinking_silently_ignored_on_unsupported_model( enable_thinking=True, ) ) - request = model_manager.infer_from_request_sync.call_args.kwargs["request"] - assert request.enable_thinking is False + assert model_manager.run_lmm.call_args.kwargs["enable_thinking"] is False @patch( @@ -399,7 +396,7 @@ def test_run_dispatches_to_local_native_with_fine_tuned_model_id(): model_manager = MagicMock() fake_prediction = MagicMock() fake_prediction.response = "finetune answer" - model_manager.infer_from_request_sync.return_value = fake_prediction + model_manager.run_lmm.return_value = {"response": fake_prediction.response} block = QwenVlmBlockV1( model_manager=model_manager, diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v2.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v2.py index b1019bd0fe..dc38b59e1e 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v2.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v2.py @@ -407,7 +407,7 @@ def test_run_native_default_max_tokens_is_forwarded(): model_manager = MagicMock() fake_prediction = MagicMock() fake_prediction.response = "answer" - model_manager.infer_from_request_sync.return_value = fake_prediction + model_manager.run_lmm.return_value = {"response": fake_prediction.response} block = QwenVlmBlockV2( model_manager=model_manager, @@ -417,15 +417,14 @@ def test_run_native_default_max_tokens_is_forwarded(): ) block.run(**_base_run_kwargs()) - request = model_manager.infer_from_request_sync.call_args.kwargs["request"] - assert request.max_new_tokens == 2048 + assert model_manager.run_lmm.call_args.kwargs["max_new_tokens"] == 2048 def test_run_native_explicit_max_tokens_is_forwarded_as_max_new_tokens(): model_manager = MagicMock() fake_prediction = MagicMock() fake_prediction.response = "answer" - model_manager.infer_from_request_sync.return_value = fake_prediction + model_manager.run_lmm.return_value = {"response": fake_prediction.response} block = QwenVlmBlockV2( model_manager=model_manager, @@ -435,15 +434,14 @@ def test_run_native_explicit_max_tokens_is_forwarded_as_max_new_tokens(): ) block.run(**_base_run_kwargs(max_tokens=1024)) - request = model_manager.infer_from_request_sync.call_args.kwargs["request"] - assert request.max_new_tokens == 1024 + assert model_manager.run_lmm.call_args.kwargs["max_new_tokens"] == 1024 def test_run_dispatches_to_local_native_when_step_mode_local(): model_manager = MagicMock() fake_prediction = MagicMock() fake_prediction.response = "native local answer" - model_manager.infer_from_request_sync.return_value = fake_prediction + model_manager.run_lmm.return_value = {"response": fake_prediction.response} block = QwenVlmBlockV2( model_manager=model_manager, @@ -464,7 +462,7 @@ def test_run_local_native_with_enable_thinking_splits_response(): model_manager = MagicMock() fake_prediction = MagicMock() fake_prediction.response = {"thinking": "reasoning...", "answer": "42"} - model_manager.infer_from_request_sync.return_value = fake_prediction + model_manager.run_lmm.return_value = {"response": fake_prediction.response} block = QwenVlmBlockV2( model_manager=model_manager, @@ -480,8 +478,7 @@ def test_run_local_native_with_enable_thinking_splits_response(): ) ) assert result == [{"output": "42", "classes": None, "thinking": "reasoning..."}] - request = model_manager.infer_from_request_sync.call_args.kwargs["request"] - assert request.enable_thinking is True + assert model_manager.run_lmm.call_args.kwargs["enable_thinking"] is True @patch( @@ -512,7 +509,7 @@ def test_run_dispatches_to_local_native_with_fine_tuned_model_id(): model_manager = MagicMock() fake_prediction = MagicMock() fake_prediction.response = "finetune answer" - model_manager.infer_from_request_sync.return_value = fake_prediction + model_manager.run_lmm.return_value = {"response": fake_prediction.response} block = QwenVlmBlockV2( model_manager=model_manager, diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v3.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v3.py index d494365773..b0898abec5 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v3.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v3.py @@ -92,7 +92,7 @@ def test_run_native_reports_none_token_usage(): model_manager = MagicMock() fake_prediction = MagicMock() fake_prediction.response = "native local answer" - model_manager.infer_from_request_sync.return_value = fake_prediction + model_manager.run_lmm.return_value = {"response": fake_prediction.response} block = QwenVlmBlockV3( model_manager=model_manager, diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 312c746442..53d3f395fe 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,26 +1,14 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 32 +# Count: 19 inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py inference.core.entities.requests.clip inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py inference.core.entities.requests.clip -inference/core/workflows/core_steps/models/foundation/cosmos3/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/depth_estimation/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py inference.core.entities.requests.easy_ocr -inference/core/workflows/core_steps/models/foundation/florence2/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/glm_ocr/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/moondream2/v1.py inference.core.entities.requests.moondream2 inference/core/workflows/core_steps/models/foundation/ocr/v1.py inference.core.entities.requests.doctr inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py inference.core.entities.requests.perception_encoder inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py inference.core.entities.requests.pp_ocr inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py inference.core.entities.requests.pp_ocr -inference/core/workflows/core_steps/models/foundation/qwen/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/qwen3_5vl/v2.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/qwen3vl/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/qwen_vlm/v1.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/qwen_vlm/v2.py inference.core.entities.requests.inference -inference/core/workflows/core_steps/models/foundation/qwen_vlm/v3.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.requests.sam3 @@ -31,5 +19,4 @@ inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py inference.core.entities.requests.sam3_3d inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.entities.requests.sam2 -inference/core/workflows/core_steps/models/foundation/smolvlm/v1.py inference.core.entities.requests.inference inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.entities.requests.yolo_world From 9b222abdb04160b2cfb8d56c1fac3443fecfa481 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:27:38 +0200 Subject: [PATCH 66/86] test(workflows): parity cases compare model_fields_set; thinking-without-token-budget shape Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../test_port_requests_match_legacy_lmm.py | 78 ++++++++++++++++--- .../test_port_requests_match_legacy.py | 9 +++ 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_lmm.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_lmm.py index 94ad65019c..c68a680bea 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_lmm.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_lmm.py @@ -5,14 +5,24 @@ `ModelManagerModelsProvider` wrapping a `MagicMock` `ModelManager`, then compares the pydantic request the adapter built against the exact request construction the block used to run inline before Task 11.10 (copied from -`git show 723646aed:...//v1.py`, the BASE commit for this task - at -that commit these blocks were unmodified, so the pre-port construction is the -same code visible today on `git show 723646aed`). +`git show 32b749865:...//v1.py` - the commit this task was actually +built on top of; it landed after this task's nominal BASE, 723646aed, but +touched none of the files these tests read, so the pre-port construction is +unchanged there). + +Every case asserts both `model_dump()` equality AND `model_fields_set` +equality: an omitted (UNSET) field must stay omitted, not be re-supplied as +its own default - `model_dump()` alone can't tell "never set" from +"explicitly set to the same value as the default" apart, but the pydantic +`model_fields_set` bookkeeping can. One case per distinct request-building shape the task touches: `LMMInferenceRequest` with neither optional field set (`cosmos3`), with only -`max_new_tokens` forwarded (`glm_ocr`), and with both `enable_thinking` and -`max_new_tokens` forwarded (`qwen3_5vl` v1); plus `DepthEstimationRequest` +`max_new_tokens` forwarded (`glm_ocr`), with both `enable_thinking` and +`max_new_tokens` forwarded (`qwen3_5vl` v1), and with `enable_thinking` set +but `max_new_tokens` left `None` - and therefore omitted, per the old +`if max_new_tokens is not None` guard also present in `qwen_vlm/v3.py`'s +`_run_native_locally` (`qwen3_5vl` v1 again); plus `DepthEstimationRequest` (`depth_estimation`) and `Moondream2InferenceRequest` (`moondream2`). """ @@ -79,7 +89,7 @@ def _captured_request(manager: MagicMock): def test_lmm_request_matches_the_pre_port_construction_without_optional_fields() -> ( None ): - # Copied verbatim from `git show 723646aed:.../cosmos3/v1.py`: neither + # Copied verbatim from `git show 32b749865:.../cosmos3/v1.py`: neither # `enable_thinking` nor `max_new_tokens` is ever set by this block. manager = _manager() images = _make_images() @@ -108,12 +118,13 @@ def test_lmm_request_matches_the_pre_port_construction_without_optional_fields() prompt="custom prompt", ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set def test_lmm_request_matches_the_pre_port_construction_with_max_new_tokens_only() -> ( None ): - # Copied verbatim from `git show 723646aed:.../glm_ocr/v1.py`: forwards + # Copied verbatim from `git show 32b749865:.../glm_ocr/v1.py`: forwards # `max_new_tokens` only when it is not None; never sets `enable_thinking`. manager = _manager() images = _make_images() @@ -143,12 +154,13 @@ def test_lmm_request_matches_the_pre_port_construction_with_max_new_tokens_only( max_new_tokens=64, ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set def test_lmm_request_matches_the_pre_port_construction_with_thinking_and_tokens() -> ( None ): - # Copied verbatim from `git show 723646aed:.../qwen3_5vl/v1.py`: always + # Copied verbatim from `git show 32b749865:.../qwen3_5vl/v1.py`: always # sets `enable_thinking`, forwards `max_new_tokens` only when not None. manager = _manager() images = _make_images() @@ -181,10 +193,54 @@ def test_lmm_request_matches_the_pre_port_construction_with_thinking_and_tokens( max_new_tokens=100, ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set + + +def test_lmm_request_matches_the_pre_port_construction_with_thinking_only() -> None: + # Copied verbatim from `git show 32b749865:.../qwen3_5vl/v1.py` (the same + # `if max_new_tokens is not None: request_kwargs["max_new_tokens"] = ...` + # guard is in `qwen_vlm/v3.py`'s `_run_native_locally`): `enable_thinking` + # is always forwarded, but with `max_new_tokens=None` the guard never + # fires, so the OLD request never had `max_new_tokens` in + # `model_fields_set` either - only `model_dump()` equality would miss a + # regression here, since an omitted field and one explicitly set to its + # own default (`None`) dump identically. + manager = _manager() + images = _make_images() + block = Qwen35VLBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally( + images=images, + model_version="m/1", + prompt="Hi", + system_prompt="Sys", + enable_thinking=True, + max_new_tokens=None, + ) + + manager.add_model.assert_called_once_with(model_id="m/1", api_key="k") + request = _captured_request(manager) + + inference_images = [i.to_inference_format(numpy_preferred=False) for i in images] + expected = LMMInferenceRequest( + api_key="k", + model_id="m/1", + image=inference_images[0], + source="workflow-execution", + prompt="HiSys", + enable_thinking=True, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set + assert "max_new_tokens" not in request.model_fields_set def test_depth_estimation_request_matches_the_pre_port_construction() -> None: - # Copied verbatim from `git show 723646aed:.../depth_estimation/v1.py`: + # Copied verbatim from `git show 32b749865:.../depth_estimation/v1.py`: # `DepthEstimationRequest(image=image)` - no `model_id` on the request # itself (it only goes to `infer_from_request_sync`'s own `model_id` kwarg). manager = _manager() @@ -204,10 +260,11 @@ def test_depth_estimation_request_matches_the_pre_port_construction() -> None: inference_images = [i.to_inference_format(numpy_preferred=False) for i in images] expected = DepthEstimationRequest(image=inference_images[0]) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set def test_moondream2_request_matches_the_pre_port_construction() -> None: - # Copied verbatim from `git show 723646aed:.../moondream2/v1.py`. + # Copied verbatim from `git show 32b749865:.../moondream2/v1.py`. manager = _manager() images = _make_images() block = Moondream2BlockV1( @@ -231,3 +288,4 @@ def test_moondream2_request_matches_the_pre_port_construction() -> None: prompt="p", ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set diff --git a/tests/workflows/unit_tests/core_steps/models/roboflow/test_port_requests_match_legacy.py b/tests/workflows/unit_tests/core_steps/models/roboflow/test_port_requests_match_legacy.py index 72cbecd4c8..a182552345 100644 --- a/tests/workflows/unit_tests/core_steps/models/roboflow/test_port_requests_match_legacy.py +++ b/tests/workflows/unit_tests/core_steps/models/roboflow/test_port_requests_match_legacy.py @@ -136,6 +136,7 @@ def test_object_detection_v1_request_matches_the_pre_port_construction() -> None source="workflow-execution", ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set def test_object_detection_v3_request_matches_the_pre_port_construction() -> None: @@ -182,6 +183,7 @@ def test_object_detection_v3_request_matches_the_pre_port_construction() -> None source="workflow-execution", ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set def test_multi_class_classification_v1_request_matches_the_pre_port_construction() -> ( @@ -219,6 +221,7 @@ def test_multi_class_classification_v1_request_matches_the_pre_port_construction active_learning_target_dataset=None, ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set def test_multi_label_classification_v1_request_matches_the_pre_port_construction() -> ( @@ -256,6 +259,7 @@ def test_multi_label_classification_v1_request_matches_the_pre_port_construction active_learning_target_dataset=None, ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set def test_multi_label_classification_v2_forwards_the_separate_confidence_keyword() -> ( @@ -297,6 +301,7 @@ def test_multi_label_classification_v2_forwards_the_separate_confidence_keyword( active_learning_target_dataset=None, ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set # Old code: `infer_from_request_sync(model_id=model_id, request=request, # confidence=confidence)` - the extra `confidence` keyword, unchanged. @@ -342,6 +347,7 @@ def test_multi_label_classification_v3_forwards_the_separate_confidence_keyword( active_learning_target_dataset=None, ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set # Old code: `infer_from_request_sync(model_id=model_id, request=request, # confidence=confidence)` - the extra `confidence` keyword, unchanged. @@ -394,6 +400,7 @@ def test_keypoint_detection_v1_request_matches_the_pre_port_construction() -> No source="workflow-execution", ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set def test_semantic_segmentation_v1_request_matches_the_pre_port_construction() -> None: @@ -422,6 +429,7 @@ def test_semantic_segmentation_v1_request_matches_the_pre_port_construction() -> source="workflow-execution", ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set def test_semantic_segmentation_v2_request_matches_the_pre_port_construction() -> None: @@ -452,3 +460,4 @@ def test_semantic_segmentation_v2_request_matches_the_pre_port_construction() -> source="workflow-execution", ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set From b205c1af8218a0c2ba362e95da711910f1079dbc Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:50:56 +0200 Subject: [PATCH 67/86] refactor(workflows): clip and perception-encoder blocks run through the plain-argument port Adds run_clip_text_embedding, run_clip_image_embedding, run_clip_comparison, run_perception_encoder_text_embedding and run_perception_encoder_image_embedding to the ModelsProvider port and its ModelManagerModelsProvider adapter, and switches clip/v1.py, clip_comparison/v1.py, clip_comparison/v2.py and perception_encoder/v1.py to call them instead of building inference.core.entities request objects inline. load_core_model stays in clip/v1.py and perception_encoder/v1.py; run_clip_comparison registers the model itself (its core model id is only known from the validated request) in the same build -> register -> infer position the blocks used. Decontamination baseline 19 -> 15. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../interfaces/workflows_models_provider.py | 90 ++++++ .../core_steps/models/foundation/clip/v1.py | 46 ++-- .../models/foundation/clip_comparison/v1.py | 26 +- .../models/foundation/clip_comparison/v2.py | 32 +-- .../foundation/perception_encoder/v1.py | 44 ++- .../workflows/prototypes/models_provider.py | 42 +++ .../test_workflows_models_provider.py | 102 +++++++ .../core_steps/models/foundation/test_clip.py | 9 +- .../test_image_prep_before_registration.py | 37 +++ .../foundation/test_perception_encoder.py | 7 +- .../test_port_requests_match_legacy_clip.py | 260 ++++++++++++++++++ .../unit_tests/decontamination_baseline.txt | 6 +- 12 files changed, 596 insertions(+), 105 deletions(-) create mode 100644 tests/workflows/unit_tests/core_steps/models/foundation/test_image_prep_before_registration.py create mode 100644 tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_clip.py diff --git a/inference/core/interfaces/workflows_models_provider.py b/inference/core/interfaces/workflows_models_provider.py index 1402d87084..2d9ad12610 100644 --- a/inference/core/interfaces/workflows_models_provider.py +++ b/inference/core/interfaces/workflows_models_provider.py @@ -30,6 +30,11 @@ from typing import Any, Dict, List, Optional, Union +from inference.core.entities.requests.clip import ( + ClipCompareRequest, + ClipImageEmbeddingRequest, + ClipTextEmbeddingRequest, +) from inference.core.entities.requests.inference import ( ClassificationInferenceRequest, DepthEstimationRequest, @@ -40,7 +45,12 @@ SemanticSegmentationInferenceRequest, ) from inference.core.entities.requests.moondream2 import Moondream2InferenceRequest +from inference.core.entities.requests.perception_encoder import ( + PerceptionEncoderImageEmbeddingRequest, + PerceptionEncoderTextEmbeddingRequest, +) from inference.core.managers.base import ModelManager +from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.prototypes.models_provider import ( UNSET, InferenceResultsDC, @@ -301,6 +311,86 @@ def run_moondream2( ) return self._dump(self._infer(model_id=model_id, request=request))[0] + def run_clip_text_embedding( + self, + model_id: str, + version_id: str, + text: List[str], + api_key: Optional[str] = None, + ) -> List[List[float]]: + request = ClipTextEmbeddingRequest( + clip_version_id=version_id, text=text, api_key=api_key + ) + return self._infer(model_id=model_id, request=request)[0].embeddings + + def run_clip_image_embedding( + self, + model_id: str, + version_id: str, + images: List[Any], + api_key: Optional[str] = None, + ) -> List[List[float]]: + request = ClipImageEmbeddingRequest( + clip_version_id=version_id, image=images, api_key=api_key + ) + return self._infer(model_id=model_id, request=request)[0].embeddings + + def run_clip_comparison( + self, + subject: Any, + subject_type: str, + prompt: Any, + prompt_type: str, + api_key: Optional[str] = None, + version_id: Union[str, None, _Unset] = UNSET, + ) -> dict: + # v1 never sets the version (UNSET -> the pydantic default, + # env.CLIP_VERSION_ID); v2 passes its `version` local, forwarded as is - + # a None stays None, and the id below is then "clip/None", exactly what + # `load_core_model` derived from the validated request before. + request = ClipCompareRequest( + api_key=api_key, + subject=subject, + subject_type=subject_type, + prompt=prompt, + prompt_type=prompt_type, + **_passed(clip_version_id=version_id), + ) + # Registration in the same position the block used: build -> register -> + # infer. The id is only knowable from the validated request when the + # version came from the pydantic default (clip_comparison/v1.py). + core_model_id = f"clip/{request.clip_version_id}" + self._model_manager.add_model( + core_model_id, api_key, endpoint_type=ModelEndpointType.CORE_MODEL + ) + # The two clip_comparison blocks used the BARE `model_dump()`, not the + # by_alias/exclude_none form - keep that, the output keys depend on it. + return self._infer(model_id=core_model_id, request=request)[0].model_dump() + + def run_perception_encoder_text_embedding( + self, + model_id: str, + version_id: str, + text: List[str], + api_key: Optional[str] = None, + ) -> List[List[float]]: + request = PerceptionEncoderTextEmbeddingRequest( + perception_encoder_version_id=version_id, text=text, api_key=api_key + ) + return self._infer(model_id=model_id, request=request)[0].embeddings + + def run_perception_encoder_image_embedding( + self, + model_id: str, + version_id: str, + images: List[Any], + api_key: Optional[str] = None, + ) -> List[List[float]]: + request = PerceptionEncoderImageEmbeddingRequest( + perception_encoder_version_id=version_id, image=images, api_key=api_key + ) + return self._infer(model_id=model_id, request=request)[0].embeddings + def run_tensor_native_inference(self, model_id: str, **kwargs: Any) -> Any: return self._model_manager.run_tensor_native_inference( model_id=model_id, **kwargs diff --git a/inference/core/workflows/core_steps/models/foundation/clip/v1.py b/inference/core/workflows/core_steps/models/foundation/clip/v1.py index 30f8d0eebe..3127cbad4c 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/clip/v1.py @@ -3,10 +3,6 @@ from pydantic import ConfigDict, Field -from inference.core.entities.requests.clip import ( - ClipImageEmbeddingRequest, - ClipTextEmbeddingRequest, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import load_core_model from inference.core.workflows.environment import ( @@ -189,40 +185,36 @@ def run_locally( if cached_value is not None: return {"embedding": cached_value} - inference_request = ClipTextEmbeddingRequest( - clip_version_id=version, - text=[data], - api_key=self._api_key, - ) clip_model_id = load_core_model( model_manager=self._model_manager, - version_id=inference_request.clip_version_id, - api_key=self._api_key, core_model="clip", + version_id=version, + api_key=self._api_key, ) - predictions = self._model_manager.infer_from_request_sync( - clip_model_id, inference_request - ) - - text_cache.set(hash_key, predictions.embeddings[0]) - - return {"embedding": predictions.embeddings[0]} - else: - inference_request = ClipImageEmbeddingRequest( - clip_version_id=version, - image=[data.to_inference_format(numpy_preferred=True)], + embeddings = self._model_manager.run_clip_text_embedding( + model_id=clip_model_id, + version_id=version, + text=[data], api_key=self._api_key, ) + text_cache.set(hash_key, embeddings[0]) + return {"embedding": embeddings[0]} + else: + # decode BEFORE registration, as HEAD does (CR-1) + image = data.to_inference_format(numpy_preferred=True) clip_model_id = load_core_model( model_manager=self._model_manager, - version_id=inference_request.clip_version_id, - api_key=self._api_key, core_model="clip", + version_id=version, + api_key=self._api_key, ) - predictions = self._model_manager.infer_from_request_sync( - clip_model_id, inference_request + embeddings = self._model_manager.run_clip_image_embedding( + model_id=clip_model_id, + version_id=version, + images=[image], + api_key=self._api_key, ) - return {"embedding": predictions.embeddings[0]} + return {"embedding": embeddings[0]} def run_remotely( self, diff --git a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py index 96845e46ac..03dfa3aab0 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py @@ -3,10 +3,8 @@ from pydantic import AliasChoices, ConfigDict, Field -from inference.core.entities.requests.clip import ClipCompareRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( - load_core_model, remove_unexpected_keys_from_dictionary, run_in_parallel, ) @@ -168,23 +166,15 @@ def run_locally( ) -> BlockResult: predictions = [] for single_image in images: - inference_request = ClipCompareRequest( - subject=single_image.to_inference_format(numpy_preferred=True), - subject_type="image", - prompt=texts, - prompt_type="text", - api_key=self._api_key, - ) - clip_model_id = load_core_model( - model_manager=self._model_manager, - version_id=inference_request.clip_version_id, - api_key=self._api_key, - core_model="clip", - ) - prediction = self._model_manager.infer_from_request_sync( - clip_model_id, inference_request + predictions.append( + self._model_manager.run_clip_comparison( + subject=single_image.to_inference_format(numpy_preferred=True), + subject_type="image", + prompt=texts, + prompt_type="text", + api_key=self._api_key, + ) ) - predictions.append(prediction.model_dump()) return self._post_process_result( images=images, predictions=predictions, diff --git a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py index cbad8d0eca..fabc5f1273 100644 --- a/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py @@ -4,12 +4,8 @@ import numpy as np from pydantic import ConfigDict, Field -from inference.core.entities.requests.clip import ClipCompareRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import ( - load_core_model, - run_in_parallel, -) +from inference.core.workflows.core_steps.common.utils import run_in_parallel from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, @@ -203,24 +199,16 @@ def run_locally( ) -> BlockResult: predictions = [] for single_image in images: - inference_request = ClipCompareRequest( - clip_version_id=version, - subject=single_image.to_inference_format(numpy_preferred=True), - subject_type="image", - prompt=classes, - prompt_type="text", - api_key=self._api_key, - ) - clip_model_id = load_core_model( - model_manager=self._model_manager, - version_id=inference_request.clip_version_id, - api_key=self._api_key, - core_model="clip", - ) - prediction = self._model_manager.infer_from_request_sync( - clip_model_id, inference_request + predictions.append( + self._model_manager.run_clip_comparison( + subject=single_image.to_inference_format(numpy_preferred=True), + subject_type="image", + prompt=classes, + prompt_type="text", + api_key=self._api_key, + version_id=version, + ) ) - predictions.append(prediction.model_dump()) return self._post_process_result( images=images, predictions=predictions, diff --git a/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py b/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py index 808984ea09..7d4e41eee4 100644 --- a/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py @@ -3,10 +3,6 @@ from pydantic import ConfigDict, Field -from inference.core.entities.requests.perception_encoder import ( - PerceptionEncoderImageEmbeddingRequest, - PerceptionEncoderTextEmbeddingRequest, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import load_core_model from inference.core.workflows.environment import ( @@ -197,38 +193,36 @@ def run_locally( cached_value = text_cache.get(hash_key) if cached_value is not None: return {"embedding": cached_value} - inference_request = PerceptionEncoderTextEmbeddingRequest( - perception_encoder_version_id=version, - text=[data], - api_key=self._api_key, - ) pe_model_id = load_core_model( model_manager=self._model_manager, - version_id=inference_request.perception_encoder_version_id, - api_key=self._api_key, core_model="perception_encoder", + version_id=version, + api_key=self._api_key, ) - predictions = self._model_manager.infer_from_request_sync( - pe_model_id, inference_request - ) - text_cache.set(hash_key, predictions.embeddings[0]) - return {"embedding": predictions.embeddings[0]} - else: - inference_request = PerceptionEncoderImageEmbeddingRequest( - perception_encoder_version_id=version, - image=[data.to_inference_format(numpy_preferred=True)], + embeddings = self._model_manager.run_perception_encoder_text_embedding( + model_id=pe_model_id, + version_id=version, + text=[data], api_key=self._api_key, ) + text_cache.set(hash_key, embeddings[0]) + return {"embedding": embeddings[0]} + else: + # decode BEFORE registration, as HEAD does (CR-1) + image = data.to_inference_format(numpy_preferred=True) pe_model_id = load_core_model( model_manager=self._model_manager, - version_id=inference_request.perception_encoder_version_id, - api_key=self._api_key, core_model="perception_encoder", + version_id=version, + api_key=self._api_key, ) - predictions = self._model_manager.infer_from_request_sync( - pe_model_id, inference_request + embeddings = self._model_manager.run_perception_encoder_image_embedding( + model_id=pe_model_id, + version_id=version, + images=[image], + api_key=self._api_key, ) - return {"embedding": predictions.embeddings[0]} + return {"embedding": embeddings[0]} def run_remotely( self, diff --git a/inference/core/workflows/prototypes/models_provider.py b/inference/core/workflows/prototypes/models_provider.py index 58309d96e3..bbb4c7eeea 100644 --- a/inference/core/workflows/prototypes/models_provider.py +++ b/inference/core/workflows/prototypes/models_provider.py @@ -172,6 +172,48 @@ def run_moondream2( api_key: Optional[str] = None, ) -> dict: ... + def run_clip_text_embedding( + self, + model_id: str, + version_id: str, + text: List[str], + api_key: Optional[str] = None, + ) -> List[List[float]]: ... + + def run_clip_image_embedding( + self, + model_id: str, + version_id: str, + images: List[Any], + api_key: Optional[str] = None, + ) -> List[List[float]]: ... + + def run_clip_comparison( + self, + subject: Any, + subject_type: str, + prompt: Any, + prompt_type: str, + api_key: Optional[str] = None, + version_id: Union[str, None, _Unset] = UNSET, + ) -> dict: ... + + def run_perception_encoder_text_embedding( + self, + model_id: str, + version_id: str, + text: List[str], + api_key: Optional[str] = None, + ) -> List[List[float]]: ... + + def run_perception_encoder_image_embedding( + self, + model_id: str, + version_id: str, + images: List[Any], + api_key: Optional[str] = None, + ) -> List[List[float]]: ... + def run_tensor_native_inference(self, model_id: str, **kwargs: Any) -> Any: ... def get_class_names(self, model_id: str) -> List[str]: ... diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py index c831f7d62d..dd42e81a8a 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py @@ -507,3 +507,105 @@ def test_run_depth_estimation_returns_the_raw_response_field() -> None: == "depth-map" ) assert isinstance(captured_request(manager), DepthEstimationRequest) + + +from inference.core.entities.requests.clip import ( + ClipCompareRequest, + ClipImageEmbeddingRequest, + ClipTextEmbeddingRequest, +) + + +class _EmbeddingResponse: + def __init__(self, embeddings): + self.embeddings = embeddings + + +class _ComparisonResponse: + def __init__(self, similarity): + self._similarity = similarity + + def model_dump(self, **_kwargs): + return {"similarity": self._similarity} + + +def test_run_clip_text_embedding_builds_the_request_and_returns_embeddings() -> None: + manager = manager_returning(_EmbeddingResponse([[0.1, 0.2]])) + assert ModelManagerModelsProvider(manager).run_clip_text_embedding( + model_id="clip/ViT-B-16", version_id="ViT-B-16", text=["a cat"], api_key="k" + ) == [[0.1, 0.2]] + manager.add_model.assert_not_called() # the block registers, not the adapter + request = captured_request(manager) + expected = ClipTextEmbeddingRequest( + clip_version_id="ViT-B-16", text=["a cat"], api_key="k" + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_run_clip_image_embedding_builds_the_request_and_returns_embeddings() -> None: + manager = manager_returning(_EmbeddingResponse([[0.3]])) + assert ModelManagerModelsProvider(manager).run_clip_image_embedding( + model_id="clip/ViT-B-16", version_id="ViT-B-16", images=IMAGES, api_key="k" + ) == [[0.3]] + request = captured_request(manager) + expected = ClipImageEmbeddingRequest( + clip_version_id="ViT-B-16", image=IMAGES, api_key="k" + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_run_clip_comparison_leaves_the_version_default_when_unset() -> None: + from inference.core.roboflow_api import ModelEndpointType + + manager = manager_returning(_ComparisonResponse([0.5])) + assert ModelManagerModelsProvider(manager).run_clip_comparison( + subject=IMAGE, + subject_type="image", + prompt=["cat"], + prompt_type="text", + api_key="k", + ) == {"similarity": [0.5]} + request = captured_request(manager) + expected = ClipCompareRequest( + api_key="k", + subject=IMAGE, + subject_type="image", + prompt=["cat"], + prompt_type="text", + ) + assert request.clip_version_id == expected.clip_version_id + # This method - and only this method, among the CLIP/PE family - registers. + manager.add_model.assert_called_once_with( + f"clip/{expected.clip_version_id}", + "k", + endpoint_type=ModelEndpointType.CORE_MODEL, + ) + + +def test_run_perception_encoder_embeddings_build_their_requests() -> None: + from inference.core.entities.requests.perception_encoder import ( + PerceptionEncoderImageEmbeddingRequest, + PerceptionEncoderTextEmbeddingRequest, + ) + + manager = manager_returning(_EmbeddingResponse([[1.0]])) + provider = ModelManagerModelsProvider(manager) + assert provider.run_perception_encoder_text_embedding( + model_id="perception_encoder/v", version_id="v", text=["a"], api_key="k" + ) == [[1.0]] + request = captured_request(manager) + expected = PerceptionEncoderTextEmbeddingRequest( + perception_encoder_version_id="v", text=["a"], api_key="k" + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + manager = manager_returning(_EmbeddingResponse([[2.0]])) + provider = ModelManagerModelsProvider(manager) + assert provider.run_perception_encoder_image_embedding( + model_id="perception_encoder/v", version_id="v", images=IMAGES, api_key="k" + ) == [[2.0]] + request = captured_request(manager) + expected = PerceptionEncoderImageEmbeddingRequest( + perception_encoder_version_id="v", image=IMAGES, api_key="k" + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_clip.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_clip.py index 6ee576a7f8..31be40e02d 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_clip.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_clip.py @@ -19,9 +19,8 @@ def mock_model_manager(): # Mock a model manager that returns a predictable embedding mock = MagicMock() - mock.infer_from_request_sync.return_value = MagicMock( - embeddings=[[0.1, 0.2, 0.3]] # Sample embedding - ) + mock.run_clip_text_embedding.return_value = [[0.1, 0.2, 0.3]] + mock.run_clip_image_embedding.return_value = [[0.1, 0.2, 0.3]] return mock @@ -84,7 +83,7 @@ def test_run_locally_with_text(mock_model_manager): assert isinstance(result, dict) assert len(result["embedding"]) == 3 assert result["embedding"] == [0.1, 0.2, 0.3] - mock_model_manager.infer_from_request_sync.assert_called_once() + mock_model_manager.run_clip_text_embedding.assert_called_once() def test_run_locally_with_image(mock_model_manager, mock_workflow_image_data): @@ -99,7 +98,7 @@ def test_run_locally_with_image(mock_model_manager, mock_workflow_image_data): assert isinstance(result, dict) assert len(result["embedding"]) == 3 assert result["embedding"] == [0.1, 0.2, 0.3] - mock_model_manager.infer_from_request_sync.assert_called_once() + mock_model_manager.run_clip_image_embedding.assert_called_once() @patch( diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_image_prep_before_registration.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_image_prep_before_registration.py new file mode 100644 index 0000000000..2062d168a9 --- /dev/null +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_image_prep_before_registration.py @@ -0,0 +1,37 @@ +"""CR-1 regression: the block must decode the image BEFORE registering the +model with `load_core_model`, matching the pre-port (BASE) ordering. If a +future edit moves the decode back after registration, `add_model` fires +before the `ValueError` from a failed decode, and this test catches it. + +One case per task; Task 11.12 and 11.14 add their own cases here. +""" + +from unittest import mock + +import pytest + +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.models.foundation.clip.v1 import ( + ClipModelBlockV1, +) +from inference.core.workflows.execution_engine.entities.base import WorkflowImageData + + +def test_failed_image_preparation_leaves_registration_and_inference_uncalled() -> None: + # given + model_manager = mock.MagicMock() + block = ClipModelBlockV1( + model_manager=model_manager, + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + image = mock.MagicMock(spec=WorkflowImageData) + image.to_inference_format.side_effect = ValueError("decode failed") + + # when + with pytest.raises(ValueError, match="decode failed"): + block.run(data=image, version="RN50") + + # then — HEAD parity: nothing registered, nothing inferred + model_manager.add_model.assert_not_called() + model_manager.run_clip_image_embedding.assert_not_called() diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_perception_encoder.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_perception_encoder.py index 45b12407fd..265dbfe9b5 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_perception_encoder.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_perception_encoder.py @@ -18,7 +18,8 @@ @pytest.fixture def mock_model_manager(): mock = MagicMock() - mock.infer_from_request_sync.return_value = MagicMock(embeddings=[[0.1, 0.2, 0.3]]) + mock.run_perception_encoder_text_embedding.return_value = [[0.1, 0.2, 0.3]] + mock.run_perception_encoder_image_embedding.return_value = [[0.1, 0.2, 0.3]] return mock @@ -66,7 +67,7 @@ def test_run_locally_with_text(mock_model_manager): result = block.run(data="hello", version="PE-Core-B16-224") assert result["embedding"] == [0.1, 0.2, 0.3] - mock_model_manager.infer_from_request_sync.assert_called_once() + mock_model_manager.run_perception_encoder_text_embedding.assert_called_once() def test_run_locally_with_image(mock_model_manager, mock_workflow_image_data): @@ -79,7 +80,7 @@ def test_run_locally_with_image(mock_model_manager, mock_workflow_image_data): result = block.run(data=mock_workflow_image_data, version="PE-Core-B16-224") assert result["embedding"] == [0.1, 0.2, 0.3] - mock_model_manager.infer_from_request_sync.assert_called_once() + mock_model_manager.run_perception_encoder_image_embedding.assert_called_once() @patch( diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_clip.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_clip.py new file mode 100644 index 0000000000..9e9a4b68bf --- /dev/null +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_clip.py @@ -0,0 +1,260 @@ +"""Block -> adapter -> request differential tests for Task 11.11's CLIP and +Perception Encoder blocks. + +Each test drives a block's real `run_locally` through a real +`ModelManagerModelsProvider` wrapping a `MagicMock` `ModelManager`, then +compares the pydantic request the adapter built against the exact request +construction the block used to run inline before Task 11.11 (this task's BASE +commit, e6ad76d4e, is also HEAD of this checkout - the pre-port construction +below is copied straight from `git show e6ad76d4e:`, i.e. the source +this task started from). + +One case per distinct request-building shape the task touches: +`ClipTextEmbeddingRequest`/`ClipImageEmbeddingRequest` (`clip/v1.py`), +`ClipCompareRequest` with the version left UNSET (`clip_comparison/v1.py`, +which never set `clip_version_id`) and with it forwarded explicitly +(`clip_comparison/v2.py`), and +`PerceptionEncoderTextEmbeddingRequest`/`PerceptionEncoderImageEmbeddingRequest` +(`perception_encoder/v1.py`). +""" + +from unittest.mock import MagicMock + +import numpy as np + +from inference.core.entities.requests.clip import ( + ClipCompareRequest, + ClipImageEmbeddingRequest, + ClipTextEmbeddingRequest, +) +from inference.core.entities.requests.perception_encoder import ( + PerceptionEncoderImageEmbeddingRequest, + PerceptionEncoderTextEmbeddingRequest, +) +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) +from inference.core.roboflow_api import ModelEndpointType +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.models.foundation.clip.v1 import ( + ClipModelBlockV1, +) +from inference.core.workflows.core_steps.models.foundation.clip_comparison.v1 import ( + ClipComparisonBlockV1, +) +from inference.core.workflows.core_steps.models.foundation.clip_comparison.v2 import ( + ClipComparisonBlockV2, +) +from inference.core.workflows.core_steps.models.foundation.perception_encoder.v1 import ( + PerceptionEncoderModelBlockV1, +) +from inference.core.workflows.execution_engine.entities.base import ( + Batch, + ImageParentMetadata, + WorkflowImageData, +) +from inference.core.workflows.prototypes.models_provider import CORE_MODEL_ENDPOINT_TYPE + + +class _EmbeddingResponse: + def __init__(self, embeddings): + self.embeddings = embeddings + + +class _ComparisonResponse: + def __init__(self, similarity): + self._similarity = similarity + + def model_dump(self, **_kwargs): + return {"similarity": self._similarity} + + +def _make_image() -> WorkflowImageData: + return WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="p"), + numpy_image=np.zeros((20, 10, 3), dtype=np.uint8), + ) + + +def _manager() -> MagicMock: + return MagicMock() + + +def _captured_request(manager: MagicMock): + assert manager.infer_from_request_sync.call_count == 1 + call = manager.infer_from_request_sync.call_args + return call.kwargs["request"] if "request" in call.kwargs else call.args[1] + + +def test_clip_text_embedding_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show e6ad76d4e:.../clip/v1.py`: + # `ClipTextEmbeddingRequest(clip_version_id=version, text=[data], api_key=...)`. + manager = _manager() + manager.infer_from_request_sync.return_value = _EmbeddingResponse([[0.1, 0.2]]) + block = ClipModelBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + result = block.run_locally(data="a parity test cat", version="ViT-B-16") + + manager.add_model.assert_called_once_with( + model_id="clip/ViT-B-16", api_key="k", endpoint_type=CORE_MODEL_ENDPOINT_TYPE + ) + request = _captured_request(manager) + expected = ClipTextEmbeddingRequest( + clip_version_id="ViT-B-16", text=["a parity test cat"], api_key="k" + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert result == {"embedding": [0.1, 0.2]} + + +def test_clip_image_embedding_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show e6ad76d4e:.../clip/v1.py`: + # `ClipImageEmbeddingRequest(clip_version_id=version, + # image=[data.to_inference_format(numpy_preferred=True)], api_key=...)`. + manager = _manager() + manager.infer_from_request_sync.return_value = _EmbeddingResponse([[0.3]]) + image = _make_image() + block = ClipModelBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + result = block.run_locally(data=image, version="RN50") + + manager.add_model.assert_called_once_with( + model_id="clip/RN50", api_key="k", endpoint_type=CORE_MODEL_ENDPOINT_TYPE + ) + request = _captured_request(manager) + expected = ClipImageEmbeddingRequest( + clip_version_id="RN50", + image=[image.to_inference_format(numpy_preferred=True)], + api_key="k", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert result == {"embedding": [0.3]} + + +def test_clip_comparison_v1_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show e6ad76d4e:.../clip_comparison/v1.py`: + # `ClipCompareRequest(subject=..., subject_type="image", prompt=texts, + # prompt_type="text", api_key=...)` - no `clip_version_id`, so the + # pydantic default (`env.CLIP_VERSION_ID`) applies. + manager = _manager() + manager.infer_from_request_sync.return_value = _ComparisonResponse([0.5, 0.6]) + image = _make_image() + images = Batch(content=[image], indices=[(0,)]) + block = ClipComparisonBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally(images=images, texts=["cat", "dog"]) + + request = _captured_request(manager) + expected = ClipCompareRequest( + api_key="k", + subject=image.to_inference_format(numpy_preferred=True), + subject_type="image", + prompt=["cat", "dog"], + prompt_type="text", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + manager.add_model.assert_called_once_with( + f"clip/{expected.clip_version_id}", + "k", + endpoint_type=ModelEndpointType.CORE_MODEL, + ) + + +def test_clip_comparison_v2_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show e6ad76d4e:.../clip_comparison/v2.py`: + # `ClipCompareRequest(clip_version_id=version, subject=..., ...)`. + manager = _manager() + manager.infer_from_request_sync.return_value = _ComparisonResponse([0.2, 0.9]) + image = _make_image() + images = Batch(content=[image], indices=[(0,)]) + block = ClipComparisonBlockV2( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally(images=images, classes=["a", "b"], version="RN50") + + request = _captured_request(manager) + expected = ClipCompareRequest( + clip_version_id="RN50", + subject=image.to_inference_format(numpy_preferred=True), + subject_type="image", + prompt=["a", "b"], + prompt_type="text", + api_key="k", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + manager.add_model.assert_called_once_with( + "clip/RN50", "k", endpoint_type=ModelEndpointType.CORE_MODEL + ) + + +def test_perception_encoder_text_embedding_request_matches_the_pre_port_construction() -> ( + None +): + # Copied verbatim from `git show e6ad76d4e:.../perception_encoder/v1.py`. + manager = _manager() + manager.infer_from_request_sync.return_value = _EmbeddingResponse([[0.4]]) + block = PerceptionEncoderModelBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + result = block.run_locally(data="a parity test string", version="PE-Core-B16-224") + + manager.add_model.assert_called_once_with( + model_id="perception_encoder/PE-Core-B16-224", + api_key="k", + endpoint_type=CORE_MODEL_ENDPOINT_TYPE, + ) + request = _captured_request(manager) + expected = PerceptionEncoderTextEmbeddingRequest( + perception_encoder_version_id="PE-Core-B16-224", + text=["a parity test string"], + api_key="k", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert result == {"embedding": [0.4]} + + +def test_perception_encoder_image_embedding_request_matches_the_pre_port_construction() -> ( + None +): + # Copied verbatim from `git show e6ad76d4e:.../perception_encoder/v1.py`. + manager = _manager() + manager.infer_from_request_sync.return_value = _EmbeddingResponse([[0.5]]) + image = _make_image() + block = PerceptionEncoderModelBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + result = block.run_locally(data=image, version="PE-Core-L14-336") + + manager.add_model.assert_called_once_with( + model_id="perception_encoder/PE-Core-L14-336", + api_key="k", + endpoint_type=CORE_MODEL_ENDPOINT_TYPE, + ) + request = _captured_request(manager) + expected = PerceptionEncoderImageEmbeddingRequest( + perception_encoder_version_id="PE-Core-L14-336", + image=[image.to_inference_format(numpy_preferred=True)], + api_key="k", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert result == {"embedding": [0.5]} diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 53d3f395fe..6171c0f526 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,12 +1,8 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 19 -inference/core/workflows/core_steps/models/foundation/clip/v1.py inference.core.entities.requests.clip -inference/core/workflows/core_steps/models/foundation/clip_comparison/v1.py inference.core.entities.requests.clip -inference/core/workflows/core_steps/models/foundation/clip_comparison/v2.py inference.core.entities.requests.clip +# Count: 15 inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py inference.core.entities.requests.easy_ocr inference/core/workflows/core_steps/models/foundation/ocr/v1.py inference.core.entities.requests.doctr -inference/core/workflows/core_steps/models/foundation/perception_encoder/v1.py inference.core.entities.requests.perception_encoder inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py inference.core.entities.requests.pp_ocr inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py inference.core.entities.requests.pp_ocr inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.requests.sam2 From ef0575e792536068835c81e46ecf8048d4deb659 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:05:33 +0200 Subject: [PATCH 68/86] test(http): codec-binding tests count only this image's fetches The GET-only filter still caught the usage collector's GET of /usage/plan when it happened to flush mid-test; match on the image URL. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../core/interfaces/test_image_codec_binding.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py b/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py index 96cce2fa0b..fb48c789ca 100644 --- a/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py +++ b/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py @@ -133,8 +133,10 @@ def _decode_serialised_image(payload: dict) -> np.ndarray: ) -def _get_urls(requests_mock: Mocker) -> list: - return [r.url for r in requests_mock.request_history if r.method == "GET"] +def _fetches_of(requests_mock: Mocker, url: str) -> list: + # Only this image's fetches: the process-global usage collector also talks + # through the mocked transport (POSTs and a GET of /usage/plan). + return [r.url for r in requests_mock.request_history if r.url == url] def _assert_one_object_on_both_paths(captured: dict, expected) -> None: @@ -376,9 +378,7 @@ def test_http_run_route_fetches_an_allow_listed_url_input_through_the_bound_code ) assert response.status_code == 200, response.text - # GETs only: the process-global usage collector may POST through the same - # mocked transport (order-dependent). - assert _get_urls(requests_mock) == [url] + assert _fetches_of(requests_mock, url) == [url] _assert_one_object_on_both_paths(forwarded_engine_init, GUARDED_IMAGE_CODEC) blurred = _decode_serialised_image(response.json()["outputs"][0]["blurred"]) assert blurred.shape == (16, 24, 3) @@ -570,9 +570,7 @@ def load_image(self, value, disable_preproc_auto_orient=False): assert isinstance(image, WorkflowImageData) assert image.numpy_image.shape == (16, 24, 3) # cached pixels, no reload assert recorder.calls == [("fetch_url", url)] - # GETs only: the process-global usage collector may POST through the same - # mocked transport (order-dependent). - assert _get_urls(requests_mock) == [url] + assert _fetches_of(requests_mock, url) == [url] # Stage 2 - Path B: the downstream block re-loads THAT image from its # inference-format dict through the process registry - the same object. @@ -596,7 +594,7 @@ def load_image(self, value, disable_preproc_auto_orient=False): "image": {"width": 24, "height": 16}, } assert recorder.calls == [("fetch_url", url), ("load_image", payload)] - assert _get_urls(requests_mock) == [url, url] + assert _fetches_of(requests_mock, url) == [url, url] client.chat.completions.create.assert_called_once() From 7864a376186c2c83b3323fbad399862ee820f6ba Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:11:32 +0200 Subject: [PATCH 69/86] test(workflows): clip parity cases compare model_fields_set; text-subject/image-prompt comparison Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../test_port_requests_match_legacy_clip.py | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_clip.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_clip.py index 9e9a4b68bf..c3c5605411 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_clip.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_clip.py @@ -15,7 +15,18 @@ which never set `clip_version_id`) and with it forwarded explicitly (`clip_comparison/v2.py`), and `PerceptionEncoderTextEmbeddingRequest`/`PerceptionEncoderImageEmbeddingRequest` -(`perception_encoder/v1.py`). +(`perception_encoder/v1.py`). Plus one case for the OTHER `ClipCompareRequest` +union shape (text subject, image prompt(s)) that no current block exercises +(both blocks hardcode `subject_type="image"`/`prompt_type="text"`), driven +directly through the adapter's `run_clip_comparison`, compared against the +same pre-Task-11.11 `ClipCompareRequest(...)` constructor pattern +(`git show ed9034266:.../clip_comparison/v2.py`) with those inputs substituted. + +Every case asserts both `model_dump()` equality AND `model_fields_set` +equality: a field the pre-port code never set must stay unset on the ported +request too, not be re-supplied with its own default value - `model_dump()` +alone cannot tell "never passed" from "passed the same value as the default" +apart. """ from unittest.mock import MagicMock @@ -55,6 +66,9 @@ ) from inference.core.workflows.prototypes.models_provider import CORE_MODEL_ENDPOINT_TYPE +# A payload `InferenceRequestImage` accepts. A bare string does not validate. +IMAGE = {"type": "base64", "value": "aGVsbG8="} + class _EmbeddingResponse: def __init__(self, embeddings): @@ -107,6 +121,7 @@ def test_clip_text_embedding_request_matches_the_pre_port_construction() -> None clip_version_id="ViT-B-16", text=["a parity test cat"], api_key="k" ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set assert result == {"embedding": [0.1, 0.2]} @@ -135,6 +150,7 @@ def test_clip_image_embedding_request_matches_the_pre_port_construction() -> Non api_key="k", ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set assert result == {"embedding": [0.3]} @@ -164,6 +180,7 @@ def test_clip_comparison_v1_request_matches_the_pre_port_construction() -> None: prompt_type="text", ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set manager.add_model.assert_called_once_with( f"clip/{expected.clip_version_id}", "k", @@ -196,6 +213,50 @@ def test_clip_comparison_v2_request_matches_the_pre_port_construction() -> None: api_key="k", ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set + manager.add_model.assert_called_once_with( + "clip/RN50", "k", endpoint_type=ModelEndpointType.CORE_MODEL + ) + + +def test_clip_comparison_text_subject_image_prompt_matches_the_pre_port_construction() -> ( + None +): + # Neither block ever builds this shape - both hardcode + # `subject_type="image"`/`prompt_type="text"` - but `ClipCompareRequest` + # (and the adapter's generic `run_clip_comparison`) support the other + # union member on `subject`/`prompt` too. Drive it directly through the + # adapter and compare against the same constructor pattern + # `clip_comparison/v2.py` used pre-Task-11.11 + # (`git show ed9034266:.../clip_comparison/v2.py`: + # `ClipCompareRequest(clip_version_id=version, subject=..., subject_type=..., + # prompt=..., prompt_type=..., api_key=...)`), with a text subject and an + # image-list prompt substituted for the inputs. + manager = _manager() + manager.infer_from_request_sync.return_value = _ComparisonResponse([0.7]) + provider = ModelManagerModelsProvider(manager) + + result = provider.run_clip_comparison( + subject="a parity test cat", + subject_type="text", + prompt=[IMAGE], + prompt_type="image", + api_key="k", + version_id="RN50", + ) + + assert result == {"similarity": [0.7]} + request = _captured_request(manager) + expected = ClipCompareRequest( + clip_version_id="RN50", + subject="a parity test cat", + subject_type="text", + prompt=[IMAGE], + prompt_type="image", + api_key="k", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set manager.add_model.assert_called_once_with( "clip/RN50", "k", endpoint_type=ModelEndpointType.CORE_MODEL ) @@ -227,6 +288,7 @@ def test_perception_encoder_text_embedding_request_matches_the_pre_port_construc api_key="k", ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set assert result == {"embedding": [0.4]} @@ -257,4 +319,5 @@ def test_perception_encoder_image_embedding_request_matches_the_pre_port_constru api_key="k", ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set assert result == {"embedding": [0.5]} From d8e1f2d4fbc4fe32ee18c28123ffab8b44a4d8b9 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:35:42 +0200 Subject: [PATCH 70/86] refactor(workflows): OCR and YOLO-World blocks run through the plain-argument port Adds run_doctr_ocr, run_easy_ocr, run_pp_ocr and run_yolo_world to the ModelsProvider port and its ModelManagerModelsProvider adapter, then switches ocr/v1.py, easy_ocr/v1.py, pp_ocr/v1.py, pp_ocr/v1_tensor.py and yolo_world/v1.py to call them instead of building pydantic request objects inline. pp_ocr registers the validator-derived model id from the adapter (build -> register -> infer), matching run_clip_comparison; the other three keep block-side load_core_model. Decontamination baseline 15 -> 10. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../interfaces/workflows_models_provider.py | 79 +++++++ .../models/foundation/easy_ocr/v1.py | 27 +-- .../core_steps/models/foundation/ocr/v1.py | 23 +- .../core_steps/models/foundation/pp_ocr/v1.py | 28 +-- .../models/foundation/pp_ocr/v1_tensor.py | 24 +- .../models/foundation/yolo_world/v1.py | 25 +- .../workflows/prototypes/models_provider.py | 36 +++ .../test_workflows_models_provider.py | 135 +++++++++++ .../test_image_prep_before_registration.py | 75 +++++- .../test_port_requests_match_legacy_ocr.py | 217 ++++++++++++++++++ .../models/foundation/test_pp_ocr.py | 80 +++---- .../unit_tests/decontamination_baseline.txt | 7 +- 12 files changed, 626 insertions(+), 130 deletions(-) create mode 100644 tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_ocr.py diff --git a/inference/core/interfaces/workflows_models_provider.py b/inference/core/interfaces/workflows_models_provider.py index 2d9ad12610..9fd8569b1a 100644 --- a/inference/core/interfaces/workflows_models_provider.py +++ b/inference/core/interfaces/workflows_models_provider.py @@ -35,6 +35,8 @@ ClipImageEmbeddingRequest, ClipTextEmbeddingRequest, ) +from inference.core.entities.requests.doctr import DoctrOCRInferenceRequest +from inference.core.entities.requests.easy_ocr import EasyOCRInferenceRequest from inference.core.entities.requests.inference import ( ClassificationInferenceRequest, DepthEstimationRequest, @@ -49,6 +51,8 @@ PerceptionEncoderImageEmbeddingRequest, PerceptionEncoderTextEmbeddingRequest, ) +from inference.core.entities.requests.pp_ocr import PPOCRInferenceRequest +from inference.core.entities.requests.yolo_world import YOLOWorldInferenceRequest from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType from inference.core.workflows.prototypes.models_provider import ( @@ -391,6 +395,81 @@ def run_perception_encoder_image_embedding( ) return self._infer(model_id=model_id, request=request)[0].embeddings + def run_doctr_ocr( + self, + model_id: str, + image: Any, + api_key: Optional[str] = None, + generate_bounding_boxes: Union[bool, None, _Unset] = UNSET, + ) -> dict: + request = DoctrOCRInferenceRequest( + image=image, + api_key=api_key, + **_passed(generate_bounding_boxes=generate_bounding_boxes), + ) + return self._dump(self._infer(model_id=model_id, request=request))[0] + + def run_easy_ocr( + self, + model_id: str, + version_id: str, + image: Any, + api_key: Optional[str] = None, + language_codes: Optional[List[str]] = None, + quantize: Optional[bool] = None, + ) -> dict: + request = EasyOCRInferenceRequest( + easy_ocr_version_id=version_id, + image=image, + api_key=api_key, + language_codes=language_codes, + quantize=quantize, + ) + return self._dump(self._infer(model_id=model_id, request=request))[0] + + def run_pp_ocr( + self, + image: Any, + api_key: Optional[str] = None, + text_detection: Union[str, None, _Unset] = UNSET, + text_recognition: Union[str, None, _Unset] = UNSET, + ) -> dict: + # The request has its own "omitted" sentinel with different semantics + # from None (requests/pp_ocr.py:13): omitted -> "small", None -> the + # stage is disabled. Forward only what the caller actually passed. + request = PPOCRInferenceRequest( + image=image, + api_key=api_key, + **_passed(text_detection=text_detection, text_recognition=text_recognition), + ) + # The validator derived `pp_ocr_version_id` from the normalised stages; + # `load_core_model` read exactly that attribute before, so the id is + # unchanged. Registration keeps its position: build -> register -> + # infer. + core_model_id = f"pp_ocr/{request.pp_ocr_version_id}" + self._model_manager.add_model( + core_model_id, api_key, endpoint_type=ModelEndpointType.CORE_MODEL + ) + return self._dump(self._infer(model_id=core_model_id, request=request))[0] + + def run_yolo_world( + self, + model_id: str, + version_id: str, + image: Any, + text: List[str], + api_key: Optional[str] = None, + confidence: Optional[Union[float, str]] = None, + ) -> dict: + request = YOLOWorldInferenceRequest( + image=image, + yolo_world_version_id=version_id, + confidence=confidence, + text=text, + api_key=api_key, + ) + return self._dump(self._infer(model_id=model_id, request=request))[0] + def run_tensor_native_inference(self, model_id: str, **kwargs: Any) -> Any: return self._model_manager.run_tensor_native_inference( model_id=model_id, **kwargs diff --git a/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py index 507248ad63..707105a9a4 100644 --- a/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py @@ -6,7 +6,6 @@ import supervision as sv from pydantic import ConfigDict, Field -from inference.core.entities.requests.easy_ocr import EasyOCRInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( load_core_model, @@ -236,26 +235,24 @@ def run_locally( predictions = [] for single_image in images: - - inference_request = EasyOCRInferenceRequest( - easy_ocr_version_id=version, - image=single_image.to_inference_format(numpy_preferred=True), - api_key=self._api_key, - language_codes=language_codes, - quantize=quantize, - ) + image = single_image.to_inference_format(numpy_preferred=True) model_id = load_core_model( model_manager=self._model_manager, - version_id=inference_request.easy_ocr_version_id, - api_key=self._api_key, core_model="easy_ocr", + version_id=version, + api_key=self._api_key, ) - result = self._model_manager.infer_from_request_sync( - model_id, inference_request + predictions.append( + self._model_manager.run_easy_ocr( + model_id=model_id, + version_id=version, + image=image, + api_key=self._api_key, + language_codes=language_codes, + quantize=quantize, + ) ) - predictions.append(result.model_dump(by_alias=True, exclude_none=True)) - return post_process_ocr_result( predictions=predictions, images=images, diff --git a/inference/core/workflows/core_steps/models/foundation/ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/ocr/v1.py index 47ce43eab9..43329784e4 100644 --- a/inference/core/workflows/core_steps/models/foundation/ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/ocr/v1.py @@ -7,7 +7,6 @@ import supervision as sv from pydantic import ConfigDict, Field -from inference.core.entities.requests.doctr import DoctrOCRInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( load_core_model, @@ -169,21 +168,23 @@ def run_locally( ) -> BlockResult: predictions = [] for single_image in images: - inference_request = DoctrOCRInferenceRequest( - image=single_image.to_inference_format(numpy_preferred=True), - api_key=self._api_key, - generate_bounding_boxes=True, - ) + image = single_image.to_inference_format(numpy_preferred=True) doctr_model_id = load_core_model( model_manager=self._model_manager, - version_id=inference_request.doctr_version_id, - api_key=self._api_key, core_model="doctr", + # `DoctrOCRInferenceRequest.doctr_version_id` is the literal + # "default" (requests/doctr.py:20), not an env value. + version_id="default", + api_key=self._api_key, ) - result = self._model_manager.infer_from_request_sync( - doctr_model_id, inference_request + predictions.append( + self._model_manager.run_doctr_ocr( + model_id=doctr_model_id, + image=image, + api_key=self._api_key, + generate_bounding_boxes=True, + ) ) - predictions.append(result.model_dump(by_alias=True, exclude_none=True)) return post_process_ocr_result( predictions=predictions, images=images, diff --git a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py index 758884856e..5716bdd453 100644 --- a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py @@ -2,12 +2,8 @@ from pydantic import ConfigDict, Field, model_validator -from inference.core.entities.requests.pp_ocr import PPOCRInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode -from inference.core.workflows.core_steps.common.utils import ( - load_core_model, - post_process_ocr_result, -) +from inference.core.workflows.core_steps.common.utils import post_process_ocr_result from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, @@ -207,22 +203,14 @@ def run_locally( ) -> BlockResult: predictions = [] for single_image in images: - inference_request = PPOCRInferenceRequest( - text_detection=text_detection, - text_recognition=text_recognition, - image=single_image.to_inference_format(numpy_preferred=True), - api_key=self._api_key, - ) - model_id = load_core_model( - model_manager=self._model_manager, - version_id=inference_request.pp_ocr_version_id, - api_key=self._api_key, - core_model="pp_ocr", - ) - result = self._model_manager.infer_from_request_sync( - model_id, inference_request + predictions.append( + self._model_manager.run_pp_ocr( + image=single_image.to_inference_format(numpy_preferred=True), + api_key=self._api_key, + text_detection=text_detection, + text_recognition=text_recognition, + ) ) - predictions.append(result.model_dump(by_alias=True, exclude_none=True)) return post_process_ocr_result( predictions=predictions, images=images, diff --git a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py index 182f937f0e..2966f6b0fa 100644 --- a/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py @@ -27,12 +27,10 @@ from pydantic import ConfigDict, Field, model_validator -from inference.core.entities.requests.pp_ocr import PPOCRInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( native_detections_from_inference_predictions, ) -from inference.core.workflows.core_steps.common.utils import load_core_model from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, LOCAL_INFERENCE_API_URL, @@ -236,25 +234,15 @@ def run_locally( ) -> BlockResult: predictions = [] for single_image in images: - inference_request = PPOCRInferenceRequest( - text_detection=text_detection, - text_recognition=text_recognition, - image=single_image.to_inference_format(numpy_preferred=True), - api_key=self._api_key, - ) - model_id = load_core_model( - model_manager=self._model_manager, - version_id=inference_request.pp_ocr_version_id, - api_key=self._api_key, - core_model="pp_ocr", - ) - result = self._model_manager.infer_from_request_sync( - model_id, inference_request - ) predictions.append( _build_native_prediction( image=single_image, - response=result.model_dump(by_alias=True, exclude_none=True), + response=self._model_manager.run_pp_ocr( + image=single_image.to_inference_format(numpy_preferred=True), + api_key=self._api_key, + text_detection=text_detection, + text_recognition=text_recognition, + ), ) ) return predictions diff --git a/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py b/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py index 14cd359485..aeef473196 100644 --- a/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py @@ -2,7 +2,6 @@ from pydantic import ConfigDict, Field -from inference.core.entities.requests.yolo_world import YOLOWorldInferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_batch_of_sv_detections, @@ -216,23 +215,23 @@ def run_locally( ) -> BlockResult: predictions = [] for single_image in images: - inference_request = YOLOWorldInferenceRequest( - image=single_image.to_inference_format(numpy_preferred=True), - yolo_world_version_id=version, - confidence=confidence, - text=class_names, - api_key=self._api_key, - ) + image = single_image.to_inference_format(numpy_preferred=True) yolo_world_model_id = load_core_model( model_manager=self._model_manager, - version_id=inference_request.yolo_world_version_id, - api_key=self._api_key, core_model="yolo_world", + version_id=version, + api_key=self._api_key, ) - prediction = self._model_manager.infer_from_request_sync( - yolo_world_model_id, inference_request + predictions.append( + self._model_manager.run_yolo_world( + model_id=yolo_world_model_id, + version_id=version, + image=image, + text=class_names, + api_key=self._api_key, + confidence=confidence, + ) ) - predictions.append(prediction.model_dump(by_alias=True, exclude_none=True)) return self._post_process_result( images=images, predictions=predictions, diff --git a/inference/core/workflows/prototypes/models_provider.py b/inference/core/workflows/prototypes/models_provider.py index bbb4c7eeea..856badc89e 100644 --- a/inference/core/workflows/prototypes/models_provider.py +++ b/inference/core/workflows/prototypes/models_provider.py @@ -214,6 +214,42 @@ def run_perception_encoder_image_embedding( api_key: Optional[str] = None, ) -> List[List[float]]: ... + def run_doctr_ocr( + self, + model_id: str, + image: Any, + api_key: Optional[str] = None, + generate_bounding_boxes: Union[bool, None, _Unset] = UNSET, + ) -> dict: ... + + def run_easy_ocr( + self, + model_id: str, + version_id: str, + image: Any, + api_key: Optional[str] = None, + language_codes: Optional[List[str]] = None, + quantize: Optional[bool] = None, + ) -> dict: ... + + def run_pp_ocr( + self, + image: Any, + api_key: Optional[str] = None, + text_detection: Union[str, None, _Unset] = UNSET, + text_recognition: Union[str, None, _Unset] = UNSET, + ) -> dict: ... + + def run_yolo_world( + self, + model_id: str, + version_id: str, + image: Any, + text: List[str], + api_key: Optional[str] = None, + confidence: Optional[Union[float, str]] = None, + ) -> dict: ... + def run_tensor_native_inference(self, model_id: str, **kwargs: Any) -> Any: ... def get_class_names(self, model_id: str) -> List[str]: ... diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py index dd42e81a8a..d986478215 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py @@ -609,3 +609,138 @@ def test_run_perception_encoder_embeddings_build_their_requests() -> None: perception_encoder_version_id="v", image=IMAGES, api_key="k" ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +from inference.core.entities.requests.doctr import DoctrOCRInferenceRequest +from inference.core.entities.requests.easy_ocr import EasyOCRInferenceRequest +from inference.core.entities.requests.pp_ocr import PPOCRInferenceRequest +from inference.core.entities.requests.yolo_world import YOLOWorldInferenceRequest + + +class _DictResponse: + def __init__(self, payload): + self._payload = payload + + def model_dump(self, **_kwargs): + return self._payload + + +def test_run_doctr_ocr_builds_the_request_the_block_used_to_build() -> None: + manager = manager_returning(_DictResponse({"result": "HELLO"})) + assert ModelManagerModelsProvider(manager).run_doctr_ocr( + model_id="doctr/x", image=IMAGE, api_key="k", generate_bounding_boxes=True + ) == {"result": "HELLO"} + manager.add_model.assert_not_called() + request = captured_request(manager) + expected = DoctrOCRInferenceRequest( + image=IMAGE, api_key="k", generate_bounding_boxes=True + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_run_easy_ocr_builds_the_request_the_block_used_to_build() -> None: + manager = manager_returning(_DictResponse({"result": "HI"})) + ModelManagerModelsProvider(manager).run_easy_ocr( + model_id="easy_ocr/v", + version_id="v", + image=IMAGE, + api_key="k", + language_codes=["en"], + quantize=False, + ) + request = captured_request(manager) + expected = EasyOCRInferenceRequest( + easy_ocr_version_id="v", + image=IMAGE, + api_key="k", + language_codes=["en"], + quantize=False, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_run_pp_ocr_builds_the_request_and_registers_the_derived_model_id() -> None: + from inference.core.roboflow_api import ModelEndpointType + + manager = manager_returning(_DictResponse({"result": "HI"})) + ModelManagerModelsProvider(manager).run_pp_ocr( + image=IMAGE, api_key="k", text_detection="small", text_recognition="small" + ) + request = captured_request(manager) + expected = PPOCRInferenceRequest( + text_detection="small", text_recognition="small", image=IMAGE, api_key="k" + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + manager.add_model.assert_called_once_with( + "pp_ocr/small-small", "k", endpoint_type=ModelEndpointType.CORE_MODEL + ) + + +def test_run_pp_ocr_covers_detection_only_recognition_only_and_normalisation() -> None: + """The validator (requests/pp_ocr.py:33-72) lower-cases the stages, maps a + disabled stage to "none" and derives the id from the normalised pair.""" + for (detection, recognition), expected_id in [ + (("small", None), "pp_ocr/small-none"), + ((None, "small"), "pp_ocr/none-small"), + (("SMALL", "Medium"), "pp_ocr/small-medium"), + ]: + manager = manager_returning(_DictResponse({"result": "HI"})) + ModelManagerModelsProvider(manager).run_pp_ocr( + image=IMAGE, + api_key="k", + text_detection=detection, + text_recognition=recognition, + ) + assert manager.add_model.call_args.args[0] == expected_id, ( + detection, + recognition, + ) + + +def test_run_pp_ocr_rejects_an_invalid_stage_before_registering() -> None: + import pytest + from pydantic import ValidationError + + manager = manager_returning(_DictResponse({})) + with pytest.raises(ValidationError): + ModelManagerModelsProvider(manager).run_pp_ocr( + image=IMAGE, + api_key="k", + text_detection="enormous", + text_recognition="small", + ) + manager.add_model.assert_not_called() + manager.infer_from_request_sync.assert_not_called() + + +def test_run_pp_ocr_rejects_both_stages_disabled() -> None: + import pytest + from pydantic import ValidationError + + manager = manager_returning(_DictResponse({})) + with pytest.raises(ValidationError): + ModelManagerModelsProvider(manager).run_pp_ocr( + image=IMAGE, api_key="k", text_detection=None, text_recognition=None + ) + manager.add_model.assert_not_called() + + +def test_run_yolo_world_builds_the_request_the_block_used_to_build() -> None: + manager = manager_returning(_DictResponse({"predictions": []})) + ModelManagerModelsProvider(manager).run_yolo_world( + model_id="yolo_world/v", + version_id="v", + image=IMAGE, + text=["cat"], + api_key="k", + confidence=0.3, + ) + request = captured_request(manager) + expected = YOLOWorldInferenceRequest( + image=IMAGE, + yolo_world_version_id="v", + confidence=0.3, + text=["cat"], + api_key="k", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_image_prep_before_registration.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_image_prep_before_registration.py index 2062d168a9..357d39bffc 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_image_prep_before_registration.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_image_prep_before_registration.py @@ -14,7 +14,17 @@ from inference.core.workflows.core_steps.models.foundation.clip.v1 import ( ClipModelBlockV1, ) -from inference.core.workflows.execution_engine.entities.base import WorkflowImageData +from inference.core.workflows.core_steps.models.foundation.easy_ocr.v1 import ( + EasyOCRBlockV1, +) +from inference.core.workflows.core_steps.models.foundation.ocr.v1 import OCRModelBlockV1 +from inference.core.workflows.core_steps.models.foundation.yolo_world.v1 import ( + YoloWorldModelBlockV1, +) +from inference.core.workflows.execution_engine.entities.base import ( + Batch, + WorkflowImageData, +) def test_failed_image_preparation_leaves_registration_and_inference_uncalled() -> None: @@ -35,3 +45,66 @@ def test_failed_image_preparation_leaves_registration_and_inference_uncalled() - # then — HEAD parity: nothing registered, nothing inferred model_manager.add_model.assert_not_called() model_manager.run_clip_image_embedding.assert_not_called() + + +def _failing_image_batch() -> Batch: + image = mock.MagicMock(spec=WorkflowImageData) + image.to_inference_format.side_effect = ValueError("decode failed") + return Batch(content=[image], indices=[(0,)]) + + +def test_failed_doctr_ocr_image_preparation_leaves_registration_and_inference_uncalled() -> ( + None +): + # Task 11.12 CR-1 site: ocr/v1.py:173 decodes before load_core_model:177. + model_manager = mock.MagicMock() + block = OCRModelBlockV1( + model_manager=model_manager, + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + images = _failing_image_batch() + + with pytest.raises(ValueError, match="decode failed"): + block.run(images=images) + + model_manager.add_model.assert_not_called() + model_manager.run_doctr_ocr.assert_not_called() + + +def test_failed_easy_ocr_image_preparation_leaves_registration_and_inference_uncalled() -> ( + None +): + # Task 11.12 CR-1 site: easy_ocr/v1.py:242 decodes before load_core_model:247. + model_manager = mock.MagicMock() + block = EasyOCRBlockV1( + model_manager=model_manager, + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + images = _failing_image_batch() + + with pytest.raises(ValueError, match="decode failed"): + block.run(images=images, language="English", quantize=False) + + model_manager.add_model.assert_not_called() + model_manager.run_easy_ocr.assert_not_called() + + +def test_failed_yolo_world_image_preparation_leaves_registration_and_inference_uncalled() -> ( + None +): + # Task 11.12 CR-1 site: yolo_world/v1.py:220 decodes before load_core_model:226. + model_manager = mock.MagicMock() + block = YoloWorldModelBlockV1( + model_manager=model_manager, + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + images = _failing_image_batch() + + with pytest.raises(ValueError, match="decode failed"): + block.run(images=images, class_names=["cat"], version="s", confidence=0.3) + + model_manager.add_model.assert_not_called() + model_manager.run_yolo_world.assert_not_called() diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_ocr.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_ocr.py new file mode 100644 index 0000000000..31a214e4e5 --- /dev/null +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_ocr.py @@ -0,0 +1,217 @@ +"""Block -> adapter -> request differential tests for Task 11.12's OCR family +(DocTR, EasyOCR, PP-OCR) and YOLO-World blocks. + +Each test drives a block's real ``run_locally`` through a real +``ModelManagerModelsProvider`` wrapping a ``MagicMock`` ``ModelManager``, then +compares the pydantic request the adapter built against the exact request +construction the block used to run inline before Task 11.12 (this task's BASE +commit, 3dc9e17cb, is also HEAD of this checkout - the pre-port construction +below is copied straight from ``git show 3dc9e17cb:``, i.e. the source +this task started from). + +One case per distinct request-building shape the task touches: +``DoctrOCRInferenceRequest`` (``ocr/v1.py``), ``EasyOCRInferenceRequest`` +(``easy_ocr/v1.py``), ``PPOCRInferenceRequest`` (``pp_ocr/v1.py`` - registers +via the validator-derived id, same as the adapter-level tests in +``test_workflows_models_provider.py``) and ``YOLOWorldInferenceRequest`` +(``yolo_world/v1.py``). + +Every case asserts both ``model_dump()`` equality AND ``model_fields_set`` +equality: an omitted field must stay omitted, not be re-supplied as its own +default. +""" + +from unittest.mock import MagicMock + +import numpy as np + +from inference.core.entities.requests.doctr import DoctrOCRInferenceRequest +from inference.core.entities.requests.easy_ocr import EasyOCRInferenceRequest +from inference.core.entities.requests.pp_ocr import PPOCRInferenceRequest +from inference.core.entities.requests.yolo_world import YOLOWorldInferenceRequest +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) +from inference.core.roboflow_api import ModelEndpointType +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.models.foundation.easy_ocr.v1 import ( + EasyOCRBlockV1, +) +from inference.core.workflows.core_steps.models.foundation.ocr.v1 import OCRModelBlockV1 +from inference.core.workflows.core_steps.models.foundation.pp_ocr.v1 import PPOCRBlockV1 +from inference.core.workflows.core_steps.models.foundation.yolo_world.v1 import ( + YoloWorldModelBlockV1, +) +from inference.core.workflows.execution_engine.entities.base import ( + Batch, + ImageParentMetadata, + WorkflowImageData, +) +from inference.core.workflows.prototypes.models_provider import CORE_MODEL_ENDPOINT_TYPE + + +class _DictResponse: + def __init__(self, payload): + self._payload = payload + + def model_dump(self, **_kwargs): + return self._payload + + +def _make_image() -> WorkflowImageData: + return WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="p"), + numpy_image=np.zeros((20, 10, 3), dtype=np.uint8), + ) + + +def _ocr_response() -> _DictResponse: + return _DictResponse( + {"result": "HELLO", "image": {"width": 10, "height": 20}, "predictions": []} + ) + + +def _manager() -> MagicMock: + return MagicMock() + + +def _captured_request(manager: MagicMock): + assert manager.infer_from_request_sync.call_count == 1 + call = manager.infer_from_request_sync.call_args + return call.kwargs["request"] if "request" in call.kwargs else call.args[1] + + +def test_doctr_ocr_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show 3dc9e17cb:.../ocr/v1.py`: + # `DoctrOCRInferenceRequest(image=..., api_key=..., generate_bounding_boxes=True)` + # with no `doctr_version_id` set - the field's own pydantic default + # ("default", requests/doctr.py:20) applies, and the pre-port + # `load_core_model` read exactly that default off the validated request. + manager = _manager() + manager.infer_from_request_sync.return_value = _ocr_response() + image = _make_image() + images = Batch(content=[image], indices=[(0,)]) + block = OCRModelBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally(images=images) + + manager.add_model.assert_called_once_with( + model_id="doctr/default", api_key="k", endpoint_type=CORE_MODEL_ENDPOINT_TYPE + ) + request = _captured_request(manager) + expected = DoctrOCRInferenceRequest( + image=image.to_inference_format(numpy_preferred=True), + api_key="k", + generate_bounding_boxes=True, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set + + +def test_easy_ocr_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show 3dc9e17cb:.../easy_ocr/v1.py`: + # `EasyOCRInferenceRequest(easy_ocr_version_id=version, image=..., api_key=..., + # language_codes=language_codes, quantize=quantize)`. + manager = _manager() + manager.infer_from_request_sync.return_value = _ocr_response() + image = _make_image() + images = Batch(content=[image], indices=[(0,)]) + block = EasyOCRBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally( + images=images, + language_codes=["en"], + version="english_g2", + quantize=False, + ) + + manager.add_model.assert_called_once_with( + model_id="easy_ocr/english_g2", + api_key="k", + endpoint_type=CORE_MODEL_ENDPOINT_TYPE, + ) + request = _captured_request(manager) + expected = EasyOCRInferenceRequest( + easy_ocr_version_id="english_g2", + image=image.to_inference_format(numpy_preferred=True), + api_key="k", + language_codes=["en"], + quantize=False, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set + + +def test_pp_ocr_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show 3dc9e17cb:.../pp_ocr/v1.py`: + # `PPOCRInferenceRequest(text_detection=..., text_recognition=..., image=..., + # api_key=...)`. Registration is the round-2-defect-4 exception (round-2 in + # the Phase 11 plan predates this task): the validator derives + # `pp_ocr_version_id`, which only exists post-validation, so the adapter + # registers (build -> register -> infer) instead of the block calling + # `load_core_model`. + manager = _manager() + manager.infer_from_request_sync.return_value = _ocr_response() + image = _make_image() + images = Batch(content=[image], indices=[(0,)]) + block = PPOCRBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally(images=images, text_detection="small", text_recognition="small") + + request = _captured_request(manager) + expected = PPOCRInferenceRequest( + text_detection="small", + text_recognition="small", + image=image.to_inference_format(numpy_preferred=True), + api_key="k", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set + manager.add_model.assert_called_once_with( + "pp_ocr/small-small", "k", endpoint_type=ModelEndpointType.CORE_MODEL + ) + + +def test_yolo_world_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show 3dc9e17cb:.../yolo_world/v1.py`: + # `YOLOWorldInferenceRequest(image=..., yolo_world_version_id=version, + # confidence=confidence, text=class_names, api_key=...)`. + manager = _manager() + manager.infer_from_request_sync.return_value = _DictResponse( + {"image": {"width": 10, "height": 20}, "predictions": []} + ) + image = _make_image() + images = Batch(content=[image], indices=[(0,)]) + block = YoloWorldModelBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally(images=images, class_names=["cat"], version="l", confidence=0.3) + + manager.add_model.assert_called_once_with( + model_id="yolo_world/l", api_key="k", endpoint_type=CORE_MODEL_ENDPOINT_TYPE + ) + request = _captured_request(manager) + expected = YOLOWorldInferenceRequest( + image=image.to_inference_format(numpy_preferred=True), + yolo_world_version_id="l", + confidence=0.3, + text=["cat"], + api_key="k", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_pp_ocr.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_pp_ocr.py index 07366acabe..338f88742d 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_pp_ocr.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_pp_ocr.py @@ -148,25 +148,21 @@ def _make_images() -> Batch: def test_pp_ocr_run_locally_full_mode() -> None: # given model_manager = MagicMock() - model_manager.infer_from_request_sync.return_value = MagicMock( - model_dump=MagicMock( - return_value={ - "result": "HELLO", - "image": {"width": 168, "height": 192}, - "predictions": [ - { - "x": 15.0, - "y": 10.0, - "width": 30.0, - "height": 20.0, - "confidence": 0.9, - "class": "HELLO", - "class_id": 0, - } - ], + model_manager.run_pp_ocr.return_value = { + "result": "HELLO", + "image": {"width": 168, "height": 192}, + "predictions": [ + { + "x": 15.0, + "y": 10.0, + "width": 30.0, + "height": 20.0, + "confidence": 0.9, + "class": "HELLO", + "class_id": 0, } - ) - ) + ], + } block = _make_block(model_manager) images = _make_images() @@ -174,7 +170,7 @@ def test_pp_ocr_run_locally_full_mode() -> None: result = block.run(images=images, text_detection="small", text_recognition="small") # then - model_manager.infer_from_request_sync.assert_called_once() + model_manager.run_pp_ocr.assert_called_once() assert len(result) == 1 single = result[0] assert single["result"] == "HELLO" @@ -194,25 +190,21 @@ def test_pp_ocr_run_locally_full_mode() -> None: def test_pp_ocr_run_locally_detect_only() -> None: # given model_manager = MagicMock() - model_manager.infer_from_request_sync.return_value = MagicMock( - model_dump=MagicMock( - return_value={ - "result": "", - "image": {"width": 168, "height": 192}, - "predictions": [ - { - "x": 15.0, - "y": 10.0, - "width": 30.0, - "height": 20.0, - "confidence": 0.9, - "class": "", - "class_id": 0, - } - ], + model_manager.run_pp_ocr.return_value = { + "result": "", + "image": {"width": 168, "height": 192}, + "predictions": [ + { + "x": 15.0, + "y": 10.0, + "width": 30.0, + "height": 20.0, + "confidence": 0.9, + "class": "", + "class_id": 0, } - ) - ) + ], + } block = _make_block(model_manager) images = _make_images() @@ -230,15 +222,11 @@ def test_pp_ocr_run_locally_detect_only() -> None: def test_pp_ocr_run_locally_recognize_only() -> None: # given model_manager = MagicMock() - model_manager.infer_from_request_sync.return_value = MagicMock( - model_dump=MagicMock( - return_value={ - "result": "HELLO", - "image": {"width": 168, "height": 192}, - "predictions": [], - } - ) - ) + model_manager.run_pp_ocr.return_value = { + "result": "HELLO", + "image": {"width": 168, "height": 192}, + "predictions": [], + } block = _make_block(model_manager) images = _make_images() diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 6171c0f526..3eb326cbc2 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,10 +1,6 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 15 -inference/core/workflows/core_steps/models/foundation/easy_ocr/v1.py inference.core.entities.requests.easy_ocr -inference/core/workflows/core_steps/models/foundation/ocr/v1.py inference.core.entities.requests.doctr -inference/core/workflows/core_steps/models/foundation/pp_ocr/v1.py inference.core.entities.requests.pp_ocr -inference/core/workflows/core_steps/models/foundation/pp_ocr/v1_tensor.py inference.core.entities.requests.pp_ocr +# Count: 10 inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.requests.sam3 @@ -15,4 +11,3 @@ inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py inference.core.entities.requests.sam3_3d inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.entities.requests.sam2 -inference/core/workflows/core_steps/models/foundation/yolo_world/v1.py inference.core.entities.requests.yolo_world From e781cd5460fd8bcf0eaa0a8d0e5e93bb1e3190a4 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:46:48 +0200 Subject: [PATCH 71/86] test: PP-OCR omitted-stage sentinel coverage; tensor-block parity case Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../test_workflows_models_provider.py | 48 +++++++++++++ .../test_port_requests_match_legacy_ocr.py | 67 +++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py index d986478215..4f3c7352d3 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py @@ -725,6 +725,54 @@ def test_run_pp_ocr_rejects_both_stages_disabled() -> None: manager.add_model.assert_not_called() +def test_run_pp_ocr_defaults_to_small_small_when_both_stages_omitted() -> None: + """Fix round 1: every other `run_pp_ocr` test passes both stage keywords + explicitly, so a regression that swapped the adapter's `UNSET` defaults for + a plain `None` would go undetected - a plain `None` default is NOT filtered + by `_passed()` (it only drops `_Unset` instances), so it would be forwarded + as an explicit `text_detection=None, text_recognition=None`, which the + validator rejects outright (see `test_run_pp_ocr_rejects_both_stages_disabled` + above), rather than resolving to "small"/"small" as it does today.""" + from inference.core.roboflow_api import ModelEndpointType + + manager = manager_returning(_DictResponse({"result": "HI"})) + ModelManagerModelsProvider(manager).run_pp_ocr(image=IMAGE, api_key="k") + request = captured_request(manager) + # Neither stage keyword is passed to the constructor either - mirroring + # what `_passed()` forwards when both arguments are UNSET. + expected = PPOCRInferenceRequest(image=IMAGE, api_key="k") + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set + assert request.text_detection == "small" + assert request.text_recognition == "small" + manager.add_model.assert_called_once_with( + "pp_ocr/small-small", "k", endpoint_type=ModelEndpointType.CORE_MODEL + ) + + +def test_run_pp_ocr_omits_one_stage_while_disabling_the_other() -> None: + """Fix round 1: one stage omitted (UNSET, so the "small" default applies), + the other explicitly disabled (`None`) - the two omitted-stage cases the + existing coverage never exercised together.""" + from inference.core.roboflow_api import ModelEndpointType + + manager = manager_returning(_DictResponse({"result": "HI"})) + ModelManagerModelsProvider(manager).run_pp_ocr( + image=IMAGE, api_key="k", text_recognition=None + ) + request = captured_request(manager) + # `text_detection` is not passed to the constructor (UNSET was dropped by + # `_passed()`); `text_recognition=None` is forwarded explicitly. + expected = PPOCRInferenceRequest(image=IMAGE, api_key="k", text_recognition=None) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set + assert request.text_detection == "small" + assert request.text_recognition == "none" + manager.add_model.assert_called_once_with( + "pp_ocr/small-none", "k", endpoint_type=ModelEndpointType.CORE_MODEL + ) + + def test_run_yolo_world_builds_the_request_the_block_used_to_build() -> None: manager = manager_returning(_DictResponse({"predictions": []})) ModelManagerModelsProvider(manager).run_yolo_world( diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_ocr.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_ocr.py index 31a214e4e5..cef17237ee 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_ocr.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_ocr.py @@ -16,6 +16,11 @@ ``test_workflows_models_provider.py``) and ``YOLOWorldInferenceRequest`` (``yolo_world/v1.py``). +Fix round 1 adds a parity case for the tensor-native sibling +(``pp_ocr/v1_tensor.py``), which shares ``PPOCRInferenceRequest`` construction +with ``pp_ocr/v1.py`` but returns a native ``Detections`` instead of the numpy +``post_process_ocr_result`` shape. + Every case asserts both ``model_dump()`` equality AND ``model_fields_set`` equality: an omitted field must stay omitted, not be re-supplied as its own default. @@ -39,6 +44,9 @@ ) from inference.core.workflows.core_steps.models.foundation.ocr.v1 import OCRModelBlockV1 from inference.core.workflows.core_steps.models.foundation.pp_ocr.v1 import PPOCRBlockV1 +from inference.core.workflows.core_steps.models.foundation.pp_ocr.v1_tensor import ( + PPOCRBlockV1 as PPOCRTensorBlockV1, +) from inference.core.workflows.core_steps.models.foundation.yolo_world.v1 import ( YoloWorldModelBlockV1, ) @@ -48,6 +56,7 @@ WorkflowImageData, ) from inference.core.workflows.prototypes.models_provider import CORE_MODEL_ENDPOINT_TYPE +from inference_models.models.base.object_detection import Detections class _DictResponse: @@ -184,6 +193,64 @@ def test_pp_ocr_request_matches_the_pre_port_construction() -> None: ) +def test_pp_ocr_tensor_request_matches_the_pre_port_construction() -> None: + """Fix round 1 (minor): the tensor-native sibling shares `PPOCRInferenceRequest` + construction with `pp_ocr/v1.py`. Copied verbatim from + `git show fbe20ca6e:.../pp_ocr/v1_tensor.py` (identical to `pp_ocr/v1.py`'s + pre-port body). Also asserts the native-output path is taken: the block + returns a `Detections` object directly (not the numpy `post_process_ocr_result` + dict-of-`sv.Detections` shape `pp_ocr/v1.py` uses).""" + manager = _manager() + manager.infer_from_request_sync.return_value = _DictResponse( + { + "result": "HELLO", + "image": {"width": 10, "height": 20}, + "predictions": [ + { + "x": 5.0, + "y": 5.0, + "width": 4.0, + "height": 4.0, + "confidence": 0.9, + "class": "HELLO", + "class_id": 0, + } + ], + } + ) + image = _make_image() + images = Batch(content=[image], indices=[(0,)]) + block = PPOCRTensorBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + result = block.run_locally( + images=images, text_detection="small", text_recognition="small" + ) + + request = _captured_request(manager) + expected = PPOCRInferenceRequest( + text_detection="small", + text_recognition="small", + image=image.to_inference_format(numpy_preferred=True), + api_key="k", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set + manager.add_model.assert_called_once_with( + "pp_ocr/small-small", "k", endpoint_type=ModelEndpointType.CORE_MODEL + ) + + # Native-output path: one dict per image, `predictions` is a native + # `Detections`, not `sv.Detections` (the numpy `pp_ocr/v1.py` shape). + assert len(result) == 1 + assert result[0]["result"] == "HELLO" + assert isinstance(result[0]["predictions"], Detections) + assert len(result[0]["predictions"]) == 1 + + def test_yolo_world_request_matches_the_pre_port_construction() -> None: # Copied verbatim from `git show 3dc9e17cb:.../yolo_world/v1.py`: # `YOLOWorldInferenceRequest(image=..., yolo_world_version_id=version, From 5758cd71e9ff9abc0b584221a1dd1a28b3e8479f Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:59:55 +0200 Subject: [PATCH 72/86] refactor(workflows): own the SAM prompt classes; the server re-exports them Move Box, Point, Sam2Prompt, Sam2PromptSet (from requests/sam2.py) and Sam3Prompt (from requests/sam3.py) verbatim into core_steps/models/foundation/segment_anything_common/prompts.py; both server modules now re-export the same class objects. Migrate the three tensor block files (segment_anything3/v2_tensor.py, v3_tensor.py, segment_anything3_interactive/v1_tensor.py) to import from the new workflows-owned module (one import line each, no other changes). Decontamination baseline 10 -> 7. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/entities/requests/sam2.py | 76 +---- inference/core/entities/requests/sam3.py | 84 +----- .../foundation/segment_anything3/v2_tensor.py | 4 +- .../foundation/segment_anything3/v3_tensor.py | 4 +- .../v1_tensor.py | 7 +- .../segment_anything_common/prompts.py | 161 ++++++++++ scripts/phase11_move_sam_prompts.py | 122 ++++++++ .../models/foundation/test_sam_prompts.py | 275 ++++++++++++++++++ .../unit_tests/decontamination_baseline.txt | 5 +- 9 files changed, 584 insertions(+), 154 deletions(-) create mode 100644 inference/core/workflows/core_steps/models/foundation/segment_anything_common/prompts.py create mode 100644 scripts/phase11_move_sam_prompts.py create mode 100644 tests/workflows/unit_tests/core_steps/models/foundation/test_sam_prompts.py diff --git a/inference/core/entities/requests/sam2.py b/inference/core/entities/requests/sam2.py index 78a3863b71..e2d306913b 100644 --- a/inference/core/entities/requests/sam2.py +++ b/inference/core/entities/requests/sam2.py @@ -1,12 +1,18 @@ -from typing import Any, List, Optional, Tuple, Union +from typing import Any, List, Optional, Union -from pydantic import BaseModel, Field, root_validator, validator +from pydantic import Field, root_validator, validator from inference.core.entities.requests.inference import ( BaseRequest, InferenceRequestImage, ) from inference.core.env import SAM2_VERSION_ID +from inference.core.workflows.core_steps.models.foundation.segment_anything_common.prompts import ( # noqa: F401 + Box, + Point, + Sam2Prompt, + Sam2PromptSet, +) class Sam2InferenceRequest(BaseRequest): @@ -56,72 +62,6 @@ class Sam2EmbeddingRequest(Sam2InferenceRequest): ) -class Box(BaseModel): - x: float - y: float - width: float - height: float - - -class Point(BaseModel): - x: float - y: float - positive: bool - - def to_hashable(self) -> Tuple[float, float, bool]: - return (self.x, self.y, self.positive) - - -class Sam2Prompt(BaseModel): - box: Optional[Box] = Field(default=None) - points: Optional[List[Point]] = Field(default=None) - - def num_points(self) -> int: - return len(self.points or []) - - -class Sam2PromptSet(BaseModel): - prompts: Optional[List[Sam2Prompt]] = Field( - default=None, - description="An optional list of prompts for masks to predict. Each prompt can include a bounding box and / or a set of postive or negative points", - ) - - def num_points(self) -> int: - if not self.prompts: - return 0 - return sum(prompt.num_points() for prompt in self.prompts) - - def to_sam2_inputs(self): - if self.prompts is None: - return {"point_coords": None, "point_labels": None, "box": None} - return_dict = {"point_coords": [], "point_labels": [], "box": []} - for prompt in self.prompts: - if prompt.box is not None: - x1 = prompt.box.x - prompt.box.width / 2 - y1 = prompt.box.y - prompt.box.height / 2 - x2 = prompt.box.x + prompt.box.width / 2 - y2 = prompt.box.y + prompt.box.height / 2 - return_dict["box"].append([x1, y1, x2, y2]) - if prompt.points is not None: - return_dict["point_coords"].append( - list([point.x, point.y] for point in prompt.points) - ) - return_dict["point_labels"].append( - list(int(point.positive) for point in prompt.points) - ) - else: - return_dict["point_coords"].append([]) - return_dict["point_labels"].append([]) - - if not any(return_dict["point_coords"]): - return_dict["point_coords"] = None - if not any(return_dict["point_labels"]): - return_dict["point_labels"] = None - - return_dict = {k: v if v else None for k, v in return_dict.items()} - return return_dict - - class Sam2SegmentationRequest(Sam2InferenceRequest): """SAM segmentation request. diff --git a/inference/core/entities/requests/sam3.py b/inference/core/entities/requests/sam3.py index bec6602c47..5ad71d1ba6 100644 --- a/inference/core/entities/requests/sam3.py +++ b/inference/core/entities/requests/sam3.py @@ -1,89 +1,15 @@ -from typing import List, Optional, Union +from typing import List, Optional -from pydantic import BaseModel, Field, validator +from pydantic import Field, validator from inference.core.entities.requests.inference import ( BaseRequest, InferenceRequestImage, ) from inference.core.env import SAM3_MAX_PROMPT_BATCH_SIZE - - -class Sam3Prompt(BaseModel): - """Unified prompt that can contain text and/or geometry. - - Absolute pixel coordinates are used for boxes. Labels accept 0/1 or booleans. - """ - - type: Optional[str] = Field( - default=None, - description="Optional hint: 'text' or 'visual'. 'visual' requires at least one box.", - ) - text: Optional[str] = Field( - default=None, - description="Concept to segment as a short noun phrase (e.g. 'person'). " - "All matching instances are returned. Can be combined with exemplar boxes in the same prompt.", - ) - - output_prob_thresh: Optional[float] = Field( - default=None, - description="Score threshold for this prompt's outputs. Overrides request-level threshold if set.", - ) - - # Absolute-coordinate boxes (preferred) in pixels. - # XYWH absolute pixels - class Box(BaseModel): - x: float - y: float - width: float - height: float - - # XYXY absolute pixels - class BoxXYXY(BaseModel): - x0: float - y0: float - x1: float - y1: float - - # Single unified boxes field; each entry can be XYWH or XYXY - boxes: Optional[List[Union[Box, BoxXYXY]]] = Field( - default=None, - description="Exemplar boxes in absolute pixels, as XYWH entries " - "({x, y, width, height}, top-left anchored) or XYXY entries ({x0, y0, x1, y1}). " - "Each box marks an example object; the model segments every instance matching " - "the exemplars (and text, if provided), not just the boxed objects. " - "Requires box_labels.", - ) - box_labels: Optional[List[Union[int, bool]]] = Field( - default=None, - description="Per-box exemplar labels, one per entry in boxes: " - "1/true marks a positive exemplar (segment objects like this), " - "0/false marks a negative exemplar (exclude objects like this). " - "Required when boxes is set.", - ) - - @validator("boxes", always=True) - def _validate_visual_boxes(cls, boxes, values): - prompt_type = values.get("type") - if prompt_type == "visual": - if not boxes or len(boxes) == 0: - raise ValueError("Visual prompt requires at least one box") - return boxes - - @validator("box_labels", always=True) - def _validate_box_labels(cls, labels, values): - boxes = values.get("boxes") - if labels is None: - return labels - if boxes is None or len(labels) != len(boxes): - raise ValueError("box_labels must match boxes length when provided") - return labels - - @validator("output_prob_thresh") - def _validate_output_prob_thresh(cls, v): - if v is not None and (v < 0.0 or v > 1.0): - raise ValueError("output_prob_thresh must be between 0.0 and 1.0") - return v +from inference.core.workflows.core_steps.models.foundation.segment_anything_common.prompts import ( # noqa: F401 + Sam3Prompt, +) class Sam3InferenceRequest(BaseRequest): diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py index 3917f8c77e..73ee09c384 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py @@ -28,7 +28,6 @@ from pycocotools import mask as mask_utils from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core.entities.requests.sam3 import Sam3Prompt from inference.core.workflows.core_steps.common.entities import StepExecutionMode # Reuse the v1_tensor SAM3 conversion machinery verbatim. @@ -39,6 +38,9 @@ _build_instance_detections_from_polygons, _normalize_class_names, ) +from inference.core.workflows.core_steps.models.foundation.segment_anything_common.prompts import ( + Sam3Prompt, +) from inference.core.workflows.environment import ( API_BASE_URL, CORE_MODEL_SAM3_ENABLED, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py index f311e6f0ae..1a36e84ef2 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py @@ -44,7 +44,6 @@ import requests from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core.entities.requests.sam3 import Sam3Prompt from inference.core.workflows.core_steps.common.entities import StepExecutionMode # Reuse the v1_tensor conversion machinery + the v2_tensor per-class/NMS collector. @@ -60,6 +59,9 @@ _min_floor, _per_class_threshold, ) +from inference.core.workflows.core_steps.models.foundation.segment_anything_common.prompts import ( + Sam3Prompt, +) from inference.core.workflows.environment import ( API_BASE_URL, CORE_MODEL_SAM3_ENABLED, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py index 42199e6bd4..732c2a97d7 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py @@ -37,12 +37,17 @@ import torch from pydantic import ConfigDict, Field, model_validator -from inference.core.entities.requests.sam2 import Box, Point, Sam2Prompt, Sam2PromptSet from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( build_native_image_metadata, split_key_point_prediction, ) +from inference.core.workflows.core_steps.models.foundation.segment_anything_common.prompts import ( + Box, + Point, + Sam2Prompt, + Sam2PromptSet, +) from inference.core.workflows.core_steps.models.foundation.segment_anything_common.visual_prompt import ( SYNTHETIC_POINT_PROMPT_CLASS_ID, SYNTHETIC_POINT_PROMPT_CLASS_NAME, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything_common/prompts.py b/inference/core/workflows/core_steps/models/foundation/segment_anything_common/prompts.py new file mode 100644 index 0000000000..82838d96c7 --- /dev/null +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything_common/prompts.py @@ -0,0 +1,161 @@ +"""SAM prompt value objects, owned by Workflows. + +`Box`, `Point`, `Sam2Prompt`, `Sam2PromptSet` (from `inference/core/entities/ +requests/sam2.py`) and `Sam3Prompt` (from `requests/sam3.py`) were MOVED here +verbatim; both server modules now re-export them, so there is exactly ONE class +object per name. That is what keeps `isinstance(raw_point, Point)` in the two +SAM 3 interactive blocks true for a `Point` built through either import path, +lets the server request classes accept prompt sets the blocks build, and keeps +the `ValidationError` a bad prompt raises the same object either way. + +Pinned by `tests/workflows/unit_tests/core_steps/models/foundation/test_sam_prompts.py` +(identity through the re-export, the accepted-input matrix of `_as_sam2_points`, +frozen `to_sam2_inputs()` / payload / validation tables). +""" + +from typing import List, Optional, Tuple, Union + +from pydantic import BaseModel, Field, validator + + +class Box(BaseModel): + x: float + y: float + width: float + height: float + + +class Point(BaseModel): + x: float + y: float + positive: bool + + def to_hashable(self) -> Tuple[float, float, bool]: + return (self.x, self.y, self.positive) + + +class Sam2Prompt(BaseModel): + box: Optional[Box] = Field(default=None) + points: Optional[List[Point]] = Field(default=None) + + def num_points(self) -> int: + return len(self.points or []) + + +class Sam2PromptSet(BaseModel): + prompts: Optional[List[Sam2Prompt]] = Field( + default=None, + description="An optional list of prompts for masks to predict. Each prompt can include a bounding box and / or a set of postive or negative points", + ) + + def num_points(self) -> int: + if not self.prompts: + return 0 + return sum(prompt.num_points() for prompt in self.prompts) + + def to_sam2_inputs(self): + if self.prompts is None: + return {"point_coords": None, "point_labels": None, "box": None} + return_dict = {"point_coords": [], "point_labels": [], "box": []} + for prompt in self.prompts: + if prompt.box is not None: + x1 = prompt.box.x - prompt.box.width / 2 + y1 = prompt.box.y - prompt.box.height / 2 + x2 = prompt.box.x + prompt.box.width / 2 + y2 = prompt.box.y + prompt.box.height / 2 + return_dict["box"].append([x1, y1, x2, y2]) + if prompt.points is not None: + return_dict["point_coords"].append( + list([point.x, point.y] for point in prompt.points) + ) + return_dict["point_labels"].append( + list(int(point.positive) for point in prompt.points) + ) + else: + return_dict["point_coords"].append([]) + return_dict["point_labels"].append([]) + + if not any(return_dict["point_coords"]): + return_dict["point_coords"] = None + if not any(return_dict["point_labels"]): + return_dict["point_labels"] = None + + return_dict = {k: v if v else None for k, v in return_dict.items()} + return return_dict + + +class Sam3Prompt(BaseModel): + """Unified prompt that can contain text and/or geometry. + + Absolute pixel coordinates are used for boxes. Labels accept 0/1 or booleans. + """ + + type: Optional[str] = Field( + default=None, + description="Optional hint: 'text' or 'visual'. 'visual' requires at least one box.", + ) + text: Optional[str] = Field( + default=None, + description="Concept to segment as a short noun phrase (e.g. 'person'). " + "All matching instances are returned. Can be combined with exemplar boxes in the same prompt.", + ) + + output_prob_thresh: Optional[float] = Field( + default=None, + description="Score threshold for this prompt's outputs. Overrides request-level threshold if set.", + ) + + # Absolute-coordinate boxes (preferred) in pixels. + # XYWH absolute pixels + class Box(BaseModel): + x: float + y: float + width: float + height: float + + # XYXY absolute pixels + class BoxXYXY(BaseModel): + x0: float + y0: float + x1: float + y1: float + + # Single unified boxes field; each entry can be XYWH or XYXY + boxes: Optional[List[Union[Box, BoxXYXY]]] = Field( + default=None, + description="Exemplar boxes in absolute pixels, as XYWH entries " + "({x, y, width, height}, top-left anchored) or XYXY entries ({x0, y0, x1, y1}). " + "Each box marks an example object; the model segments every instance matching " + "the exemplars (and text, if provided), not just the boxed objects. " + "Requires box_labels.", + ) + box_labels: Optional[List[Union[int, bool]]] = Field( + default=None, + description="Per-box exemplar labels, one per entry in boxes: " + "1/true marks a positive exemplar (segment objects like this), " + "0/false marks a negative exemplar (exclude objects like this). " + "Required when boxes is set.", + ) + + @validator("boxes", always=True) + def _validate_visual_boxes(cls, boxes, values): + prompt_type = values.get("type") + if prompt_type == "visual": + if not boxes or len(boxes) == 0: + raise ValueError("Visual prompt requires at least one box") + return boxes + + @validator("box_labels", always=True) + def _validate_box_labels(cls, labels, values): + boxes = values.get("boxes") + if labels is None: + return labels + if boxes is None or len(labels) != len(boxes): + raise ValueError("box_labels must match boxes length when provided") + return labels + + @validator("output_prob_thresh") + def _validate_output_prob_thresh(cls, v): + if v is not None and (v < 0.0 or v > 1.0): + raise ValueError("output_prob_thresh must be between 0.0 and 1.0") + return v diff --git a/scripts/phase11_move_sam_prompts.py b/scripts/phase11_move_sam_prompts.py new file mode 100644 index 0000000000..b571c12fb5 --- /dev/null +++ b/scripts/phase11_move_sam_prompts.py @@ -0,0 +1,122 @@ +"""Move the SAM prompt classes into workflows; the server re-exports them (Task 11.13). + +Creates `inference/core/workflows/core_steps/models/foundation/segment_anything_common/prompts.py` +from two spans copied character for character - `class Box` .. `to_sam2_inputs` +of `inference/core/entities/requests/sam2.py` and `class Sam3Prompt` .. +`_validate_output_prob_thresh` of `requests/sam3.py` - then replaces each span +in its server module with a re-export import, and trims the typing/pydantic +names only those spans used (`Tuple`, `BaseModel` in sam2.py; `Union`, +`BaseModel` in sam3.py). Content-anchored (never line numbers), re-parses every +output before writing, and idempotent: a second run prints SKIP and changes +nothing. A half-applied state (one of the three files already edited) aborts +with a message instead of guessing. + +Run: python scripts/phase11_move_sam_prompts.py [] +""" + +import ast +import sys +from pathlib import Path + +ROOT = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".") +TARGET = ( + ROOT + / "inference/core/workflows/core_steps/models/foundation/segment_anything_common/prompts.py" +) +SAM2 = ROOT / "inference/core/entities/requests/sam2.py" +SAM3 = ROOT / "inference/core/entities/requests/sam3.py" +PROMPTS_MODULE = "inference.core.workflows.core_steps.models.foundation.segment_anything_common.prompts" +SAM2_IMPORT = ( + f"from {PROMPTS_MODULE} import ( # noqa: F401\n" + " Box,\n Point,\n Sam2Prompt,\n Sam2PromptSet,\n)\n" +) +SAM3_IMPORT = f"from {PROMPTS_MODULE} import ( # noqa: F401\n Sam3Prompt,\n)\n" +SAM2_ANCHOR = "from inference.core.env import SAM2_VERSION_ID\n" +SAM3_ANCHOR = "from inference.core.env import SAM3_MAX_PROMPT_BATCH_SIZE\n" +HEADER = '''"""SAM prompt value objects, owned by Workflows. + +`Box`, `Point`, `Sam2Prompt`, `Sam2PromptSet` (from `inference/core/entities/ +requests/sam2.py`) and `Sam3Prompt` (from `requests/sam3.py`) were MOVED here +verbatim; both server modules now re-export them, so there is exactly ONE class +object per name. That is what keeps `isinstance(raw_point, Point)` in the two +SAM 3 interactive blocks true for a `Point` built through either import path, +lets the server request classes accept prompt sets the blocks build, and keeps +the `ValidationError` a bad prompt raises the same object either way. + +Pinned by `tests/workflows/unit_tests/core_steps/models/foundation/test_sam_prompts.py` +(identity through the re-export, the accepted-input matrix of `_as_sam2_points`, +frozen `to_sam2_inputs()` / payload / validation tables). +""" + +from typing import List, Optional, Tuple, Union + +from pydantic import BaseModel, Field, validator + + +''' + + +def _span(source: str, start: str, end: str) -> str: + first, last = source.index(start), source.index(end) + assert first < last, (start, end) + return source[first:last] + + +def _drop_names(source: str, prefix: str, names: list) -> str: + lines = source.split("\n") + matches = [index for index, line in enumerate(lines) if line.startswith(prefix)] + assert len(matches) == 1, (prefix, matches) + present = [name.strip() for name in lines[matches[0]][len(prefix) :].split(",")] + for name in names: + assert name in present, (prefix, name) + kept = [name for name in present if name not in names] + lines[matches[0]] = prefix + " ".join(", ".join(kept).split()) + return "\n".join(lines) + + +def main() -> int: + sam2, sam3 = SAM2.read_text(encoding="utf-8"), SAM3.read_text(encoding="utf-8") + applied = (SAM2_IMPORT in sam2, SAM3_IMPORT in sam3, TARGET.exists()) + if all(applied): + print("SKIP (already applied)") + return 0 + if any(applied): + raise SystemExit( + f"half-applied state {applied}; restore the three files from git first" + ) + sam2_span = _span(sam2, "class Box(BaseModel):", "class Sam2SegmentationRequest(") + sam3_span = _span( + sam3, "class Sam3Prompt(BaseModel):", "class Sam3InferenceRequest(" + ) + module = HEADER + sam2_span.rstrip() + "\n\n\n" + sam3_span.rstrip() + "\n" + ast.parse(module) + new_sam2 = sam2.replace(sam2_span, "", 1) + assert new_sam2.count(SAM2_ANCHOR) == 1 + new_sam2 = new_sam2.replace(SAM2_ANCHOR, SAM2_ANCHOR + SAM2_IMPORT, 1) + new_sam2 = _drop_names(new_sam2, "from typing import ", ["Tuple"]) + new_sam2 = _drop_names(new_sam2, "from pydantic import ", ["BaseModel"]) + new_sam3 = sam3.replace(sam3_span, "", 1) + assert new_sam3.count(SAM3_ANCHOR) == 1 + new_sam3 = new_sam3.replace(SAM3_ANCHOR, SAM3_ANCHOR + SAM3_IMPORT, 1) + new_sam3 = _drop_names(new_sam3, "from typing import ", ["Union"]) + new_sam3 = _drop_names(new_sam3, "from pydantic import ", ["BaseModel"]) + for text in (new_sam2, new_sam3): + ast.parse(text) + for name in ( + "class Box(", + "class Point(", + "class Sam2Prompt(", + "class Sam2PromptSet(", + "class Sam3Prompt(", + ): + assert name not in text, name + TARGET.write_text(module, encoding="utf-8") + SAM2.write_text(new_sam2, encoding="utf-8") + SAM3.write_text(new_sam3, encoding="utf-8") + for path in (TARGET, SAM2, SAM3): + print(f"{path}: {len(path.read_text(encoding='utf-8').splitlines())} lines") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_sam_prompts.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_sam_prompts.py new file mode 100644 index 0000000000..28a23a4bfe --- /dev/null +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_sam_prompts.py @@ -0,0 +1,275 @@ +"""The SAM prompt classes live in workflows; the server re-exports them. + +One class object per name (`Box`, `Point`, `Sam2Prompt`, `Sam2PromptSet`, +`Sam3Prompt`). That is what keeps `isinstance(raw_point, Point)` in the two +SAM 3 interactive blocks true for a `Point` built through either import path +(round-4 defect 2), lets the server request classes accept prompt sets the +blocks build, and keeps the `ValidationError` a bad prompt raises the same +object either way. The tables below are frozen from the classes as they stood +in `requests/sam2.py` / `requests/sam3.py` before the move, so a later edit to +the moved classes is caught here. +""" + +import ast +from pathlib import Path +from typing import List + +import pytest +from pydantic import ValidationError + +from inference.core.entities.requests import sam2 as server_sam2 +from inference.core.entities.requests import sam3 as server_sam3 +from inference.core.workflows.core_steps.models.foundation.segment_anything3_interactive import ( + v1 as interactive_v1, +) +from inference.core.workflows.core_steps.models.foundation.segment_anything3_interactive import ( + v1_tensor as interactive_v1_tensor, +) +from inference.core.workflows.core_steps.models.foundation.segment_anything_common import ( + prompts as local, +) + +# tests/workflows/unit_tests/core_steps/models/foundation/ -> parents[6] is the repo root +PROMPTS_MODULE = ( + Path(__file__).resolve().parents[6] + / "inference/core/workflows/core_steps/models/foundation/segment_anything_common/prompts.py" +) +IMAGE = {"type": "base64", "value": "aGVsbG8="} +BOX = dict(x=5.0, y=6.0, width=4.0, height=8.0) +POINTS = [dict(x=1.0, y=2.0, positive=True), dict(x=3.0, y=4.0, positive=False)] +PAIRS = [ + (local.Box, server_sam2.Box), + (local.Point, server_sam2.Point), + (local.Sam2Prompt, server_sam2.Sam2Prompt), + (local.Sam2PromptSet, server_sam2.Sam2PromptSet), + (local.Sam3Prompt, server_sam3.Sam3Prompt), +] + + +@pytest.mark.parametrize( + "local_cls,server_cls", PAIRS, ids=[pair[1].__name__ for pair in PAIRS] +) +def test_server_module_re_exports_the_workflows_class(local_cls, server_cls) -> None: + assert local_cls is server_cls + + +def test_request_classes_resolve_their_prompt_fields_to_the_workflows_classes() -> None: + assert ( + server_sam2.Sam2SegmentationRequest.model_fields["prompts"].annotation + is local.Sam2PromptSet + ) + assert ( + server_sam3.Sam3SegmentationRequest.model_fields["prompts"].annotation + == List[local.Sam3Prompt] + ) + + +def test_prompts_module_imports_nothing_from_the_server() -> None: + assert PROMPTS_MODULE.is_file(), PROMPTS_MODULE + tree = ast.parse(PROMPTS_MODULE.read_text(encoding="utf-8")) + imported = sorted( + node.module + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module + ) + assert imported == ["pydantic", "typing"], imported + + +def test_server_request_classes_accept_block_built_prompt_sets() -> None: + prompt_set = local.Sam2PromptSet( + prompts=[local.Sam2Prompt(points=[local.Point(x=1.0, y=2.0, positive=True)])] + ) + request = server_sam2.Sam2SegmentationRequest( + image=IMAGE, api_key="k", prompts=prompt_set + ) + assert request.prompts is prompt_set # `_coerce_prompts`'s isinstance branch + request = server_sam3.Sam3SegmentationRequest( + image=IMAGE, api_key="k", prompts=[local.Sam3Prompt(type="text", text="cat")] + ) + assert type(request.prompts[0]) is local.Sam3Prompt + + +# --- the accepted-input matrix of `_as_sam2_points`, both interactive variants --- + +VARIANTS = [interactive_v1._as_sam2_points, interactive_v1_tensor._as_sam2_points] +ACCEPTED = [ + ("dict", {"x": 1, "y": 2, "positive": False}, (1.0, 2.0, False)), + ("dict without positive", {"x": 3, "y": 4}, (3.0, 4.0, True)), + ("(x, y) list", [5, 6], (5.0, 6.0, True)), + ("(x, y, positive) tuple", (7, 8, False), (7.0, 8.0, False)), + ( + "server Point", + server_sam2.Point(x=9.0, y=10.0, positive=True), + (9.0, 10.0, True), + ), + ("local Point", local.Point(x=11.0, y=12.0, positive=False), (11.0, 12.0, False)), +] +REJECTED = [ + ("string", "1,2", "Each point prompt must be an object or a sequence"), + ("dict missing y", {"x": 1}, "must define `x` and `y`"), + ("one-element list", [1], "sequence with two or three values"), + ("bool coordinates", {"x": True, "y": 1}, "Point coordinates must be numbers"), +] + + +@pytest.mark.parametrize("as_sam2_points", VARIANTS, ids=["v1", "v1_tensor"]) +@pytest.mark.parametrize("case", ACCEPTED, ids=[case[0] for case in ACCEPTED]) +def test_as_sam2_points_accepts_every_input_shape_the_original_accepted( + as_sam2_points, case +) -> None: + _, raw, expected = case + [point] = as_sam2_points([raw]) + assert point.to_hashable() == expected + assert type(point) is local.Point + if isinstance(raw, local.Point): + assert point is raw # an accepted Point object passes through untouched + + +@pytest.mark.parametrize("as_sam2_points", VARIANTS, ids=["v1", "v1_tensor"]) +@pytest.mark.parametrize("case", REJECTED, ids=[case[0] for case in REJECTED]) +def test_as_sam2_points_rejects_what_the_original_rejected( + as_sam2_points, case +) -> None: + _, raw, message = case + with pytest.raises(ValueError, match=message): + as_sam2_points([raw]) + + +# --- frozen behaviour of the moved classes --- + + +def _prompt_set() -> local.Sam2PromptSet: + return local.Sam2PromptSet( + prompts=[ + local.Sam2Prompt(box=local.Box(**BOX)), + local.Sam2Prompt(points=[local.Point(**p) for p in POINTS]), + ] + ) + + +def test_to_sam2_inputs_is_frozen() -> None: + assert _prompt_set().to_sam2_inputs() == { + "point_coords": [[], [[1.0, 2.0], [3.0, 4.0]]], + "point_labels": [[], [1, 0]], + "box": [[3.0, 2.0, 7.0, 10.0]], + } + empty = {"point_coords": None, "point_labels": None, "box": None} + assert local.Sam2PromptSet().to_sam2_inputs() == empty + assert local.Sam2PromptSet(prompts=[]).to_sam2_inputs() == empty + + +def test_remote_payload_shape_is_frozen() -> None: + expected = { + "prompts": [ + {"box": {"x": 5.0, "y": 6.0, "width": 4.0, "height": 8.0}}, + { + "points": [ + {"x": 1.0, "y": 2.0, "positive": True}, + {"x": 3.0, "y": 4.0, "positive": False}, + ] + }, + ] + } + assert _prompt_set().dict(exclude_none=True) == expected + assert _prompt_set().model_dump(exclude_none=True) == expected + + +def test_num_points_and_hashable_are_frozen() -> None: + assert _prompt_set().num_points() == 2 + assert local.Point(**POINTS[0]).to_hashable() == (1.0, 2.0, True) + + +def _outcome(cls, kwargs): + try: + return ("ok", cls(**kwargs).model_dump(exclude_none=True)) + except ValidationError as error: + return ("error", [(e["loc"], e["type"]) for e in error.errors()]) + + +SAM3_TABLE = [ + ( + dict(type="text", text="cat", output_prob_thresh=0.4), + ("ok", {"type": "text", "text": "cat", "output_prob_thresh": 0.4}), + ), + ( + dict( + type="visual", + boxes=[{"x": 1, "y": 1, "width": 2, "height": 2}], + box_labels=[1], + ), + ( + "ok", + { + "type": "visual", + "boxes": [{"x": 1.0, "y": 1.0, "width": 2.0, "height": 2.0}], + "box_labels": [1], + }, + ), + ), + ( + dict(type="text", text="cat", boxes=[{"x0": 0, "y0": 0, "x1": 1, "y1": 1}]), + ( + "ok", + { + "type": "text", + "text": "cat", + "boxes": [{"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0}], + }, + ), + ), + (dict(type="text", text=123), ("error", [(("text",), "string_type")])), + ( + dict(type="visual", boxes=[{}]), + ( + "error", + [ + (("boxes", 0, "Box", "x"), "missing"), + (("boxes", 0, "Box", "y"), "missing"), + (("boxes", 0, "Box", "width"), "missing"), + (("boxes", 0, "Box", "height"), "missing"), + (("boxes", 0, "BoxXYXY", "x0"), "missing"), + (("boxes", 0, "BoxXYXY", "y0"), "missing"), + (("boxes", 0, "BoxXYXY", "x1"), "missing"), + (("boxes", 0, "BoxXYXY", "y1"), "missing"), + ], + ), + ), + ( + dict(type="text", text="cat", output_prob_thresh=1.5), + ("error", [(("output_prob_thresh",), "value_error")]), + ), + (dict(type="visual", text=None), ("error", [(("boxes",), "value_error")])), + ( + dict( + type="text", + text="cat", + boxes=[{"x": 1, "y": 1, "width": 2, "height": 2}], + box_labels=[1, 0], + ), + ("error", [(("box_labels",), "value_error")]), + ), + ( + dict(type="text", text="cat", box_labels=[1]), + ("error", [(("box_labels",), "value_error")]), + ), +] + + +@pytest.mark.parametrize( + "kwargs,expected", SAM3_TABLE, ids=[str(i) for i in range(len(SAM3_TABLE))] +) +def test_sam3_prompt_validation_is_frozen(kwargs, expected) -> None: + assert _outcome(local.Sam3Prompt, kwargs) == expected + + +def test_validation_errors_still_name_the_class() -> None: + """error_handlers.py:148-152 exposes `str(inner_error)` to HTTP clients.""" + with pytest.raises(ValidationError) as error: + local.Sam3Prompt(type="text", text=123) + assert str(error.value).startswith("1 validation error for Sam3Prompt\ntext\n") + with pytest.raises(ValidationError) as error: + local.Sam2Prompt(box=dict(x="a", y=2, width=3, height=4)) + assert str(error.value).startswith("1 validation error for Sam2Prompt\nbox.x\n") + assert [(e["loc"], e["type"]) for e in error.value.errors()] == [ + (("box", "x"), "float_parsing") + ] diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index 3eb326cbc2..c6bf7f17e4 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,13 +1,10 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 10 +# Count: 7 inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.requests.sam2 inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.requests.sam3 -inference/core/workflows/core_steps/models/foundation/segment_anything3/v2_tensor.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.entities.requests.sam3 -inference/core/workflows/core_steps/models/foundation/segment_anything3/v3_tensor.py inference.core.entities.requests.sam3 inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py inference.core.entities.requests.sam3_3d inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py inference.core.entities.requests.sam3_3d inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.entities.requests.sam2 -inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1_tensor.py inference.core.entities.requests.sam2 From f5ef4756e3ac3495f1d8fc9f610be7235422969e Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:12:27 +0200 Subject: [PATCH 73/86] =?UTF-8?q?test(workflows):=20SAM=20prompt=20move=20?= =?UTF-8?q?=E2=80=94=20nested-dict=20coercion,=20schema=20descriptions,=20?= =?UTF-8?q?full=20purity=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../models/foundation/test_sam_prompts.py | 102 +++++++++++++++++- 1 file changed, 100 insertions(+), 2 deletions(-) diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_sam_prompts.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_sam_prompts.py index 28a23a4bfe..1980779df3 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_sam_prompts.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_sam_prompts.py @@ -68,9 +68,11 @@ def test_prompts_module_imports_nothing_from_the_server() -> None: assert PROMPTS_MODULE.is_file(), PROMPTS_MODULE tree = ast.parse(PROMPTS_MODULE.read_text(encoding="utf-8")) imported = sorted( - node.module + (node.module if isinstance(node, ast.ImportFrom) else alias.name) for node in ast.walk(tree) - if isinstance(node, ast.ImportFrom) and node.module + if isinstance(node, (ast.Import, ast.ImportFrom)) + for alias in (node.names if isinstance(node, ast.Import) else [None]) + if (node.module if isinstance(node, ast.ImportFrom) else alias.name) ) assert imported == ["pydantic", "typing"], imported @@ -89,6 +91,102 @@ def test_server_request_classes_accept_block_built_prompt_sets() -> None: assert type(request.prompts[0]) is local.Sam3Prompt +def test_sam2_segmentation_request_coerces_nested_dict_prompts_to_workflows_classes() -> ( + None +): + """`_coerce_prompts` accepts `{"prompts": [...]}` and hands it to + `Sam2PromptSet(**value)`; pydantic then builds every nested prompt/box/ + point from the same (workflows-owned) classes, not copies.""" + request = server_sam2.Sam2SegmentationRequest( + image=IMAGE, + api_key="k", + prompts={"prompts": [{"box": BOX}, {"points": POINTS}]}, + ) + assert type(request.prompts) is local.Sam2PromptSet + assert type(request.prompts.prompts[0]) is local.Sam2Prompt + assert type(request.prompts.prompts[0].box) is local.Box + assert type(request.prompts.prompts[1].points[0]) is local.Point + + +def test_sam2_segmentation_request_rejects_invalid_nested_dict_prompts() -> None: + with pytest.raises(ValidationError) as error: + server_sam2.Sam2SegmentationRequest( + image=IMAGE, + api_key="k", + prompts={ + "prompts": [{"box": {"x": "a", "y": 6.0, "width": 4.0, "height": 8.0}}] + }, + ) + assert [(e["loc"], e["type"]) for e in error.value.errors()] == [ + (("prompts", "prompts", 0, "box", "x"), "float_parsing") + ] + + +def test_sam3_segmentation_request_coerces_nested_dict_prompts_to_workflows_classes() -> ( + None +): + request = server_sam3.Sam3SegmentationRequest( + image=IMAGE, api_key="k", prompts=[{"type": "text", "text": "cat"}] + ) + assert type(request.prompts[0]) is local.Sam3Prompt + + +def test_sam3_segmentation_request_rejects_invalid_nested_dict_prompts() -> None: + with pytest.raises(ValidationError) as error: + server_sam3.Sam3SegmentationRequest( + image=IMAGE, api_key="k", prompts=[{"type": "text", "text": 123}] + ) + assert [(e["loc"], e["type"]) for e in error.value.errors()] == [ + (("prompts", 0, "text"), "string_type") + ] + + +def test_schema_descriptions_survive_the_move() -> None: + """The exact `description=`/docstring text in `prompts.py` must still show + up in `model_json_schema()` after the move (the HTTP `/model/schema` + surface for SAM2/SAM3 prompts is generated from these classes).""" + sam2_prompt_set_schema = local.Sam2PromptSet.model_json_schema() + assert sam2_prompt_set_schema["properties"]["prompts"]["description"] == ( + "An optional list of prompts for masks to predict. Each prompt can " + "include a bounding box and / or a set of postive or negative points" + ) + + sam3_prompt_schema = local.Sam3Prompt.model_json_schema() + assert ( + "Unified prompt that can contain text and/or geometry." + in sam3_prompt_schema["description"] + ) + assert ( + "Absolute pixel coordinates are used for boxes. Labels accept 0/1 or booleans." + in sam3_prompt_schema["description"] + ) + assert sam3_prompt_schema["properties"]["type"]["description"] == ( + "Optional hint: 'text' or 'visual'. 'visual' requires at least one box." + ) + assert sam3_prompt_schema["properties"]["text"]["description"] == ( + "Concept to segment as a short noun phrase (e.g. 'person'). All " + "matching instances are returned. Can be combined with exemplar " + "boxes in the same prompt." + ) + assert sam3_prompt_schema["properties"]["output_prob_thresh"]["description"] == ( + "Score threshold for this prompt's outputs. Overrides request-level " + "threshold if set." + ) + assert sam3_prompt_schema["properties"]["boxes"]["description"] == ( + "Exemplar boxes in absolute pixels, as XYWH entries " + "({x, y, width, height}, top-left anchored) or XYXY entries " + "({x0, y0, x1, y1}). Each box marks an example object; the model " + "segments every instance matching the exemplars (and text, if " + "provided), not just the boxed objects. Requires box_labels." + ) + assert sam3_prompt_schema["properties"]["box_labels"]["description"] == ( + "Per-box exemplar labels, one per entry in boxes: 1/true marks a " + "positive exemplar (segment objects like this), 0/false marks a " + "negative exemplar (exclude objects like this). Required when boxes " + "is set." + ) + + # --- the accepted-input matrix of `_as_sam2_points`, both interactive variants --- VARIANTS = [interactive_v1._as_sam2_points, interactive_v1_tensor._as_sam2_points] From c2e5348f4f4430ebf7816331753f6b0e7b42d105 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:40:50 +0200 Subject: [PATCH 74/86] refactor(workflows): SAM blocks run through the plain-argument port Port SAM2, SAM3 (v1/v2/v3), SAM3 interactive and SAM3-3D (v1/v1_tensor) onto ModelManagerModelsProvider.run_sam2_segmentation / run_sam3_segmentation / run_sam3_3d_objects, following the UNSET/_passed forwarding rule Tasks 11.8-11.12 established for the other model families. Registration (add_model / load_core_model) stays exactly where each block already had it. segment_anything2/v1.py keeps decoding the image before registering (CR-1), matching its pre-port order. Decontamination baseline: 7 -> 0. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../interfaces/workflows_models_provider.py | 89 ++++ .../models/foundation/segment_anything2/v1.py | 35 +- .../models/foundation/segment_anything3/v1.py | 18 +- .../models/foundation/segment_anything3/v2.py | 18 +- .../models/foundation/segment_anything3/v3.py | 18 +- .../foundation/segment_anything3_3d/v1.py | 9 +- .../segment_anything3_3d/v1_tensor.py | 8 +- .../segment_anything3_interactive/v1.py | 28 +- .../workflows/prototypes/models_provider.py | 31 ++ .../test_workflows_models_provider.py | 460 ++++++++++++++++++ .../test_image_prep_before_registration.py | 30 ++ .../test_port_requests_match_legacy_sam.py | 367 ++++++++++++++ .../foundation/test_segment_anything2.py | 61 ++- .../foundation/test_segment_anything3_3d.py | 57 ++- .../test_segment_anything3_interactive.py | 37 +- .../foundation/test_segment_anything3_v3.py | 4 +- .../unit_tests/decontamination_baseline.txt | 10 +- 17 files changed, 1174 insertions(+), 106 deletions(-) create mode 100644 tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_sam.py diff --git a/inference/core/interfaces/workflows_models_provider.py b/inference/core/interfaces/workflows_models_provider.py index 9fd8569b1a..5cfffbe81c 100644 --- a/inference/core/interfaces/workflows_models_provider.py +++ b/inference/core/interfaces/workflows_models_provider.py @@ -52,6 +52,15 @@ PerceptionEncoderTextEmbeddingRequest, ) from inference.core.entities.requests.pp_ocr import PPOCRInferenceRequest +from inference.core.entities.requests.sam2 import ( + Box, + Point, + Sam2Prompt, + Sam2PromptSet, + Sam2SegmentationRequest, +) +from inference.core.entities.requests.sam3 import Sam3Prompt, Sam3SegmentationRequest +from inference.core.entities.requests.sam3_3d import Sam3_3D_Objects_InferenceRequest from inference.core.entities.requests.yolo_world import YOLOWorldInferenceRequest from inference.core.managers.base import ModelManager from inference.core.roboflow_api import ModelEndpointType @@ -470,6 +479,86 @@ def run_yolo_world( ) return self._dump(self._infer(model_id=model_id, request=request))[0] + @staticmethod + def _sam2_prompt_set(prompts: List[dict]) -> Sam2PromptSet: + revived = [] + for prompt in prompts: + if "box" in prompt: + revived.append(Sam2Prompt(box=Box(**prompt["box"]))) + elif "points" in prompt: + revived.append( + Sam2Prompt(points=[Point(**point) for point in prompt["points"]]) + ) + else: + raise ValueError( + f"SAM2 prompt must carry 'box' or 'points'; got {sorted(prompt)}" + ) + return Sam2PromptSet(prompts=revived) + + def run_sam2_segmentation( + self, + model_id: str, + image: Any, + prompts: List[dict], + api_key: Optional[str] = None, + version_id: Union[str, None, _Unset] = UNSET, + request_model_id: Union[str, None, _Unset] = UNSET, + multimask_output: Union[bool, None, _Unset] = UNSET, + threshold: Union[float, None, _Unset] = UNSET, + ) -> List[Any]: + # segment_anything2/v1.py passes sam2_version_id, threshold and + # multimask_output; segment_anything3_interactive/v1.py passes model_id + # and multimask_output. Each is forwarded exactly as passed - None + # included: `multimask_output: bool` rejects it, as it did inline. + request = Sam2SegmentationRequest( + image=image, + api_key=api_key, + source=_WORKFLOW_SOURCE, + prompts=self._sam2_prompt_set(prompts), + **_passed( + sam2_version_id=version_id, + model_id=request_model_id, + multimask_output=multimask_output, + threshold=threshold, + ), + ) + return self._infer(model_id=model_id, request=request) + + def run_sam3_segmentation( + self, + model_id: str, + image: Any, + prompts: List[dict], + api_key: Optional[str] = None, + output_prob_thresh: Union[float, None, _Unset] = UNSET, + nms_iou_threshold: Union[float, None, _Unset] = UNSET, + format: Union[str, None, _Unset] = UNSET, + ) -> List[Any]: + request = Sam3SegmentationRequest( + api_key=api_key, + model_id=model_id, + image=image, + prompts=[Sam3Prompt(**prompt) for prompt in prompts], + **_passed( + output_prob_thresh=output_prob_thresh, + nms_iou_threshold=nms_iou_threshold, + format=format, + ), + ) + return self._infer(model_id=model_id, request=request) + + def run_sam3_3d_objects( + self, + model_id: str, + image: Any, + mask_input: Any, + api_key: Optional[str] = None, + ) -> Any: + request = Sam3_3D_Objects_InferenceRequest( + image=image, mask_input=mask_input, api_key=api_key, model_id=model_id + ) + return self._infer(model_id=model_id, request=request)[0] + def run_tensor_native_inference(self, model_id: str, **kwargs: Any) -> Any: return self._model_manager.run_tensor_native_inference( model_id=model_id, **kwargs diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py index 244d37c0e7..910e2958d0 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py @@ -4,12 +4,6 @@ import supervision as sv from pydantic import ConfigDict, Field -from inference.core.entities.requests.sam2 import ( - Box, - Sam2Prompt, - Sam2PromptSet, - Sam2SegmentationRequest, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.inference_response_dc import ( InferenceResponseImageDC, @@ -25,6 +19,10 @@ convert_inference_detections_batch_to_sv_detections, load_core_model, ) +from inference.core.workflows.core_steps.models.foundation.segment_anything_common.prompts import ( + Box, + Sam2Prompt, +) from inference.core.workflows.environment import ( CORE_MODEL_SAM2_ENABLED, HOSTED_CORE_MODEL_URL, @@ -448,25 +446,22 @@ def run_locally( ) ) prompts.append(prompt) - inference_request = Sam2SegmentationRequest( - image=single_image.to_inference_format(numpy_preferred=True), - sam2_version_id=version, - api_key=self._api_key, - source="workflow-execution", - prompts=Sam2PromptSet(prompts=prompts), - threshold=threshold, - multimask_output=multimask_output, - ) + image = single_image.to_inference_format(numpy_preferred=True) sam_model_id = load_core_model( model_manager=self._model_manager, - version_id=inference_request.sam2_version_id, + version_id=version, api_key=self._api_key, core_model="sam2", ) - - sam2_segmentation_response = self._model_manager.infer_from_request_sync( - sam_model_id, inference_request - ) + sam2_segmentation_response = self._model_manager.run_sam2_segmentation( + model_id=sam_model_id, + image=image, + prompts=[prompt.model_dump(exclude_none=True) for prompt in prompts], + api_key=self._api_key, + version_id=version, + threshold=threshold, + multimask_output=multimask_output, + )[0] prediction = convert_sam2_segmentation_response_to_inference_instances_seg_response( sam2_segmentation_predictions=sam2_segmentation_response.predictions, diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py index 559f0433bd..8243469bfc 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py @@ -7,7 +7,6 @@ import supervision as sv from pydantic import ConfigDict, Field -from inference.core.entities.requests.sam3 import Sam3Prompt, Sam3SegmentationRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.inference_response_dc import ( InferenceResponseImageDC, @@ -23,6 +22,9 @@ convert_inference_detections_batch_to_sv_detections, load_core_model, ) +from inference.core.workflows.core_steps.models.foundation.segment_anything_common.prompts import ( + Sam3Prompt, +) from inference.core.workflows.environment import ( API_BASE_URL, CORE_MODEL_SAM3_ENABLED, @@ -286,17 +288,15 @@ def run_locally( unified_prompts.append(Sam3Prompt(type="text", text=class_name)) # Single batched request with all prompts - inference_request = Sam3SegmentationRequest( - image=single_image.to_inference_format(numpy_preferred=True), + sam3_response = self._model_manager.run_sam3_segmentation( model_id=model_id, + image=single_image.to_inference_format(numpy_preferred=True), + prompts=[ + prompt.model_dump(exclude_none=True) for prompt in unified_prompts + ], api_key=self._api_key, - prompts=unified_prompts, output_prob_thresh=threshold, - ) - - sam3_response = self._model_manager.infer_from_request_sync( - model_id, inference_request - ) + )[0] # Unpack unified batch response class_predictions = [] diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py index 7921ba8609..d3794e90a9 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py @@ -7,7 +7,6 @@ import supervision as sv from pydantic import ConfigDict, Field, model_validator, validator -from inference.core.entities.requests.sam3 import Sam3Prompt, Sam3SegmentationRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.inference_response_dc import ( InferenceResponseImageDC, @@ -23,6 +22,9 @@ convert_inference_detections_batch_to_sv_detections, load_core_model, ) +from inference.core.workflows.core_steps.models.foundation.segment_anything_common.prompts import ( + Sam3Prompt, +) from inference.core.workflows.environment import ( API_BASE_URL, CORE_MODEL_SAM3_ENABLED, @@ -360,18 +362,16 @@ def run_locally( ) # Single batched request with all prompts - inference_request = Sam3SegmentationRequest( - image=single_image.to_inference_format(numpy_preferred=True), + sam3_response = self._model_manager.run_sam3_segmentation( model_id=model_id, + image=single_image.to_inference_format(numpy_preferred=True), + prompts=[ + prompt.model_dump(exclude_none=True) for prompt in unified_prompts + ], api_key=self._api_key, - prompts=unified_prompts, output_prob_thresh=confidence, nms_iou_threshold=nms_iou_threshold if apply_nms else None, - ) - - sam3_response = self._model_manager.infer_from_request_sync( - model_id, inference_request - ) + )[0] # Unpack unified batch response class_predictions = [] diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py index 9a1bfba0d1..aa0c98ad7e 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py @@ -7,7 +7,6 @@ from pycocotools import mask as mask_utils from pydantic import ConfigDict, Field, model_validator, validator -from inference.core.entities.requests.sam3 import Sam3Prompt, Sam3SegmentationRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.inference_response_dc import ( InferenceResponseImageDC, @@ -22,6 +21,9 @@ attach_parents_coordinates_to_batch_of_sv_detections, attach_prediction_type_info_to_sv_detections_batch, ) +from inference.core.workflows.core_steps.models.foundation.segment_anything_common.prompts import ( + Sam3Prompt, +) from inference.core.workflows.environment import ( API_BASE_URL, CORE_MODEL_SAM3_ENABLED, @@ -395,19 +397,17 @@ def run_locally( ) # Single batched request with all prompts - inference_request = Sam3SegmentationRequest( - image=single_image.to_inference_format(numpy_preferred=True), + sam3_response = self._model_manager.run_sam3_segmentation( model_id=model_id, + image=single_image.to_inference_format(numpy_preferred=True), + prompts=[ + prompt.model_dump(exclude_none=True) for prompt in unified_prompts + ], api_key=self._api_key, - prompts=unified_prompts, output_prob_thresh=confidence, nms_iou_threshold=nms_iou_threshold if apply_nms else None, format=model_format, - ) - - sam3_response = self._model_manager.infer_from_request_sync( - model_id, inference_request - ) + )[0] image_width = single_image.numpy_image.shape[1] image_height = single_image.numpy_image.shape[0] diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py index c5a74ff70e..a22833e3f2 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py @@ -5,7 +5,6 @@ import supervision as sv from pydantic import ConfigDict, Field -from inference.core.entities.requests.sam3_3d import Sam3_3D_Objects_InferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.environment import ( HOSTED_CORE_MODEL_URL, @@ -255,15 +254,11 @@ def run_locally( for single_image, single_mask_input in zip(images, mask_input): converted_mask = extract_masks_from_input(single_mask_input) - inference_request = Sam3_3D_Objects_InferenceRequest( + response = self._model_manager.run_sam3_3d_objects( + model_id=model_id, image=single_image.to_inference_format(numpy_preferred=True), mask_input=converted_mask, api_key=self._api_key, - model_id=model_id, - ) - - response = self._model_manager.infer_from_request_sync( - model_id, inference_request ) results.append(_format_response(response)) diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py index 5b842bdc30..7e26b702cb 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py @@ -23,7 +23,6 @@ import numpy as np from pydantic import ConfigDict, Field -from inference.core.entities.requests.sam3_3d import Sam3_3D_Objects_InferenceRequest from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.tensor_native import ( instance_mask_to_numpy, @@ -256,14 +255,11 @@ def run_locally( for single_image, single_mask_input in zip(images, mask_input): converted_mask = extract_masks_from_input(single_mask_input) - inference_request = Sam3_3D_Objects_InferenceRequest( + response = self._model_manager.run_sam3_3d_objects( + model_id=model_id, image=single_image.to_inference_format(numpy_preferred=True), mask_input=converted_mask, api_key=self._api_key, - model_id=model_id, - ) - response = self._model_manager.infer_from_request_sync( - model_id, inference_request ) results.append(_format_response(response)) return results diff --git a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py index 8492c2b9b7..1b315457e3 100644 --- a/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py @@ -6,13 +6,6 @@ import supervision as sv from pydantic import ConfigDict, Field, model_validator -from inference.core.entities.requests.sam2 import ( - Box, - Point, - Sam2Prompt, - Sam2PromptSet, - Sam2SegmentationRequest, -) from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.segmentation_entities import ( Sam2SegmentationPrediction, @@ -25,6 +18,12 @@ from inference.core.workflows.core_steps.models.foundation.segment_anything2.v1 import ( convert_sam2_segmentation_response_to_inference_instances_seg_response, ) +from inference.core.workflows.core_steps.models.foundation.segment_anything_common.prompts import ( + Box, + Point, + Sam2Prompt, + Sam2PromptSet, +) from inference.core.workflows.core_steps.models.foundation.segment_anything_common.visual_prompt import ( SYNTHETIC_POINT_PROMPT_CLASS_ID, SYNTHETIC_POINT_PROMPT_CLASS_NAME, @@ -333,17 +332,16 @@ def run_locally( [], ) for group in groups: - inference_request = Sam2SegmentationRequest( - image=single_image.to_inference_format(numpy_preferred=True), + segmentation_response = self._model_manager.run_sam2_segmentation( model_id=SAM3_INTERACTIVE_MODEL_ID, + image=single_image.to_inference_format(numpy_preferred=True), + prompts=[ + prompt.model_dump(exclude_none=True) for prompt in group.prompts + ], api_key=self._api_key, - source="workflow-execution", - prompts=Sam2PromptSet(prompts=group.prompts), + request_model_id=SAM3_INTERACTIVE_MODEL_ID, multimask_output=multimask_output, - ) - segmentation_response = self._model_manager.infer_from_request_sync( - SAM3_INTERACTIVE_MODEL_ID, inference_request - ) + )[0] segmentation_predictions.extend(segmentation_response.predictions) class_ids.extend(group.class_ids) class_names.extend(group.class_names) diff --git a/inference/core/workflows/prototypes/models_provider.py b/inference/core/workflows/prototypes/models_provider.py index 856badc89e..9bbe3aa275 100644 --- a/inference/core/workflows/prototypes/models_provider.py +++ b/inference/core/workflows/prototypes/models_provider.py @@ -250,6 +250,37 @@ def run_yolo_world( confidence: Optional[Union[float, str]] = None, ) -> dict: ... + def run_sam2_segmentation( + self, + model_id: str, + image: Any, + prompts: List[dict], + api_key: Optional[str] = None, + version_id: Union[str, None, _Unset] = UNSET, + request_model_id: Union[str, None, _Unset] = UNSET, + multimask_output: Union[bool, None, _Unset] = UNSET, + threshold: Union[float, None, _Unset] = UNSET, + ) -> List[Any]: ... + + def run_sam3_segmentation( + self, + model_id: str, + image: Any, + prompts: List[dict], + api_key: Optional[str] = None, + output_prob_thresh: Union[float, None, _Unset] = UNSET, + nms_iou_threshold: Union[float, None, _Unset] = UNSET, + format: Union[str, None, _Unset] = UNSET, + ) -> List[Any]: ... + + def run_sam3_3d_objects( + self, + model_id: str, + image: Any, + mask_input: Any, + api_key: Optional[str] = None, + ) -> Any: ... + def run_tensor_native_inference(self, model_id: str, **kwargs: Any) -> Any: ... def get_class_names(self, model_id: str) -> List[str]: ... diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py index 4f3c7352d3..d4c4e550d6 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py @@ -792,3 +792,463 @@ def test_run_yolo_world_builds_the_request_the_block_used_to_build() -> None: api_key="k", ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +from inference.core.entities.requests.sam2 import ( + Box, + Point, + Sam2Prompt, + Sam2PromptSet, + Sam2SegmentationRequest, +) +from inference.core.entities.requests.sam3 import Sam3Prompt, Sam3SegmentationRequest +from inference.core.entities.requests.sam3_3d import Sam3_3D_Objects_InferenceRequest + + +def test_run_sam2_segmentation_revives_box_and_point_prompts_from_a_version_id() -> ( + None +): + response = object() + manager = manager_returning(response) + result = ModelManagerModelsProvider(manager).run_sam2_segmentation( + model_id="sam2/hiera_large", + image=IMAGE, + prompts=[ + {"box": {"x": 5.0, "y": 5.0, "width": 4.0, "height": 4.0}}, + {"points": [{"x": 1.0, "y": 2.0, "positive": True}]}, + ], + api_key="k", + version_id="hiera_large", + multimask_output=False, + threshold=0.5, + ) + assert result == [response] + manager.add_model.assert_not_called() + request = captured_request(manager) + expected = Sam2SegmentationRequest( + image=IMAGE, + sam2_version_id="hiera_large", + api_key="k", + source="workflow-execution", + prompts=Sam2PromptSet( + prompts=[ + Sam2Prompt(box=Box(x=5.0, y=5.0, width=4.0, height=4.0)), + Sam2Prompt(points=[Point(x=1.0, y=2.0, positive=True)]), + ] + ), + threshold=0.5, + multimask_output=False, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_run_sam2_segmentation_accepts_an_explicit_request_model_id() -> None: + manager = manager_returning(object()) + ModelManagerModelsProvider(manager).run_sam2_segmentation( + model_id="sam3-interactive", + image=IMAGE, + prompts=[{"box": {"x": 1.0, "y": 1.0, "width": 2.0, "height": 2.0}}], + api_key="k", + request_model_id="sam3-interactive", + multimask_output=True, + ) + assert captured_request(manager).model_id == "sam3-interactive" + + +def test_run_sam3_segmentation_revives_text_prompts() -> None: + manager = manager_returning(object()) + ModelManagerModelsProvider(manager).run_sam3_segmentation( + model_id="sam3/sam3_final", + image=IMAGE, + api_key="k", + prompts=[{"type": "text", "text": "cat", "output_prob_thresh": 0.4}], + output_prob_thresh=0.4, + nms_iou_threshold=0.5, + ) + request = captured_request(manager) + expected = Sam3SegmentationRequest( + api_key="k", + model_id="sam3/sam3_final", + image=IMAGE, + prompts=[Sam3Prompt(type="text", text="cat", output_prob_thresh=0.4)], + output_prob_thresh=0.4, + nms_iou_threshold=0.5, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_run_sam3_segmentation_omits_unset_optionals() -> None: + manager = manager_returning(object()) + ModelManagerModelsProvider(manager).run_sam3_segmentation( + model_id="sam3/sam3_final", + image=IMAGE, + api_key="k", + prompts=[{"type": "text", "text": "cat"}], + ) + request = captured_request(manager) + default = Sam3SegmentationRequest( + api_key="k", + model_id="sam3/sam3_final", + image=IMAGE, + prompts=[Sam3Prompt(type="text", text="cat")], + ) + assert request.output_prob_thresh == default.output_prob_thresh + assert request.nms_iou_threshold == default.nms_iou_threshold + assert request.format == default.format + + +def test_run_sam3_3d_objects_builds_the_request_and_returns_the_response() -> None: + response = object() + manager = manager_returning(response) + assert ( + ModelManagerModelsProvider(manager).run_sam3_3d_objects( + model_id="sam3-3d-objects", image=IMAGE, mask_input=[[0, 1]], api_key="k" + ) + is response + ) + manager.add_model.assert_not_called() + request = captured_request(manager) + expected = Sam3_3D_Objects_InferenceRequest( + image=IMAGE, mask_input=[[0, 1]], api_key="k", model_id="sam3-3d-objects" + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + + +def test_sam2_prompt_dicts_encode_the_same_prompt_set_as_the_inline_construction() -> ( + None +): + produced = ModelManagerModelsProvider(MagicMock())._sam2_prompt_set( + [ + {"box": {"x": 1.0, "y": 2.0, "width": 3.0, "height": 4.0}}, + {"points": [{"x": 5.0, "y": 6.0, "positive": False}]}, + ] + ) + expected = Sam2PromptSet( + prompts=[ + Sam2Prompt(box=Box(x=1.0, y=2.0, width=3.0, height=4.0)), + Sam2Prompt(points=[Point(x=5.0, y=6.0, positive=False)]), + ] + ) + assert produced.model_dump() == expected.model_dump() + + +# --- round-5 defect 1: an explicit None must have the SAME outcome through the +# adapter as through the block's inline construction - the same stored value, +# or the same ValidationError (type, message, error table). One row per caller +# shape whose argument the none-matrix flagged (Global Constraints, forwarding +# rule); the request classes are imported above (Tasks 11.8-11.14). +from pydantic import ValidationError + +BOX_PROMPT = {"box": {"x": 30.0, "y": 30.0, "width": 40.0, "height": 40.0}} +BOX_PROMPT_SET = Sam2PromptSet( + prompts=[Sam2Prompt(box=Box(x=30.0, y=30.0, width=40.0, height=40.0))] +) +ISEG_COMMON = dict( + class_agnostic_nms=False, + class_filter=None, + confidence=0.4, + iou_threshold=0.3, + max_detections=300, + max_candidates=3000, + mask_decode_mode="accurate", + tradeoff_factor=0.0, + disable_active_learning=False, + active_learning_target_dataset=None, +) + + +def _outcome(build): + """What the caller observes: the request dump, or the error as the HTTP + layer exposes it (error_handlers.py:148-152: type name and str(error)).""" + try: + return ("ok", build().model_dump(exclude={"id"})) + except ValidationError as error: + return ( + "error", + type(error).__name__, + str(error), + [(e["loc"], e["type"]) for e in error.errors()], + ) + + +# (id, adapter method, adapter kwargs, request class, the kwargs the block passed inline) +NONE_CASES = [ + ( + "segment_anything2/v1.py: multimask_output=None", + "run_sam2_segmentation", + dict( + model_id="sam2/hiera_large", + image=IMAGE, + prompts=[BOX_PROMPT], + api_key="k", + version_id="hiera_large", + threshold=0.0, + multimask_output=None, + ), + Sam2SegmentationRequest, + dict( + image=IMAGE, + sam2_version_id="hiera_large", + api_key="k", + source="workflow-execution", + prompts=BOX_PROMPT_SET, + threshold=0.0, + multimask_output=None, + ), + ), + ( + "segment_anything3_interactive/v1.py: multimask_output=None", + "run_sam2_segmentation", + dict( + model_id="sam3-interactive", + image=IMAGE, + prompts=[BOX_PROMPT], + api_key="k", + request_model_id="sam3-interactive", + multimask_output=None, + ), + Sam2SegmentationRequest, + dict( + image=IMAGE, + model_id="sam3-interactive", + api_key="k", + source="workflow-execution", + prompts=BOX_PROMPT_SET, + multimask_output=None, + ), + ), + ( + "segment_anything2/v1.py: version=None", + "run_sam2_segmentation", + dict( + model_id="sam2/None", + image=IMAGE, + prompts=[BOX_PROMPT], + api_key="k", + version_id=None, + threshold=0.0, + multimask_output=True, + ), + Sam2SegmentationRequest, + dict( + image=IMAGE, + sam2_version_id=None, + api_key="k", + source="workflow-execution", + prompts=BOX_PROMPT_SET, + threshold=0.0, + multimask_output=True, + ), + ), + ( + "segment_anything3/v1.py: threshold=None", + "run_sam3_segmentation", + dict( + model_id="sam3/sam3_final", + image=IMAGE, + prompts=[{"type": "text", "text": "cat"}], + api_key="k", + output_prob_thresh=None, + ), + Sam3SegmentationRequest, + dict( + image=IMAGE, + model_id="sam3/sam3_final", + api_key="k", + prompts=[Sam3Prompt(type="text", text="cat")], + output_prob_thresh=None, + ), + ), + ( + "segment_anything3/v2.py: apply_nms=False (nms_iou_threshold=None)", + "run_sam3_segmentation", + dict( + model_id="sam3/sam3_final", + image=IMAGE, + prompts=[{"type": "text", "text": "cat"}], + api_key="k", + output_prob_thresh=0.5, + nms_iou_threshold=None, + ), + Sam3SegmentationRequest, + dict( + image=IMAGE, + model_id="sam3/sam3_final", + api_key="k", + prompts=[Sam3Prompt(type="text", text="cat")], + output_prob_thresh=0.5, + nms_iou_threshold=None, + ), + ), + ( + "segment_anything3/v3.py: format=None", + "run_sam3_segmentation", + dict( + model_id="sam3/sam3_final", + image=IMAGE, + prompts=[{"type": "text", "text": "cat"}], + api_key="k", + output_prob_thresh=0.5, + nms_iou_threshold=0.9, + format=None, + ), + Sam3SegmentationRequest, + dict( + image=IMAGE, + model_id="sam3/sam3_final", + api_key="k", + prompts=[Sam3Prompt(type="text", text="cat")], + output_prob_thresh=0.5, + nms_iou_threshold=0.9, + format=None, + ), + ), + ( + "instance_segmentation/v1.py: enforce_dense_masks_in_inference_models=None", + "run_instance_segmentation", + dict( + model_id="m/1", + images=IMAGES, + api_key="k", + enforce_dense_masks_in_inference_models=None, + **ISEG_COMMON, + ), + InstanceSegmentationInferenceRequest, + dict( + api_key="k", + model_id="m/1", + image=IMAGES, + source="workflow-execution", + enforce_dense_masks_in_inference_models=None, + **ISEG_COMMON, + ), + ), + ( + "qwen3_5vl/v1.py (any LMM block passing it): enable_thinking=None", + "run_lmm", + dict( + model_id="m/1", image=IMAGE, prompt="p", api_key="k", enable_thinking=None + ), + LMMInferenceRequest, + dict( + api_key="k", + model_id="m/1", + image=IMAGE, + source="workflow-execution", + prompt="p", + enable_thinking=None, + ), + ), + ( + "clip_comparison/v2.py: version=None", + "run_clip_comparison", + dict( + subject=IMAGE, + subject_type="image", + prompt=["cat"], + prompt_type="text", + api_key="k", + version_id=None, + ), + ClipCompareRequest, + dict( + clip_version_id=None, + subject=IMAGE, + subject_type="image", + prompt=["cat"], + prompt_type="text", + api_key="k", + ), + ), + ( + "semantic_segmentation/v2.py: confidence=None", + "run_semantic_segmentation", + dict( + model_id="m/1", + images=IMAGES, + api_key="k", + confidence=None, + response_mask_format="numpy", + ), + SemanticSegmentationInferenceRequest, + dict( + api_key="k", + model_id="m/1", + image=IMAGES, + confidence=None, + response_mask_format="numpy", + source="workflow-execution", + ), + ), +] + + +@pytest.mark.parametrize("case", NONE_CASES, ids=[case[0] for case in NONE_CASES]) +def test_an_explicit_none_has_the_same_outcome_through_the_adapter_as_inline( + case, +) -> None: + _, method, adapter_kwargs, request_class, inline_kwargs = case + manager = MagicMock() + manager.__contains__.return_value = True + manager.infer_from_request_sync.return_value = MagicMock(embeddings=[[0.0]]) + provider = ModelManagerModelsProvider(manager) + + def through_adapter(): + getattr(provider, method)(**adapter_kwargs) + return captured_request(manager) + + inline = _outcome(lambda: request_class(**inline_kwargs)) + assert _outcome(through_adapter) == inline + if inline[0] == "error": + manager.infer_from_request_sync.assert_not_called() + + +def test_clip_comparison_registers_the_id_the_block_derived_for_a_none_version() -> ( + None +): + """`load_core_model` read `inference_request.clip_version_id` (None) and + registered `clip/None`; the adapter must not silently upgrade that to the + default version.""" + manager = MagicMock() + manager.infer_from_request_sync.return_value = MagicMock() + ModelManagerModelsProvider(manager).run_clip_comparison( + subject=IMAGE, + subject_type="image", + prompt=["cat"], + prompt_type="text", + api_key="k", + version_id=None, + ) + assert manager.add_model.call_args.args[0] == "clip/None" + + +def test_sam2_multimask_none_is_rejected_before_inference_with_the_inline_message() -> ( + None +): + """The reviewer's reproduction: both consuming manifests allow None, both + forward it, and the request field is a non-optional bool.""" + manager = MagicMock() + with pytest.raises(ValidationError) as through_adapter: + ModelManagerModelsProvider(manager).run_sam2_segmentation( + model_id="sam2/hiera_large", + image=IMAGE, + prompts=[BOX_PROMPT], + api_key="k", + version_id="hiera_large", + threshold=0.0, + multimask_output=None, + ) + with pytest.raises(ValidationError) as inline: + Sam2SegmentationRequest( + image=IMAGE, + sam2_version_id="hiera_large", + api_key="k", + source="workflow-execution", + prompts=BOX_PROMPT_SET, + threshold=0.0, + multimask_output=None, + ) + assert type(through_adapter.value) is type(inline.value) + assert str(through_adapter.value) == str(inline.value) + assert "multimask_output" in str(inline.value) and "bool_type" in str(inline.value) + manager.infer_from_request_sync.assert_not_called() diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_image_prep_before_registration.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_image_prep_before_registration.py index 357d39bffc..8782a02a62 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_image_prep_before_registration.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_image_prep_before_registration.py @@ -18,6 +18,9 @@ EasyOCRBlockV1, ) from inference.core.workflows.core_steps.models.foundation.ocr.v1 import OCRModelBlockV1 +from inference.core.workflows.core_steps.models.foundation.segment_anything2.v1 import ( + SegmentAnything2BlockV1, +) from inference.core.workflows.core_steps.models.foundation.yolo_world.v1 import ( YoloWorldModelBlockV1, ) @@ -108,3 +111,30 @@ def test_failed_yolo_world_image_preparation_leaves_registration_and_inference_u model_manager.add_model.assert_not_called() model_manager.run_yolo_world.assert_not_called() + + +def test_failed_sam2_image_preparation_leaves_registration_and_inference_uncalled() -> ( + None +): + # Task 11.14 CR-1 site: segment_anything2/v1.py decodes the image (now the + # local `image = single_image.to_inference_format(...)`) BEFORE + # `load_core_model`. + model_manager = mock.MagicMock() + block = SegmentAnything2BlockV1( + model_manager=model_manager, + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + images = _failing_image_batch() + + with pytest.raises(ValueError, match="decode failed"): + block.run_locally( + images=images, + boxes=None, + version="hiera_large", + threshold=0.0, + multimask_output=True, + ) + + model_manager.add_model.assert_not_called() + model_manager.run_sam2_segmentation.assert_not_called() diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_sam.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_sam.py new file mode 100644 index 0000000000..a919943377 --- /dev/null +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_sam.py @@ -0,0 +1,367 @@ +"""Block -> adapter -> request differential tests for Task 11.14's SAM family +(SAM2, SAM3 interactive, SAM3 v1/v2/v3, SAM3-3D). + +Each test drives a block's real ``run_locally`` through a real +``ModelManagerModelsProvider`` wrapping a ``MagicMock`` ``ModelManager``, then +compares the pydantic request the adapter built against the exact request +construction the block used to run inline before Task 11.14 (this task's BASE +commit, 1c535758e, is also HEAD of this checkout - the pre-port construction +below is copied straight from ``git show 1c535758e:``, i.e. the source +this task started from). + +One case per distinct request-building shape the task touches: +``Sam2SegmentationRequest`` built from a box prompt (``segment_anything2/v1.py``) +and from a point prompt with the ``model_id=`` selector shape +(``segment_anything3_interactive/v1.py``); ``Sam3SegmentationRequest`` in its +v1 (bare), v2 (``nms_iou_threshold``) and v3 (``format``) shapes; and +``Sam3_3D_Objects_InferenceRequest`` (``segment_anything3_3d/v1.py``). + +Every case asserts both ``model_dump()`` equality AND ``model_fields_set`` +equality: an omitted field must stay omitted, not be re-supplied as its own +default. +""" + +from unittest.mock import MagicMock + +import numpy as np +import supervision as sv + +from inference.core.entities.requests.sam2 import ( + Box, + Point, + Sam2Prompt, + Sam2PromptSet, + Sam2SegmentationRequest, +) +from inference.core.entities.requests.sam3 import Sam3Prompt, Sam3SegmentationRequest +from inference.core.entities.requests.sam3_3d import Sam3_3D_Objects_InferenceRequest +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.models.foundation.segment_anything2.v1 import ( + SegmentAnything2BlockV1, +) +from inference.core.workflows.core_steps.models.foundation.segment_anything3.v1 import ( + SegmentAnything3BlockV1, +) +from inference.core.workflows.core_steps.models.foundation.segment_anything3.v2 import ( + SegmentAnything3BlockV2, +) +from inference.core.workflows.core_steps.models.foundation.segment_anything3.v3 import ( + SegmentAnything3BlockV3, +) +from inference.core.workflows.core_steps.models.foundation.segment_anything3_3d.v1 import ( + SegmentAnything3_3D_ObjectsBlockV1, +) +from inference.core.workflows.core_steps.models.foundation.segment_anything3_interactive.v1 import ( + SAM3_INTERACTIVE_MODEL_ID, + SegmentAnything3InteractiveBlockV1, +) +from inference.core.workflows.execution_engine.entities.base import ( + Batch, + ImageParentMetadata, + WorkflowImageData, +) +from inference.core.workflows.prototypes.models_provider import CORE_MODEL_ENDPOINT_TYPE + + +def _make_image() -> WorkflowImageData: + return WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="p"), + numpy_image=np.zeros((20, 10, 3), dtype=np.uint8), + ) + + +def _batch_of_one(item) -> Batch: + return Batch(content=[item], indices=[(0,)]) + + +def _manager() -> MagicMock: + return MagicMock() + + +def _captured_request(manager: MagicMock): + assert manager.infer_from_request_sync.call_count == 1 + call = manager.infer_from_request_sync.call_args + return call.kwargs["request"] if "request" in call.kwargs else call.args[1] + + +def _box_detections() -> sv.Detections: + detections = sv.Detections( + xyxy=np.array([[10.0, 10.0, 50.0, 50.0]], dtype=np.float32), + confidence=np.array([0.9], dtype=np.float32), + class_id=np.array([0]), + data={ + "class_name": np.array(["object"]), + "detection_id": np.array(["d1"]), + }, + ) + return detections + + +def test_sam2_v1_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show 1c535758e:.../segment_anything2/v1.py`: + # `Sam2SegmentationRequest(image=..., sam2_version_id=version, api_key=..., + # source="workflow-execution", prompts=Sam2PromptSet(prompts=prompts), + # threshold=threshold, multimask_output=multimask_output)`, with `prompts` + # built from the box-centre arithmetic (`cx`/`cy`/`width`/`height` of + # [10, 10, 50, 50] -> centre (30, 30), width/height 40). + manager = _manager() + manager.infer_from_request_sync.return_value = MagicMock(predictions=[]) + image = _make_image() + block = SegmentAnything2BlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally( + images=_batch_of_one(image), + boxes=_batch_of_one(_box_detections()), + version="hiera_large", + threshold=0.0, + multimask_output=True, + ) + + manager.add_model.assert_called_once_with( + model_id="sam2/hiera_large", api_key="k", endpoint_type=CORE_MODEL_ENDPOINT_TYPE + ) + request = _captured_request(manager) + expected = Sam2SegmentationRequest( + image=image.to_inference_format(numpy_preferred=True), + sam2_version_id="hiera_large", + api_key="k", + source="workflow-execution", + prompts=Sam2PromptSet( + prompts=[Sam2Prompt(box=Box(x=30.0, y=30.0, width=40.0, height=40.0))] + ), + threshold=0.0, + multimask_output=True, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set + + +def test_sam3_interactive_v1_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show 1c535758e:.../segment_anything3_interactive/v1.py`: + # `Sam2SegmentationRequest(image=..., model_id=SAM3_INTERACTIVE_MODEL_ID, + # api_key=..., source="workflow-execution", + # prompts=Sam2PromptSet(prompts=group.prompts), multimask_output=...)` - + # this family identifies the model by `model_id=`, not `sam2_version_id=`. + manager = _manager() + manager.infer_from_request_sync.return_value = MagicMock(predictions=[]) + image = _make_image() + block = SegmentAnything3InteractiveBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally( + images=_batch_of_one(image), + points=[{"x": 320, "y": 240, "positive": True}], + boxes=None, + threshold=0.0, + multimask_output=True, + ) + + manager.add_model.assert_called_once_with( + model_id=SAM3_INTERACTIVE_MODEL_ID, api_key="k" + ) + request = _captured_request(manager) + expected = Sam2SegmentationRequest( + image=image.to_inference_format(numpy_preferred=True), + model_id=SAM3_INTERACTIVE_MODEL_ID, + api_key="k", + source="workflow-execution", + prompts=Sam2PromptSet( + prompts=[Sam2Prompt(points=[Point(x=320.0, y=240.0, positive=True)])] + ), + multimask_output=True, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set + + +def test_sam3_v1_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show 1c535758e:.../segment_anything3/v1.py`: + # `Sam3SegmentationRequest(image=..., model_id=model_id, api_key=..., + # prompts=unified_prompts, output_prob_thresh=threshold)`. + manager = _manager() + manager.infer_from_request_sync.return_value = MagicMock(prompt_results=[]) + image = _make_image() + block = SegmentAnything3BlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally( + images=_batch_of_one(image), + model_id="sam3/sam3_final", + class_names=["cat"], + threshold=0.5, + ) + + manager.add_model.assert_called_once_with(model_id="sam3/sam3_final", api_key="k") + request = _captured_request(manager) + expected = Sam3SegmentationRequest( + image=image.to_inference_format(numpy_preferred=True), + model_id="sam3/sam3_final", + api_key="k", + prompts=[Sam3Prompt(type="text", text="cat")], + output_prob_thresh=0.5, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set + + +def test_sam3_v2_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show 1c535758e:.../segment_anything3/v2.py`: + # `Sam3SegmentationRequest(image=..., model_id=model_id, api_key=..., + # prompts=unified_prompts, output_prob_thresh=confidence, + # nms_iou_threshold=nms_iou_threshold if apply_nms else None)`. No + # `format=` - v2 never sets it, so the request's own default applies. + manager = _manager() + manager.infer_from_request_sync.return_value = MagicMock(prompt_results=[]) + image = _make_image() + block = SegmentAnything3BlockV2( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally( + images=_batch_of_one(image), + model_id="sam3/sam3_final", + class_names=["cat"], + confidence=0.4, + per_class_confidence=None, + apply_nms=True, + nms_iou_threshold=0.9, + ) + + manager.add_model.assert_called_once_with(model_id="sam3/sam3_final", api_key="k") + request = _captured_request(manager) + expected = Sam3SegmentationRequest( + image=image.to_inference_format(numpy_preferred=True), + model_id="sam3/sam3_final", + api_key="k", + prompts=[Sam3Prompt(type="text", text="cat", output_prob_thresh=None)], + output_prob_thresh=0.4, + nms_iou_threshold=0.9, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set + + +def test_sam3_v2_disables_nms_when_apply_nms_is_false() -> None: + """`nms_iou_threshold=nms_iou_threshold if apply_nms else None` - the + request stores an explicit `None`, matching the pydantic default, but the + block always forwards it (round-5 defect 1 territory).""" + manager = _manager() + manager.infer_from_request_sync.return_value = MagicMock(prompt_results=[]) + image = _make_image() + block = SegmentAnything3BlockV2( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally( + images=_batch_of_one(image), + model_id="sam3/sam3_final", + class_names=["cat"], + confidence=0.4, + per_class_confidence=None, + apply_nms=False, + nms_iou_threshold=0.9, + ) + + request = _captured_request(manager) + expected = Sam3SegmentationRequest( + image=image.to_inference_format(numpy_preferred=True), + model_id="sam3/sam3_final", + api_key="k", + prompts=[Sam3Prompt(type="text", text="cat", output_prob_thresh=None)], + output_prob_thresh=0.4, + nms_iou_threshold=None, + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set + + +def test_sam3_v3_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show 1c535758e:.../segment_anything3/v3.py`: + # `Sam3SegmentationRequest(image=..., model_id=model_id, api_key=..., + # prompts=unified_prompts, output_prob_thresh=confidence, + # nms_iou_threshold=nms_iou_threshold if apply_nms else None, + # format=model_format)` - v3 alone sets `format`. + manager = _manager() + manager.infer_from_request_sync.return_value = MagicMock(prompt_results=[]) + image = _make_image() + block = SegmentAnything3BlockV3( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally( + images=_batch_of_one(image), + model_id="sam3/sam3_final", + class_names=["cat"], + confidence=0.5, + per_class_confidence=None, + apply_nms=True, + nms_iou_threshold=0.9, + output_format="rle", + ) + + manager.add_model.assert_called_once_with(model_id="sam3/sam3_final", api_key="k") + request = _captured_request(manager) + expected = Sam3SegmentationRequest( + image=image.to_inference_format(numpy_preferred=True), + model_id="sam3/sam3_final", + api_key="k", + prompts=[Sam3Prompt(type="text", text="cat", output_prob_thresh=None)], + output_prob_thresh=0.5, + nms_iou_threshold=0.9, + format="rle", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set + + +def test_sam3_3d_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show 1c535758e:.../segment_anything3_3d/v1.py`: + # `Sam3_3D_Objects_InferenceRequest(image=..., mask_input=converted_mask, + # api_key=..., model_id=model_id)`. `mask_input` is a plain polygon list, + # so `extract_masks_from_input` passes it through unchanged. + manager = _manager() + response = MagicMock() + response.mesh_glb = b"mesh" + response.gaussian_ply = b"gaussian" + response.time = 1.0 + response.objects = [] + manager.infer_from_request_sync.return_value = response + image = _make_image() + mask_input = [10, 10, 100, 10, 100, 100, 10, 100] + block = SegmentAnything3_3D_ObjectsBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally(images=_batch_of_one(image), mask_input=_batch_of_one(mask_input)) + + manager.add_model.assert_called_once_with(model_id="sam3-3d-objects", api_key="k") + request = _captured_request(manager) + expected = Sam3_3D_Objects_InferenceRequest( + image=image.to_inference_format(numpy_preferred=True), + mask_input=mask_input, + api_key="k", + model_id="sam3-3d-objects", + ) + assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) + assert request.model_fields_set == expected.model_fields_set diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything2.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything2.py index dc17b911f5..d484ccbdf2 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything2.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything2.py @@ -22,7 +22,7 @@ def mock_model_manager(): mock_prediction = MagicMock() mock_prediction.masks = [[[0, 0], [100, 0], [100, 100], [0, 100]]] mock_prediction.confidence = 0.95 - mock.infer_from_request_sync.return_value = MagicMock(predictions=[mock_prediction]) + mock.run_sam2_segmentation.return_value = [MagicMock(predictions=[mock_prediction])] return mock @@ -192,3 +192,62 @@ def test_convert_sam2_response_produces_the_same_dict_as_the_pydantic_form() -> ], ).model_dump(by_alias=True, exclude_none=True) assert produced == expected + + +def test_sam2_v1_local_sends_the_box_centre_prompt_it_computed() -> None: + """Drives `run_locally` with one detection so the box-centre arithmetic and + the prompt encoding both execute. A replacement range that swallowed the + `cx`/`cy` assignments raises NameError here.""" + import supervision as sv + + from inference.core.roboflow_api import ModelEndpointType + from inference.core.workflows.core_steps.models.foundation.segment_anything2.v1 import ( + DETECTION_ID_FIELD, + DETECTIONS_CLASS_NAME_FIELD, + ) + from inference.core.workflows.execution_engine.entities.base import Batch + + detections = sv.Detections( + xyxy=np.array([[10.0, 10.0, 50.0, 50.0]], dtype=np.float32), + confidence=np.array([0.9], dtype=np.float32), + class_id=np.array([0]), + data={ + DETECTIONS_CLASS_NAME_FIELD: np.array(["object"]), + DETECTION_ID_FIELD: np.array(["d1"]), + }, + ) + model_manager = MagicMock() + model_manager.run_sam2_segmentation.return_value = [MagicMock(predictions=[])] + block = SegmentAnything2BlockV1( + model_manager=model_manager, + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + images = Batch( + content=[ + WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="p"), + numpy_image=np.zeros((100, 100, 3), dtype=np.uint8), + ) + ], + indices=[(0,)], + ) + block.run_locally( + images=images, + boxes=Batch(content=[detections], indices=[(0,)]), + version="hiera_large", + threshold=0.0, + multimask_output=True, + ) + prompts = model_manager.run_sam2_segmentation.call_args.kwargs["prompts"] + # centre of [10, 10, 50, 50] is (30, 30) with width/height 40 + assert prompts == [{"box": {"x": 30.0, "y": 30.0, "width": 40.0, "height": 40.0}}] + # Registration stayed in the block, in its original position. Asserted by + # enum coercion: `load_core_model` passes the enum before Phase 9 and the + # string "core_model" after it (Task 9.2) - `ModelEndpointType(...)` maps + # both to CORE_MODEL, and `ModelEndpointType.CORE_MODEL == "core_model"` + # is False (plain Enum, roboflow_api.py:582). + args, kwargs = model_manager.add_model.call_args + assert args == ("sam2/hiera_large", "k") + assert set(kwargs) == {"endpoint_type"} + assert ModelEndpointType(kwargs["endpoint_type"]) is ModelEndpointType.CORE_MODEL diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_3d.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_3d.py index f4f7805566..3a5b4c0bd1 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_3d.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_3d.py @@ -30,7 +30,7 @@ def mock_model_manager(): mock_obj.metadata.translation = [0, 0, 0] mock_obj.metadata.scale = [1, 1, 1] mock_response.objects = [mock_obj] - mock.infer_from_request_sync.return_value = mock_response + mock.run_sam3_3d_objects.return_value = mock_response return mock @@ -78,7 +78,7 @@ def test_run_locally(mock_model_manager, mock_workflow_image_data, mock_mask_inp assert "objects" in result[0] assert "inference_time" in result[0] mock_model_manager.add_model.assert_called_once() - mock_model_manager.infer_from_request_sync.assert_called_once() + mock_model_manager.run_sam3_3d_objects.assert_called_once() @patch( @@ -169,3 +169,56 @@ def test_run_remotely_converts_numpy_masks_to_lists( call_args = mock_client.sam3_3d_infer.call_args mask_input = call_args.kwargs.get("mask_input") assert isinstance(mask_input, list) + + +def test_sam3_3d_registers_the_model_once_before_inferring( + mock_workflow_image_data, mock_mask_input +) -> None: + """Round-2 defect 3: inference needs an already-loaded model + (`managers/base.py:639` raises otherwise), so the block keeps its single + pre-loop registration and the adapter never registers for this family.""" + from unittest.mock import MagicMock + + from inference.core.workflows.core_steps.common.entities import StepExecutionMode + from inference.core.workflows.core_steps.models.foundation.segment_anything3_3d.v1 import ( + SegmentAnything3_3D_ObjectsBlockV1, + ) + + calls = [] + response = MagicMock() + response.mesh_glb = b"mesh" + response.gaussian_ply = b"gaussian" + response.time = 1.5 + obj = MagicMock() + obj.mesh_glb = b"obj_mesh" + obj.gaussian_ply = b"obj_gaussian" + obj.metadata.rotation = [0, 0, 0, 1] + obj.metadata.translation = [0, 0, 0] + obj.metadata.scale = [1, 1, 1] + response.objects = [obj] + + model_manager = MagicMock() + model_manager.add_model.side_effect = lambda **kwargs: calls.append( + ("add_model", kwargs) + ) + + def _run(**kwargs): + calls.append(("infer", kwargs)) + return response + + model_manager.run_sam3_3d_objects.side_effect = _run + + block = SegmentAnything3_3D_ObjectsBlockV1( + model_manager=model_manager, + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + results = block.run_locally( + images=[mock_workflow_image_data, mock_workflow_image_data], + mask_input=[mock_mask_input, mock_mask_input], + ) + + # One registration, before the loop; then one inference per image. + assert [name for name, _ in calls] == ["add_model", "infer", "infer"] + assert calls[0][1] == {"model_id": "sam3-3d-objects", "api_key": "k"} + assert len(results) == 2 diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_interactive.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_interactive.py index 6970596238..b942bcf415 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_interactive.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_interactive.py @@ -29,7 +29,7 @@ def mock_model_manager(): mock_prediction = MagicMock() mock_prediction.masks = [[[0, 0], [100, 0], [100, 100], [0, 100]]] mock_prediction.confidence = 0.95 - mock.infer_from_request_sync.return_value = MagicMock(predictions=[mock_prediction]) + mock.run_sam2_segmentation.return_value = [MagicMock(predictions=[mock_prediction])] return mock @@ -144,13 +144,12 @@ def test_run_locally_with_point_prompts( assert len(result) == 1 assert "predictions" in result[0] mock_model_manager.add_model.assert_called_once() - inference_request = mock_model_manager.infer_from_request_sync.call_args[0][1] - prompts = inference_request.prompts.prompts + prompts = mock_model_manager.run_sam2_segmentation.call_args.kwargs["prompts"] assert len(prompts) == 1 - assert len(prompts[0].points) == 2 - assert prompts[0].points[0].x == 320 - assert prompts[0].points[0].positive is True - assert prompts[0].points[1].positive is False + assert len(prompts[0]["points"]) == 2 + assert prompts[0]["points"][0]["x"] == 320 + assert prompts[0]["points"][0]["positive"] is True + assert prompts[0]["points"][1]["positive"] is False predictions = result[0]["predictions"] assert predictions.class_id.tolist() == [-1] assert predictions["class_name"].tolist() == ["foreground"] @@ -176,18 +175,20 @@ def test_numpy_boxes_and_points_keep_class_zero_distinct_from_point_prompt( assert len(result) == 1 # box and point prompts cannot be mixed in a single SAM prompt batch, # so the block issues one request per prompt group - assert mock_model_manager.infer_from_request_sync.call_count == 2 - box_request = mock_model_manager.infer_from_request_sync.call_args_list[0][0][1] - box_prompts = box_request.prompts.prompts + assert mock_model_manager.run_sam2_segmentation.call_count == 2 + box_prompts = mock_model_manager.run_sam2_segmentation.call_args_list[0].kwargs[ + "prompts" + ] assert len(box_prompts) == 1 - assert box_prompts[0].box is not None - assert box_prompts[0].box.x == 30 # box centre of [10, 10, 50, 50] - assert box_prompts[0].points is None - points_request = mock_model_manager.infer_from_request_sync.call_args_list[1][0][1] - point_prompts = points_request.prompts.prompts + assert "box" in box_prompts[0] + assert box_prompts[0]["box"]["x"] == 30 # box centre of [10, 10, 50, 50] + assert "points" not in box_prompts[0] + point_prompts = mock_model_manager.run_sam2_segmentation.call_args_list[1].kwargs[ + "prompts" + ] assert len(point_prompts) == 1 - assert point_prompts[0].box is None - assert point_prompts[0].points[0].x == 320 + assert "box" not in point_prompts[0] + assert point_prompts[0]["points"][0]["x"] == 320 # masks from both requests are merged into a single output predictions = result[0]["predictions"] assert predictions.class_id.tolist() == [0, -1] @@ -257,7 +258,7 @@ def test_run_locally_with_empty_detections_and_no_points( assert len(result) == 1 assert len(result[0]["predictions"]) == 0 - mock_model_manager.infer_from_request_sync.assert_not_called() + mock_model_manager.run_sam2_segmentation.assert_not_called() @patch( diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_v3.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_v3.py index 0171fee295..113d758dee 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_v3.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_segment_anything3_v3.py @@ -254,7 +254,7 @@ def test_v3_local_polygon_path_converts_through_supervision() -> None: `sv.Detections.from_inference` path actually executes. Before Task 11.4 Step 8 this raised `TypeError: … object is not subscriptable`.""" model_manager = MagicMock() - model_manager.infer_from_request_sync.return_value = _sam3_polygon_response() + model_manager.run_sam3_segmentation.return_value = [_sam3_polygon_response()] block = SegmentAnything3BlockV3( model_manager=model_manager, api_key="k", @@ -268,7 +268,7 @@ def test_v3_local_polygon_path_converts_through_supervision() -> None: detections = result[0]["predictions"] assert len(detections) == 1 assert detections.xyxy.tolist() == [[0.0, 0.0, 8.0, 6.0]] - model_manager.infer_from_request_sync.assert_called_once() + model_manager.run_sam3_segmentation.assert_called_once() def test_v3_remote_polygon_path_converts_through_supervision() -> None: diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt index c6bf7f17e4..919bb564ce 100644 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ b/tests/workflows/unit_tests/decontamination_baseline.txt @@ -1,10 +1,4 @@ # Remaining `inference.*` imports inside inference/core/workflows. # This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 7 -inference/core/workflows/core_steps/models/foundation/segment_anything2/v1.py inference.core.entities.requests.sam2 -inference/core/workflows/core_steps/models/foundation/segment_anything3/v1.py inference.core.entities.requests.sam3 -inference/core/workflows/core_steps/models/foundation/segment_anything3/v2.py inference.core.entities.requests.sam3 -inference/core/workflows/core_steps/models/foundation/segment_anything3/v3.py inference.core.entities.requests.sam3 -inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1.py inference.core.entities.requests.sam3_3d -inference/core/workflows/core_steps/models/foundation/segment_anything3_3d/v1_tensor.py inference.core.entities.requests.sam3_3d -inference/core/workflows/core_steps/models/foundation/segment_anything3_interactive/v1.py inference.core.entities.requests.sam2 +# Count: 0 + From 4ce13b30908c8185edb6d4f40f7b395bf651a42c Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:57:51 +0200 Subject: [PATCH 75/86] refactor(workflows): drop infer_from_request_sync from the ModelsProvider port Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../roboflow/semantic_segmentation/v1_tensor.py | 6 +++--- .../core/workflows/prototypes/models_provider.py | 14 +++++++------- .../unit_tests/prototypes/test_models_provider.py | 14 +++++++++++++- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1_tensor.py b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1_tensor.py index 37952bd809..a10f717be3 100644 --- a/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1_tensor.py +++ b/inference/core/workflows/core_steps/models/roboflow/semantic_segmentation/v1_tensor.py @@ -1,7 +1,7 @@ """Tensor-native sibling of `roboflow_core/roboflow_semantic_segmentation_model@v1`. -Numpy `semantic_segmentation/v1.py` runs the model (LOCAL: via -`SemanticSegmentationInferenceRequest` + `infer_from_request_sync`; REMOTE: via the +Numpy `semantic_segmentation/v1.py` runs the model (LOCAL: via the models port's +`run_semantic_segmentation` / `run_instance_segmentation`; REMOTE: via the HTTP client) and converts each *dense* per-pixel response into an ``sv.Detections`` carrying one COCO-RLE mask per class (under ``data['rle_mask']``). Under ENABLE_TENSOR_DATA_REPRESENTATION this sibling must instead emit a native @@ -530,7 +530,7 @@ def _build_instance_detections_from_inference_response( ) -> InstanceDetections: """Standard inference semantic-seg response (a single ``dict``, matching numpy ``v1.py``'s ``_convert_to_sv_detections`` input - produced identically by v1's - LOCAL ``infer_from_request_sync`` dump and its REMOTE HTTP client) -> + LOCAL ``run_semantic_segmentation`` dump and its REMOTE HTTP client) -> one RLE instance per present non-background/non-ignore class. Response shape (see numpy ``v1.py``): diff --git a/inference/core/workflows/prototypes/models_provider.py b/inference/core/workflows/prototypes/models_provider.py index 9bbe3aa275..d3bcb36b82 100644 --- a/inference/core/workflows/prototypes/models_provider.py +++ b/inference/core/workflows/prototypes/models_provider.py @@ -56,9 +56,13 @@ class ModelsProvider(Protocol): ``is_stream_pipelined`` are already a *block*-level duck-typed protocol that the executor and the server's stream handler call on step instances. - PROVISIONAL MEMBER. ``infer_from_request_sync`` takes a pydantic request - object built by the caller from ``inference.core.entities`` - it is the - method Phase 11 removes entirely. Do not build new code against it. + Every member here takes plain arguments. The server implementation + (`inference.core.interfaces.workflows_models_provider.ModelManagerModelsProvider`) + builds the HTTP request objects; an `inference_sdk`-backed implementation + can satisfy the same port for remote execution. Three families still return + the server's response objects unchanged (SAM2/SAM3 predictions, the SAM3-3D + object tree, the rfdetr async handoff) - those are `Any` by design; nothing + about them is imported here. """ content_addressed_artifact_cache: Any @@ -75,10 +79,6 @@ def load_action_recognition_model( self, model_id: str, api_key: Optional[str] = None, **kwargs: Any ) -> Any: ... - def infer_from_request_sync( - self, model_id: str, request: Any, **kwargs: Any - ) -> Any: ... - def run_object_detection( self, model_id: str, diff --git a/tests/workflows/unit_tests/prototypes/test_models_provider.py b/tests/workflows/unit_tests/prototypes/test_models_provider.py index 6efb46082e..d7750e4868 100644 --- a/tests/workflows/unit_tests/prototypes/test_models_provider.py +++ b/tests/workflows/unit_tests/prototypes/test_models_provider.py @@ -8,7 +8,6 @@ REQUIRED_METHODS = [ "add_model", - "infer_from_request_sync", "run_tensor_native_inference", "get_class_names", "get_keypoints_classes", @@ -105,3 +104,16 @@ def test_port_dropped_getitem_and_declares_first_class_model_access() -> None: assert ( name in ModelManagerDecorator.__dict__ ), f"{name} must be forwarded by ModelManagerDecorator" + + +def test_port_no_longer_carries_provisional_members() -> None: + from pathlib import Path + + assert "infer_from_request_sync" not in ModelsProvider.__dict__ + assert "__getitem__" not in ModelsProvider.__dict__ + # tests/workflows/unit_tests/prototypes/ -> parents[4] is the repo root + source = ( + Path(__file__).resolve().parents[4] + / "inference/core/workflows/prototypes/models_provider.py" + ).read_text(encoding="utf-8") + assert "PROVISIONAL" not in source From 295a86e0e14b21e8ab4e0ee6e25ee3fbab3134f9 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:01:35 +0200 Subject: [PATCH 76/86] test(workflows): isolation probe is green; drop the strict xfail wrapper Phases 5-11 removed every inference.* import from workflows outside the bound ports; the probe now passes, so the strict xfail placed in Phase 9 would fail as XPASS. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- tests/workflows/isolation/test_isolation_probe.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/workflows/isolation/test_isolation_probe.py b/tests/workflows/isolation/test_isolation_probe.py index c474188afb..742b500e39 100644 --- a/tests/workflows/isolation/test_isolation_probe.py +++ b/tests/workflows/isolation/test_isolation_probe.py @@ -22,7 +22,6 @@ def __init__(self, stdout: str, returncode: int = 0, stderr: str = "") -> None: @pytest.mark.slow -@pytest.mark.xfail(strict=True, reason="decontamination in progress") def test_workflows_module_works_in_isolation(bundled_fonts) -> None: # `bundled_fonts` (tests/workflows/conftest.py) provisions the assets the # probe copies; the probe itself never downloads anything. From 3a0c969165978990577366312c23fcaade855168 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:06:09 +0200 Subject: [PATCH 77/86] fix(server): SAM2 prompt revival keeps box and points together; 3D-tensor parity case Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- .../interfaces/workflows_models_provider.py | 14 +-- .../test_workflows_models_provider.py | 24 +++++ .../test_port_requests_match_legacy_sam.py | 95 ++++++++++++++++--- 3 files changed, 114 insertions(+), 19 deletions(-) diff --git a/inference/core/interfaces/workflows_models_provider.py b/inference/core/interfaces/workflows_models_provider.py index 5cfffbe81c..97c4f8ff2a 100644 --- a/inference/core/interfaces/workflows_models_provider.py +++ b/inference/core/interfaces/workflows_models_provider.py @@ -483,16 +483,16 @@ def run_yolo_world( def _sam2_prompt_set(prompts: List[dict]) -> Sam2PromptSet: revived = [] for prompt in prompts: - if "box" in prompt: - revived.append(Sam2Prompt(box=Box(**prompt["box"]))) - elif "points" in prompt: - revived.append( - Sam2Prompt(points=[Point(**point) for point in prompt["points"]]) - ) - else: + if "box" not in prompt and "points" not in prompt: raise ValueError( f"SAM2 prompt must carry 'box' or 'points'; got {sorted(prompt)}" ) + kwargs: Dict[str, Any] = {} + if "box" in prompt: + kwargs["box"] = Box(**prompt["box"]) + if "points" in prompt: + kwargs["points"] = [Point(**point) for point in prompt["points"]] + revived.append(Sam2Prompt(**kwargs)) return Sam2PromptSet(prompts=revived) def run_sam2_segmentation( diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py index d4c4e550d6..d32ee234d3 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_models_provider.py @@ -932,6 +932,30 @@ def test_sam2_prompt_dicts_encode_the_same_prompt_set_as_the_inline_construction assert produced.model_dump() == expected.model_dump() +def test_sam2_prompt_dict_carrying_both_box_and_points_revives_both() -> None: + """Fix round 1: the `if "box" ... elif "points"` shape silently dropped + `points` when a single prompt dict carried both keys. Both must survive.""" + produced = ModelManagerModelsProvider(MagicMock())._sam2_prompt_set( + [ + { + "box": {"x": 1.0, "y": 2.0, "width": 3.0, "height": 4.0}, + "points": [{"x": 5.0, "y": 6.0, "positive": False}], + } + ] + ) + expected = Sam2PromptSet( + prompts=[ + Sam2Prompt( + box=Box(x=1.0, y=2.0, width=3.0, height=4.0), + points=[Point(x=5.0, y=6.0, positive=False)], + ) + ] + ) + assert produced.model_dump() == expected.model_dump() + assert produced.prompts[0].box is not None + assert produced.prompts[0].points is not None + + # --- round-5 defect 1: an explicit None must have the SAME outcome through the # adapter as through the block's inline construction - the same stored value, # or the same ValidationError (type, message, error table). One row per caller diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_sam.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_sam.py index a919943377..a5171f35ce 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_sam.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_port_requests_match_legacy_sam.py @@ -1,20 +1,27 @@ """Block -> adapter -> request differential tests for Task 11.14's SAM family -(SAM2, SAM3 interactive, SAM3 v1/v2/v3, SAM3-3D). +(SAM2, SAM3 interactive, SAM3 v1/v2/v3, SAM3-3D v1/v1_tensor). Each test drives a block's real ``run_locally`` through a real ``ModelManagerModelsProvider`` wrapping a ``MagicMock`` ``ModelManager``, then compares the pydantic request the adapter built against the exact request -construction the block used to run inline before Task 11.14 (this task's BASE -commit, 1c535758e, is also HEAD of this checkout - the pre-port construction -below is copied straight from ``git show 1c535758e:``, i.e. the source -this task started from). +construction the block used to run inline before Task 11.14 (this task's +actual BASE, ``8cde35edf``, is the immediate parent of Task 11.14's commit on +this branch - the pre-port construction below is copied straight from +``git show 8cde35edf:``, i.e. the source this task started from; that +tree is byte-identical to ``1c535758e``'s for every block file this suite +reads, since the one commit between them touched only +``test_sam_prompts.py``). One case per distinct request-building shape the task touches: ``Sam2SegmentationRequest`` built from a box prompt (``segment_anything2/v1.py``) and from a point prompt with the ``model_id=`` selector shape (``segment_anything3_interactive/v1.py``); ``Sam3SegmentationRequest`` in its v1 (bare), v2 (``nms_iou_threshold``) and v3 (``format``) shapes; and -``Sam3_3D_Objects_InferenceRequest`` (``segment_anything3_3d/v1.py``). +``Sam3_3D_Objects_InferenceRequest`` for both the numpy block +(``segment_anything3_3d/v1.py``) and its tensor-native sibling +(``segment_anything3_3d/v1_tensor.py``, which materialises masks from +``InstanceDetections`` instead of ``sv.Detections`` before the same request +shape). Every case asserts both ``model_dump()`` equality AND ``model_fields_set`` equality: an omitted field must stay omitted, not be re-supplied as its own @@ -25,6 +32,7 @@ import numpy as np import supervision as sv +import torch from inference.core.entities.requests.sam2 import ( Box, @@ -39,6 +47,9 @@ ModelManagerModelsProvider, ) from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.common.tensor_native import ( + instance_mask_to_numpy, +) from inference.core.workflows.core_steps.models.foundation.segment_anything2.v1 import ( SegmentAnything2BlockV1, ) @@ -54,6 +65,9 @@ from inference.core.workflows.core_steps.models.foundation.segment_anything3_3d.v1 import ( SegmentAnything3_3D_ObjectsBlockV1, ) +from inference.core.workflows.core_steps.models.foundation.segment_anything3_3d.v1_tensor import ( + SegmentAnything3_3D_ObjectsBlockV1 as SegmentAnything3_3D_ObjectsTensorBlockV1, +) from inference.core.workflows.core_steps.models.foundation.segment_anything3_interactive.v1 import ( SAM3_INTERACTIVE_MODEL_ID, SegmentAnything3InteractiveBlockV1, @@ -64,6 +78,7 @@ WorkflowImageData, ) from inference.core.workflows.prototypes.models_provider import CORE_MODEL_ENDPOINT_TYPE +from inference_models.models.base.instance_segmentation import InstanceDetections def _make_image() -> WorkflowImageData: @@ -101,7 +116,7 @@ def _box_detections() -> sv.Detections: def test_sam2_v1_request_matches_the_pre_port_construction() -> None: - # Copied verbatim from `git show 1c535758e:.../segment_anything2/v1.py`: + # Copied verbatim from `git show 8cde35edf:.../segment_anything2/v1.py`: # `Sam2SegmentationRequest(image=..., sam2_version_id=version, api_key=..., # source="workflow-execution", prompts=Sam2PromptSet(prompts=prompts), # threshold=threshold, multimask_output=multimask_output)`, with `prompts` @@ -144,7 +159,7 @@ def test_sam2_v1_request_matches_the_pre_port_construction() -> None: def test_sam3_interactive_v1_request_matches_the_pre_port_construction() -> None: - # Copied verbatim from `git show 1c535758e:.../segment_anything3_interactive/v1.py`: + # Copied verbatim from `git show 8cde35edf:.../segment_anything3_interactive/v1.py`: # `Sam2SegmentationRequest(image=..., model_id=SAM3_INTERACTIVE_MODEL_ID, # api_key=..., source="workflow-execution", # prompts=Sam2PromptSet(prompts=group.prompts), multimask_output=...)` - @@ -185,7 +200,7 @@ def test_sam3_interactive_v1_request_matches_the_pre_port_construction() -> None def test_sam3_v1_request_matches_the_pre_port_construction() -> None: - # Copied verbatim from `git show 1c535758e:.../segment_anything3/v1.py`: + # Copied verbatim from `git show 8cde35edf:.../segment_anything3/v1.py`: # `Sam3SegmentationRequest(image=..., model_id=model_id, api_key=..., # prompts=unified_prompts, output_prob_thresh=threshold)`. manager = _manager() @@ -218,7 +233,7 @@ def test_sam3_v1_request_matches_the_pre_port_construction() -> None: def test_sam3_v2_request_matches_the_pre_port_construction() -> None: - # Copied verbatim from `git show 1c535758e:.../segment_anything3/v2.py`: + # Copied verbatim from `git show 8cde35edf:.../segment_anything3/v2.py`: # `Sam3SegmentationRequest(image=..., model_id=model_id, api_key=..., # prompts=unified_prompts, output_prob_thresh=confidence, # nms_iou_threshold=nms_iou_threshold if apply_nms else None)`. No @@ -293,7 +308,7 @@ def test_sam3_v2_disables_nms_when_apply_nms_is_false() -> None: def test_sam3_v3_request_matches_the_pre_port_construction() -> None: - # Copied verbatim from `git show 1c535758e:.../segment_anything3/v3.py`: + # Copied verbatim from `git show 8cde35edf:.../segment_anything3/v3.py`: # `Sam3SegmentationRequest(image=..., model_id=model_id, api_key=..., # prompts=unified_prompts, output_prob_thresh=confidence, # nms_iou_threshold=nms_iou_threshold if apply_nms else None, @@ -334,7 +349,7 @@ def test_sam3_v3_request_matches_the_pre_port_construction() -> None: def test_sam3_3d_request_matches_the_pre_port_construction() -> None: - # Copied verbatim from `git show 1c535758e:.../segment_anything3_3d/v1.py`: + # Copied verbatim from `git show 8cde35edf:.../segment_anything3_3d/v1.py`: # `Sam3_3D_Objects_InferenceRequest(image=..., mask_input=converted_mask, # api_key=..., model_id=model_id)`. `mask_input` is a plain polygon list, # so `extract_masks_from_input` passes it through unchanged. @@ -365,3 +380,59 @@ def test_sam3_3d_request_matches_the_pre_port_construction() -> None: ) assert request.model_dump(exclude={"id"}) == expected.model_dump(exclude={"id"}) assert request.model_fields_set == expected.model_fields_set + + +def test_sam3_3d_tensor_request_matches_the_pre_port_construction() -> None: + # Copied verbatim from `git show 8cde35edf:.../segment_anything3_3d/v1_tensor.py`: + # `Sam3_3D_Objects_InferenceRequest(image=..., mask_input=converted_mask, + # api_key=..., model_id=model_id)` - identical shape to the numpy sibling, + # but `converted_mask` here comes from `extract_masks_from_input` reading a + # tensor-native `InstanceDetections` (`instance_mask_to_numpy` per + # instance), not a plain polygon list. + manager = _manager() + response = MagicMock() + response.mesh_glb = b"mesh" + response.gaussian_ply = b"gaussian" + response.time = 1.0 + response.objects = [] + manager.infer_from_request_sync.return_value = response + image = _make_image() + dense_mask = torch.zeros((1, 8, 8), dtype=torch.bool) + dense_mask[0, 2:6, 3:7] = True + mask_input = InstanceDetections( + xyxy=torch.tensor([[3.0, 2.0, 7.0, 6.0]]), + class_id=torch.tensor([0]), + confidence=torch.tensor([0.9]), + mask=dense_mask, + image_metadata={"class_names": {0: "object"}, "image_dimensions": [8, 8]}, + bboxes_metadata=[{"detection_id": "d1"}], + ) + block = SegmentAnything3_3D_ObjectsTensorBlockV1( + model_manager=ModelManagerModelsProvider(manager), + api_key="k", + step_execution_mode=StepExecutionMode.LOCAL, + ) + + block.run_locally(images=_batch_of_one(image), mask_input=_batch_of_one(mask_input)) + + manager.add_model.assert_called_once_with(model_id="sam3-3d-objects", api_key="k") + request = _captured_request(manager) + # The exact mask conversion the block performs before the call + # (`extract_masks_from_input` -> `instance_mask_to_numpy` per instance). + converted_mask = [instance_mask_to_numpy(mask_input, 0)] + expected = Sam3_3D_Objects_InferenceRequest( + image=image.to_inference_format(numpy_preferred=True), + mask_input=converted_mask, + api_key="k", + model_id="sam3-3d-objects", + ) + # `mask_input` holds numpy arrays - dict/model_dump equality on those + # raises ("truth value of an array is ambiguous"), so it is compared + # separately with array equality; every other field goes through the + # same dump comparison as the rest of this suite. + assert request.model_dump(exclude={"id", "mask_input"}) == expected.model_dump( + exclude={"id", "mask_input"} + ) + assert request.model_fields_set == expected.model_fields_set + assert len(request.mask_input) == len(expected.mask_input) == 1 + assert np.array_equal(request.mask_input[0], expected.mask_input[0]) From 194a96548f4fe9356536a7e20fd7f5460798ab40 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:14:39 +0200 Subject: [PATCH 78/86] docs(workflows): the ModelsProvider port is implemented by the server adapter Whole-phase review minor: the docstring still named the raw ModelManager, which no longer satisfies the completed port. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J7uVwZpZKevHkxCr1ZWSvo --- inference/core/workflows/prototypes/models_provider.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/inference/core/workflows/prototypes/models_provider.py b/inference/core/workflows/prototypes/models_provider.py index d3bcb36b82..136f2b982a 100644 --- a/inference/core/workflows/prototypes/models_provider.py +++ b/inference/core/workflows/prototypes/models_provider.py @@ -37,7 +37,9 @@ class ModelsProvider(Protocol): """The port through which Workflows reach models. Implemented in the Roboflow inference server by - ``inference.core.managers.base.ModelManager``. Declared here so that + ``inference.core.interfaces.workflows_models_provider.ModelManagerModelsProvider``, + an adapter over ``inference.core.managers.base.ModelManager`` bound at the + composition roots. Declared here so that ``inference.core.workflows`` does not import the server package for a type annotation - that import alone pulls in FastAPI, the model registry, the cache, telemetry and usage tracking. From a70b4b910d24983eac1b710a2038c27f95b3eb7d Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:33:42 +0200 Subject: [PATCH 79/86] fix tests --- .../interfaces/workflows_configuration.py | 5 ++ inference/core/workflows/configuration.py | 5 +- .../common/vlm_decoding/classification.py | 4 +- .../common/vlm_decoding/detections.py | 4 +- .../common/vlm_decoding/json_extraction.py | 4 +- .../core_steps/common/vlm_decoding/outputs.py | 4 +- .../common/vlm_decoding/tensor_native.py | 2 +- .../flow_control/inner_workflow/v1.py | 2 +- .../models/foundation/anthropic_claude/v5.py | 33 ++++++--- .../models/foundation/google_gemini/v6.py | 28 +++++-- .../models/foundation/meta_vlm/v3.py | 2 +- .../core_steps/models/foundation/openai/v7.py | 28 +++++-- .../models/foundation/openai_compatible/v1.py | 2 +- .../models/foundation/qwen_vlm/v4.py | 44 +++++------ .../models/foundation/spacexai/v3.py | 28 +++++-- .../models/foundation/zai_vlm/v2.py | 2 +- inference/core/workflows/environment.py | 10 +++ .../test_workflows_configuration.py | 38 +++++++++- .../common/test_platform_client_injection.py | 74 +++++++++++++++++++ .../foundation/test_anthropic_claude_v5.py | 4 +- .../foundation/test_google_gemini_v6.py | 2 +- .../models/foundation/test_openai_v7.py | 2 +- .../models/foundation/test_qwen_vlm_v4.py | 4 +- .../models/foundation/test_spacexai_v3.py | 2 +- .../transformations/test_image_slicer_v2.py | 4 +- .../test_bounding_box_v1_tensor_gpu.py | 2 + .../test_label_v1_tensor_gpu.py | 8 +- .../test_viz_phase_transfer_audit.py | 19 +++-- .../execution_engine/entities/test_base.py | 1 - .../prototypes/test_platform_client.py | 2 +- .../unit_tests/test_configuration.py | 12 ++- 31 files changed, 294 insertions(+), 87 deletions(-) diff --git a/inference/core/interfaces/workflows_configuration.py b/inference/core/interfaces/workflows_configuration.py index 649d0ea807..cbf150bb9d 100644 --- a/inference/core/interfaces/workflows_configuration.py +++ b/inference/core/interfaces/workflows_configuration.py @@ -74,6 +74,11 @@ def build_configuration_from_env() -> WorkflowsConfiguration: hosted_core_model_url=env.HOSTED_CORE_MODEL_URL, max_step_batch_size=env.WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_BATCH_SIZE, max_step_concurrent_requests=env.WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, + inner_workflow_remote_target=env.WORKFLOWS_INNER_WORKFLOW_REMOTE_TARGET, + inner_workflow_remote_dispatch_request_timeout=env.WORKFLOWS_INNER_WORKFLOW_REMOTE_DISPATCH_REQUEST_TIMEOUT, + openai_compatible_allowed_base_urls=tuple( + sorted(env.OPENAI_COMPATIBLE_ALLOWED_BASE_URLS) + ), ), platform=PlatformConfiguration( api_base_url=env.API_BASE_URL, diff --git a/inference/core/workflows/configuration.py b/inference/core/workflows/configuration.py index 0e44092951..20d01880f4 100644 --- a/inference/core/workflows/configuration.py +++ b/inference/core/workflows/configuration.py @@ -4,7 +4,7 @@ `WorkflowsConfiguration`, which the host builds from its own already-resolved settings and installs once per process with `configure_process`. -The configuration is PROCESS-WIDE in its entirety. Every one of the 67 values +The configuration is PROCESS-WIDE in its entirety. Every configuration value is read from a module constant frozen at import - `core_steps/loader.py` branches on the tensor flag while it is being imported, `offline.py:34` reads `SECURE_GATEWAY`, `block_assembler.py:100` reads the custom-Python @@ -78,6 +78,9 @@ class RemoteExecutionConfiguration: hosted_core_model_url: str = "https://infer.roboflow.com" max_step_batch_size: int = 1 max_step_concurrent_requests: int = 8 + inner_workflow_remote_target: str = "https://serverless.roboflow.com" + inner_workflow_remote_dispatch_request_timeout: float = 300.0 + openai_compatible_allowed_base_urls: Tuple[str, ...] = ("*",) @dataclass(frozen=True) diff --git a/inference/core/workflows/core_steps/common/vlm_decoding/classification.py b/inference/core/workflows/core_steps/common/vlm_decoding/classification.py index 363672ccda..929b10c467 100644 --- a/inference/core/workflows/core_steps/common/vlm_decoding/classification.py +++ b/inference/core/workflows/core_steps/common/vlm_decoding/classification.py @@ -6,9 +6,9 @@ in ``common/serializers_tensor.py``). """ +import logging from typing import List, Optional, Tuple -from inference.core.logger import logger from inference.core.workflows.core_steps.common.vlm_decoding.json_extraction import ( extract_json, ) @@ -21,6 +21,8 @@ ) from inference.core.workflows.execution_engine.entities.base import WorkflowImageData +logger = logging.getLogger(__name__) + def decode_classification( raw_output: str, diff --git a/inference/core/workflows/core_steps/common/vlm_decoding/detections.py b/inference/core/workflows/core_steps/common/vlm_decoding/detections.py index 2858dcc131..890339eb1b 100644 --- a/inference/core/workflows/core_steps/common/vlm_decoding/detections.py +++ b/inference/core/workflows/core_steps/common/vlm_decoding/detections.py @@ -1,5 +1,6 @@ """Turn a VLM object-detection answer into ``sv.Detections``.""" +import logging from typing import List, Optional, Tuple from uuid import uuid4 @@ -7,7 +8,6 @@ import supervision as sv from supervision.config import CLASS_NAME_DATA_FIELD -from inference.core.logger import logger from inference.core.workflows.core_steps.common.utils import ( attach_parents_coordinates_to_sv_detections, ) @@ -31,6 +31,8 @@ ) from inference.core.workflows.execution_engine.entities.base import WorkflowImageData +logger = logging.getLogger(__name__) + PREDICTION_TYPE = "object-detection" diff --git a/inference/core/workflows/core_steps/common/vlm_decoding/json_extraction.py b/inference/core/workflows/core_steps/common/vlm_decoding/json_extraction.py index 0f1c7d2825..74b77f0e5e 100644 --- a/inference/core/workflows/core_steps/common/vlm_decoding/json_extraction.py +++ b/inference/core/workflows/core_steps/common/vlm_decoding/json_extraction.py @@ -10,11 +10,13 @@ """ import json +import logging from typing import Any, List, Tuple -from inference.core.logger import logger from inference.core.workflows.core_steps.common.vlm_json import extract_json_payload +logger = logging.getLogger(__name__) + _NAMED_BOX_FIELDS = ("x_min", "y_min", "x_max", "y_max") diff --git a/inference/core/workflows/core_steps/common/vlm_decoding/outputs.py b/inference/core/workflows/core_steps/common/vlm_decoding/outputs.py index 3f3a21e85f..cb7723eafc 100644 --- a/inference/core/workflows/core_steps/common/vlm_decoding/outputs.py +++ b/inference/core/workflows/core_steps/common/vlm_decoding/outputs.py @@ -7,9 +7,9 @@ even for tasks that decode nothing - it is simply ``None`` at runtime. """ +import logging from typing import Any, List, Optional, Tuple -from inference.core.logger import logger from inference.core.workflows.core_steps.common.vlm_decoding.classification import ( decode_classification, ) @@ -30,6 +30,8 @@ OBJECT_DETECTION_PREDICTION_KIND, ) +logger = logging.getLogger(__name__) + DETECTION_TASKS = {"object-detection"} CLASSIFICATION_TASKS = {"classification", "multi-label-classification"} diff --git a/inference/core/workflows/core_steps/common/vlm_decoding/tensor_native.py b/inference/core/workflows/core_steps/common/vlm_decoding/tensor_native.py index f74485f045..1c8a990c98 100644 --- a/inference/core/workflows/core_steps/common/vlm_decoding/tensor_native.py +++ b/inference/core/workflows/core_steps/common/vlm_decoding/tensor_native.py @@ -38,7 +38,7 @@ import supervision as sv from supervision.config import CLASS_NAME_DATA_FIELD -from inference.core.env import ( +from inference.core.workflows.environment import ( ENABLE_TENSOR_DATA_REPRESENTATION, WORKFLOWS_IMAGE_TENSOR_DEVICE, ) diff --git a/inference/core/workflows/core_steps/flow_control/inner_workflow/v1.py b/inference/core/workflows/core_steps/flow_control/inner_workflow/v1.py index 6e277c0099..b747a7019e 100644 --- a/inference/core/workflows/core_steps/flow_control/inner_workflow/v1.py +++ b/inference/core/workflows/core_steps/flow_control/inner_workflow/v1.py @@ -12,7 +12,7 @@ from fastapi import BackgroundTasks from pydantic import BaseModel, ConfigDict, Field, model_validator -from inference.core.env import ( +from inference.core.workflows.environment import ( ENABLE_TENSOR_DATA_REPRESENTATION, WORKFLOWS_INNER_WORKFLOW_REMOTE_DISPATCH_REQUEST_TIMEOUT, WORKFLOWS_MAX_INNER_WORKFLOW_DEPTH, diff --git a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v5.py b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v5.py index c4bcacf9f7..d44f236468 100644 --- a/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v5.py +++ b/inference/core/workflows/core_steps/models/foundation/anthropic_claude/v5.py @@ -28,11 +28,6 @@ from anthropic import NOT_GIVEN from pydantic import ConfigDict, Field, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager -from inference.core.roboflow_api import post_to_roboflow_api -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image -from inference.core.utils.preprocess import downscale_image_keeping_aspect_ratio from inference.core.workflows.core_steps.common.token_usage import ( TOKEN_OUTPUT_DEFINITIONS, parse_responses_api_usage, @@ -52,6 +47,9 @@ build_thinking_config, resolve_temperature, ) +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, @@ -78,6 +76,15 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) +from inference.core.workflows.utils.images import ( + downscale_image_keeping_aspect_ratio, + encode_image_to_jpeg_bytes, + load_image, +) CLAUDE_MODELS = [ { @@ -506,15 +513,15 @@ class AnthropicClaudeBlockV5(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): - self._model_manager = model_manager self._api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -542,6 +549,7 @@ def run( ) -> BlockResult: inference_images = [i.to_inference_format() for i in images] raw_outputs = run_claude_prompting( + platform_client=self._platform_client, roboflow_api_key=self._api_key, images=inference_images, task_type=task_type, @@ -614,6 +622,7 @@ def detection_upload_dimensions( def run_claude_prompting( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, images: List[Dict[str, Any]], task_type: TaskType, prompt: Optional[str], @@ -647,6 +656,7 @@ def run_claude_prompting( ) prompts.append(generated_prompt) return execute_claude_requests( + platform_client=platform_client, roboflow_api_key=roboflow_api_key, anthropic_api_key=anthropic_api_key, prompts=prompts, @@ -728,6 +738,7 @@ def _encode_image_to_jpeg_bytes_with_quality( def execute_claude_requests( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, anthropic_api_key: str, prompts: List[Tuple[Optional[str], List[dict]]], model_version: str, @@ -740,6 +751,7 @@ def execute_claude_requests( tasks = [ partial( execute_claude_request, + platform_client=platform_client, roboflow_api_key=roboflow_api_key, anthropic_api_key=anthropic_api_key, system_prompt=prompt[0], @@ -764,6 +776,7 @@ def execute_claude_requests( def execute_claude_request( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, anthropic_api_key: str, system_prompt: Optional[str], messages: List[dict], @@ -776,6 +789,7 @@ def execute_claude_request( """Route to proxied or direct execution based on API key format.""" if anthropic_api_key.startswith(("rf_key:account", "rf_key:user:")): return _execute_proxied_claude_request( + platform_client=platform_client, roboflow_api_key=roboflow_api_key, anthropic_api_key=anthropic_api_key, system_prompt=system_prompt, @@ -801,6 +815,7 @@ def execute_claude_request( def _execute_proxied_claude_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, anthropic_api_key: str, system_prompt: Optional[str], messages: List[dict], @@ -844,7 +859,7 @@ def _execute_proxied_claude_request( endpoint = "apiproxy/anthropic" try: - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint=endpoint, api_key=roboflow_api_key, payload=payload, diff --git a/inference/core/workflows/core_steps/models/foundation/google_gemini/v6.py b/inference/core/workflows/core_steps/models/foundation/google_gemini/v6.py index 919165d43c..54abedef96 100644 --- a/inference/core/workflows/core_steps/models/foundation/google_gemini/v6.py +++ b/inference/core/workflows/core_steps/models/foundation/google_gemini/v6.py @@ -21,10 +21,6 @@ from pydantic import ConfigDict, Field, field_validator, model_validator from requests import Response -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager -from inference.core.roboflow_api import post_to_roboflow_api -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.reasoning import ( attach_reasoning_levels, models_supporting_reasoning, @@ -42,6 +38,9 @@ describe_vlm_prediction_outputs, ) from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, @@ -67,6 +66,11 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image GOOGLE_API_KEY_PATTERN = re.compile(r"key=(.[^&]*)") GOOGLE_API_KEY_VALUE_GROUP = 1 @@ -498,15 +502,15 @@ class GoogleGeminiBlockV6(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): - self._model_manager = model_manager self._api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -533,6 +537,7 @@ def run( ) -> BlockResult: inference_images = [i.to_inference_format() for i in images] raw_outputs = run_gemini_prompting( + platform_client=self._platform_client, roboflow_api_key=self._api_key, images=inference_images, task_type=task_type, @@ -574,6 +579,7 @@ def run( def run_gemini_prompting( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, images: List[Dict[str, Any]], task_type: TaskType, prompt: Optional[str], @@ -614,6 +620,7 @@ def run_gemini_prompting( gemini_prompts.append(generated_prompt) return execute_gemini_requests( + platform_client=platform_client, roboflow_api_key=roboflow_api_key, google_api_key=google_api_key, gemini_prompts=gemini_prompts, @@ -624,6 +631,7 @@ def run_gemini_prompting( def execute_gemini_requests( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, google_api_key: str, gemini_prompts: List[dict], model_version: str, @@ -632,6 +640,7 @@ def execute_gemini_requests( tasks = [ partial( execute_gemini_request, + platform_client=platform_client, roboflow_api_key=roboflow_api_key, google_api_key=google_api_key, prompt=prompt, @@ -651,6 +660,7 @@ def execute_gemini_requests( def execute_gemini_request( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, google_api_key: str, prompt: dict, model_version: str, @@ -658,6 +668,7 @@ def execute_gemini_request( """Route to proxied or direct execution based on API key format.""" if google_api_key.startswith(("rf_key:account", "rf_key:user:")): return _execute_proxied_gemini_request( + platform_client=platform_client, roboflow_api_key=roboflow_api_key, google_api_key=google_api_key, prompt=prompt, @@ -673,6 +684,7 @@ def execute_gemini_request( def _execute_proxied_gemini_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, google_api_key: str, prompt: dict, model_version: str, @@ -687,7 +699,7 @@ def _execute_proxied_gemini_request( endpoint = "apiproxy/gemini" try: - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint=endpoint, api_key=roboflow_api_key, payload=payload, diff --git a/inference/core/workflows/core_steps/models/foundation/meta_vlm/v3.py b/inference/core/workflows/core_steps/models/foundation/meta_vlm/v3.py index 7444496d61..2697eb05e7 100644 --- a/inference/core/workflows/core_steps/models/foundation/meta_vlm/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/meta_vlm/v3.py @@ -21,7 +21,6 @@ import numpy as np from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.openrouter import ( PRIVACY_LEVEL_LITERAL, PRIVACY_LEVEL_METADATA, @@ -71,6 +70,7 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes # Spark `none` is HTTP 400. Glimmer has no `minimal`. MODEL_VARIANTS: Dict[str, Dict[str, Any]] = { diff --git a/inference/core/workflows/core_steps/models/foundation/openai/v7.py b/inference/core/workflows/core_steps/models/foundation/openai/v7.py index 38ccd4a338..0f10b804fb 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai/v7.py +++ b/inference/core/workflows/core_steps/models/foundation/openai/v7.py @@ -22,10 +22,6 @@ from openai import OpenAI from pydantic import ConfigDict, Field, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager -from inference.core.roboflow_api import post_to_roboflow_api -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.reasoning import ( attach_reasoning_levels, models_supporting_reasoning, @@ -47,6 +43,9 @@ describe_vlm_prediction_outputs, ) from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, @@ -71,6 +70,11 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image # Detection prompt styles (selected per model based on a 17-model x 10-format # x 100-image benchmark; see the object-detection prompt builders below): @@ -616,15 +620,15 @@ class OpenAIBlockV7(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): - self._model_manager = model_manager self._api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -651,6 +655,7 @@ def run( ) -> BlockResult: inference_images = [i.to_inference_format() for i in images] raw_outputs = run_openai_prompting( + platform_client=self._platform_client, roboflow_api_key=self._api_key, images=inference_images, task_type=task_type, @@ -724,6 +729,7 @@ def detection_upload_dimensions( def run_openai_prompting( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, images: List[Dict[str, Any]], task_type: TaskType, prompt: Optional[str], @@ -787,6 +793,7 @@ def run_openai_prompting( ) openai_prompts.append(generated_prompt) return execute_openai_requests( + platform_client=platform_client, roboflow_api_key=roboflow_api_key, openai_api_key=openai_api_key, openai_prompts=openai_prompts, @@ -851,6 +858,7 @@ def _encode_image_to_png_bytes(image: np.ndarray) -> bytes: def execute_openai_requests( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, openai_api_key: str, openai_prompts: List[dict], model_version: str, @@ -880,6 +888,7 @@ def execute_openai_requests( tasks = [ partial( execute_openai_request, + platform_client=platform_client, roboflow_api_key=roboflow_api_key, openai_api_key=openai_api_key, instructions=prompt.get("instructions"), @@ -904,6 +913,7 @@ def execute_openai_requests( def _execute_proxied_openai_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, openai_api_key: str, instructions: Optional[str], input_content: List[dict], @@ -947,7 +957,7 @@ def _execute_proxied_openai_request( try: # Use the Roboflow API post function (this ensures proper auth headers used based on invocation context) - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint=endpoint, api_key=roboflow_api_key, payload=payload, @@ -1091,6 +1101,7 @@ def _execute_direct_openai_request( def execute_openai_request( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, openai_api_key: str, instructions: Optional[str], input_content: List[dict], @@ -1128,6 +1139,7 @@ def execute_openai_request( ) return _execute_proxied_openai_request( + platform_client=platform_client, roboflow_api_key=roboflow_api_key, openai_api_key=openai_api_key, instructions=instructions, diff --git a/inference/core/workflows/core_steps/models/foundation/openai_compatible/v1.py b/inference/core/workflows/core_steps/models/foundation/openai_compatible/v1.py index c021549caf..0a87b40eda 100644 --- a/inference/core/workflows/core_steps/models/foundation/openai_compatible/v1.py +++ b/inference/core/workflows/core_steps/models/foundation/openai_compatible/v1.py @@ -8,13 +8,13 @@ from openai import DefaultHttpxClient, OpenAI from pydantic import ConfigDict, Field -from inference.core.env import OPENAI_COMPATIBLE_ALLOWED_BASE_URLS from inference.core.workflows.core_steps.common.query_language.entities.operations import ( AllOperationsType, ) from inference.core.workflows.core_steps.common.query_language.operations.core import ( build_operations_chain, ) +from inference.core.workflows.environment import OPENAI_COMPATIBLE_ALLOWED_BASE_URLS from inference.core.workflows.execution_engine.entities.base import ( OutputDefinition, WorkflowImageData, diff --git a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v4.py b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v4.py index f92377e8be..cb5192a336 100644 --- a/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v4.py +++ b/inference/core/workflows/core_steps/models/foundation/qwen_vlm/v4.py @@ -39,14 +39,6 @@ import numpy as np from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core.entities.requests.inference import LMMInferenceRequest -from inference.core.env import ( - HOSTED_CORE_MODEL_URL, - LOCAL_INFERENCE_API_URL, - WORKFLOWS_REMOTE_API_TARGET, -) -from inference.core.managers.base import ModelManager -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.core_steps.common.openrouter import ( PRIVACY_LEVEL_LITERAL, @@ -73,6 +65,11 @@ decode_vlm_output, describe_vlm_prediction_outputs, ) +from inference.core.workflows.environment import ( + HOSTED_CORE_MODEL_URL, + LOCAL_INFERENCE_API_URL, + WORKFLOWS_REMOTE_API_TARGET, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, @@ -99,6 +96,12 @@ roboflow_platform_model, third_party_model, ) +from inference.core.workflows.prototypes.models_provider import ModelsProvider +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes from inference_sdk import InferenceHTTPClient # --------------------------------------------------------------------------- @@ -1245,16 +1248,21 @@ class QwenVlmBlockV4(OpenRouterWorkflowBlockBase): def __init__( self, - model_manager: ModelManager, + model_manager: ModelsProvider, api_key: Optional[str], step_execution_mode: StepExecutionMode, + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): - super().__init__(model_manager=model_manager, api_key=api_key) + super().__init__( + model_manager=model_manager, + api_key=api_key, + platform_client=platform_client, + ) self._step_execution_mode = step_execution_mode @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key", "step_execution_mode"] + return ["model_manager", "api_key", "step_execution_mode", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -1459,21 +1467,15 @@ def _run_native_locally( self._model_manager.add_model(model_id=model_id, api_key=self._roboflow_api_key) outputs: List[Dict[str, str]] = [] for image in inference_images: - request_kwargs: Dict[str, Any] = dict( - api_key=self._roboflow_api_key, + prediction = self._model_manager.run_lmm( model_id=model_id, image=image, - source="workflow-execution", prompt=combined_prompt, + api_key=self._roboflow_api_key, enable_thinking=enable_thinking, + max_new_tokens=max_new_tokens, ) - if max_new_tokens is not None: - request_kwargs["max_new_tokens"] = max_new_tokens - request = LMMInferenceRequest(**request_kwargs) - prediction = self._model_manager.infer_from_request_sync( - model_id=model_id, request=request - ) - output, thinking = _coerce_native_response(prediction.response) + output, thinking = _coerce_native_response(prediction["response"]) outputs.append({"output": output, "thinking": thinking}) return outputs diff --git a/inference/core/workflows/core_steps/models/foundation/spacexai/v3.py b/inference/core/workflows/core_steps/models/foundation/spacexai/v3.py index cbb1dc8bb5..e72747c3a4 100644 --- a/inference/core/workflows/core_steps/models/foundation/spacexai/v3.py +++ b/inference/core/workflows/core_steps/models/foundation/spacexai/v3.py @@ -23,10 +23,6 @@ from openai import OpenAI from pydantic import ConfigDict, Field, model_validator -from inference.core.env import WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS -from inference.core.managers.base import ModelManager -from inference.core.roboflow_api import post_to_roboflow_api -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes, load_image from inference.core.workflows.core_steps.common.reasoning import ( attach_reasoning_levels, validate_reasoning_level, @@ -43,6 +39,9 @@ describe_vlm_prediction_outputs, ) from inference.core.workflows.core_steps.common.vlms import VLM_TASKS_METADATA +from inference.core.workflows.environment import ( + WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS, +) from inference.core.workflows.execution_engine.entities.base import ( Batch, OutputDefinition, @@ -67,6 +66,11 @@ WorkflowBlockManifest, third_party_model, ) +from inference.core.workflows.prototypes.platform_client import ( + OFFLINE_PLATFORM_CLIENT, + RoboflowPlatformClient, +) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes, load_image XAI_BASE_URL = "https://api.x.ai/v1" @@ -390,15 +394,15 @@ class SpaceXAIBlockV3(WorkflowBlock): def __init__( self, - model_manager: ModelManager, api_key: Optional[str], + platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT, ): - self._model_manager = model_manager self._api_key = api_key + self._platform_client = platform_client @classmethod def get_init_parameters(cls) -> List[str]: - return ["model_manager", "api_key"] + return ["api_key", "platform_client"] @classmethod def get_manifest(cls) -> Type[WorkflowBlockManifest]: @@ -424,6 +428,7 @@ def run( ) -> BlockResult: inference_images = [i.to_inference_format() for i in images] raw_outputs = run_spacexai_prompting( + platform_client=self._platform_client, roboflow_api_key=self._api_key, images=inference_images, task_type=task_type, @@ -464,6 +469,7 @@ def run( def run_spacexai_prompting( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, images: List[Dict[str, Any]], task_type: TaskType, prompt: Optional[str], @@ -516,6 +522,7 @@ def run_spacexai_prompting( ) spacexai_prompts.append(generated_prompt) return execute_spacexai_requests( + platform_client=platform_client, roboflow_api_key=roboflow_api_key, xai_api_key=xai_api_key, spacexai_prompts=spacexai_prompts, @@ -563,6 +570,7 @@ def _encode_image_to_png_bytes(image: np.ndarray) -> bytes: def execute_spacexai_requests( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, xai_api_key: str, spacexai_prompts: List[dict], model_version: str, @@ -590,6 +598,7 @@ def execute_spacexai_requests( tasks = [ partial( execute_spacexai_request, + platform_client=platform_client, roboflow_api_key=roboflow_api_key, xai_api_key=xai_api_key, instructions=prompt.get("instructions"), @@ -613,6 +622,7 @@ def execute_spacexai_requests( def execute_spacexai_request( roboflow_api_key: Optional[str], + platform_client: RoboflowPlatformClient, xai_api_key: str, instructions: Optional[str], input_content: List[dict], @@ -643,6 +653,7 @@ def execute_spacexai_request( ) return _execute_proxied_spacexai_request( + platform_client=platform_client, roboflow_api_key=roboflow_api_key, xai_api_key=xai_api_key, instructions=instructions, @@ -666,6 +677,7 @@ def execute_spacexai_request( def _execute_proxied_spacexai_request( roboflow_api_key: str, + platform_client: RoboflowPlatformClient, xai_api_key: str, instructions: Optional[str], input_content: List[dict], @@ -700,7 +712,7 @@ def _execute_proxied_spacexai_request( payload["reasoning"] = {"effort": reasoning_effort} try: - response_data = post_to_roboflow_api( + response_data = platform_client.post( endpoint="apiproxy/xai", api_key=roboflow_api_key, payload=payload, diff --git a/inference/core/workflows/core_steps/models/foundation/zai_vlm/v2.py b/inference/core/workflows/core_steps/models/foundation/zai_vlm/v2.py index dae0cb5ec9..8aa23d4918 100644 --- a/inference/core/workflows/core_steps/models/foundation/zai_vlm/v2.py +++ b/inference/core/workflows/core_steps/models/foundation/zai_vlm/v2.py @@ -21,7 +21,6 @@ import numpy as np from pydantic import ConfigDict, Field, field_validator, model_validator -from inference.core.utils.image_utils import encode_image_to_jpeg_bytes from inference.core.workflows.core_steps.common.openrouter import ( PRIVACY_LEVEL_LITERAL, PRIVACY_LEVEL_METADATA, @@ -72,6 +71,7 @@ is_workflow_selector, third_party_model, ) +from inference.core.workflows.utils.images import encode_image_to_jpeg_bytes # Detection and classification answers are decoded in-block now, so only # `structured-answering` still points at a downstream parser. diff --git a/inference/core/workflows/environment.py b/inference/core/workflows/environment.py index 02821ac9bc..2bf1a1ceea 100644 --- a/inference/core/workflows/environment.py +++ b/inference/core/workflows/environment.py @@ -74,6 +74,16 @@ _CONFIGURATION.remote.max_step_concurrent_requests ) +WORKFLOWS_INNER_WORKFLOW_REMOTE_TARGET = ( + _CONFIGURATION.remote.inner_workflow_remote_target +) +WORKFLOWS_INNER_WORKFLOW_REMOTE_DISPATCH_REQUEST_TIMEOUT = ( + _CONFIGURATION.remote.inner_workflow_remote_dispatch_request_timeout +) +OPENAI_COMPATIBLE_ALLOWED_BASE_URLS = set( + _CONFIGURATION.remote.openai_compatible_allowed_base_urls +) + # --- platform / deployment --- API_BASE_URL = _CONFIGURATION.platform.api_base_url OFFLINE_MODE = _CONFIGURATION.platform.offline_mode diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py b/tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py index f97cba3421..76785d1470 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py @@ -2,6 +2,7 @@ import dataclasses import json import os +import runpy import subprocess import sys from pathlib import Path @@ -12,7 +13,7 @@ import pytest from fastapi.testclient import TestClient -# Module level on purpose (round-5 defect 1, D5 rule): the facade binds its 67 +# Module level on purpose (round-5 defect 1, D5 rule): the facade binds its # constants at its FIRST import from whatever the registry holds at that # instant. Importing it here, at collection time, right after `inference.core` # has installed the server configuration, guarantees the values compared below @@ -102,6 +103,18 @@ "WORKFLOWS_REMOTE_EXECUTION_MAX_STEP_CONCURRENT_REQUESTS", lambda c: c.remote.max_step_concurrent_requests, ), + ( + "WORKFLOWS_INNER_WORKFLOW_REMOTE_TARGET", + lambda c: c.remote.inner_workflow_remote_target, + ), + ( + "WORKFLOWS_INNER_WORKFLOW_REMOTE_DISPATCH_REQUEST_TIMEOUT", + lambda c: c.remote.inner_workflow_remote_dispatch_request_timeout, + ), + ( + "OPENAI_COMPATIBLE_ALLOWED_BASE_URLS", + lambda c: set(c.remote.openai_compatible_allowed_base_urls), + ), ("API_BASE_URL", lambda c: c.platform.api_base_url), ("OFFLINE_MODE", lambda c: c.platform.offline_mode), ("SECURE_GATEWAY", lambda c: c.platform.secure_gateway), @@ -181,7 +194,7 @@ def test_the_field_table_matches_the_facade_exports() -> None: "missing_from_table": sorted(exported - tabled), "missing_from_facade": sorted(tabled - exported), } - assert len(tabled) == 67, len(tabled) + assert len(tabled) == 70, len(tabled) def test_every_name_workflows_imports_from_the_facade_is_exported() -> None: @@ -240,6 +253,27 @@ def test_server_configuration_equals_env_field_by_field(name, reader) -> None: assert type(actual) is type(expected), (name, type(actual), type(expected)) +@pytest.mark.parametrize( + "name, value", + [ + ("WORKFLOWS_INNER_WORKFLOW_REMOTE_TARGET", "https://deployment.example/v1"), + ("WORKFLOWS_INNER_WORKFLOW_REMOTE_DISPATCH_REQUEST_TIMEOUT", 12.5), + ("OPENAI_COMPATIBLE_ALLOWED_BASE_URLS", set()), + ("OPENAI_COMPATIBLE_ALLOWED_BASE_URLS", {"https://approved.example/v1"}), + ], +) +def test_server_configuration_preserves_new_remote_settings(monkeypatch, name, value): + monkeypatch.setattr(env, name, value) + configuration = build_configuration_from_env() + assert dict(FIELDS)[name](configuration) == value + monkeypatch.setattr( + "inference.core.workflows.configuration.get_configuration", + lambda: configuration, + ) + facade = runpy.run_path(workflows_environment.__file__) + assert facade[name] == value + + def test_the_facade_equals_env_field_by_field() -> None: # The 151 workflows files read the FACADE, so the facade - not just the # builder - is what has to agree with env.py in the running server. diff --git a/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py b/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py index b6c9f4245e..68b97311a6 100644 --- a/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py +++ b/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py @@ -25,13 +25,17 @@ (".models.foundation.openai.v4", "OpenAIBlockV4"), (".models.foundation.openai.v5", "OpenAIBlockV5"), (".models.foundation.openai.v6", "OpenAIBlockV6"), + (".models.foundation.openai.v7", "OpenAIBlockV7"), (".models.foundation.google_gemini.v3", "GoogleGeminiBlockV3"), (".models.foundation.google_gemini.v4", "GoogleGeminiBlockV4"), (".models.foundation.google_gemini.v5", "GoogleGeminiBlockV5"), + (".models.foundation.google_gemini.v6", "GoogleGeminiBlockV6"), (".models.foundation.anthropic_claude.v3", "AnthropicClaudeBlockV3"), (".models.foundation.anthropic_claude.v4", "AnthropicClaudeBlockV4"), + (".models.foundation.anthropic_claude.v5", "AnthropicClaudeBlockV5"), (".models.foundation.spacexai.v1", "SpaceXAIBlockV1"), (".models.foundation.spacexai.v2", "SpaceXAIBlockV2"), + (".models.foundation.spacexai.v3", "SpaceXAIBlockV3"), (".models.foundation.google_vision_ocr.v1", "GoogleVisionOCRBlockV1"), (".models.foundation.google_vision_ocr.v1_tensor", "GoogleVisionOCRBlockV1"), (".sinks.email_notification.v2", "EmailNotificationBlockV2"), @@ -40,6 +44,7 @@ (".models.foundation.qwen_vlm.v1", "QwenVlmBlockV1"), (".models.foundation.qwen_vlm.v2", "QwenVlmBlockV2"), (".models.foundation.qwen_vlm.v3", "QwenVlmBlockV3"), + (".models.foundation.qwen_vlm.v4", "QwenVlmBlockV4"), ] WORKFLOWS_ROOT = ( pathlib.Path(__file__).resolve().parents[5] / "inference" / "core" / "workflows" @@ -159,3 +164,72 @@ def test_no_workflows_module_imports_the_roboflow_api_client_at_all() -> None: if alias.name.startswith("inference.core.roboflow_api"): offenders.append(f"{path}:{node.lineno}") assert not offenders, offenders + + +@pytest.mark.parametrize( + "module_suffix,class_name,model_version", + [ + (".models.foundation.openai.v7", "OpenAIBlockV7", "gpt-5.1"), + ( + ".models.foundation.anthropic_claude.v5", + "AnthropicClaudeBlockV5", + "claude-sonnet-4-5", + ), + ( + ".models.foundation.google_gemini.v6", + "GoogleGeminiBlockV6", + "gemini-2.5-flash", + ), + (".models.foundation.spacexai.v3", "SpaceXAIBlockV3", "grok-4"), + ], +) +def test_new_vlm_blocks_forward_managed_keys_through_the_injected_client( + module_suffix, class_name, model_version +): + import numpy as np + + from inference.core.workflows.execution_engine.entities.base import ( + Batch, + ImageParentMetadata, + WorkflowImageData, + ) + + client = RecordingPlatformClient( + post_response={ + "content": [{"type": "text", "text": "ok"}], + "output": [ + {"type": "message", "content": [{"type": "output_text", "text": "ok"}]} + ], + "candidates": [{"content": {"parts": [{"text": "ok"}]}}], + } + ) + block = _load(module_suffix, class_name)( + api_key="workspace-key", platform_client=client + ) + image = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="image"), + numpy_image=np.zeros((8, 8, 3), dtype=np.uint8), + ) + kwargs = { + name: ( + parameter.default + if parameter.default is not inspect.Parameter.empty + else None + ) + for name, parameter in inspect.signature(block.run).parameters.items() + } + kwargs.update( + images=Batch.init(content=[image], indices=[(0,)]), + task_type="caption", + model_version=model_version, + api_key="rf_key:account:managed-key", + max_concurrent_requests=1, + ) + if "max_image_size" in kwargs: + kwargs["max_image_size"] = 512 + if "image_detail" in kwargs: + kwargs["image_detail"] = "auto" + result = block.run(**kwargs) + assert result[0]["output"] == "ok" + assert len(client.posts) == 1 + assert client.posts[0]["api_key"] == "workspace-key" diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_anthropic_claude_v5.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_anthropic_claude_v5.py index cdd80c88c5..aa5c120253 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_anthropic_claude_v5.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_anthropic_claude_v5.py @@ -72,7 +72,7 @@ def _run_block( classes: Optional[List[str]] = None, prompt: Optional[str] = None, ) -> dict: - block = AnthropicClaudeBlockV5(model_manager=None, api_key="rf-key") + block = AnthropicClaudeBlockV5(api_key="rf-key") with patch(EXECUTE_REQUESTS_SEAM) as mock_execute: mock_execute.return_value = [(raw_output, 11, 3)] results = block.run( @@ -407,7 +407,7 @@ def _direct_request(model_version: str, **overrides: Any) -> dict: kwargs.update(overrides) with patch(ANTHROPIC_CLIENT_SEAM) as mock_anthropic_class: mock_client = _mock_streaming_client(mock_anthropic_class) - execute_claude_request(**kwargs) + execute_claude_request(platform_client=MagicMock(), **kwargs) return mock_client.messages.stream.call_args.kwargs diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_google_gemini_v6.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_google_gemini_v6.py index cf69412ebf..f5960c35a1 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_google_gemini_v6.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_google_gemini_v6.py @@ -71,7 +71,7 @@ def _run_block( classes: Optional[List[str]] = None, prompt: Optional[str] = None, ) -> dict: - block = GoogleGeminiBlockV6(model_manager=None, api_key="rf-key") + block = GoogleGeminiBlockV6(api_key="rf-key") with patch(EXECUTE_REQUESTS_SEAM) as mock_execute: mock_execute.return_value = [(raw_output, 11, 3)] results = block.run( diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_openai_v7.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_openai_v7.py index 182ab514f3..32af9b9a50 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_openai_v7.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_openai_v7.py @@ -88,7 +88,7 @@ def _run_block( classes: Optional[List[str]] = None, prompt: Optional[str] = None, ) -> dict: - block = OpenAIBlockV7(model_manager=None, api_key="rf-key") + block = OpenAIBlockV7(api_key="rf-key") with patch(EXECUTE_REQUESTS_SEAM) as mock_execute: mock_execute.return_value = [(raw_output, 11, 3)] results = block.run( diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v4.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v4.py index e9ed748a8f..1a6a50879b 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v4.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_qwen_vlm_v4.py @@ -94,9 +94,7 @@ def _openrouter_block() -> QwenVlmBlockV4: def _native_block(response: str) -> QwenVlmBlockV4: model_manager = MagicMock() - prediction = MagicMock() - prediction.response = response - model_manager.infer_from_request_sync.return_value = prediction + model_manager.run_lmm.return_value = {"response": response} return QwenVlmBlockV4( model_manager=model_manager, api_key="ws-key", diff --git a/tests/workflows/unit_tests/core_steps/models/foundation/test_spacexai_v3.py b/tests/workflows/unit_tests/core_steps/models/foundation/test_spacexai_v3.py index 4a161d38b5..97ce91d80c 100644 --- a/tests/workflows/unit_tests/core_steps/models/foundation/test_spacexai_v3.py +++ b/tests/workflows/unit_tests/core_steps/models/foundation/test_spacexai_v3.py @@ -82,7 +82,7 @@ def _kinds(outputs, name): def _block() -> SpaceXAIBlockV3: - return SpaceXAIBlockV3(model_manager=MagicMock(), api_key="rf_abc") + return SpaceXAIBlockV3(api_key="rf_abc") def test_manifest_parses_new_type(): diff --git a/tests/workflows/unit_tests/core_steps/transformations/test_image_slicer_v2.py b/tests/workflows/unit_tests/core_steps/transformations/test_image_slicer_v2.py index 18c6396810..35e50c3be1 100644 --- a/tests/workflows/unit_tests/core_steps/transformations/test_image_slicer_v2.py +++ b/tests/workflows/unit_tests/core_steps/transformations/test_image_slicer_v2.py @@ -269,7 +269,9 @@ def test_running_block_when_tensor_image_given_keeps_crops_on_device() -> None: ), f"Expected {i}th crop to have the correct coordinates regarding root" # zero-copy content correctness: the first crop equals the source tensor slice - assert torch.equal(result[0]["slices"].tensor_image, tensor_image[:, 0:100, 0:200]) + assert torch.equal( + result[0]["slices"].tensor_image, image.tensor_image[:, 0:100, 0:200] + ) def test_running_block_when_slice_size_exceed_image_size() -> None: diff --git a/tests/workflows/unit_tests/core_steps/visualizations/test_bounding_box_v1_tensor_gpu.py b/tests/workflows/unit_tests/core_steps/visualizations/test_bounding_box_v1_tensor_gpu.py index fdb81b9cea..cc61068b30 100644 --- a/tests/workflows/unit_tests/core_steps/visualizations/test_bounding_box_v1_tensor_gpu.py +++ b/tests/workflows/unit_tests/core_steps/visualizations/test_bounding_box_v1_tensor_gpu.py @@ -397,6 +397,8 @@ def _warnings(): # (see inference/core/logger.py), so its records never reach the root # logger pytest's caplog handler is attached to. Attach the caplog # handler directly to the module logger. + # Capture once regardless of handlers installed on ancestor loggers. + monkeypatch.setattr(bounding_box_v1_tensor.logger, "propagate", False) bounding_box_v1_tensor.logger.addHandler(caplog.handler) try: with caplog.at_level( diff --git a/tests/workflows/unit_tests/core_steps/visualizations/test_label_v1_tensor_gpu.py b/tests/workflows/unit_tests/core_steps/visualizations/test_label_v1_tensor_gpu.py index b468c03de3..0db1ff0483 100644 --- a/tests/workflows/unit_tests/core_steps/visualizations/test_label_v1_tensor_gpu.py +++ b/tests/workflows/unit_tests/core_steps/visualizations/test_label_v1_tensor_gpu.py @@ -409,8 +409,12 @@ def counting_render(*args, **kwargs): @pytest.mark.parametrize("device", DEVICES) -def test_cached_sprites_are_device_resident_tensors(device: str) -> None: +def test_cached_sprites_are_device_resident_tensors(device: str, monkeypatch) -> None: # given + monkeypatch.setattr( + "inference.core.workflows.execution_engine.entities.base.WORKFLOWS_IMAGE_TENSOR_DEVICE", + torch.device(device), + ) scene = _make_scene(89) detections = _default_detections(device=device) block = LabelVisualizationBlockV1() @@ -752,6 +756,8 @@ def _warnings(): # (see inference/core/logger.py), so its records never reach the root # logger pytest's caplog handler is attached to. Attach the caplog # handler directly to the module logger. + # Capture once regardless of handlers installed on ancestor loggers. + monkeypatch.setattr(label_v1_tensor.logger, "propagate", False) label_v1_tensor.logger.addHandler(caplog.handler) try: with caplog.at_level(logging.WARNING, logger=label_v1_tensor.logger.name): diff --git a/tests/workflows/unit_tests/core_steps/visualizations/test_viz_phase_transfer_audit.py b/tests/workflows/unit_tests/core_steps/visualizations/test_viz_phase_transfer_audit.py index 6d8d740ace..f12d2fc7c4 100644 --- a/tests/workflows/unit_tests/core_steps/visualizations/test_viz_phase_transfer_audit.py +++ b/tests/workflows/unit_tests/core_steps/visualizations/test_viz_phase_transfer_audit.py @@ -153,6 +153,8 @@ def _tensor_image(seed: int = 7) -> WorkflowImageData: def _mirrored_od_detections(n: int = N_BOXES) -> Detections: + image = _tensor_image() + device = image.tensor_image.device boxes = np.array( [ [40.5, 60.25, 200.75, 200.125], @@ -163,17 +165,19 @@ def _mirrored_od_detections(n: int = N_BOXES) -> Detections: dtype=np.float32, )[:n] detections = Detections( - xyxy=torch.tensor(boxes, dtype=torch.float32), - class_id=torch.tensor([index % 3 for index in range(n)], dtype=torch.long), + xyxy=torch.tensor(boxes, dtype=torch.float32, device=device), + class_id=torch.tensor( + [index % 3 for index in range(n)], dtype=torch.long, device=device + ), confidence=torch.tensor( - np.linspace(0.42, 0.99, max(n, 1))[:n], dtype=torch.float32 + np.linspace(0.42, 0.99, max(n, 1))[:n], dtype=torch.float32, device=device ), image_metadata=None, bboxes_metadata=None, ) return attach_native_detection_metadata( detections=detections, - image=_tensor_image(), + image=image, class_names=CLASS_NAMES, prediction_type="object-detection", ) @@ -206,7 +210,9 @@ def _disjoint_od_detections(n: int) -> Detections: def _mirrored_is_detections(n: int = 2) -> InstanceDetections: base = _mirrored_od_detections(n) - masks = torch.zeros((n, SCENE_H, SCENE_W), dtype=torch.bool) + masks = torch.zeros( + (n, SCENE_H, SCENE_W), dtype=torch.bool, device=base.xyxy.device + ) masks[0, 70:180, 50:190] = True if n > 1: masks[1, 100:250, 260:410] = True @@ -237,10 +243,11 @@ def test_viz_phase_mask_then_label_combined_trace_is_transfer_clean() -> None: label_block.run(image=_tensor_image(), predictions=detections, **_LABEL_RUN_KWARGS) # when - the audited steady-state viz phase, label consuming mask's output + audited_image = _tensor_image() mask_audit = _TransferAudit() with mask_audit: masked = mask_block.run( - image=_tensor_image(), predictions=segmentation, **_MASK_RUN_KWARGS + image=audited_image, predictions=segmentation, **_MASK_RUN_KWARGS ) label_audit = _TransferAudit() with label_audit: diff --git a/tests/workflows/unit_tests/execution_engine/entities/test_base.py b/tests/workflows/unit_tests/execution_engine/entities/test_base.py index 29bc74d6aa..d0fc2b80ab 100644 --- a/tests/workflows/unit_tests/execution_engine/entities/test_base.py +++ b/tests/workflows/unit_tests/execution_engine/entities/test_base.py @@ -1162,7 +1162,6 @@ def test_init_workflow_image_data_from_tensor_only() -> None: # then assert image.tensor_image is tensor - assert image.tensor_image.device == WORKFLOWS_IMAGE_TENSOR_DEVICE def test_workflow_image_data_numpy_fallback_does_rgb_to_bgr() -> None: diff --git a/tests/workflows/unit_tests/prototypes/test_platform_client.py b/tests/workflows/unit_tests/prototypes/test_platform_client.py index b7ff4d768f..ed4f1ce375 100644 --- a/tests/workflows/unit_tests/prototypes/test_platform_client.py +++ b/tests/workflows/unit_tests/prototypes/test_platform_client.py @@ -96,7 +96,7 @@ def test_server_adapter_wrap_url_really_proxies_through_the_secure_gateway(monke # Measured: urllib.parse.quote(..., safe="~()*!'") encodes the slashes too. assert wrapped == ( - "http://gateway.local/proxy?url=" + "https://gateway.local/proxy?url=" "https%3A%2F%2Fapi.roboflow.com%2Fx%3Fapi_key%3Dabcd1234%26a%3D1" ) assert wrapped == url_utils.wrap_url(raw) diff --git a/tests/workflows/unit_tests/test_configuration.py b/tests/workflows/unit_tests/test_configuration.py index 0c9cb6d3fe..a604399b74 100644 --- a/tests/workflows/unit_tests/test_configuration.py +++ b/tests/workflows/unit_tests/test_configuration.py @@ -12,7 +12,7 @@ import pytest # The facade is imported HERE, at collection time, on purpose (round-5 defect 1): -# it binds its 67 constants from `get_configuration()` at its FIRST import, and the +# it binds its constants from `get_configuration()` at its FIRST import, and the # autouse fixture below resets the registry before every test. If a test body # performed the first import, the facade would freeze standalone defaults, the # fixture would restore only `_CONFIGURATION`, and the server/facade parity test @@ -69,7 +69,7 @@ def test_configuration_is_frozen_in_every_group() -> None: total_fields += len(dataclasses.fields(value)) with pytest.raises(dataclasses.FrozenInstanceError): setattr(value, dataclasses.fields(value)[0].name, "mutated") - assert total_fields == 67, total_fields + assert total_fields == 70, total_fields def test_default_configuration_matches_env_pys_empty_environment_defaults() -> None: @@ -109,6 +109,12 @@ def test_default_configuration_matches_env_pys_empty_environment_defaults() -> N assert configuration.remote.hosted_core_model_url == "https://infer.roboflow.com" assert configuration.remote.max_step_batch_size == 1 assert configuration.remote.max_step_concurrent_requests == 8 + assert ( + configuration.remote.inner_workflow_remote_target + == "https://serverless.roboflow.com" + ) + assert configuration.remote.inner_workflow_remote_dispatch_request_timeout == 300.0 + assert configuration.remote.openai_compatible_allowed_base_urls == ("*",) assert configuration.platform.api_base_url == "https://api.roboflow.com" assert configuration.platform.offline_mode is False assert configuration.platform.secure_gateway is None @@ -564,7 +570,7 @@ def test_environment_facade_exports_every_owned_symbol() -> None: for name in vars(workflows_environment) if name.isupper() and not name.startswith("_") } - assert len(exported) == 67, sorted(exported) + assert len(exported) == 70, sorted(exported) assert isinstance(workflows_environment.WORKFLOW_DISABLED_BLOCK_TYPES, list) assert isinstance(workflows_environment.WORKFLOW_DISABLED_BLOCK_PATTERNS, list) assert isinstance(workflows_environment.ENABLE_TENSOR_DATA_REPRESENTATION, bool) From 11ce624be4916d7d8ac3c5ff1fff3d679966eaa8 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:45:04 +0200 Subject: [PATCH 80/86] fix test --- scripts/workflows_isolation_probe.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/scripts/workflows_isolation_probe.py b/scripts/workflows_isolation_probe.py index f05b98ef94..368b3dc9ba 100644 --- a/scripts/workflows_isolation_probe.py +++ b/scripts/workflows_isolation_probe.py @@ -9,13 +9,10 @@ package is a stub (verified 2026-09-08), so stub parents alone are not isolation. -Deviation from the plan text (recorded 2026-09-08): the child's `PYTHONPATH` -is `` **plus** `/inference_models`. `inference_models` and -`inference_sdk` are allowed dependencies of workflows and the probe is -specified to run with both installed, but in this checkout `inference_models` -is importable only through that path entry - the venv's editable install -resolves it from a different checkout. Setting `PYTHONPATH` to the scratch -tree alone would silently swap in the other checkout's copy. +The scratch tree also contains this checkout's `inference_sdk`, an allowed +workflow dependency. The child's `PYTHONPATH` includes `/inference_models` +for the other allowed package. Neither package needs an editable installation, +and the repository root is not added to the child's search path. """ import argparse @@ -156,6 +153,10 @@ def assert_only_copied_modules_loaded(): def import_everything(): + import inference_sdk + assert _inside_copy(inference_sdk.__file__), ( + f"SDK must come from the isolated checkout: {inference_sdk.__file__}" + ) import inference # Directory containment on resolved paths, not a string prefix: on macOS # `tempfile.mkdtemp` hands back `/var/...` while the child resolves the @@ -364,6 +365,11 @@ def build_tree(target: Path) -> None: target / "inference" / "core" / "workflows", ignore=shutil.ignore_patterns("__pycache__"), ) + shutil.copytree( + REPO_ROOT / "inference_sdk", + target / "inference_sdk", + ignore=shutil.ignore_patterns("__pycache__"), + ) def _child_diagnostics(proc: subprocess.CompletedProcess) -> str: From d0842727cc2e2c7107ba7de4fe1d9e5a95f99735 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:59:31 +0200 Subject: [PATCH 81/86] tests --- .../execution/test_workflow_with_google_vision_ocr.py | 4 ++++ .../execution/test_workflow_with_postgresql.py | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/workflows/integration_tests/execution/test_workflow_with_google_vision_ocr.py b/tests/workflows/integration_tests/execution/test_workflow_with_google_vision_ocr.py index 344a292aec..10b36b313e 100644 --- a/tests/workflows/integration_tests/execution/test_workflow_with_google_vision_ocr.py +++ b/tests/workflows/integration_tests/execution/test_workflow_with_google_vision_ocr.py @@ -4,6 +4,9 @@ import pytest from inference.core.env import WORKFLOWS_MAX_CONCURRENT_STEPS +from inference.core.interfaces.roboflow_platform_client import ( + workflows_platform_bindings, +) from inference.core.managers.base import ModelManager from inference.core.workflows.core_steps.common.entities import StepExecutionMode from inference.core.workflows.execution_engine.core import ExecutionEngine @@ -191,6 +194,7 @@ def test_workflow_with_google_ocr_without_api_key_via_proxy( ) -> None: # given workflow_init_parameters = { + **workflows_platform_bindings(), "workflows_core.model_manager": model_manager, "workflows_core.api_key": roboflow_api_key, "workflows_core.step_execution_mode": StepExecutionMode.LOCAL, diff --git a/tests/workflows/integration_tests/execution/test_workflow_with_postgresql.py b/tests/workflows/integration_tests/execution/test_workflow_with_postgresql.py index 144a4f8883..0d4842701f 100644 --- a/tests/workflows/integration_tests/execution/test_workflow_with_postgresql.py +++ b/tests/workflows/integration_tests/execution/test_workflow_with_postgresql.py @@ -110,14 +110,16 @@ def test_statement_timeout_releases_connection(database): @pytest.fixture def enterprise_blocks(monkeypatch): - from inference.core import env + from inference.core.env import ENTERPRISE_BLOCKS_PLUGIN from inference.core.workflows.execution_engine.introspection import blocks_loader from inference.core.workflows.execution_engine.v1.compiler.core import ( COMPILATION_CACHE, ) - monkeypatch.setattr(env, "LOAD_ENTERPRISE_BLOCKS", True) - monkeypatch.setattr(blocks_loader, "LOAD_ENTERPRISE_BLOCKS", True) + plugins = blocks_loader.get_plugin_modules() + if ENTERPRISE_BLOCKS_PLUGIN not in plugins: + plugins.append(ENTERPRISE_BLOCKS_PLUGIN) + monkeypatch.setenv(blocks_loader.WORKFLOWS_PLUGINS_ENV, ",".join(plugins)) blocks_loader.load_core_workflow_blocks.cache_clear() yield blocks_loader.load_core_workflow_blocks.cache_clear() From 740aa45f411c6681e5e4fab5736e6f5af3189338 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:18:14 +0200 Subject: [PATCH 82/86] tests --- .github/workflows/unit_tests_inference_x86.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests_inference_x86.yml b/.github/workflows/unit_tests_inference_x86.yml index 99ee003bc2..02eba2ae58 100644 --- a/.github/workflows/unit_tests_inference_x86.yml +++ b/.github/workflows/unit_tests_inference_x86.yml @@ -50,7 +50,8 @@ jobs: run: | python -m pip install --upgrade pip pip install --upgrade setuptools - pip install -r requirements/_requirements.txt -r requirements/requirements.cpu.txt -r requirements/requirements.sdk.http.txt -r requirements/requirements.test.unit.txt -r requirements/requirements.http.txt -r requirements/requirements.transformers.txt + # Composition-root tests also exercise the CLI entry points. + pip install -r requirements/requirements.cli.txt -r requirements/_requirements.txt -r requirements/requirements.cpu.txt -r requirements/requirements.sdk.http.txt -r requirements/requirements.test.unit.txt -r requirements/requirements.http.txt -r requirements/requirements.transformers.txt pip install --no-deps ./inference_models - name: 🧪 Unit Tests of Inference timeout-minutes: 30 From 636d95af7afacc299728356e71c8de44056e0875 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:29:45 +0200 Subject: [PATCH 83/86] Remove plan scripts --- scripts/generate_env_import_manifest.py | 117 ------ scripts/phase11_annotation_scan.py | 50 --- scripts/phase11_bind_models_provider.py | 88 ----- scripts/phase11_move_sam_prompts.py | 122 ------ scripts/phase11_move_segmentation_entities.py | 149 ------- scripts/phase11_port_edit.py | 134 ------- scripts/phase11_swap_lmm_call_sites.py | 120 ------ scripts/phase9_check_test_references.py | 57 --- scripts/phase9_endpoint_type_constant.py | 112 ------ scripts/phase9_move_roboflow_plugin.py | 106 ----- scripts/phase9_platform_client.py | 365 ------------------ scripts/phase9_platform_client_tests.py | 251 ------------ scripts/phase9_platform_errors_imports.py | 96 ----- scripts/phase9_platform_headers.py | 89 ----- scripts/phase9_probe_baseline_crosscheck.py | 69 ---- scripts/relocate_logger.py | 88 ----- scripts/repoint_env_imports.py | 96 ----- scripts/repoint_image_utils_imports.py | 87 ----- scripts/verify_loader_registration_parity.py | 122 ------ .../test_loader_tensor_mode_parity.py | 7 +- .../workflows/unit_tests/scripts/__init__.py | 0 .../test_phase9_codemods_preserve_crlf.py | 102 ----- ...t_phase9_codemods_validate_before_write.py | 268 ------------- 23 files changed, 2 insertions(+), 2693 deletions(-) delete mode 100644 scripts/generate_env_import_manifest.py delete mode 100644 scripts/phase11_annotation_scan.py delete mode 100644 scripts/phase11_bind_models_provider.py delete mode 100644 scripts/phase11_move_sam_prompts.py delete mode 100644 scripts/phase11_move_segmentation_entities.py delete mode 100644 scripts/phase11_port_edit.py delete mode 100644 scripts/phase11_swap_lmm_call_sites.py delete mode 100644 scripts/phase9_check_test_references.py delete mode 100644 scripts/phase9_endpoint_type_constant.py delete mode 100644 scripts/phase9_move_roboflow_plugin.py delete mode 100644 scripts/phase9_platform_client.py delete mode 100644 scripts/phase9_platform_client_tests.py delete mode 100644 scripts/phase9_platform_errors_imports.py delete mode 100644 scripts/phase9_platform_headers.py delete mode 100644 scripts/phase9_probe_baseline_crosscheck.py delete mode 100644 scripts/relocate_logger.py delete mode 100644 scripts/repoint_env_imports.py delete mode 100644 scripts/repoint_image_utils_imports.py delete mode 100644 scripts/verify_loader_registration_parity.py delete mode 100644 tests/workflows/unit_tests/scripts/__init__.py delete mode 100644 tests/workflows/unit_tests/scripts/test_phase9_codemods_preserve_crlf.py delete mode 100644 tests/workflows/unit_tests/scripts/test_phase9_codemods_validate_before_write.py diff --git a/scripts/generate_env_import_manifest.py b/scripts/generate_env_import_manifest.py deleted file mode 100644 index 1ff71c818d..0000000000 --- a/scripts/generate_env_import_manifest.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Emit the `inference.core.env` symbol -> call-site manifest for the workflows tree. - -Every count in DECONTAMINATION.PLAN.PHASE-5.MD comes from this script. Phase 9 -owns the rows under `core_steps/sinks/roboflow/**` and -`core_steps/integrations/roboflow/**` (controller ruling R-S), so they are -reported separately and excluded from the "owned" figures. - - python scripts/generate_env_import_manifest.py --summary - python scripts/generate_env_import_manifest.py --json out.json -""" - -import argparse -import ast -import json -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] -WORKFLOWS_ROOT = REPO_ROOT / "inference" / "core" / "workflows" -ENV_MODULE = "inference.core.env" -PHASE_9_PREFIXES = ( - WORKFLOWS_ROOT / "core_steps" / "sinks" / "roboflow", - WORKFLOWS_ROOT / "core_steps" / "integrations" / "roboflow", -) - - -def phase_9_owned(path: Path) -> bool: - return any(prefix in path.parents for prefix in PHASE_9_PREFIXES) - - -def _function_local_nodes(tree: ast.AST) -> set: - local = set() - for node in ast.walk(tree): - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): - for sub in ast.walk(node): - if isinstance(sub, ast.ImportFrom): - local.add(id(sub)) - return local - - -def build() -> dict: - symbols, owned_files, owned_statements = {}, set(), 0 - phase_9_files, phase_9_statements = set(), 0 - for path in sorted(WORKFLOWS_ROOT.rglob("*.py")): - if "__pycache__" in str(path): - continue - tree = ast.parse(path.read_bytes().decode("utf-8"), filename=str(path)) - local = _function_local_nodes(tree) - relative = path.relative_to(REPO_ROOT).as_posix() - deferred = phase_9_owned(path) - for node in ast.walk(tree): - if not isinstance(node, ast.ImportFrom): - continue - if node.module != ENV_MODULE or node.level != 0: - continue - if deferred: - phase_9_files.add(relative) - phase_9_statements += 1 - continue - owned_files.add(relative) - owned_statements += 1 - for alias in node.names: - symbols.setdefault(alias.name, []).append( - { - "file": relative, - "line": node.lineno, - "function_local": id(node) in local, - } - ) - return { - "symbols": {k: symbols[k] for k in sorted(symbols)}, - "owned_symbols": sorted(symbols), - "owned_files": sorted(owned_files), - "owned_statements": owned_statements, - "phase_9_files": sorted(phase_9_files), - "phase_9_statements": phase_9_statements, - } - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--json", metavar="PATH") - parser.add_argument("--summary", action="store_true") - args = parser.parse_args() - manifest = build() - if args.json: - Path(args.json).write_text(json.dumps(manifest, indent=1)) - if args.summary or not args.json: - print(f"owned symbols: {len(manifest['owned_symbols'])}") - print(f"owned files: {len(manifest['owned_files'])}") - print(f"owned statements: {manifest['owned_statements']}") - print(f"phase 9 files: {len(manifest['phase_9_files'])}") - print(f"phase 9 stmts: {manifest['phase_9_statements']}") - multi = [ - f - for f in manifest["owned_files"] - if sum( - 1 - for sites in manifest["symbols"].values() - for s in sites - if s["file"] == f - ) - and len( - { - s["line"] - for sites in manifest["symbols"].values() - for s in sites - if s["file"] == f - } - ) - > 1 - ] - print(f"files with >1 statement: {multi}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/phase11_annotation_scan.py b/scripts/phase11_annotation_scan.py deleted file mode 100644 index e971f47e61..0000000000 --- a/scripts/phase11_annotation_scan.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Lists every annotation in a file that references one of the given names. - -`compileall` does not evaluate annotations, so a deleted import that survives -only in a `-> List[Point]` return type compiles and then fails at import time. - -Run: python scripts/phase11_annotation_scan.py Name1,Name2,... -Exit code 1 (and a printed table) when any annotation still references one. -""" - -import ast -import sys - - -def scan(path: str, names: set) -> list: - tree = ast.parse(open(path, encoding="utf-8").read()) - hits = [] - for node in ast.walk(tree): - annotations = [] - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - if node.returns is not None: - annotations.append(("return", node.returns)) - arguments = ( - list(node.args.posonlyargs) - + list(node.args.args) - + list(node.args.kwonlyargs) - ) - for argument in arguments: - if argument.annotation is not None: - annotations.append((f"arg {argument.arg}", argument.annotation)) - elif isinstance(node, ast.AnnAssign): - annotations.append(("annotated assignment", node.annotation)) - for label, annotation in annotations: - referenced = sorted( - { - inner.id - for inner in ast.walk(annotation) - if isinstance(inner, ast.Name) and inner.id in names - } - ) - if referenced: - hits.append((node.lineno, label, referenced, ast.unparse(annotation))) - return hits - - -if __name__ == "__main__": - path, names = sys.argv[1], set(sys.argv[2].split(",")) - hits = scan(path, names) - for lineno, label, referenced, text in hits: - print(f"{path}:{lineno}: {label}: {text} -> {referenced}") - sys.exit(1 if hits else 0) diff --git a/scripts/phase11_bind_models_provider.py b/scripts/phase11_bind_models_provider.py deleted file mode 100644 index 2c5880a72d..0000000000 --- a/scripts/phase11_bind_models_provider.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Bind `ModelManagerModelsProvider(model_manager)` at the four composition roots (Task 11.7). - -AST-located, one value substitution per root: every expression bound to the -`"workflows_core.model_manager"` key - a dict-literal entry or a -`params["workflows_core.model_manager"] = ...` assignment - whose value is the -bare name `model_manager` becomes `ModelManagerModelsProvider(model_manager)`, -and the import is added once after the last top-level import. Nothing else on -the line changes, so the edit composes with whatever wrapper another phase -put around the dictionary (Phase 9's `install_workflows_platform_bindings({...})`, -Phase 5/6/10's extra keys). Re-parses before writing; idempotent (a file whose -bindings are already wrapped reports 0 and is left alone). - -Run: python scripts/phase11_bind_models_provider.py [ ...] -""" - -import ast -import sys -from pathlib import Path - -KEY = "workflows_core.model_manager" -IMPORT = ( - "from inference.core.interfaces.workflows_models_provider import (\n" - " ModelManagerModelsProvider,\n" - ")\n" -) - - -def _bound_values(tree): - for node in ast.walk(tree): - if isinstance(node, ast.Dict): - for key, value in zip(node.keys, node.values): - if isinstance(key, ast.Constant) and key.value == KEY: - yield value - elif isinstance(node, ast.Assign): - for target in node.targets: - if ( - isinstance(target, ast.Subscript) - and isinstance(target.slice, ast.Constant) - and target.slice.value == KEY - ): - yield node.value - - -def rewrite(path: Path) -> int: - source = path.read_text(encoding="utf-8") - newline = "\r\n" if "\r\n" in source else "\n" - lines = source.split(newline) - tree = ast.parse(source) - targets = [ - v - for v in _bound_values(tree) - if isinstance(v, ast.Name) and v.id == "model_manager" - ] - if not targets: - print(f"{path}: 0 bindings rewritten (already wrapped or absent)") - return 0 - for value in sorted(targets, key=lambda n: (n.lineno, n.col_offset), reverse=True): - assert value.lineno == value.end_lineno - line = lines[value.lineno - 1] - assert line[value.col_offset : value.end_col_offset] == "model_manager" - lines[value.lineno - 1] = ( - line[: value.col_offset] - + "ModelManagerModelsProvider(model_manager)" - + line[value.end_col_offset :] - ) - if "ModelManagerModelsProvider" not in source: - last_import = max( - n.end_lineno - for n in tree.body - if isinstance(n, (ast.Import, ast.ImportFrom)) - ) - lines[last_import:last_import] = IMPORT.rstrip("\n").split("\n") - updated = newline.join(lines) - ast.parse(updated) - remaining = [ - v - for v in _bound_values(ast.parse(updated)) - if isinstance(v, ast.Name) and v.id == "model_manager" - ] - assert not remaining, "post-state: a raw binding survived" - path.write_text(updated, encoding="utf-8") - print(f"{path}: {len(targets)} bindings rewritten") - return len(targets) - - -if __name__ == "__main__": - total = sum(rewrite(Path(p)) for p in sys.argv[1:]) - print(f"TOTAL {total}") diff --git a/scripts/phase11_move_sam_prompts.py b/scripts/phase11_move_sam_prompts.py deleted file mode 100644 index b571c12fb5..0000000000 --- a/scripts/phase11_move_sam_prompts.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Move the SAM prompt classes into workflows; the server re-exports them (Task 11.13). - -Creates `inference/core/workflows/core_steps/models/foundation/segment_anything_common/prompts.py` -from two spans copied character for character - `class Box` .. `to_sam2_inputs` -of `inference/core/entities/requests/sam2.py` and `class Sam3Prompt` .. -`_validate_output_prob_thresh` of `requests/sam3.py` - then replaces each span -in its server module with a re-export import, and trims the typing/pydantic -names only those spans used (`Tuple`, `BaseModel` in sam2.py; `Union`, -`BaseModel` in sam3.py). Content-anchored (never line numbers), re-parses every -output before writing, and idempotent: a second run prints SKIP and changes -nothing. A half-applied state (one of the three files already edited) aborts -with a message instead of guessing. - -Run: python scripts/phase11_move_sam_prompts.py [] -""" - -import ast -import sys -from pathlib import Path - -ROOT = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".") -TARGET = ( - ROOT - / "inference/core/workflows/core_steps/models/foundation/segment_anything_common/prompts.py" -) -SAM2 = ROOT / "inference/core/entities/requests/sam2.py" -SAM3 = ROOT / "inference/core/entities/requests/sam3.py" -PROMPTS_MODULE = "inference.core.workflows.core_steps.models.foundation.segment_anything_common.prompts" -SAM2_IMPORT = ( - f"from {PROMPTS_MODULE} import ( # noqa: F401\n" - " Box,\n Point,\n Sam2Prompt,\n Sam2PromptSet,\n)\n" -) -SAM3_IMPORT = f"from {PROMPTS_MODULE} import ( # noqa: F401\n Sam3Prompt,\n)\n" -SAM2_ANCHOR = "from inference.core.env import SAM2_VERSION_ID\n" -SAM3_ANCHOR = "from inference.core.env import SAM3_MAX_PROMPT_BATCH_SIZE\n" -HEADER = '''"""SAM prompt value objects, owned by Workflows. - -`Box`, `Point`, `Sam2Prompt`, `Sam2PromptSet` (from `inference/core/entities/ -requests/sam2.py`) and `Sam3Prompt` (from `requests/sam3.py`) were MOVED here -verbatim; both server modules now re-export them, so there is exactly ONE class -object per name. That is what keeps `isinstance(raw_point, Point)` in the two -SAM 3 interactive blocks true for a `Point` built through either import path, -lets the server request classes accept prompt sets the blocks build, and keeps -the `ValidationError` a bad prompt raises the same object either way. - -Pinned by `tests/workflows/unit_tests/core_steps/models/foundation/test_sam_prompts.py` -(identity through the re-export, the accepted-input matrix of `_as_sam2_points`, -frozen `to_sam2_inputs()` / payload / validation tables). -""" - -from typing import List, Optional, Tuple, Union - -from pydantic import BaseModel, Field, validator - - -''' - - -def _span(source: str, start: str, end: str) -> str: - first, last = source.index(start), source.index(end) - assert first < last, (start, end) - return source[first:last] - - -def _drop_names(source: str, prefix: str, names: list) -> str: - lines = source.split("\n") - matches = [index for index, line in enumerate(lines) if line.startswith(prefix)] - assert len(matches) == 1, (prefix, matches) - present = [name.strip() for name in lines[matches[0]][len(prefix) :].split(",")] - for name in names: - assert name in present, (prefix, name) - kept = [name for name in present if name not in names] - lines[matches[0]] = prefix + " ".join(", ".join(kept).split()) - return "\n".join(lines) - - -def main() -> int: - sam2, sam3 = SAM2.read_text(encoding="utf-8"), SAM3.read_text(encoding="utf-8") - applied = (SAM2_IMPORT in sam2, SAM3_IMPORT in sam3, TARGET.exists()) - if all(applied): - print("SKIP (already applied)") - return 0 - if any(applied): - raise SystemExit( - f"half-applied state {applied}; restore the three files from git first" - ) - sam2_span = _span(sam2, "class Box(BaseModel):", "class Sam2SegmentationRequest(") - sam3_span = _span( - sam3, "class Sam3Prompt(BaseModel):", "class Sam3InferenceRequest(" - ) - module = HEADER + sam2_span.rstrip() + "\n\n\n" + sam3_span.rstrip() + "\n" - ast.parse(module) - new_sam2 = sam2.replace(sam2_span, "", 1) - assert new_sam2.count(SAM2_ANCHOR) == 1 - new_sam2 = new_sam2.replace(SAM2_ANCHOR, SAM2_ANCHOR + SAM2_IMPORT, 1) - new_sam2 = _drop_names(new_sam2, "from typing import ", ["Tuple"]) - new_sam2 = _drop_names(new_sam2, "from pydantic import ", ["BaseModel"]) - new_sam3 = sam3.replace(sam3_span, "", 1) - assert new_sam3.count(SAM3_ANCHOR) == 1 - new_sam3 = new_sam3.replace(SAM3_ANCHOR, SAM3_ANCHOR + SAM3_IMPORT, 1) - new_sam3 = _drop_names(new_sam3, "from typing import ", ["Union"]) - new_sam3 = _drop_names(new_sam3, "from pydantic import ", ["BaseModel"]) - for text in (new_sam2, new_sam3): - ast.parse(text) - for name in ( - "class Box(", - "class Point(", - "class Sam2Prompt(", - "class Sam2PromptSet(", - "class Sam3Prompt(", - ): - assert name not in text, name - TARGET.write_text(module, encoding="utf-8") - SAM2.write_text(new_sam2, encoding="utf-8") - SAM3.write_text(new_sam3, encoding="utf-8") - for path in (TARGET, SAM2, SAM3): - print(f"{path}: {len(path.read_text(encoding='utf-8').splitlines())} lines") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/phase11_move_segmentation_entities.py b/scripts/phase11_move_segmentation_entities.py deleted file mode 100644 index abb70719da..0000000000 --- a/scripts/phase11_move_segmentation_entities.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Move the segmentation prediction classes into workflows; the server re-exports them (Task 11.4). - -Creates `inference/core/workflows/core_steps/common/segmentation_entities.py` from -three spans copied character for character: - - `class Point` .. (up to `class Point3D`) and - `class InstanceSegmentationBasePrediction` .. (up to `def _mask_to_base64_png`) - of `inference/core/entities/responses/inference.py`; - - `class Sam2SegmentationPrediction` .. (up to `class Sam2SegmentationResponse`) - of `inference/core/entities/responses/sam2.py`; -then replaces each span in its server module with a re-export import placed right -after the module's `from pydantic import ...` line (so `Point3D(Point)`, -`Keypoint(Point)` and the response classes still see the names), and trims the -typing names only the moved span used (`responses/sam2.py`: `Any`, `Dict`, -`Optional`, `Union`; `responses/inference.py` keeps every name - the rest of the -module still uses them). Content-anchored, re-parses every output before -writing, idempotent (second run prints SKIP), aborts on a half-applied state. - -Run: python scripts/phase11_move_segmentation_entities.py [] -""" - -import ast -import sys -from pathlib import Path - -ROOT = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".") -TARGET = ROOT / "inference/core/workflows/core_steps/common/segmentation_entities.py" -INFERENCE = ROOT / "inference/core/entities/responses/inference.py" -SAM2 = ROOT / "inference/core/entities/responses/sam2.py" -MODULE = "inference.core.workflows.core_steps.common.segmentation_entities" -INFERENCE_IMPORT = ( - f"from {MODULE} import ( # noqa: F401\n" - " InstanceSegmentationBasePrediction,\n" - " InstanceSegmentationPrediction,\n" - " InstanceSegmentationRLEPrediction,\n" - " Point,\n" - ")\n" -) -SAM2_IMPORT = ( - f"from {MODULE} import ( # noqa: F401\n Sam2SegmentationPrediction,\n)\n" -) -INFERENCE_ANCHOR = "from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_serializer\n" -SAM2_ANCHOR = "from pydantic import BaseModel, Field\n" -HEADER = '''"""Segmentation prediction entities, owned by Workflows. - -`Point`, `InstanceSegmentationBasePrediction`, `InstanceSegmentationPrediction` -and `InstanceSegmentationRLEPrediction` (from `inference/core/entities/ -responses/inference.py`) and `Sam2SegmentationPrediction` (from -`responses/sam2.py`) were MOVED here verbatim; both server modules re-export -them, so there is exactly ONE class object per name. The blocks build these -from REMOTE responses as well as from their own arithmetic - the SAM 3 -interactive parser, the SAM2 remote converter, the SAM3 v1/v2/v3 and -seg-preview remote/proxy paths - so they must keep pydantic validation and -coercion (`"0.9"` -> 0.9, nested `Point` and mask validation, alias `class`). - -Pinned by `tests/workflows/unit_tests/core_steps/common/test_segmentation_entities.py` -(identity through the re-export, the remote-parser coercion/rejection matrix -through both remote branches, frozen validation tables). -""" - -from typing import Any, Dict, List, Literal, Optional, Union -from uuid import uuid4 - -from pydantic import BaseModel, Field - - -''' - - -def _span(source: str, start: str, end: str) -> str: - first, last = source.index(start), source.index(end) - assert first < last, (start, end) - return source[first:last] - - -def _drop_names(source: str, prefix: str, names: list) -> str: - lines = source.split("\n") - matches = [index for index, line in enumerate(lines) if line.startswith(prefix)] - assert len(matches) == 1, (prefix, matches) - present = [name.strip() for name in lines[matches[0]][len(prefix) :].split(",")] - for name in names: - assert name in present, (prefix, name) - kept = [name for name in present if name not in names] - lines[matches[0]] = prefix + ", ".join(kept) - return "\n".join(lines) - - -def main() -> int: - inference = INFERENCE.read_text(encoding="utf-8") - sam2 = SAM2.read_text(encoding="utf-8") - applied = (INFERENCE_IMPORT in inference, SAM2_IMPORT in sam2, TARGET.exists()) - if all(applied): - print("SKIP (already applied)") - return 0 - if any(applied): - raise SystemExit( - f"half-applied state {applied}; restore the three files from git first" - ) - point_span = _span(inference, "class Point(BaseModel):", "class Point3D(Point):") - base_span = _span( - inference, - "class InstanceSegmentationBasePrediction(BaseModel):", - "def _mask_to_base64_png(", - ) - sam2_span = _span( - sam2, - "class Sam2SegmentationPrediction(BaseModel):", - "class Sam2SegmentationResponse(", - ) - module = ( - HEADER - + point_span.rstrip() - + "\n\n\n" - + base_span.rstrip() - + "\n\n\n" - + sam2_span.rstrip() - + "\n" - ) - ast.parse(module) - new_inference = inference.replace(point_span, "", 1).replace(base_span, "", 1) - assert new_inference.count(INFERENCE_ANCHOR) == 1 - new_inference = new_inference.replace( - INFERENCE_ANCHOR, INFERENCE_ANCHOR + INFERENCE_IMPORT, 1 - ) - new_sam2 = sam2.replace(sam2_span, "", 1) - assert new_sam2.count(SAM2_ANCHOR) == 1 - new_sam2 = new_sam2.replace(SAM2_ANCHOR, SAM2_ANCHOR + SAM2_IMPORT, 1) - new_sam2 = _drop_names( - new_sam2, "from typing import ", ["Any", "Dict", "Optional", "Union"] - ) - for text in (new_inference, new_sam2): - ast.parse(text) - for name in ( - "class Point(", - "class InstanceSegmentationBasePrediction(", - "class InstanceSegmentationPrediction(", - "class InstanceSegmentationRLEPrediction(", - ): - assert name not in new_inference, name - assert "class Sam2SegmentationPrediction(" not in new_sam2 - TARGET.write_text(module, encoding="utf-8") - INFERENCE.write_text(new_inference, encoding="utf-8") - SAM2.write_text(new_sam2, encoding="utf-8") - for path in (TARGET, INFERENCE, SAM2): - print(f"{path}: {len(path.read_text(encoding='utf-8').splitlines())} lines") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/phase11_port_edit.py b/scripts/phase11_port_edit.py deleted file mode 100644 index af5e5c6b71..0000000000 --- a/scripts/phase11_port_edit.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Additive edit of `inference/core/workflows/prototypes/models_provider.py` (Task 11.1). - -Anchor-based, never a whole-file replacement, so whatever another phase has -already appended to the module survives - Phase 9's `CORE_MODEL_ENDPOINT_TYPE` -constant (Task 9.2) and its `load_action_recognition_model` declaration -(Task 9.9) in particular. It: - - 1. rewrites the `from typing import ...` line to carry Dict and Union as well - (Tasks 11.8/11.9 add annotations that need them); - 2. deletes the `__getitem__` declaration; - 3. inserts the five first-class model-access declarations before - `def __contains__`; - 4. replaces the "PROVISIONAL MEMBERS." docstring paragraph with the - single-member form Task 11.15 later deletes. - -Re-parses before writing and verifies the post-state. Idempotent: a second run -reports SKIP and changes nothing. - -Run: python scripts/phase11_port_edit.py inference/core/workflows/prototypes/models_provider.py -""" - -import ast -import re -import sys -from pathlib import Path - -TYPING_NAMES = ("Any", "Dict", "List", "Optional", "Protocol", "Union") - -GETITEM_DECL = " def __getitem__(self, key: str) -> Any: ...\n" - -NEW_MEMBERS = """ def get_keypoints_classes(self, model_id: str) -> List[List[str]]: ... - - def model_supports_stream_pipeline(self, model_id: str) -> bool: ... - - def get_model_pipeline_depth(self, model_id: str) -> int: ... - - def flush_model_stream_pipeline(self, model_id: str) -> Optional[List[Any]]: ... - - def shutdown_model_stream_pipeline(self, model_id: str) -> None: ... - -""" - -CONTAINS_ANCHOR = " def __contains__(self, model_id: str) -> bool: ...\n" - -NEW_PARAGRAPH = """ The stream-pipeline members are prefixed because ``flush_stream_pipeline``, - ``stream_pipeline_depth``, ``close_stream_pipeline`` and - ``is_stream_pipelined`` are already a *block*-level duck-typed protocol that - the executor and the server's stream handler call on step instances. - - PROVISIONAL MEMBER. ``infer_from_request_sync`` takes a pydantic request - object built by the caller from ``inference.core.entities`` - it is the - method Phase 11 removes entirely. Do not build new code against it. -""" - - -def edit(source: str) -> str: - if "def get_keypoints_classes" in source and "__getitem__" not in source: - return source # already applied - # 1. typing import: union of what is there and what Tasks 11.8/11.9 need. - match = re.search(r"^from typing import ([^\n]+)\n", source, re.MULTILINE) - if match is None: - raise SystemExit("no `from typing import` line found") - present = {name.strip() for name in match.group(1).split(",")} - names = sorted(present | set(TYPING_NAMES)) - source = source.replace( - match.group(0), f"from typing import {', '.join(names)}\n", 1 - ) - # 2. drop __getitem__. - if source.count(GETITEM_DECL) != 1: - raise SystemExit("expected exactly one __getitem__ declaration") - source = source.replace(GETITEM_DECL, "", 1) - # 3. insert the five members before __contains__. - if source.count(CONTAINS_ANCHOR) != 1: - raise SystemExit("expected exactly one __contains__ declaration") - source = source.replace(CONTAINS_ANCHOR, NEW_MEMBERS + CONTAINS_ANCHOR, 1) - # 4. the docstring paragraph: from " PROVISIONAL MEMBERS." to the closing quotes. - paragraph = re.search( - r" PROVISIONAL MEMBERS\..*?(?=\n \"\"\"\n)", source, re.DOTALL - ) - if paragraph is None: - raise SystemExit("PROVISIONAL MEMBERS paragraph not found") - source = ( - source[: paragraph.start()] - + NEW_PARAGRAPH.rstrip("\n") - + source[paragraph.end() :] - ) - source = re.sub(r"\n{3,}( def __contains__)", r"\n\n\1", source) - return source - - -def verify(source: str, original: str) -> None: - tree = ast.parse(source) - declared = { - node.name - for cls in tree.body - if isinstance(cls, ast.ClassDef) and cls.name == "ModelsProvider" - for node in cls.body - if isinstance(node, ast.FunctionDef) - } - for name in ( - "get_keypoints_classes", - "model_supports_stream_pipeline", - "get_model_pipeline_depth", - "flush_model_stream_pipeline", - "shutdown_model_stream_pipeline", - "__contains__", - "add_model", - "run_tensor_native_inference", - "get_class_names", - "infer_from_request_sync", - ): - assert name in declared, f"missing {name}" - assert "__getitem__" not in declared - # Whatever other phases put here must survive untouched. - for preserved in ("CORE_MODEL_ENDPOINT_TYPE", "load_action_recognition_model"): - assert (preserved in source) == (preserved in original), preserved - assert "PROVISIONAL MEMBERS" not in source and "PROVISIONAL MEMBER." in source - - -def main(paths): - for raw in paths: - path = Path(raw) - original = path.read_text(encoding="utf-8") - updated = edit(original) - if updated == original: - print(f"SKIP (already applied): {path}") - continue - verify(updated, original) - path.write_text(updated, encoding="utf-8") - print(f"edited: {path}") - - -if __name__ == "__main__": - main(sys.argv[1:]) diff --git a/scripts/phase11_swap_lmm_call_sites.py b/scripts/phase11_swap_lmm_call_sites.py deleted file mode 100644 index 60698026e6..0000000000 --- a/scripts/phase11_swap_lmm_call_sites.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Rewrites the 11 uniform LMMInferenceRequest call sites onto `run_lmm`. - -Each site is one of two shapes: - - A) request = LMMInferenceRequest(api_key=..., model_id=..., image=..., - source="workflow-execution", prompt=...) - prediction = self._model_manager.infer_from_request_sync( - model_id=..., request=request) - - B) request_kwargs = dict(api_key=..., model_id=..., image=..., - source="workflow-execution", prompt=..., - [enable_thinking=...]) - if max_new_tokens is not None: - request_kwargs["max_new_tokens"] = max_new_tokens - request = LMMInferenceRequest(**request_kwargs) - prediction = self._model_manager.infer_from_request_sync( - model_id=..., request=request) - -Both collapse to one `self._model_manager.run_lmm(...)` carrying the same -argument expressions. The script reads the arguments off the AST, edits with a -`\r?\n`-safe regex, re-parses before writing, and refuses a file whose shape it -does not recognise - so a drifted site is reported, never mangled. It does NOT -touch `add_model` (registration stays in the block) or the `prediction.response` -read that follows; both are handled by hand in the next step. - -Run: python scripts/phase11_swap_lmm_call_sites.py [ ...] -""" - -import ast -import re -import sys -from pathlib import Path - -_SOURCE_LITERAL = "workflow-execution" - - -def _kwargs_of(call: ast.Call, source: str) -> dict: - return { - keyword.arg: ast.get_source_segment(source, keyword.value) - for keyword in call.keywords - if keyword.arg is not None - } - - -def rewrite(path: Path) -> bool: - source = path.read_text(encoding="utf-8") - tree = ast.parse(source) - calls = [ - node - for node in ast.walk(tree) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == "LMMInferenceRequest" - ] - if not calls: - # Already rewritten. An AST check, not a grep: the class name survives - # in comments and docstrings (`qwen/v1.py:265`, `qwen_vlm/v1.py:203`), - # so a text test would make the second run abort instead of skip. - return False - if len(calls) != 1: - raise SystemExit( - f"{path}: expected exactly 1 LMMInferenceRequest, got {len(calls)}" - ) - kwargs = _kwargs_of(calls[0], source) - if not kwargs: # shape B keeps its arguments in a preceding dict(...) - dict_calls = [ - node - for node in ast.walk(tree) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == "dict" - and any( - keyword.arg == "source" - and isinstance(keyword.value, ast.Constant) - and keyword.value.value == _SOURCE_LITERAL - for keyword in node.keywords - ) - ] - if len(dict_calls) != 1: - raise SystemExit(f"{path}: expected exactly one request_kwargs dict()") - kwargs = _kwargs_of(dict_calls[0], source) - for required in ("api_key", "model_id", "image", "prompt"): - if required not in kwargs: - raise SystemExit(f"{path}: missing {required} in the request kwargs") - arguments = [ - f"model_id={kwargs['model_id']}", - f"image={kwargs['image']}", - f"prompt={kwargs['prompt']}", - f"api_key={kwargs['api_key']}", - ] - if "enable_thinking" in kwargs: - arguments.append(f"enable_thinking={kwargs['enable_thinking']}") - if "max_new_tokens" in source: - arguments.append("max_new_tokens=max_new_tokens") - replacement = ( - " prediction = self._model_manager.run_lmm(\n" - + "".join(f" {argument},\n" for argument in arguments) - + " )\n" - ) - pattern = re.compile( - r"[ \t]*(?:request_kwargs[^\n]*=\s*dict\(|request\s*=\s*LMMInferenceRequest\()" - r".*?infer_from_request_sync\(\r?\n.*?\r?\n[ \t]*\)\r?\n", - re.DOTALL, - ) - new_source, count = pattern.subn(replacement, source, count=1) - if count != 1: - raise SystemExit(f"{path}: could not locate the call block to replace") - new_source = re.sub( - r"from inference\.core\.entities\.requests\.inference import LMMInferenceRequest\r?\n", - "", - new_source, - ) - ast.parse(new_source) # refuse to write anything that does not parse - path.write_text(new_source, encoding="utf-8") - return True - - -if __name__ == "__main__": - for argument in sys.argv[1:]: - print(argument, "rewritten" if rewrite(Path(argument)) else "skipped") diff --git a/scripts/phase9_check_test_references.py b/scripts/phase9_check_test_references.py deleted file mode 100644 index 997fc148e0..0000000000 --- a/scripts/phase9_check_test_references.py +++ /dev/null @@ -1,57 +0,0 @@ -"""AST check: mock targets (string, f-string, patch.object) and `from` imports naming -the given symbols under a test root. Prints file:line per hit and a TOTAL; exits 1 on any hit -outside --allow files.""" - -import argparse -import ast -import pathlib -import sys - -p = argparse.ArgumentParser() -p.add_argument("root") -p.add_argument("--symbols", required=True) -p.add_argument("--allow", default="") -a = p.parse_args() -symbols = set(a.symbols.split(",")) -allow = {x for x in a.allow.split(",") if x} -hits = [] -for path in sorted(pathlib.Path(a.root).rglob("*.py")): - try: - tree = ast.parse(path.read_text(encoding="utf-8")) - except SyntaxError as e: - print(f"FAIL: {path}: {e}", file=sys.stderr) - sys.exit(2) - for n in ast.walk(tree): - if isinstance(n, ast.ImportFrom) and n.module in { - "inference.core.roboflow_api", - "inference.core.utils.url_utils", - }: - for al in n.names: - if al.name in symbols: - hits.append((str(path), n.lineno, f"import {al.name}")) - if isinstance(n, ast.Call): - f = n.func - is_patch = (isinstance(f, ast.Name) and f.id == "patch") or ( - isinstance(f, ast.Attribute) - and f.attr in {"patch", "object", "setattr"} - ) - if not is_patch: - continue - for arg in list(n.args) + [k.value for k in n.keywords]: - text = None - if isinstance(arg, ast.Constant) and isinstance(arg.value, str): - text = arg.value - elif isinstance(arg, ast.JoinedStr): - text = "".join( - v.value for v in arg.values if isinstance(v, ast.Constant) - ) - if text and (text in symbols or text.rsplit(".", 1)[-1] in symbols): - hits.append( - (str(path), n.lineno, f"mock target {text.rsplit('.', 1)[-1]}") - ) - break -bad = [h for h in hits if h[0] not in allow] -for h in hits: - print(("allowed " if h[0] in allow else "") + f"{h[0]}:{h[1]} {h[2]}") -print(f"TOTAL {len(hits)} references, {len(bad)} outside the allow-list") -sys.exit(1 if bad else 0) diff --git a/scripts/phase9_endpoint_type_constant.py b/scripts/phase9_endpoint_type_constant.py deleted file mode 100644 index 8dbf5881a5..0000000000 --- a/scripts/phase9_endpoint_type_constant.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Replace `ModelEndpointType.CORE_MODEL` with the workflows-local constant. - -BSD `sed` here does not honour `\\b`, so this is a Python codemod: it locates -the attribute accesses through `ast`, counts them, re-parses its own output and -verifies the post-state (no `ModelEndpointType` reference left in the file). -Idempotent: a file with no matches contributes zero and still passes. - -Every file's rewrite is computed fully in memory first (`patch()` never -writes); `main()` validates the aggregate `--expected*` counts against the -in-memory results and only then writes - a count mismatch never leaves any -file half-rewritten. -""" - -import argparse -import ast -import pathlib -import sys -from typing import Optional - -NEW_IMPORT = ( - "from inference.core.workflows.prototypes.models_provider import (\n" - " CORE_MODEL_ENDPOINT_TYPE,\n" - ")" -) - - -def patch(path: pathlib.Path) -> tuple[int, int, Optional[str]]: - """Compute the rewrite for `path` without writing anything. - - Returns `(imports, usages, updated)`. `updated` is `None` when the file is - already repointed (nothing to write). - """ - source = path.read_text(encoding="utf-8") - newline = "\r\n" if "\r\n" in source else "\n" - lines = source.split(newline) - tree = ast.parse(source) - - usages = [] - for node in ast.walk(tree): - if ( - isinstance(node, ast.Attribute) - and node.attr == "CORE_MODEL" - and isinstance(node.value, ast.Name) - and node.value.id == "ModelEndpointType" - ): - usages.append(node) - import_span = None - for node in tree.body: - if ( - isinstance(node, ast.ImportFrom) - and node.module == "inference.core.roboflow_api" - and [a.name for a in node.names] == ["ModelEndpointType"] - ): - import_span = (node.lineno, node.end_lineno) - if not usages and import_span is None: - print(f"SKIP (already repointed): {path}") - return 0, 0, None - - for node in sorted(usages, key=lambda n: (n.lineno, n.col_offset), reverse=True): - index = node.lineno - 1 - line = lines[index] - assert ( - line[node.col_offset : node.end_col_offset] - == "ModelEndpointType.CORE_MODEL" - ) - lines[index] = ( - line[: node.col_offset] - + "CORE_MODEL_ENDPOINT_TYPE" - + line[node.end_col_offset :] - ) - if import_span is not None: - start, end = import_span - lines[start - 1 : end] = NEW_IMPORT.split("\n") - updated = newline.join(lines) - if "ModelEndpointType" in updated: - print(f"FAIL: {path} still mentions ModelEndpointType", file=sys.stderr) - raise SystemExit(2) - ast.parse(updated) - return (1 if import_span else 0), len(usages), updated - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("files", nargs="+") - parser.add_argument("--expected-imports", type=int, required=True) - parser.add_argument("--expected-usages", type=int, required=True) - args = parser.parse_args() - total_i = total_u = 0 - pending_writes = [] - for name in args.files: - path = pathlib.Path(name) - i, u, updated = patch(path) - total_i += i - total_u += u - print(f"{i:3d} imports {u:3d} usages {name}") - if updated is not None: - pending_writes.append((path, updated)) - print(f"TOTAL {total_i} imports, {total_u} usages") - print("POST-STATE verified: no ModelEndpointType reference remains") - if total_i != args.expected_imports or total_u != args.expected_usages: - print("FAIL: counts do not match", file=sys.stderr) - return 1 - - # Validation above passed for the aggregate and every individual file - - # only now do we write. - for path, updated in pending_writes: - path.write_text(updated, encoding="utf-8") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/phase9_move_roboflow_plugin.py b/scripts/phase9_move_roboflow_plugin.py deleted file mode 100644 index 4804d4bd69..0000000000 --- a/scripts/phase9_move_roboflow_plugin.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Rewrite module paths after Phase 9's `git mv` of the Roboflow-platform blocks. - -Two prefix rewrites, applied to the moved sources (they import each other) and -to every test that names them. Order-independent: if another phase already -repointed some references the count is lower, and `--expected` is the number -the caller measured immediately before. - -Every rewrite is computed fully in memory first: the original source is -parsed with `ast` (to catch a file that was already broken before we touched -it), the rewritten source is parsed too, and the aggregate `--expected` count -plus the post-state (no stale prefix anywhere) are both validated against the -in-memory results. Only once all of that passes does the script write -anything to disk - a count mismatch or a parse failure never leaves any file -half-rewritten. -""" - -import argparse -import ast -import pathlib -import sys - -REPLACEMENTS = ( - ( - "inference.core.workflows.core_steps.sinks.roboflow", - "inference.roboflow_workflows_plugin.sinks", - ), - ( - "inference.core.workflows.core_steps.integrations.roboflow", - "inference.roboflow_workflows_plugin.integrations", - ), -) - - -def compute_rewrite(path: pathlib.Path): - """Compute the rewritten text for `path` without writing anything. - - Returns `(hits, updated)`. `updated` is `None` when `hits == 0` (nothing - to do). Both the original and the rewritten source are parsed with `ast` - so a syntax problem - pre-existing or introduced by the rewrite - is - caught before any file on disk is touched. - """ - original = path.read_text(encoding="utf-8") - updated = original - hits = 0 - for old, new in REPLACEMENTS: - hits += updated.count(old) - updated = updated.replace(old, new) - if not hits: - return 0, None - try: - ast.parse(original) - except SyntaxError as error: - print(f"FAIL: {path} original source would not parse: {error}", file=sys.stderr) - raise SystemExit(2) - try: - ast.parse(updated) - except SyntaxError as error: - print(f"FAIL: {path} would not parse after rewrite: {error}", file=sys.stderr) - raise SystemExit(2) - return hits, updated - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("roots", nargs="+") - parser.add_argument("--expected", type=int, required=True) - args = parser.parse_args() - - total, touched, remaining, pending_writes = 0, [], [], [] - for root in args.roots: - base = pathlib.Path(root) - files = [base] if base.is_file() else sorted(base.rglob("*.py")) - for path in files: - if "__pycache__" in path.parts: - continue - hits, updated = compute_rewrite(path) - if not hits: - continue - total += hits - touched.append((str(path), hits)) - pending_writes.append((path, updated)) - if any(old in updated for old, _ in REPLACEMENTS): - remaining.append(str(path)) - - for name, hits in touched: - print(f"{hits:4d} {name}") - print(f"TOTAL {total} replacements in {len(touched)} files") - print(f"POST-STATE stale references remaining: {len(remaining)} {remaining}") - - if total != args.expected or remaining: - print("FAIL", file=sys.stderr) - return 1 - - if not pending_writes: - print("SKIP: no-op, nothing to write") - return 0 - - # Validation above passed for the aggregate and every individual file - - # only now do we write, and only the files that actually changed. - for path, updated in pending_writes: - path.write_text(updated, encoding="utf-8") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/phase9_platform_client.py b/scripts/phase9_platform_client.py deleted file mode 100644 index 842c8f6bcb..0000000000 --- a/scripts/phase9_platform_client.py +++ /dev/null @@ -1,365 +0,0 @@ -"""Phase 9: give a Workflow block the `platform_client` port, in one pass. - -Three transformations computed from ONE parse and applied as (line, col) spans -bottom-up, so they cannot reject each other's output: - - A every top-level `def` with a `roboflow_api_key` parameter gains - `platform_client: RoboflowPlatformClient` right after it, and every call - passing `roboflow_api_key=` gains `platform_client=`; - B the named block class gains the `platform_client` init parameter, the - `self._platform_client` assignment (or `platform_client=platform_client` - forwarded to `super().__init__`), and `"platform_client"` in - `get_init_parameters()`; - C `from inference.core.roboflow_api import post_to_roboflow_api` is dropped, - `post_to_roboflow_api(` becomes `platform_client.post(`, and the port - import is added. - -Guards are SPECIFIC - a def's argument list, a call's keyword set, the -`get_init_parameters` literal, the constructor's argument list - never -file-wide or class-wide substring presence. That makes the script idempotent -and order-independent, and it is why Task 9.5 can reuse it on files that still -import OTHER names from `inference.core.roboflow_api`: the verifier rejects a -surviving `post_to_roboflow_api` import, not the module. - -The constructor parameter is appended AFTER the last default value when the -signature has one (`arg.end_col_offset` stops after the annotation, before -` = False`), which is what `email_notification/v2.py` needs. -""" - -import argparse -import ast -import pathlib -import sys - -PARAM_ANNOTATION = "platform_client: RoboflowPlatformClient" -CTOR_PARAM = "platform_client: RoboflowPlatformClient = OFFLINE_PLATFORM_CLIENT" -MAPPING = { - "roboflow_api_key": "platform_client", - "self._api_key": "self._platform_client", - "self._roboflow_api_key": "self._platform_client", -} -IMPORT_BLOCK = ( - "from inference.core.workflows.prototypes.platform_client import (\n" - " OFFLINE_PLATFORM_CLIENT,\n" - " RoboflowPlatformClient,\n" - ")" -) -OLD_IMPORT_MODULE = "inference.core.roboflow_api" -PROXY_HELPER = "post_to_roboflow_api" - - -def _apply(lines, edits): - """edits: (lineno, col, end_lineno, end_col, text), 1-based lines.""" - for lineno, col, end_lineno, end_col, text in sorted( - edits, key=lambda e: (e[0], e[1]), reverse=True - ): - first, last = lines[lineno - 1], lines[end_lineno - 1] - lines[lineno - 1 : end_lineno] = [first[:col] + text + last[end_col:]] - return lines - - -def transform(source: str, class_name: str, path: str): - newline = "\r\n" if "\r\n" in source else "\n" - lines = source.split(newline) - tree = ast.parse(source) - edits = [] - stats = { - "defs": 0, - "calls": 0, - "ctor": 0, - "gip": 0, - "super": 0, - "post_calls": 0, - "old_imports": 0, - } - - for node in tree.body: # A: defs - if not isinstance(node, ast.FunctionDef): - continue - names = [a.arg for a in node.args.args] - if "roboflow_api_key" not in names or "platform_client" in names: - continue - if ( - node.args.vararg - or node.args.kwarg - or node.args.kwonlyargs - or node.args.posonlyargs - ): - raise SystemExit(f"{path}: unsupported signature {node.name}") - arg = node.args.args[names.index("roboflow_api_key")] - edits.append( - ( - arg.end_lineno, - arg.end_col_offset, - arg.end_lineno, - arg.end_col_offset, - f", {PARAM_ANNOTATION}", - ) - ) - stats["defs"] += 1 - - for node in ast.walk(tree): # A: calls - if not isinstance(node, ast.Call): - continue - keys = {k.arg for k in node.keywords} - if "roboflow_api_key" not in keys or "platform_client" in keys: - continue - kw = next(k for k in node.keywords if k.arg == "roboflow_api_key") - expr = ast.unparse(kw.value) - if expr not in MAPPING: - raise SystemExit( - f"{path}: unexpected roboflow_api_key expression {expr!r} " - f"at line {kw.value.lineno}" - ) - edits.append( - ( - kw.value.end_lineno, - kw.value.end_col_offset, - kw.value.end_lineno, - kw.value.end_col_offset, - f", platform_client={MAPPING[expr]}", - ) - ) - stats["calls"] += 1 - - target = next( - ( - n - for n in ast.walk(tree) # B: the class - if isinstance(n, ast.ClassDef) and n.name == class_name - ), - None, - ) - if target is None: - raise SystemExit(f"{path}: no class {class_name}") - init = next( - ( - n - for n in target.body - if isinstance(n, ast.FunctionDef) and n.name == "__init__" - ), - None, - ) - gip = next( - ( - n - for n in target.body - if isinstance(n, ast.FunctionDef) and n.name == "get_init_parameters" - ), - None, - ) - if init is None or gip is None: - raise SystemExit(f"{path}: {class_name} needs __init__ and get_init_parameters") - - ret = next((n for n in ast.walk(gip) if isinstance(n, ast.Return)), None) - if ret is None or not isinstance(ret.value, ast.List): - raise SystemExit(f"{path}: {class_name}.get_init_parameters must return a list") - declared = [ - e.value - for e in ret.value.elts - if isinstance(e, ast.Constant) and isinstance(e.value, str) - ] - if len(declared) != len(ret.value.elts): - raise SystemExit(f"{path}: non-literal in get_init_parameters") - if "platform_client" not in declared: - rendered = ( - " return [" - + ", ".join(f'"{n}"' for n in declared + ["platform_client"]) - + "]" - ) - edits.append( - (ret.lineno, 0, ret.end_lineno, len(lines[ret.end_lineno - 1]), rendered) - ) - stats["gip"] += 1 - - if "platform_client" not in [a.arg for a in init.args.args]: - last_arg = init.args.args[-1] - if last_arg.arg == "self": - raise SystemExit(f"{path}: {class_name}.__init__ takes no parameters") - anchor = init.args.defaults[-1] if init.args.defaults else last_arg - edits.append( - ( - anchor.end_lineno, - anchor.end_col_offset, - anchor.end_lineno, - anchor.end_col_offset, - f", {CTOR_PARAM}", - ) - ) - stats["ctor"] += 1 - super_call = None - for node in ast.walk(init): - if ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "__init__" - and isinstance(node.func.value, ast.Call) - and isinstance(node.func.value.func, ast.Name) - and node.func.value.func.id == "super" - ): - super_call = node - if super_call is not None: - last_kw = super_call.keywords[-1] - edits.append( - ( - last_kw.value.end_lineno, - last_kw.value.end_col_offset, - last_kw.value.end_lineno, - last_kw.value.end_col_offset, - ", platform_client=platform_client", - ) - ) - stats["super"] += 1 - else: - end = init.body[-1].end_lineno - edits.append( - ( - end, - len(lines[end - 1]), - end, - len(lines[end - 1]), - newline + " self._platform_client = platform_client", - ) - ) - - for node in ast.walk(tree): # C: the proxy call - if ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == PROXY_HELPER - ): - edits.append( - ( - node.func.lineno, - node.func.col_offset, - node.func.end_lineno, - node.func.end_col_offset, - "platform_client.post", - ) - ) - stats["post_calls"] += 1 - old_import_span = None - for node in tree.body: - if ( - isinstance(node, ast.ImportFrom) - and node.module == OLD_IMPORT_MODULE - and [a.name for a in node.names] == [PROXY_HELPER] - ): - old_import_span = (node.lineno, node.end_lineno) - stats["old_imports"] += 1 - - updated = newline.join(_apply(lines, edits)) - if old_import_span is not None: - out = updated.split(newline) - start, end = old_import_span - del out[start - 1 : end] - updated = newline.join(out) - if "prototypes.platform_client" not in updated: - out = updated.split(newline) - anchor = None - for node in ast.parse(updated).body: - if isinstance(node, (ast.Import, ast.ImportFrom)): - anchor = node - if anchor is None: - raise SystemExit(f"{path}: no import anchor") - out.insert(anchor.end_lineno, IMPORT_BLOCK.replace("\n", newline)) - updated = newline.join(out) - ast.parse(updated) - return updated, stats - - -def verify(source: str, class_name: str, path: str): - """Post-state, SPECIFIC to what this script removes: the proxy helper. - - Other `inference.core.roboflow_api` names (header builders in the Task 9.5 - inputs) legitimately survive this script; `scripts/phase9_platform_headers.py` - removes and verifies those. - """ - tree = ast.parse(source) - problems = [] - for node in tree.body: - if isinstance(node, ast.FunctionDef): - names = [a.arg for a in node.args.args] - if "roboflow_api_key" in names and "platform_client" not in names: - problems.append(f"def {node.name} lacks platform_client") - for node in ast.walk(tree): - if isinstance(node, ast.Call): - keys = {k.arg for k in node.keywords} - if "roboflow_api_key" in keys and "platform_client" not in keys: - problems.append(f"call at line {node.lineno} lacks platform_client") - if isinstance(node, ast.Name) and node.id == PROXY_HELPER: - problems.append(f"{PROXY_HELPER} referenced at line {node.lineno}") - if ( - isinstance(node, ast.ImportFrom) - and node.module == OLD_IMPORT_MODULE - and any(a.name == PROXY_HELPER for a in node.names) - ): - problems.append(f"{PROXY_HELPER} import survives at line {node.lineno}") - cls = next( - n - for n in ast.walk(tree) - if isinstance(n, ast.ClassDef) and n.name == class_name - ) - gip = next( - n - for n in cls.body - if isinstance(n, ast.FunctionDef) and n.name == "get_init_parameters" - ) - ret = next(n for n in ast.walk(gip) if isinstance(n, ast.Return)) - if "platform_client" not in [e.value for e in ret.value.elts]: - problems.append("get_init_parameters lacks platform_client") - init = next( - n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == "__init__" - ) - if "platform_client" not in [a.arg for a in init.args.args]: - problems.append("__init__ lacks platform_client") - return problems - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("pairs", nargs="+", help="path=ClassName") - parser.add_argument("--expected-defs", type=int, required=True) - parser.add_argument("--expected-calls", type=int, required=True) - parser.add_argument("--expected-post-calls", type=int, required=True) - parser.add_argument("--expected-ctor", type=int, required=True) - args = parser.parse_args() - totals = dict(defs=0, calls=0, ctor=0, gip=0, super=0, post_calls=0, old_imports=0) - outputs = {} - for pair in args.pairs: - path_text, _, class_name = pair.partition("=") - path = pathlib.Path(path_text) - updated, stats = transform( - path.read_bytes().decode("utf-8"), class_name, path_text - ) - problems = verify(updated, class_name, path_text) - if problems: - print(f"FAIL {path_text}: {problems}", file=sys.stderr) - return 2 - again, _ = transform(updated, class_name, path_text) - if again != updated: - print(f"FAIL {path_text}: not idempotent", file=sys.stderr) - return 2 - for key in totals: - totals[key] += stats[key] - outputs[path_text] = updated - print(f" {path_text}: {stats}") - print("TOTALS", totals) - for name, expected in ( - ("defs", args.expected_defs), - ("calls", args.expected_calls), - ("post_calls", args.expected_post_calls), - ("ctor", args.expected_ctor), - ): - if totals[name] != expected: - print(f"FAIL: {name} {totals[name]} != {expected}", file=sys.stderr) - return 1 - for path_text, updated in outputs.items(): - with open(path_text, "w", encoding="utf-8", newline="") as f: - f.write(updated) - print("POST-STATE verified for every file; idempotent; written") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/phase9_platform_client_tests.py b/scripts/phase9_platform_client_tests.py deleted file mode 100644 index 21c3d45f72..0000000000 --- a/scripts/phase9_platform_client_tests.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Add `platform_client=` where a test reaches a signature Task 9.4 changed. - -Two edits, both origin-resolved so a same-named helper in an untouched module -(`anthropic_claude/v2`, `google_gemini/v2`, `lmm/v1`, `openai/v1`) is left alone: - - 1. a call whose callee resolves - through `from X import name [as alias]`, - through `module.name` where `module` was imported from a touched package, - or through a `pytest.mark.parametrize` argument whose values are such - imported names - to one of the changed modules gains - `platform_client=platform_client`; - 2. a keyword-form construction of one of the named block classes gains the - same keyword (positional constructions are refused: none exist). - -The file gains, once, after its last top-level import: - - from tests.workflows.unit_tests.prototypes.platform_client_double import ( - RecordingPlatformClient, - ) - - platform_client = RecordingPlatformClient() - - - @pytest.fixture(autouse=True) - def _reset_platform_client(): - platform_client.reset() - -so no test signature or decorator order changes and no return value leaks -between tests. Tests that seed a response or assert on the call use -`platform_client.post_mock` (a `unittest.mock.Mock`), which keeps every -existing `mock_post.*` assertion verbatim - see Task 9.4 Step 8. - -Every file's rewrite is computed fully in memory first (`patch()` never -writes); `main()` validates the aggregate `--expected-calls` / -`--expected-constructions` counts against the in-memory results and only -then writes - a count mismatch never leaves any file half-rewritten. -""" - -import argparse -import ast -import collections -import pathlib -import sys -from typing import Optional - -CHAIN = { - "run_gpt_4v_llm_prompting", - "execute_gpt_4v_requests", - "execute_gpt_4v_request", - "_execute_proxied_openai_request", - "run_openai_prompting", - "execute_openai_requests", - "execute_openai_request", - "run_gemini_prompting", - "execute_gemini_requests", - "execute_gemini_request", - "_execute_proxied_gemini_request", - "run_claude_prompting", - "execute_claude_requests", - "execute_claude_request", - "_execute_proxied_claude_request", - "run_spacexai_prompting", - "execute_spacexai_requests", - "execute_spacexai_request", - "_execute_proxied_spacexai_request", - "_execute_proxied_google_vision_request", - "send_email_via_roboflow_proxy", - "send_sms_via_roboflow_proxy", - "_execute_proxied_openrouter_request", -} -PREAMBLE = ( - "from tests.workflows.unit_tests.prototypes.platform_client_double import (\n" - " RecordingPlatformClient,\n" - ")\n" - "\n" - "platform_client = RecordingPlatformClient()\n" - "\n" - "\n" - "@pytest.fixture(autouse=True)\n" - "def _reset_platform_client():\n" - " platform_client.reset()" -) - - -def _touched_modules(list_file: str): - return { - line.strip().replace("/", ".")[:-3] - for line in open(list_file, encoding="utf-8") - if line.strip() - } - - -def _affected_calls(tree, touched, classes): - origin, alias = {}, {} - for node in ast.walk(tree): - if isinstance(node, ast.ImportFrom) and node.module: - for a in node.names: - origin[a.asname or a.name] = (node.module, a.name) - alias[a.asname or a.name] = f"{node.module}.{a.name}" - functions = [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)] - param_origins = collections.defaultdict(set) - for fn in functions: - for dec in fn.decorator_list: - if not ( - isinstance(dec, ast.Call) - and isinstance(dec.func, ast.Attribute) - and dec.func.attr == "parametrize" - ): - continue - if len(dec.args) < 2 or not isinstance(dec.args[0], ast.Constant): - continue - names = [s.strip() for s in dec.args[0].value.split(",")] - if not isinstance(dec.args[1], (ast.List, ast.Tuple)): - continue - for elt in dec.args[1].elts: - items = elt.elts if isinstance(elt, (ast.Tuple, ast.List)) else [elt] - for pname, item in zip(names, items): - if isinstance(item, ast.Name) and item.id in origin: - param_origins[(fn.name, pname)].add(origin[item.id]) - helper_calls, constructions = {}, {} - for fn in functions: - for node in ast.walk(fn): - if not isinstance(node, ast.Call): - continue - f = node.func - mods = set() - if isinstance(f, ast.Name): - if f.id in origin and origin[f.id][1] in CHAIN: - mods.add(origin[f.id][0]) - elif (fn.name, f.id) in param_origins: - for mod, name in param_origins[(fn.name, f.id)]: - if name in CHAIN: - mods.add(mod) - elif ( - f.id in origin - and origin[f.id][1] in classes - and origin[f.id][0] in touched - ): - if node.args: - raise SystemExit( - f"positional construction at line {node.lineno}" - ) - constructions[(node.lineno, node.col_offset)] = node - continue - elif ( - isinstance(f, ast.Attribute) - and f.attr in CHAIN - and isinstance(f.value, ast.Name) - ): - if f.value.id in alias: - mods.add(alias[f.value.id]) - if mods and any(m in touched for m in mods): - helper_calls[(node.lineno, node.col_offset)] = node - return list(helper_calls.values()), list(constructions.values()) - - -def patch(path: pathlib.Path, touched, classes) -> tuple[int, int, Optional[str]]: - """Compute the rewrite for `path` without writing anything. - - Returns `(edited_calls, edited_ctors, updated)`. `updated` is `None` when - nothing needed editing (nothing to write). - """ - source = path.read_bytes().decode("utf-8") - newline = "\r\n" if "\r\n" in source else "\n" - lines = source.split(newline) - tree = ast.parse(source) - calls, ctors = _affected_calls(tree, touched, classes) - edits = [] - edited_calls = edited_ctors = 0 - for node in calls + ctors: - keys = {k.arg for k in node.keywords} - if "platform_client" in keys: - continue - if node in calls: - edited_calls += 1 - else: - edited_ctors += 1 - if "roboflow_api_key" in keys: - kw = next(k for k in node.keywords if k.arg == "roboflow_api_key") - edits.append((kw.value.end_lineno, kw.value.end_col_offset)) - elif node.keywords: - last = node.keywords[-1].value - edits.append((last.end_lineno, last.end_col_offset)) - else: - raise SystemExit(f"{path}:{node.lineno}: call without keywords") - for lineno, col in sorted(set(edits), reverse=True): - line = lines[lineno - 1] - lines[lineno - 1] = ( - line[:col] + ", platform_client=platform_client" + line[col:] - ) - updated = newline.join(lines) - if edits and "RecordingPlatformClient" not in updated: - out = updated.split(newline) - new_tree = ast.parse(updated) - anchor = None - has_pytest = False - for node in new_tree.body: - if isinstance(node, (ast.Import, ast.ImportFrom)): - anchor = node - if isinstance(node, ast.Import) and any( - a.name == "pytest" for a in node.names - ): - has_pytest = True - if anchor is None: - raise SystemExit(f"{path}: no import anchor") - preamble = PREAMBLE if has_pytest else "import pytest\n\n" + PREAMBLE - out.insert(anchor.end_lineno, preamble.replace("\n", newline)) - updated = newline.join(out) - ast.parse(updated) - if not edits: - return edited_calls, edited_ctors, None - return edited_calls, edited_ctors, updated - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("files", nargs="+") - parser.add_argument( - "--touched", required=True, help="file listing the changed modules" - ) - parser.add_argument( - "--classes", required=True, help="comma-separated block class names" - ) - parser.add_argument("--expected-calls", type=int, required=True) - parser.add_argument("--expected-constructions", type=int, required=True) - args = parser.parse_args() - touched = _touched_modules(args.touched) - classes = set(args.classes.split(",")) - total_calls = total_ctors = 0 - pending_writes = [] - for name in args.files: - path = pathlib.Path(name) - calls, ctors, updated = patch(path, touched, classes) - total_calls += calls - total_ctors += ctors - print(f"{calls:3d} calls {ctors:3d} constructions edited {name}") - if updated is not None: - pending_writes.append((path, updated)) - print(f"TOTAL {total_calls} helper calls, {total_ctors} block constructions edited") - if total_calls != args.expected_calls or total_ctors != args.expected_constructions: - print("FAIL: counts do not match", file=sys.stderr) - return 1 - - # Validation above passed for the aggregate - only now do we write. - for path, updated in pending_writes: - with open(path, "w", encoding="utf-8", newline="") as f: - f.write(updated) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/phase9_platform_errors_imports.py b/scripts/phase9_platform_errors_imports.py deleted file mode 100644 index 25825add30..0000000000 --- a/scripts/phase9_platform_errors_imports.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Repoint `from inference.core.exceptions import …` at the relocated module. - -Names are unchanged, so this rewrites only the module part of the ImportFrom -node - which is why the function-local import in email_notification/v2.py keeps -its indentation. Refuses any name that did not move. - -Every file's rewrite is computed fully in memory first (`patch()` never -writes); `main()` validates the aggregate `--expected` count against the -in-memory results and only then writes - a count mismatch never leaves any -file half-rewritten. -""" - -import argparse -import ast -import pathlib -import sys -from typing import Optional - -MOVED = { - "RoboflowAPIRequestError", - "RoboflowAPIUnsuccessfulRequestError", - "RoboflowAPIForbiddenError", - "FeatureDeprecatedError", -} -NEW_MODULE = "inference.core.workflows.prototypes.platform_errors" -OLD_MODULE = "inference.core.exceptions" - - -def patch(path: pathlib.Path) -> tuple[int, Optional[str]]: - """Compute the rewrite for `path` without writing anything. - - Returns `(count, updated)`. `updated` is `None` when the file is already - repointed (nothing to write). - """ - source = path.read_text(encoding="utf-8") - newline = "\r\n" if "\r\n" in source else "\n" - lines = source.split(newline) - nodes = [ - node - for node in ast.walk(ast.parse(source)) - if isinstance(node, ast.ImportFrom) and node.module == OLD_MODULE - ] - if not nodes: - print(f"SKIP (already repointed): {path}") - return 0, None - for node in nodes: - unknown = [a.name for a in node.names if a.name not in MOVED] - if unknown: - print( - f"FAIL: {path}:{node.lineno} imports unmoved names {unknown}", - file=sys.stderr, - ) - raise SystemExit(2) - for node in sorted(nodes, key=lambda n: n.lineno, reverse=True): - # Replace only the module text; the `import ...` clause and the - # statement's indentation are preserved verbatim. - first = lines[node.lineno - 1] - assert OLD_MODULE in first, (path, node.lineno, first) - lines[node.lineno - 1] = first.replace(OLD_MODULE, NEW_MODULE, 1) - updated = newline.join(lines) - ast.parse(updated) - if OLD_MODULE in updated: - print(f"FAIL: {path} still imports {OLD_MODULE}", file=sys.stderr) - raise SystemExit(2) - return len(nodes), updated - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("files", nargs="+") - parser.add_argument("--expected", type=int, required=True) - args = parser.parse_args() - total = 0 - pending_writes = [] - for name in args.files: - path = pathlib.Path(name) - count, updated = patch(path) - total += count - print(f"{count:3d} {name}") - if updated is not None: - pending_writes.append((path, updated)) - print(f"TOTAL {total} import statements repointed") - print(f"POST-STATE verified: no {OLD_MODULE} import remains in these files") - if total != args.expected: - print("FAIL: count does not match", file=sys.stderr) - return 1 - - # Validation above passed for the aggregate and every individual file - - # only now do we write. - for path, updated in pending_writes: - path.write_text(updated, encoding="utf-8") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/phase9_platform_headers.py b/scripts/phase9_platform_headers.py deleted file mode 100644 index 060aa69814..0000000000 --- a/scripts/phase9_platform_headers.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Repoint header building and `wrap_url` at the injected platform client. - -All 24 call sites are inside methods, so `self._platform_client` is in scope. -Replacements are made on AST-located `Name` nodes in call position, so a -comment or a docstring mentioning the name is untouched. Every file is -transformed, re-parsed and post-state-verified BEFORE any file is written. -""" - -import argparse -import ast -import pathlib -import re -import sys - -RENAMES = { - "build_roboflow_api_headers": "self._platform_client.build_api_headers", - "get_extra_weights_provider_headers": "self._platform_client.build_weights_provider_headers", - "wrap_url": "self._platform_client.wrap_url", -} -IMPORTS = re.compile( - r"^from inference\.core\.(?:roboflow_api import (?:build_roboflow_api_headers" - r"|get_extra_weights_provider_headers)|utils\.url_utils import wrap_url)[ \t]*\r?\n", - re.MULTILINE, -) - - -def _call_sites(tree): - for node in ast.walk(tree): - if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): - if node.func.id in RENAMES: - yield node.func - - -def transform(source: str, path: str): - newline = "\r\n" if "\r\n" in source else "\n" - lines = source.split(newline) - sites = sorted(_call_sites(ast.parse(source)), - key=lambda n: (n.lineno, n.col_offset), reverse=True) - for func in sites: - index = func.lineno - 1 - line = lines[index] - assert line[func.col_offset : func.end_col_offset] == func.id, (path, func.lineno) - lines[index] = line[: func.col_offset] + RENAMES[func.id] + line[func.end_col_offset :] - updated = newline.join(lines) - removed = len(IMPORTS.findall(updated)) - updated = IMPORTS.sub("", updated) - tree = ast.parse(updated) - leftovers = [f.id for f in _call_sites(tree)] - for node in ast.walk(tree): - if isinstance(node, ast.ImportFrom) and node.module in { - "inference.core.roboflow_api", "inference.core.utils.url_utils"}: - leftovers.extend(a.name for a in node.names if a.name in RENAMES) - if leftovers: - print(f"FAIL: {path} still references {leftovers}", file=sys.stderr) - raise SystemExit(2) - return updated, len(sites), removed - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("files", nargs="+") - parser.add_argument("--expected-calls", type=int, required=True) - parser.add_argument("--expected-imports", type=int, required=True) - args = parser.parse_args() - total_c = total_i = 0 - outputs = {} - for name in args.files: - path = pathlib.Path(name) - updated, calls, imports = transform(path.read_text(encoding="utf-8"), name) - again, _, _ = transform(updated, name) - if again != updated: - print(f"FAIL: {name} not idempotent", file=sys.stderr) - return 2 - outputs[path] = updated - total_c += calls - total_i += imports - print(f"{calls:3d} calls {imports:3d} imports {name}") - print(f"TOTAL {total_c} calls, {total_i} imports") - if total_c != args.expected_calls or total_i != args.expected_imports: - print("FAIL: counts do not match", file=sys.stderr) - return 1 - for path, updated in outputs.items(): - path.write_text(updated, encoding="utf-8") - print("POST-STATE verified: no bare header/wrap_url call or import remains; idempotent; written") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/phase9_probe_baseline_crosscheck.py b/scripts/phase9_probe_baseline_crosscheck.py deleted file mode 100644 index be3140adee..0000000000 --- a/scripts/phase9_probe_baseline_crosscheck.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Cross-check the isolation probe's blocked list against the static baseline. - -The probe blocks at the SHALLOWEST non-allowed ancestor, so -`inference.core.entities` legitimately has no exact baseline row - its leaves -do. The comparison is therefore "exact name OR dotted descendant", with the -`(exec'd string)` suffix normalised away. - -Fails loudly if it cannot find a `blocked_import_attempts` block, or finds one -that parses to zero names: a silently empty extraction would make the whole -check vacuous. -""" - -import argparse -import pathlib -import re -import sys - -BLOCK = re.compile(r'"blocked_import_attempts":\s*\[(.*?)\]', flags=re.S) -NAME = re.compile(r'"([^"]+)"') - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("probe_output") - parser.add_argument("baseline") - parser.add_argument("--expected-blocks", type=int, default=2, - help="one blocked_import_attempts block per tensor mode") - args = parser.parse_args() - - text = pathlib.Path(args.probe_output).read_text(encoding="utf-8") - blocks = BLOCK.findall(text) - if len(blocks) != args.expected_blocks: - print( - f"FAIL: found {len(blocks)} blocked_import_attempts blocks, " - f"expected {args.expected_blocks} - the probe output shape changed", - file=sys.stderr, - ) - return 2 - blocked = set() - for raw in blocks: - names = NAME.findall(raw) - if not names: - print("FAIL: a blocked_import_attempts block parsed to zero names", - file=sys.stderr) - return 2 - blocked.update(names) - blocked = sorted(blocked) - - modules = set() - for row in pathlib.Path(args.baseline).read_text(encoding="utf-8").splitlines(): - if not row or row.startswith("#") or "\t" not in row: - continue - modules.add(row.split("\t", 1)[1].strip().split(" ")[0]) - - def covered(name: str) -> bool: - return any(m == name or m.startswith(name + ".") for m in modules) - - uncovered = [name for name in blocked if not covered(name)] - print("blocked names:", blocked) - print("uncovered by the static baseline:", uncovered) - if uncovered: - print("FAIL: the probe blocked modules the lint cannot see", file=sys.stderr) - return 1 - print("OK: every blocked module is covered by a baseline row or its descendants") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/relocate_logger.py b/scripts/relocate_logger.py deleted file mode 100644 index 41bdce3c47..0000000000 --- a/scripts/relocate_logger.py +++ /dev/null @@ -1,88 +0,0 @@ -import ast -import sys -import tokenize - -path = sys.argv[1] -# Preserve the file's own newline convention - llama_vision/v1.py is CRLF. -with tokenize.open(path) as fh: - src = fh.read() - newline = fh.newlines if isinstance(fh.newlines, str) else "\n" - -tree = ast.parse(src) -lines = src.splitlines() - - -def _is_docstring(node) -> bool: - return ( - isinstance(node, ast.Expr) - and isinstance(getattr(node, "value", None), ast.Constant) - and isinstance(node.value.value, str) - ) - - -drop = { - n.lineno - 1 - for n in tree.body - if isinstance(n, ast.ImportFrom) - and n.module in ("inference.core", "inference.core.logger") - and any(a.name == "logger" for a in n.names) -} -assert drop, f"no logger import found in {path}" - -# End of the LEADING import block, i.e. the last top-level import that still -# precedes the first top-level statement which is not an import or the module -# docstring. Using the last import in the whole module (as originally scoped) -# breaks `dynamic_blocks/modal_executor.py`, which calls `logger.info(...)` at -# module level between two import groups: the assignment would land after that -# call and raise NameError at import time. -first_code = min( - ( - n.lineno - for n in tree.body - if not isinstance(n, (ast.Import, ast.ImportFrom)) and not _is_docstring(n) - ), - default=len(lines) + 1, -) -imports = [ - n - for n in tree.body - if isinstance(n, (ast.Import, ast.ImportFrom)) and n.lineno < first_code -] -assert imports, f"no leading import block in {path}" -insert_after = max(n.end_lineno for n in imports) - -# Some modules already import `logging` at top level (vlm_as_detector/v2_tensor.py). -# A second `import logging` is an F811 redefinition, so skip the insertion there. -already_imports_logging = any( - isinstance(n, ast.Import) - and any(a.name == "logging" and a.asname is None for a in n.names) - for n in tree.body -) - -# `import logging` goes after the module docstring and any __future__ import. -head = 0 -first = tree.body[0] if tree.body else None -if _is_docstring(first): - head = first.end_lineno -for n in tree.body: - if isinstance(n, ast.ImportFrom) and n.module == "__future__": - head = max(head, n.end_lineno) - -out = [] -for i, line in enumerate(lines): - if i not in drop: - out.append(line) - # The insertion checks run even for dropped lines. When the logger import - # IS the last import - as in anthropic_claude/model_capabilities.py - the - # insertion point is a dropped line, and a `continue` here skips the - # insertion entirely, leaving the file compiling but with no `logger`. - if i + 1 == head and not already_imports_logging: - out.append("import logging") - if i + 1 == insert_after: - out.append("") - out.append("logger = logging.getLogger(__name__)") -if head == 0 and not already_imports_logging: - out.insert(0, "import logging") - -with open(path, "w", encoding="utf-8", newline="") as fh: - fh.write(newline.join(out) + newline) diff --git a/scripts/repoint_env_imports.py b/scripts/repoint_env_imports.py deleted file mode 100644 index 8e394e550e..0000000000 --- a/scripts/repoint_env_imports.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Repoint `from inference.core.env import ...` at the Workflows configuration facade. - -AST-located, byte-safe, idempotent. Only the module token on the statement's -FIRST physical line is rewritten - no name list, no expression and no body is -touched - so the codemod is order-independent with respect to Phases 9, 10 and -11, which rewrite different import statements in many of the same files. - - python scripts/repoint_env_imports.py # rewrite - python scripts/repoint_env_imports.py --check # exit 1 if anything remains - python scripts/repoint_env_imports.py --list # print the files it would touch -""" - -import argparse -import ast -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] -WORKFLOWS_ROOT = REPO_ROOT / "inference" / "core" / "workflows" -OLD_MODULE = "inference.core.env" -NEW_MODULE = "inference.core.workflows.environment" - -# Phase 9 relocates these two trees wholesale (controller ruling R-S); they -# keep their `env` imports until then, and cease to exist afterwards. -SKIP_PREFIXES = ( - WORKFLOWS_ROOT / "core_steps" / "sinks" / "roboflow", - WORKFLOWS_ROOT / "core_steps" / "integrations" / "roboflow", -) - - -def _skipped(path: Path) -> bool: - return any(prefix in path.parents for prefix in SKIP_PREFIXES) - - -def _statement_lines(source: str, path: Path) -> list: - tree = ast.parse(source, filename=str(path)) - # ast.walk, not tree.body: `modal_executor.py` has four function-local - # statements on top of its module-level one. - return sorted( - node.lineno - for node in ast.walk(tree) - if isinstance(node, ast.ImportFrom) - and node.module == OLD_MODULE - and node.level == 0 - ) - - -def targets() -> list: - found = [] - for path in sorted(WORKFLOWS_ROOT.rglob("*.py")): - if _skipped(path) or "__pycache__" in str(path): - continue - source = path.read_bytes().decode("utf-8") - lines = _statement_lines(source, path) - if lines: - found.append((path, lines)) - return found - - -def rewrite(path: Path, linenos: list) -> None: - # Bytes + keepends: `core_steps/models/foundation/llama_vision/v1.py` is - # CRLF (663 CRLF endings), and text mode with default newline translation - # would rewrite every line ending in the file. - raw = path.read_bytes() - lines = raw.decode("utf-8").splitlines(keepends=True) - for lineno in linenos: - index = lineno - 1 - line = lines[index] - if OLD_MODULE not in line: - raise SystemExit( - f"{path}:{lineno}: AST reported an `{OLD_MODULE}` import but the " - f"first physical line does not contain the token: {line!r}" - ) - lines[index] = line.replace(OLD_MODULE, NEW_MODULE, 1) - path.write_bytes("".join(lines).encode("utf-8")) - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--check", action="store_true") - parser.add_argument("--list", action="store_true") - args = parser.parse_args() - found = targets() - if args.list or args.check: - for path, linenos in found: - print(f"{path.relative_to(REPO_ROOT)}: {linenos}") - print(f"{len(found)} files, {sum(len(l) for _, l in found)} statements") - return 1 if (args.check and found) else 0 - for path, linenos in found: - rewrite(path, linenos) - print(f"rewrote {len(found)} files, {sum(len(l) for _, l in found)} statements") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/repoint_image_utils_imports.py b/scripts/repoint_image_utils_imports.py deleted file mode 100644 index 63fc757f32..0000000000 --- a/scripts/repoint_image_utils_imports.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env python -"""Repoint `from inference.core.utils.image_utils import ...` inside -`inference/core/workflows` to `inference.core.workflows.utils.images`. - -Only the module path changes; every imported name already exists under the new -path (`inference/core/workflows/utils/images.py`), so no body edit is needed. - -Located by AST, so function-local imports are caught (`modal_executor.py` has one -at line 860). Applied by substring replacement on the statement's FIRST line, -which is where the module path always is for both the single-line and the -parenthesised forms. Files are read and written as BYTES and split with -`splitlines(keepends=True)`, so the one CRLF file in the set -(`core_steps/models/foundation/llama_vision/v1.py`) keeps its line endings. - -The script is order-independent with respect to Phase 9: it rewrites whatever it -finds and prints the counts, so it works whether or not the six relocating files -are still in the tree. - -Usage: - PYTHONPATH=inference_models python scripts/repoint_image_utils_imports.py [--check] -""" - -import argparse -import ast -import sys -from pathlib import Path - -OLD_MODULE = "inference.core.utils.image_utils" -NEW_MODULE = "inference.core.workflows.utils.images" -WORKFLOWS_ROOT = Path("inference/core/workflows") - - -def statements_to_rewrite(path: Path) -> list: - source = path.read_bytes().decode("utf-8") - tree = ast.parse(source) - return sorted( - { - node.lineno - for node in ast.walk(tree) - if isinstance(node, ast.ImportFrom) and node.module == OLD_MODULE - } - ) - - -def rewrite(path: Path, check_only: bool) -> int: - linenos = statements_to_rewrite(path) - if not linenos: - return 0 - raw = path.read_bytes().decode("utf-8") - lines = raw.splitlines(keepends=True) - for lineno in linenos: - index = lineno - 1 - if OLD_MODULE not in lines[index]: - raise SystemExit( - f"{path}:{lineno}: expected the module path on the statement's " - f"first line, found: {lines[index]!r}" - ) - lines[index] = lines[index].replace(OLD_MODULE, NEW_MODULE) - if not check_only: - path.write_bytes("".join(lines).encode("utf-8")) - return len(linenos) - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--check", action="store_true", help="report, do not write") - args = parser.parse_args() - - if not WORKFLOWS_ROOT.is_dir(): - raise SystemExit("run me from the repository root") - - files = 0 - statements = 0 - for path in sorted(WORKFLOWS_ROOT.rglob("*.py")): - if "__pycache__" in path.parts: - continue - rewritten = rewrite(path, check_only=args.check) - if rewritten: - files += 1 - statements += rewritten - print(f"{path}: {rewritten} statement(s)") - print(f"TOTAL files={files} statements={statements}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/verify_loader_registration_parity.py b/scripts/verify_loader_registration_parity.py deleted file mode 100644 index 58dc81b5e8..0000000000 --- a/scripts/verify_loader_registration_parity.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Prove `core_steps/loader.py` registers the same things after a refactor. - -This is a BEFORE/AFTER comparison of the WORKING TREE - record before the edit, -compare after. It does not read git history. - - python scripts/verify_loader_registration_parity.py --record - python scripts/verify_loader_registration_parity.py --compare \ - [--expect-added-initializers configuration] - -One child per tensor mode, with USE_INFERENCE_MODELS pinned True (env.py:1486 ANDs -it into the flag) and every other registration-gating variable pinned so the answer -depends only on the flag. The child also reports the EFFECTIVE -flag it observed, and the parent asserts it matches the mode it asked for - -otherwise a broken configuration hand-off would silently compare a mode against -itself. -""" - -import argparse -import json -import os -import subprocess -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] - -PAYLOAD_KEYS = ("blocks", "kinds", "serializers", "deserializers", "initializers") - -CHILD = r""" -import json -from inference.core.workflows.core_steps import loader - -print(json.dumps({ - "flag": loader.ENABLE_TENSOR_DATA_REPRESENTATION, - "blocks": sorted(f"{b.__module__}.{b.__name__}" for b in loader.load_blocks()), - "kinds": sorted(k.name for k in loader.load_kinds()), - "serializers": sorted(loader.KINDS_SERIALIZERS), - "deserializers": sorted(loader.KINDS_DESERIALIZERS), - "initializers": sorted(loader.REGISTERED_INITIALIZERS), -})) -""" - - -def snapshot(tensor_mode: bool) -> dict: - child_env = {**os.environ, "PYTHONPATH": str(REPO_ROOT / "inference_models")} - child_env["ENABLE_TENSOR_DATA_REPRESENTATION"] = "True" if tensor_mode else "False" - # `env.py:1486` ANDs USE_INFERENCE_MODELS into the tensor flag (it defaults - # to False on Windows and may be pinned False elsewhere), so the requested - # mode is only reachable with BOTH pinned - round-3 defect 5. The child - # still reports the EFFECTIVE flag and the parent asserts it below. - child_env["USE_INFERENCE_MODELS"] = "True" - child_env["SAM3_3D_OBJECTS_ENABLED"] = "False" - child_env["WORKFLOW_DISABLED_BLOCK_TYPES"] = "" - child_env["WORKFLOW_DISABLED_BLOCK_PATTERNS"] = "" - child_env.pop("WORKFLOWS_PLUGINS", None) - completed = subprocess.run( - [sys.executable, "-c", CHILD], - cwd=REPO_ROOT, - env=child_env, - capture_output=True, - text=True, - ) - if completed.returncode != 0: - raise SystemExit(completed.stdout + completed.stderr) - payload = json.loads(completed.stdout.strip().splitlines()[-1]) - if payload["flag"] is not tensor_mode: - raise SystemExit( - f"asked for tensor={tensor_mode} but the loader observed " - f"{payload['flag']!r} - the configuration hand-off is broken" - ) - return payload - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--record", metavar="PATH") - parser.add_argument("--compare", metavar="PATH") - parser.add_argument( - "--expect-added-initializers", - nargs="*", - default=[], - help="initializer names this refactor deliberately ADDS", - ) - args = parser.parse_args() - current = {"off": snapshot(False), "on": snapshot(True)} - if args.record: - Path(args.record).write_text(json.dumps(current, indent=1)) - for mode, payload in current.items(): - print( - f"recorded tensor={mode}: {len(payload['blocks'])} blocks, " - f"{len(payload['kinds'])} kinds, " - f"{len(payload['serializers'])} serializers, " - f"{len(payload['initializers'])} initializers " - f"{payload['initializers']}" - ) - return 0 - recorded = json.loads(Path(args.compare).read_text()) - expected_added = set(args.expect_added_initializers) - ok = True - for mode in ("off", "on"): - for key in PAYLOAD_KEYS: - before, after = set(recorded[mode][key]), set(current[mode][key]) - added, removed = after - before, before - after - if key == "initializers": - # The ONE intended change: this task adds `configuration`. - if added != expected_added or removed: - ok = False - print( - f"tensor={mode} initializers: expected exactly " - f"+{sorted(expected_added)}, got +{sorted(added)} " - f"-{sorted(removed)}" - ) - continue - if added or removed: - ok = False - print(f"tensor={mode} {key}: -{sorted(removed)} +{sorted(added)}") - print("IDENTICAL" if ok else "DIFFERENT") - return 0 if ok else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/workflows/unit_tests/core_steps/test_loader_tensor_mode_parity.py b/tests/workflows/unit_tests/core_steps/test_loader_tensor_mode_parity.py index 87ae253772..6effef0506 100644 --- a/tests/workflows/unit_tests/core_steps/test_loader_tensor_mode_parity.py +++ b/tests/workflows/unit_tests/core_steps/test_loader_tensor_mode_parity.py @@ -1,10 +1,7 @@ """The loader must follow the INSTALLED configuration's tensor flag. -The exhaustive before/after identity proof is -`scripts/verify_loader_registration_parity.py` (a one-shot migration gate, run -in Task 5.3). What must keep holding forever is the mechanism: the flag the -loader branches on comes from the configuration, and each mode registers its -own variant of a block that has both. +The flag the loader branches on comes from the configuration, and each mode +registers its own variant of a block that has both. """ import json diff --git a/tests/workflows/unit_tests/scripts/__init__.py b/tests/workflows/unit_tests/scripts/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/workflows/unit_tests/scripts/test_phase9_codemods_preserve_crlf.py b/tests/workflows/unit_tests/scripts/test_phase9_codemods_preserve_crlf.py deleted file mode 100644 index 90faf21e70..0000000000 --- a/tests/workflows/unit_tests/scripts/test_phase9_codemods_preserve_crlf.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Fix round 1, F2/F3: both Phase 9 codemods must round-trip CRLF files verbatim -and resolve call/construction origins correctly (aliased imports; untouched-module -same-named classes). - -`Path.read_text()` performs universal-newline translation, so the scripts' own -`"\r\n" in source` detection was always False even on a genuinely CRLF file - the -file would silently come back out as LF. These tests write real CRLF fixtures to -disk, run the codemods exactly as `main()` does (`read_bytes().decode("utf-8")` -in, `open(..., newline="")` out), and assert every line ending survives. -""" - -import ast - -import scripts.phase9_platform_client as platform_client_codemod -import scripts.phase9_platform_client_tests as test_codemod - - -def _crlf(text: str) -> str: - return text.replace("\n", "\r\n") - - -def _assert_all_crlf(text: str) -> None: - assert "\r\n" in text, "fixture lost its CRLF endings entirely" - assert text.count("\n") == text.count("\r\n"), "a bare \\n survived the codemod" - - -MODULE_SOURCE = _crlf( - "from typing import Optional\n" - "\n" - "\n" - "def helper(roboflow_api_key: Optional[str]) -> None:\n" - " inner(roboflow_api_key=roboflow_api_key)\n" - "\n" - "\n" - "def inner(roboflow_api_key: Optional[str]) -> None:\n" - " pass\n" - "\n" - "\n" - "class Block:\n" - " def __init__(self, api_key: Optional[str]):\n" - " self._roboflow_api_key = api_key\n" - "\n" - " @classmethod\n" - " def get_init_parameters(cls):\n" - ' return ["api_key"]\n' -) - - -def test_platform_client_transform_preserves_crlf_line_endings(tmp_path): - path = tmp_path / "module.py" - path.write_bytes(MODULE_SOURCE.encode("utf-8")) - source = path.read_bytes().decode("utf-8") - - updated, stats = platform_client_codemod.transform(source, "Block", str(path)) - - assert stats["defs"] == 2 - assert stats["calls"] == 1 - assert stats["ctor"] == 1 - assert stats["gip"] == 1 - _assert_all_crlf(updated) - ast.parse(updated) # still parses - - -TEST_SOURCE = _crlf( - "import pytest\n" - "\n" - "from fake.touched.module import execute_claude_request as aliased_call\n" - "from fake.untouched.module import SomeBlock\n" - "\n" - "\n" - "def test_aliased_call_gets_client():\n" - ' aliased_call(roboflow_api_key="y")\n' - "\n" - "\n" - "def test_construction_from_untouched_module_is_left_alone():\n" - ' SomeBlock(some_kwarg="z")\n' -) - - -def test_test_codemod_preserves_crlf_and_resolves_origins_correctly(tmp_path): - path = tmp_path / "test_fixture.py" - original = TEST_SOURCE.encode("utf-8") - path.write_bytes(original) - - # `patch()` only computes the rewrite in memory - it does not write - so - # the source file on disk must be untouched after the call. - calls, ctors, updated = test_codemod.patch( - path, touched={"fake.touched.module"}, classes={"SomeBlock"} - ) - assert path.read_bytes() == original - - # F3(a): the alias's ORIGINAL imported name ("execute_claude_request") is in - # CHAIN, not the local alias ("aliased_call") - the call must still be edited. - assert calls == 1 - # F3(b): SomeBlock matches by name but its origin module ("fake.untouched.module") - # is not in `touched` - the construction must be left alone. - assert ctors == 0 - - assert "platform_client=platform_client" in updated - assert 'SomeBlock(some_kwarg="z")' in updated # untouched: byte-for-byte unchanged - _assert_all_crlf(updated) - ast.parse(updated) # still parses diff --git a/tests/workflows/unit_tests/scripts/test_phase9_codemods_validate_before_write.py b/tests/workflows/unit_tests/scripts/test_phase9_codemods_validate_before_write.py deleted file mode 100644 index 26c4361d22..0000000000 --- a/tests/workflows/unit_tests/scripts/test_phase9_codemods_validate_before_write.py +++ /dev/null @@ -1,268 +0,0 @@ -"""Phase 9 final fix wave, F1: `phase9_endpoint_type_constant.py`, -`phase9_platform_client_tests.py` and `phase9_platform_errors_imports.py` used -to write each file as soon as its own `patch()` ran, and only check the -aggregate `--expected*` totals in `main()` afterwards - a wrong count left -earlier files rewritten with a non-zero exit code. All three now compute every -file's rewrite in memory, validate the aggregate counts, and only then write - -matching `scripts/phase9_move_roboflow_plugin.py` and -`scripts/phase9_platform_client.py`. - -Each script gets a wrong-count case (asserts nothing is written) and a -happy-path case (asserts the file changed), driven through `main()` exactly -as the CLI is: `subprocess.run([sys.executable, script, ...])`. -""" - -import subprocess -import sys -from pathlib import Path - -import scripts.phase9_endpoint_type_constant as endpoint_codemod -import scripts.phase9_platform_client_tests as client_tests_codemod -import scripts.phase9_platform_errors_imports as errors_imports_codemod - - -def _run(script_path: Path, *args: str) -> subprocess.CompletedProcess: - return subprocess.run( - [sys.executable, str(script_path), *args], - capture_output=True, - text=True, - ) - - -# --- phase9_endpoint_type_constant.py ------------------------------------- - -ENDPOINT_SOURCE = ( - "from inference.core.roboflow_api import ModelEndpointType\n" - "\n" - "\n" - "def endpoint_type():\n" - " return ModelEndpointType.CORE_MODEL\n" -) - - -def test_endpoint_codemod_wrong_expected_count_writes_nothing(tmp_path): - target = tmp_path / "module.py" - target.write_text(ENDPOINT_SOURCE, encoding="utf-8") - before = target.read_bytes() - - result = _run( - Path(endpoint_codemod.__file__), - str(target), - "--expected-imports", - "0", # wrong: the fixture has 1 - "--expected-usages", - "1", - ) - - assert result.returncode != 0 - assert target.read_bytes() == before - - -def test_endpoint_codemod_happy_path_writes_and_exits_zero(tmp_path): - target = tmp_path / "module.py" - target.write_text(ENDPOINT_SOURCE, encoding="utf-8") - before = target.read_bytes() - - result = _run( - Path(endpoint_codemod.__file__), - str(target), - "--expected-imports", - "1", - "--expected-usages", - "1", - ) - - assert result.returncode == 0 - after = target.read_bytes() - assert after != before - assert b"ModelEndpointType" not in after - assert b"CORE_MODEL_ENDPOINT_TYPE" in after - - -def test_endpoint_codemod_second_run_is_a_no_op(tmp_path): - target = tmp_path / "module.py" - target.write_text(ENDPOINT_SOURCE, encoding="utf-8") - _run( - Path(endpoint_codemod.__file__), - str(target), - "--expected-imports", - "1", - "--expected-usages", - "1", - ) - migrated = target.read_bytes() - - result = _run( - Path(endpoint_codemod.__file__), - str(target), - "--expected-imports", - "0", - "--expected-usages", - "0", - ) - - assert result.returncode == 0 - assert "SKIP (already repointed)" in result.stdout - assert target.read_bytes() == migrated - - -# --- phase9_platform_errors_imports.py ------------------------------------- - -ERRORS_SOURCE = ( - "from inference.core.exceptions import RoboflowAPIRequestError\n" - "\n" - "\n" - "def use():\n" - " raise RoboflowAPIRequestError()\n" -) - - -def test_errors_imports_codemod_wrong_expected_count_writes_nothing(tmp_path): - target = tmp_path / "module.py" - target.write_text(ERRORS_SOURCE, encoding="utf-8") - before = target.read_bytes() - - result = _run( - Path(errors_imports_codemod.__file__), - str(target), - "--expected", - "0", # wrong: the fixture has 1 - ) - - assert result.returncode != 0 - assert target.read_bytes() == before - - -def test_errors_imports_codemod_happy_path_writes_and_exits_zero(tmp_path): - target = tmp_path / "module.py" - target.write_text(ERRORS_SOURCE, encoding="utf-8") - before = target.read_bytes() - - result = _run( - Path(errors_imports_codemod.__file__), - str(target), - "--expected", - "1", - ) - - assert result.returncode == 0 - after = target.read_bytes() - assert after != before - assert b"inference.core.exceptions" not in after - assert b"inference.core.workflows.prototypes.platform_errors" in after - - -def test_errors_imports_codemod_second_run_is_a_no_op(tmp_path): - target = tmp_path / "module.py" - target.write_text(ERRORS_SOURCE, encoding="utf-8") - _run(Path(errors_imports_codemod.__file__), str(target), "--expected", "1") - migrated = target.read_bytes() - - result = _run(Path(errors_imports_codemod.__file__), str(target), "--expected", "0") - - assert result.returncode == 0 - assert "SKIP (already repointed)" in result.stdout - assert target.read_bytes() == migrated - - -# --- phase9_platform_client_tests.py ---------------------------------------- - -CLIENT_TEST_SOURCE = ( - "import pytest\n" - "\n" - "from fake.touched.module import execute_claude_request as aliased_call\n" - "\n" - "\n" - "def test_aliased_call_gets_client():\n" - ' aliased_call(roboflow_api_key="y")\n' -) - - -def _touched_file(tmp_path: Path) -> Path: - touched = tmp_path / "touched.txt" - touched.write_text("fake/touched/module.py\n", encoding="utf-8") - return touched - - -def test_client_tests_codemod_wrong_expected_count_writes_nothing(tmp_path): - target = tmp_path / "test_fixture.py" - target.write_text(CLIENT_TEST_SOURCE, encoding="utf-8") - before = target.read_bytes() - touched = _touched_file(tmp_path) - - result = _run( - Path(client_tests_codemod.__file__), - str(target), - "--touched", - str(touched), - "--classes", - "SomeUnusedClass", - "--expected-calls", - "0", # wrong: the fixture has 1 - "--expected-constructions", - "0", - ) - - assert result.returncode != 0 - assert target.read_bytes() == before - - -def test_client_tests_codemod_happy_path_writes_and_exits_zero(tmp_path): - target = tmp_path / "test_fixture.py" - target.write_text(CLIENT_TEST_SOURCE, encoding="utf-8") - before = target.read_bytes() - touched = _touched_file(tmp_path) - - result = _run( - Path(client_tests_codemod.__file__), - str(target), - "--touched", - str(touched), - "--classes", - "SomeUnusedClass", - "--expected-calls", - "1", - "--expected-constructions", - "0", - ) - - assert result.returncode == 0 - after = target.read_bytes() - assert after != before - assert b"platform_client=platform_client" in after - - -def test_client_tests_codemod_second_run_is_a_no_op(tmp_path): - target = tmp_path / "test_fixture.py" - target.write_text(CLIENT_TEST_SOURCE, encoding="utf-8") - touched = _touched_file(tmp_path) - _run( - Path(client_tests_codemod.__file__), - str(target), - "--touched", - str(touched), - "--classes", - "SomeUnusedClass", - "--expected-calls", - "1", - "--expected-constructions", - "0", - ) - migrated = target.read_bytes() - - result = _run( - Path(client_tests_codemod.__file__), - str(target), - "--touched", - str(touched), - "--classes", - "SomeUnusedClass", - "--expected-calls", - "0", - "--expected-constructions", - "0", - ) - - assert result.returncode == 0 - assert "TOTAL 0 helper calls, 0 block constructions edited" in result.stdout - assert target.read_bytes() == migrated From 4c81d981c501e1de574da76c154ebbe49be45a81 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:46:22 +0200 Subject: [PATCH 84/86] Fix server bindings for direct workflow execution --- .../benchmark_engine_throughput.py | 61 ++- examples/run_perspective_correction.py | 78 +++- .../interfaces/test_direct_caller_bindings.py | 356 ++++++++++++++++++ .../test_workflows_composition_roots.py | 10 +- 4 files changed, 488 insertions(+), 17 deletions(-) create mode 100644 tests/inference/unit_tests/core/interfaces/test_direct_caller_bindings.py diff --git a/development/stream_interface/benchmark_engine_throughput.py b/development/stream_interface/benchmark_engine_throughput.py index f7b1dbdeb3..f325eb4ac6 100644 --- a/development/stream_interface/benchmark_engine_throughput.py +++ b/development/stream_interface/benchmark_engine_throughput.py @@ -19,6 +19,7 @@ If the tensor run now matches (or beats) the numpy run here, while the live pipeline showed tensor at half the FPS, the gap is the per-frame numpy->GPU conversion. """ + import argparse import os import sys @@ -42,6 +43,22 @@ MAX_ACTIVE_MODELS, WORKFLOWS_IMAGE_TENSOR_DEVICE, ) +from inference.core.interfaces.roboflow_platform_client import ( + install_workflows_platform_bindings, +) +from inference.core.interfaces.workflows_configuration import ( + server_workflows_configuration, +) +from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, +) +from inference.core.interfaces.workflows_image_codec import bind_image_codec +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) +from inference.core.interfaces.workflows_step_error_handlers import ( + resolve_step_error_handler, +) from inference.core.managers.base import ModelManager from inference.core.managers.decorators.fixed_size_cache import WithFixedSizeCache from inference.core.registries.roboflow import RoboflowModelRegistry @@ -113,7 +130,9 @@ def parse_args() -> argparse.Namespace: def build_model_manager() -> ModelManager: # Same construction as tests/workflows/integration_tests/conftest.py::model_manager. registry = RoboflowModelRegistry(ROBOFLOW_MODEL_TYPES) - return WithFixedSizeCache(ModelManager(model_registry=registry), max_size=MAX_ACTIVE_MODELS) + return WithFixedSizeCache( + ModelManager(model_registry=registry), max_size=MAX_ACTIVE_MODELS + ) def decode_frames(video_path: str, count: int) -> List[np.ndarray]: @@ -139,7 +158,10 @@ def to_gpu_tensor(bgr_hwc: np.ndarray, device: torch.device) -> torch.Tensor: def _cuda_sync_if_needed() -> None: - if ENABLE_TENSOR_DATA_REPRESENTATION and WORKFLOWS_IMAGE_TENSOR_DEVICE.type == "cuda": + if ( + ENABLE_TENSOR_DATA_REPRESENTATION + and WORKFLOWS_IMAGE_TENSOR_DEVICE.type == "cuda" + ): torch.cuda.synchronize() @@ -167,7 +189,10 @@ def main() -> None: f"WORKFLOWS_IMAGE_TENSOR_DEVICE = {WORKFLOWS_IMAGE_TENSOR_DEVICE} | " f"ENABLE_TENSOR_DATA_REPRESENTATION = {ENABLE_TENSOR_DATA_REPRESENTATION}" ) - if ENABLE_TENSOR_DATA_REPRESENTATION and WORKFLOWS_IMAGE_TENSOR_DEVICE.type != "cuda": + if ( + ENABLE_TENSOR_DATA_REPRESENTATION + and WORKFLOWS_IMAGE_TENSOR_DEVICE.type != "cuda" + ): print( "WARNING: tensor mode is on but the tensor device is not CUDA — frames live on " f"'{WORKFLOWS_IMAGE_TENSOR_DEVICE}', so the model will still copy to GPU itself." @@ -193,14 +218,26 @@ def main() -> None: ) model_manager = build_model_manager() + # The server services a direct caller must bind; `examples/ + # run_perspective_correction.py` documents what each one buys. The observer + # is billed work the engine used to do internally, so it belongs inside the + # measured loop, as it was before. + init_parameters = { + "workflows_core.model_manager": ModelManagerModelsProvider(model_manager), + "workflows_core.api_key": args.api_key, + "workflows_core.step_execution_mode": StepExecutionMode.LOCAL, + "workflows_core.execution_observer": UsageTrackingExecutionObserver(), + } + install_workflows_platform_bindings(init_parameters) + bind_image_codec(init_parameters) + init_parameters.setdefault( + "workflows_core.configuration", server_workflows_configuration() + ) engine = ExecutionEngine.init( workflow_definition=workflow, - init_parameters={ - "workflows_core.model_manager": model_manager, - "workflows_core.api_key": args.api_key, - "workflows_core.step_execution_mode": StepExecutionMode.LOCAL, - }, + init_parameters=init_parameters, workflow_id=args.workflow_id, + step_error_handler=resolve_step_error_handler(), ) extra_params = {"model_id": args.model_id} if args.model_id else {} @@ -237,13 +274,17 @@ def run_once(i: int): runs_per_s = args.engine_runs / elapsed if elapsed > 0 else 0.0 frames_per_s = total_frames / elapsed if elapsed > 0 else 0.0 print("\n=== Summary ===") - print(f"data representation : {'TENSOR' if ENABLE_TENSOR_DATA_REPRESENTATION else 'NUMPY'}") + print( + f"data representation : {'TENSOR' if ENABLE_TENSOR_DATA_REPRESENTATION else 'NUMPY'}" + ) print(f"pre-decoded frames : {n}") print(f"batch size : {batch_size}") print(f"engine runs (timed) : {args.engine_runs}") print(f"frames processed : {total_frames}") print(f"elapsed : {elapsed:.3f} s") - print(f"throughput : {frames_per_s:.1f} frames/s ({runs_per_s:.1f} runs/s)") + print( + f"throughput : {frames_per_s:.1f} frames/s ({runs_per_s:.1f} runs/s)" + ) print(f"per-run latency : {elapsed / args.engine_runs * 1000:.3f} ms") print(f"per-frame latency : {elapsed / total_frames * 1000:.3f} ms") diff --git a/examples/run_perspective_correction.py b/examples/run_perspective_correction.py index c3703bb87d..52987e71f3 100644 --- a/examples/run_perspective_correction.py +++ b/examples/run_perspective_correction.py @@ -1,3 +1,46 @@ +"""Perspective-correction demo, and the reference for what a direct Python +caller has to bind. + +The Execution Engine takes the server's capabilities as explicit +`workflows_core.*` init parameters instead of reaching for them itself, and its +standalone defaults refuse or no-op whatever is missing. A script that calls +`ExecutionEngine.init` therefore installs the same services the HTTP, stream and +CLI roots install: + +* `ModelManagerModelsProvider(model_manager)` - blocks call the provider port + (`run_object_detection`, `run_instance_segmentation`, ...), which a raw + `ModelManager` does not implement. Passing the manager itself now fails. +* `install_workflows_platform_bindings` - Roboflow-managed VLM and notification + proxy calls, workflows referenced by ID, workspace identity for authenticated + Modal execution, and the shared cache behind sink cooldown/dedup. Unbound, the + offline platform client raises and the cache is process-local. +* `bind_image_codec` - `{"type": "file"}` / `{"type": "url"}` and serialized + numpy inputs, plus the later re-load of a stored image reference; the default + codec refuses them. Call it AFTER merging overrides, so input deserialization + and reference loading share one codec and one SSRF/local-file policy. +* `UsageTrackingExecutionObserver()` - workflow and custom-block usage plus the + run's tracing spans; the default observer records nothing. +* `resolve_step_error_handler()` - the server's error classification; the + engine's own default is the mapping-free legacy handler. +* `server_workflows_configuration()` - the object the rest of the process uses, + so a mis-wire is reported instead of silently diverging. + +`workflows_core.api_key` still authenticates the calls a block makes, but an API +key alone no longer supplies any of the above. + +The usage categories are separate scopes, not substitutes: `request` (one HTTP +handler call), `workflows` (one observed engine run, with workflow identity, FPS +and preview), `workflow_block` (one custom-Python block execution) and `model` +(the existing model-level accounting). Server entry points keep their existing +layers; a direct caller gets the workflow and block scopes only from the +observer above. Leaving the observer out on purpose stays supported - execution +still runs, without workflow/custom-block collection. + +An offline platform client is not "no network": blocks calling a third party +with the user's own provider key, and the REMOTE branches that go through +`inference_sdk`, keep working on their own terms. +""" + import argparse import os from pathlib import Path @@ -6,6 +49,22 @@ import numpy as np import supervision as sv +from inference.core.interfaces.roboflow_platform_client import ( + install_workflows_platform_bindings, +) +from inference.core.interfaces.workflows_configuration import ( + server_workflows_configuration, +) +from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, +) +from inference.core.interfaces.workflows_image_codec import bind_image_codec +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) +from inference.core.interfaces.workflows_step_error_handlers import ( + resolve_step_error_handler, +) from inference.core.managers.base import ModelManager from inference.core.registries.roboflow import RoboflowModelRegistry from inference.core.workflows.core_steps.common.entities import StepExecutionMode @@ -224,13 +283,22 @@ def parse_args() -> argparse.Namespace: model_registry = RoboflowModelRegistry(ROBOFLOW_MODEL_TYPES) model_manager = ModelManager(model_registry=model_registry) + init_parameters = { + "workflows_core.model_manager": ModelManagerModelsProvider(model_manager), + "workflows_core.api_key": os.getenv("ROBOFLOW_API_KEY"), + "workflows_core.step_execution_mode": StepExecutionMode.LOCAL, + "workflows_core.execution_observer": UsageTrackingExecutionObserver(), + } + install_workflows_platform_bindings(init_parameters) + bind_image_codec(init_parameters) + init_parameters.setdefault( + "workflows_core.configuration", server_workflows_configuration() + ) + execution_engine = ExecutionEngine.init( workflow_definition=WORKFLOW_DEFINITION, - init_parameters={ - "workflows_core.model_manager": model_manager, - "workflows_core.api_key": os.getenv("ROBOFLOW_API_KEY"), - "workflows_core.step_execution_mode": StepExecutionMode.LOCAL, - }, + init_parameters=init_parameters, + step_error_handler=resolve_step_error_handler(), ) result = execution_engine.run( diff --git a/tests/inference/unit_tests/core/interfaces/test_direct_caller_bindings.py b/tests/inference/unit_tests/core/interfaces/test_direct_caller_bindings.py new file mode 100644 index 0000000000..add61d7603 --- /dev/null +++ b/tests/inference/unit_tests/core/interfaces/test_direct_caller_bindings.py @@ -0,0 +1,356 @@ +"""The two direct Python callers must install the server's workflow services. + +`test_workflows_composition_roots.py` pins the provider binding structurally. +This drives the callers' own code - model loading, video decode, the platform +fetch and `ExecutionEngine.init` stubbed - captures the arguments that actually +reach the engine, and then uses the dictionary the caller built: a real model +block through the compiler, and both image injection paths. A hand-written copy +of the intended dictionary would not cover that seam, which is the one the +decontamination broke. + +Per-service depth belongs to the neighbouring files: usage rows to +`test_workflows_observer_row_parity.py`, the URL/local-file denial matrix to +`test_image_codec_local_file_paths.py` and `test_image_codec_binding.py`, the +platform objects to `test_workflows_platform_bindings.py`. Here it is identity +of what is bound, plus one execution through each path it enables. +""" + +import importlib.util +import runpy +import sys +from pathlib import Path +from unittest import mock +from unittest.mock import MagicMock + +import cv2 +import numpy as np +import pytest +import supervision as sv + +from inference.core.entities.requests.inference import ObjectDetectionInferenceRequest +from inference.core.entities.responses.inference import ( + InferenceResponseImage, + ObjectDetectionInferenceResponse, +) +from inference.core.interfaces.roboflow_platform_client import ( + workflows_platform_bindings, +) +from inference.core.interfaces.workflows_configuration import ( + server_workflows_configuration, +) +from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, +) +from inference.core.interfaces.workflows_image_codec import GUARDED_IMAGE_CODEC +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) +from inference.core.interfaces.workflows_step_error_handlers import ( + resolve_step_error_handler, +) +from inference.core.managers.base import ModelManager +from inference.core.utils import image_utils +from inference.core.workflows.core_steps.common.deserializers import ( + deserialize_image_kind, +) +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.core_steps.models.roboflow.object_detection.v1 import ( + BlockManifest, + RoboflowObjectDetectionModelBlockV1, +) +from inference.core.workflows.execution_engine.core import ExecutionEngine +from inference.core.workflows.execution_engine.entities.base import ( + Batch, + ImageParentMetadata, + WorkflowImageData, +) +from inference.core.workflows.execution_engine.v1.compiler.entities import ( + BlockSpecification, +) +from inference.core.workflows.execution_engine.v1.compiler.steps_initialiser import ( + initialise_step, +) +from inference.core.workflows.prototypes.image_codec import ( + get_image_codec, + reset_image_codec, +) + +# tests/inference/unit_tests/core/interfaces/ -> five levels up is the repo root +REPO_ROOT = Path(__file__).resolve().parents[5] +EXAMPLE = REPO_ROOT / "examples" / "run_perspective_correction.py" +BENCHMARK = ( + REPO_ROOT / "development" / "stream_interface" / "benchmark_engine_throughput.py" +) +API_KEY = "direct-caller-key" +MODEL_ID = "some-project/1" +OBJECT_DETECTION_BLOCK = "roboflow_core/roboflow_object_detection_model@v1" + + +@pytest.fixture(autouse=True) +def _clean_registry(): + # The callers install a PROCESS-wide codec; leaving it behind would make a + # later test's install conflict. Same pattern as test_image_codec_binding.py. + reset_image_codec() + yield + reset_image_codec() + + +class _CapturedEngine: + """Stands in for the engine and remembers how the caller built it.""" + + def __init__(self, init_kwargs: dict): + self.init_kwargs = init_kwargs + self.runs = [] + + def run(self, **kwargs): + self.runs.append(kwargs) + # The example treats a result without dynamic zones as "nothing + # detected" and exits before it opens any window. + return [{}] + + +def _capture_engine_init(monkeypatch) -> list: + captured = [] + + def fake_init(**kwargs): + engine = _CapturedEngine(kwargs) + captured.append(engine) + return engine + + monkeypatch.setattr(ExecutionEngine, "init", fake_init) + return captured + + +def _fake_manager() -> MagicMock: + """A mock with the real manager's API surface, and only that. + + `spec=ModelManager` is the point: the manager has `add_model`, + `infer_from_request_sync` and `__contains__`, and has no `run_*` block + methods - the gap the provider exists to close. + """ + manager = MagicMock(spec=ModelManager) + manager.__contains__.return_value = True # MagicMock's default is False + manager.infer_from_request_sync.return_value = ObjectDetectionInferenceResponse( + image=InferenceResponseImage(width=4, height=4), predictions=[] + ) + assert not hasattr(manager, "run_object_detection") + return manager + + +def _assert_server_services_are_bound(init_kwargs: dict, manager: MagicMock) -> dict: + init_parameters = init_kwargs["init_parameters"] + + provider = init_parameters["workflows_core.model_manager"] + assert isinstance(provider, ModelManagerModelsProvider) + assert provider._model_manager is manager + + assert init_parameters["workflows_core.api_key"] == API_KEY + assert ( + init_parameters["workflows_core.step_execution_mode"] is StepExecutionMode.LOCAL + ) + + for key, value in workflows_platform_bindings().items(): + assert init_parameters[key] is value, key + + # One codec object on both injection paths: the engine's deserializer takes + # it from here, every later reference reload takes it from the registry. + codec = init_parameters["workflows_core.image_codec"] + assert codec is GUARDED_IMAGE_CODEC + assert get_image_codec() is codec + + assert isinstance( + init_parameters["workflows_core.execution_observer"], + UsageTrackingExecutionObserver, + ) + assert init_parameters["workflows_core.configuration"] is ( + server_workflows_configuration() + ) + assert init_kwargs["step_error_handler"] == resolve_step_error_handler() + return init_parameters + + +def _run_real_detection_block(init_parameters: dict, manager: MagicMock) -> list: + """A real block, built by the compiler out of the caller's own parameters. + + The block reaches `run_object_detection`, which the spec'd manager does not + have; only the provider bound above turns that into the request the manager + validates. Options are the shape a caller sends, not None - the adapter + forwards them verbatim and `confidence` is a required request field. + """ + initialised = initialise_step( + step_manifest=BlockManifest.model_construct( + name="detection", type=OBJECT_DETECTION_BLOCK + ), + block_specification=BlockSpecification( + block_source="workflows_core", + identifier=OBJECT_DETECTION_BLOCK, + block_class=RoboflowObjectDetectionModelBlockV1, + manifest_class=BlockManifest, + ), + explicit_init_parameters=init_parameters, + initializers={}, + ) + image = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="image"), + numpy_image=np.zeros((4, 4, 3), dtype=np.uint8), + ) + result = initialised.step.run( + images=Batch(content=[image], indices=[(0,)]), + model_id=MODEL_ID, + class_agnostic_nms=False, + class_filter=None, + confidence=0.4, + iou_threshold=0.3, + max_detections=300, + max_candidates=3000, + disable_active_learning=None, + active_learning_target_dataset=None, + ) + manager.add_model.assert_called_once_with(model_id=MODEL_ID, api_key=API_KEY) + request = manager.infer_from_request_sync.call_args.kwargs["request"] + assert isinstance(request, ObjectDetectionInferenceRequest) + assert request.model_id == MODEL_ID + assert request.api_key == API_KEY + assert request.confidence == 0.4 + assert len(result) == 1 + assert isinstance(result[0]["predictions"], sv.Detections) + return result + + +def _exercise_both_image_paths(init_parameters: dict, image_input: dict) -> None: + """Decode the caller's own file input, then re-load it from its reference. + + Path A is the engine's deserializer with the bound codec; Path B is a + reference-only image reading through the process registry + (`entities/base.py` -> `utils/images.ensure_local_image_load_allowed`). The + spy sits on the module attribute the codec calls, so it counts exactly the + permission checks that went through the server's policy owner. The + allow/deny matrix itself is covered by the image-codec tests. + """ + codec = init_parameters["workflows_core.image_codec"] + with mock.patch.object( + image_utils, "ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM", True + ), mock.patch.object( + image_utils, + "ensure_local_file_load_allowed", + wraps=image_utils.ensure_local_file_load_allowed, + ) as policy: + deserialized = deserialize_image_kind( + parameter="image", image=image_input, image_codec=codec + ) + assert deserialized.numpy_image.shape == (16, 24, 3) + # The reference is private; `to_inference_format()` is how a downstream + # block sees it, and it round-trips to the caller's own input dict. + assert deserialized.to_inference_format() == image_input + assert policy.call_count == 1 + + reloaded = WorkflowImageData( + parent_metadata=ImageParentMetadata(parent_id="reload"), + image_reference=image_input["value"], + ) + assert reloaded.numpy_image.shape == (16, 24, 3) + assert policy.call_count == 2 + + +def test_example_binds_the_server_services_and_reaches_a_real_model_block( + monkeypatch, tmp_path +) -> None: + manager = _fake_manager() + source = tmp_path / "scene.png" + assert cv2.imwrite(str(source), np.zeros((16, 24, 3), dtype=np.uint8)) + monkeypatch.setenv("ROBOFLOW_API_KEY", API_KEY) + monkeypatch.setattr( + "inference.core.managers.base.ModelManager", lambda **kwargs: manager + ) + monkeypatch.setattr( + sys, "argv", ["run_perspective_correction.py", "--source-path", str(source)] + ) + captured = _capture_engine_init(monkeypatch) + + # The script's work sits under `if __name__ == "__main__"`, so it is driven + # the way a user runs it; the empty result makes it exit before any window. + with pytest.raises(SystemExit) as exit_info: + runpy.run_path(str(EXAMPLE), run_name="__main__") + assert exit_info.value.code == 0 + + assert len(captured) == 1 + engine = captured[0] + init_parameters = _assert_server_services_are_bound(engine.init_kwargs, manager) + image_input = engine.runs[0]["runtime_parameters"]["image"] + assert image_input == {"type": "file", "value": str(source)} + _exercise_both_image_paths(init_parameters, image_input) + _run_real_detection_block(init_parameters, manager) + + +@pytest.mark.parametrize("tensor_mode", [False, True]) +def test_benchmark_binds_the_server_services_and_reuses_predecoded_input( + monkeypatch, tmp_path, tensor_mode: bool +) -> None: + torch = pytest.importorskip("torch") # the script imports it at module scope + spec = importlib.util.spec_from_file_location( + "benchmark_engine_throughput_under_test", BENCHMARK + ) + benchmark = importlib.util.module_from_spec(spec) + spec.loader.exec_module(benchmark) + + manager = _fake_manager() + video = tmp_path / "clip.mp4" + video.write_bytes(b"") + frames = [np.zeros((4, 4, 3), dtype=np.uint8)] + # Both data representations, on the CPU: the tensor branch is the benchmark's + # reason to exist and must survive the rewiring without a GPU. + monkeypatch.setattr(benchmark, "ENABLE_TENSOR_DATA_REPRESENTATION", tensor_mode) + monkeypatch.setattr(benchmark, "WORKFLOWS_IMAGE_TENSOR_DEVICE", torch.device("cpu")) + monkeypatch.setattr(benchmark, "build_model_manager", lambda: manager) + monkeypatch.setattr(benchmark, "decode_frames", lambda video_path, count: frames) + monkeypatch.setattr( + benchmark, + "get_workflow_specification", + lambda **kwargs: {"version": "1.0", "inputs": [], "steps": [], "outputs": []}, + ) + monkeypatch.setattr( + sys, + "argv", + [ + "benchmark_engine_throughput.py", + "--video", + str(video), + "--workspace", + "some-workspace", + "--workflow-id", + "some-workflow", + "--api-key", + API_KEY, + "--decoded-frames", + "1", + "--engine-runs", + "2", + "--warmup", + "0", + ], + ) + captured = _capture_engine_init(monkeypatch) + + benchmark.main() + + assert len(captured) == 1 + engine = captured[0] + init_parameters = _assert_server_services_are_bound(engine.init_kwargs, manager) + assert engine.init_kwargs["workflow_id"] == "some-workflow" + + assert len(engine.runs) == 2 + prepared = engine.runs[0]["runtime_parameters"]["image"][0] + if tensor_mode: + assert isinstance(prepared, torch.Tensor) + assert tuple(prepared.shape) == (3, 4, 4) + assert prepared.dtype is torch.uint8 + assert prepared.device.type == "cpu" + else: + assert prepared is frames[0] + # The materialization stays out of the timed loop: the same object again. + assert engine.runs[1]["runtime_parameters"]["image"][0] is prepared + + if not tensor_mode: + # Once is enough - the provider path does not depend on this caller's + # image representation, and the example already covers the other one. + _run_real_detection_block(init_parameters, manager) diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_composition_roots.py b/tests/inference/unit_tests/core/interfaces/test_workflows_composition_roots.py index daa0c49296..dd6daa63ef 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_composition_roots.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_composition_roots.py @@ -1,7 +1,11 @@ -"""Every composition root must bind the adapter, not the raw manager. +"""Every composition root, and every direct Python caller this repo maintains, +must bind the adapter, not the raw manager. Round-1 defect 8: a name-occurrence check passes on an unused import while all four raw-manager assignments stay unchanged. + +The two script callers are inventoried here and driven for real in +`test_direct_caller_bindings.py`. """ import ast @@ -16,6 +20,8 @@ "inference/core/interfaces/http/http_api.py": 2, "inference/core/interfaces/stream/inference_pipeline.py": 1, "inference_cli/lib/workflows/local_image_adapter.py": 1, + "development/stream_interface/benchmark_engine_throughput.py": 1, + "examples/run_perspective_correction.py": 1, } KEY = "workflows_core.model_manager" ADAPTER = "ModelManagerModelsProvider" @@ -82,7 +88,7 @@ def test_every_binding_wraps_the_manager_in_the_adapter() -> None: assert engine_inits == expected_engine_inits, (relative, engine_inits) assert len(values) == engine_inits, (relative, len(values), engine_inits) total += len(values) - assert total == 4, total + assert total == 6, total def test_a_step_constructed_through_the_engine_receives_the_adapter() -> None: From 5f92d7cfddf3483c72e3c456003857bba0d1a228 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:30:34 +0200 Subject: [PATCH 85/86] Remove redundant workflow decontamination tests --- .../interfaces/test_direct_caller_bindings.py | 9 +- .../interfaces/test_image_codec_binding.py | 266 ++++-------- .../test_step_error_handler_selection.py | 34 +- .../test_workflows_composition_roots.py | 146 ++----- .../test_workflows_configuration.py | 252 +----------- ...st_workflows_execution_observer_binding.py | 379 +----------------- .../test_workflows_platform_bindings.py | 169 +------- .../common/test_platform_client_injection.py | 83 +--- .../unit_tests/decontamination_baseline.txt | 4 - .../test_observer_resolution.py | 87 +--- .../prototypes/platform_client_double.py | 8 - .../prototypes/test_platform_client.py | 15 - .../prototypes/test_platform_errors.py | 24 +- .../unit_tests/test_decontamination_lint.py | 113 +----- .../unit_tests/test_no_server_env_imports.py | 5 +- .../test_observer_decontamination.py | 73 +--- 16 files changed, 192 insertions(+), 1475 deletions(-) delete mode 100644 tests/workflows/unit_tests/decontamination_baseline.txt diff --git a/tests/inference/unit_tests/core/interfaces/test_direct_caller_bindings.py b/tests/inference/unit_tests/core/interfaces/test_direct_caller_bindings.py index add61d7603..378f600086 100644 --- a/tests/inference/unit_tests/core/interfaces/test_direct_caller_bindings.py +++ b/tests/inference/unit_tests/core/interfaces/test_direct_caller_bindings.py @@ -1,6 +1,8 @@ """The two direct Python callers must install the server's workflow services. -`test_workflows_composition_roots.py` pins the provider binding structurally. +`test_workflows_composition_roots.py` pins the inventory of engine +construction sites; the four server/CLI roots are driven in +`test_image_codec_binding.py`. This drives the callers' own code - model loading, video decode, the platform fetch and `ExecutionEngine.init` stubbed - captures the arguments that actually reach the engine, and then uses the dictionary the caller built: a real model @@ -10,8 +12,9 @@ Per-service depth belongs to the neighbouring files: usage rows to `test_workflows_observer_row_parity.py`, the URL/local-file denial matrix to -`test_image_codec_local_file_paths.py` and `test_image_codec_binding.py`, the -platform objects to `test_workflows_platform_bindings.py`. Here it is identity +`test_image_codec_local_file_paths.py` and `test_image_codec_binding.py`, +caller overrides of the platform bindings to +`test_workflows_platform_bindings.py`. Here it is identity of what is bound, plus one execution through each path it enables. """ diff --git a/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py b/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py index fb48c789ca..e8bf7b4085 100644 --- a/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py +++ b/tests/inference/unit_tests/core/interfaces/test_image_codec_binding.py @@ -4,19 +4,20 @@ anywhere in a file accepts a call placed AFTER `ExecutionEngine.init`, or inside a nested function that is never reached, and proves nothing about the dictionary that actually reaches the engine; and a root test that replaces the -engine proves nothing about image behaviour. So this file has two halves: - -* STRUCTURE - an AST test that resolves each `ExecutionEngine.init` call to its - innermost enclosing function, scans that function WITHOUT descending into - nested functions, and requires a `bind_image_codec()` call earlier in it - whose argument is the very Name passed as `init_parameters=`. -* EXECUTION - every root runs the REAL `ExecutionEngine.init` (the fixture only - records the `init_parameters` object on its way through), compiles a - model-free workflow, and runs it: the HTTP run route on a base64 input and on - URL inputs that the server's URL policy must accept / refuse, the validate - route through compilation, the pipeline root through the `on_video_frame` - callable it hands to `init_with_custom_logic`, and the CLI root end to end - including a caller override and a conflicting override. +engine proves nothing about image behaviour. So every root here runs the REAL +`ExecutionEngine.init` (the fixture only records the `init_parameters` object +on its way through), compiles a model-free workflow, and runs it: the HTTP run +route on a base64 input and on URL inputs that the server's URL policy must +accept / refuse, the validate route through compilation, the pipeline root +through the `on_video_frame` callable it hands to `init_with_custom_logic`, and +the CLI root end to end including a caller override and a conflicting +override. The AST structural half that used to duplicate this coverage (a +scope-scanning search for a `bind_image_codec(...)` call preceding each +`ExecutionEngine.init`) is redundant with these runtime proofs, which assert +the SAME identity by actually running the root. The same capture also proves +the OTHER services each root binds - configuration, execution observer, +platform client/cache/resolvers, models provider and step error handler - +reach the engine as the objects the server owns. Fixtures reused from the repository: `tests/inference/unit_tests/core/interfaces/http/test_http_api.py` @@ -25,11 +26,9 @@ and the real CLI function `_run_workflow_for_single_image_with_inference`. """ -import ast import base64 from concurrent.futures import ThreadPoolExecutor from datetime import datetime -from pathlib import Path from unittest import mock from unittest.mock import MagicMock @@ -39,10 +38,28 @@ from fastapi.testclient import TestClient from requests_mock import Mocker +from inference.core.cache import cache as server_cache +from inference.core.interfaces.roboflow_platform_client import ( + SERVER_PLATFORM_CLIENT, + SERVER_WORKSPACE_RESOLVER, + default_inner_workflow_spec_resolver, +) +from inference.core.interfaces.workflows_configuration import ( + server_workflows_configuration, +) +from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, +) from inference.core.interfaces.workflows_image_codec import ( GUARDED_IMAGE_CODEC, install_guarded_image_codec, ) +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) +from inference.core.interfaces.workflows_step_error_handlers import ( + resolve_step_error_handler, +) from inference.core.utils import image_utils from inference.core.workflows.errors import WorkflowEnvironmentConfigurationError from inference.core.workflows.execution_engine.core import ExecutionEngine @@ -56,13 +73,6 @@ _build_plain_interface, ) -# tests/inference/unit_tests/core/interfaces/ -> five levels up is the repo root -REPO_ROOT = Path(__file__).resolve().parents[5] -COMPOSITION_ROOTS = [ - "inference/core/interfaces/http/http_api.py", - "inference/core/interfaces/stream/inference_pipeline.py", - "inference_cli/lib/workflows/local_image_adapter.py", -] CODEC_INIT_PARAMETER = "workflows_core.image_codec" ALLOWED_HOST = "cdn.allowed.example.com" DENIED_HOST = "metadata.internal.example.com" @@ -104,10 +114,11 @@ def forwarded_engine_init(monkeypatch) -> dict: all resolve. """ real_init = ExecutionEngine.init # bound classmethod, captured before patching - captured = {"init_parameters": [], "engines": []} + captured = {"init_parameters": [], "engines": [], "step_error_handlers": []} def _forwarding_init(**kwargs): captured["init_parameters"].append(kwargs["init_parameters"]) + captured["step_error_handlers"].append(kwargs.get("step_error_handler")) engine = real_init(**kwargs) captured["engines"].append(engine) return engine @@ -139,154 +150,42 @@ def _fetches_of(requests_mock: Mocker, url: str) -> list: return [r.url for r in requests_mock.request_history if r.url == url] -def _assert_one_object_on_both_paths(captured: dict, expected) -> None: +def _assert_bound_services(captured: dict, expected_codec, model_manager) -> None: assert len(captured["init_parameters"]) == 1, "expected exactly one engine init" + init_parameters = captured["init_parameters"][0] # Path A: the SAME dict object the root handed to the engine carries the codec... - assert captured["init_parameters"][0][CODEC_INIT_PARAMETER] is expected + assert init_parameters[CODEC_INIT_PARAMETER] is expected_codec # ...and the engine really rebound its image deserializer to that object... engine = captured["engines"][0] bound = engine._engine._compiled_workflow.kinds_deserializers[IMAGE_KIND.name] - assert bound.keywords == {"image_codec": expected} + assert bound.keywords == {"image_codec": expected_codec} # ...while Path B (the process registry) holds the identical object. - assert get_image_codec() is expected - - -# -------------------------------------------------------------------------- -# Structure: the binding must precede the engine and target the SAME dict -# -------------------------------------------------------------------------- - - -def _parent_map(tree: ast.AST) -> dict: - parents = {} - for node in ast.walk(tree): - for child in ast.iter_child_nodes(node): - parents[child] = node - return parents - - -def _enclosing_function(node, parents): - current = parents.get(node) - while current is not None: - if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef)): - return current - current = parents.get(current) - return None - - -def _nodes_in_scope(scope): - """Nodes belonging to `scope`, NOT descending into nested functions. - - `ast.walk` would happily accept a binding buried in an uncalled inner - helper; this generator stops at every function/lambda boundary. - """ - stack = list(ast.iter_child_nodes(scope)) - while stack: - node = stack.pop() - yield node - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): - continue - stack.extend(ast.iter_child_nodes(node)) - - -def _engine_init_calls(tree: ast.AST) -> list: - return [ - node - for node in ast.walk(tree) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "init" - and getattr(node.func.value, "id", None) == "ExecutionEngine" - ] - - -def _bind_calls_in_scope(scope) -> list: - return [ - node - for node in _nodes_in_scope(scope) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == "bind_image_codec" - ] - - -@pytest.mark.parametrize("relative", COMPOSITION_ROOTS) -def test_each_engine_init_is_preceded_by_a_binding_of_its_own_parameters( - relative: str, -) -> None: - tree = ast.parse((REPO_ROOT / relative).read_text(encoding="utf-8")) - parents = _parent_map(tree) - engine_inits = _engine_init_calls(tree) - assert engine_inits, relative - - for call in engine_inits: - scope = _enclosing_function(call, parents) - assert scope is not None, (relative, call.lineno) - - init_parameters_kwarg = next( - (k for k in call.keywords if k.arg == "init_parameters"), None - ) - assert init_parameters_kwarg is not None, (relative, call.lineno) - assert isinstance(init_parameters_kwarg.value, ast.Name), ( - f"{relative}:{call.lineno} - init_parameters must be a named dict so " - f"the binding can be matched against it" - ) - parameters_name = init_parameters_kwarg.value.id - - matching = [ - bind - for bind in _bind_calls_in_scope(scope) - if bind.lineno < call.lineno - and bind.args - and isinstance(bind.args[0], ast.Name) - and bind.args[0].id == parameters_name - ] - assert matching, ( - f"{relative}:{call.lineno} - no bind_image_codec({parameters_name}) " - f"before ExecutionEngine.init inside {scope.name}" - ) - - -@pytest.mark.parametrize("relative", COMPOSITION_ROOTS) -def test_every_composition_root_imports_the_binder(relative: str) -> None: - tree = ast.parse((REPO_ROOT / relative).read_text(encoding="utf-8")) - imported = { - alias.name - for node in ast.walk(tree) - if isinstance(node, ast.ImportFrom) - and node.module == "inference.core.interfaces.workflows_image_codec" - for alias in node.names - } - assert "bind_image_codec" in imported, (relative, sorted(imported)) - - -def test_the_scope_scan_rejects_a_binding_hidden_in_a_nested_function() -> None: - # Guards the guard (round-2 Defect 2): `ast.walk` accepted this shape. - source = ( - "def root():\n" - " def never_called():\n" - " bind_image_codec(params)\n" - " params = {}\n" - " ExecutionEngine.init(init_parameters=params)\n" + assert get_image_codec() is expected_codec + + # The other services this root owns, in the dictionary that reached the + # engine: the provider must wrap the manager the root was called with, and + # every platform object must be the server's singleton, not a copy. + provider = init_parameters["workflows_core.model_manager"] + assert isinstance(provider, ModelManagerModelsProvider) + assert provider._model_manager is model_manager + assert ( + init_parameters["workflows_core.configuration"] + is server_workflows_configuration() ) - tree = ast.parse(source) - scope = tree.body[0] - assert _bind_calls_in_scope(scope) == [] - assert [ - n - for n in ast.walk(scope) - if isinstance(n, ast.Call) - and isinstance(n.func, ast.Name) - and n.func.id == "bind_image_codec" - ] - - -def test_http_api_binds_at_both_engine_entry_points() -> None: - tree = ast.parse( - (REPO_ROOT / "inference/core/interfaces/http/http_api.py").read_text( - encoding="utf-8" - ) + assert isinstance( + init_parameters["workflows_core.execution_observer"], + UsageTrackingExecutionObserver, ) - assert len(_engine_init_calls(tree)) == 2 + # Literal, not `workflows_platform_bindings()`: deriving the expectation + # from production makes a dropped key disappear from both sides. + for key, value in { + "workflows_core.cache": server_cache, + "workflows_core.platform_client": SERVER_PLATFORM_CLIENT, + "workflows_core.workspace_resolver": SERVER_WORKSPACE_RESOLVER, + "workflows_core.inner_workflow_spec_resolver": default_inner_workflow_spec_resolver, + }.items(): + assert init_parameters[key] is value, key + assert captured["step_error_handlers"][0] == resolve_step_error_handler() # -------------------------------------------------------------------------- @@ -297,7 +196,7 @@ def test_http_api_binds_at_both_engine_entry_points() -> None: def test_http_run_route_runs_a_real_engine_and_loads_the_input_through_the_bound_codec( monkeypatch, forwarded_engine_init ) -> None: - interface, _ = _build_plain_interface(monkeypatch) + interface, manager = _build_plain_interface(monkeypatch) payload = _png_base64() # `ServerImageCodec.decode_string` calls the MODULE attribute, so a spy @@ -315,7 +214,7 @@ def test_http_run_route_runs_a_real_engine_and_loads_the_input_through_the_bound ) assert response.status_code == 200, response.text - _assert_one_object_on_both_paths(forwarded_engine_init, GUARDED_IMAGE_CODEC) + _assert_bound_services(forwarded_engine_init, GUARDED_IMAGE_CODEC, manager) server_decoder.assert_called_once() assert server_decoder.call_args.kwargs["value"] == payload blurred = _decode_serialised_image(response.json()["outputs"][0]["blurred"]) @@ -333,7 +232,7 @@ def test_http_run_route_refuses_a_deny_listed_url_input_through_the_bound_codec( # The SSRF deny-list is the server's; Path A must reach it for a URL that # arrives as a workflow input. The engine is real, the request is refused # before any transport, and the refusal surfaces as a client error. - interface, _ = _build_plain_interface(monkeypatch) + interface, manager = _build_plain_interface(monkeypatch) with TestClient(interface.app) as client: response = client.post( @@ -347,7 +246,7 @@ def test_http_run_route_refuses_a_deny_listed_url_input_through_the_bound_codec( assert response.status_code == 400, response.text assert "blacklisted" in response.text - _assert_one_object_on_both_paths(forwarded_engine_init, GUARDED_IMAGE_CODEC) + _assert_bound_services(forwarded_engine_init, GUARDED_IMAGE_CODEC, manager) @mock.patch.object(image_utils, "VALIDATE_IMAGE_URL_REDIRECTS", False) @@ -368,7 +267,7 @@ def test_http_run_route_fetches_an_allow_listed_url_input_through_the_bound_code # `TestClient` speaks httpx, so `requests_mock` does not intercept it. url = f"https://{ALLOWED_HOST}/image.png" requests_mock.get(url, content=_png_bytes()) - interface, _ = _build_plain_interface(monkeypatch) + interface, manager = _build_plain_interface(monkeypatch) with TestClient(interface.app) as client: response = client.post( @@ -379,7 +278,7 @@ def test_http_run_route_fetches_an_allow_listed_url_input_through_the_bound_code assert response.status_code == 200, response.text assert _fetches_of(requests_mock, url) == [url] - _assert_one_object_on_both_paths(forwarded_engine_init, GUARDED_IMAGE_CODEC) + _assert_bound_services(forwarded_engine_init, GUARDED_IMAGE_CODEC, manager) blurred = _decode_serialised_image(response.json()["outputs"][0]["blurred"]) assert blurred.shape == (16, 24, 3) @@ -387,7 +286,7 @@ def test_http_run_route_fetches_an_allow_listed_url_input_through_the_bound_code def test_http_validate_route_compiles_a_real_engine_with_the_guarded_codec( monkeypatch, forwarded_engine_init ) -> None: - interface, _ = _build_plain_interface(monkeypatch) + interface, manager = _build_plain_interface(monkeypatch) with TestClient(interface.app) as client: response = client.post( @@ -396,7 +295,7 @@ def test_http_validate_route_compiles_a_real_engine_with_the_guarded_codec( assert response.status_code == 200, response.text assert response.json() == {"status": "ok"} - _assert_one_object_on_both_paths(forwarded_engine_init, GUARDED_IMAGE_CODEC) + _assert_bound_services(forwarded_engine_init, GUARDED_IMAGE_CODEC, manager) # -------------------------------------------------------------------------- @@ -417,14 +316,15 @@ def test_pipeline_root_runs_a_real_engine_with_the_guarded_codec( InferencePipeline, "init_with_custom_logic", init_with_custom_logic ) + manager = MagicMock() InferencePipeline.init_with_workflow( video_reference="video.mp4", workflow_specification=BLUR_WORKFLOW, - model_manager=MagicMock(), + model_manager=manager, image_input_name="image", ) - _assert_one_object_on_both_paths(forwarded_engine_init, GUARDED_IMAGE_CODEC) + _assert_bound_services(forwarded_engine_init, GUARDED_IMAGE_CODEC, manager) on_video_frame = init_with_custom_logic.call_args.kwargs["on_video_frame"] frame = VideoFrame( image=np.zeros((16, 24, 3), dtype=np.uint8), @@ -440,7 +340,7 @@ def test_pipeline_root_runs_a_real_engine_with_the_guarded_codec( # -------------------------------------------------------------------------- -def _run_cli_root(tmp_path, init_params=None) -> dict: +def _run_cli_root(tmp_path, model_manager, init_params=None) -> dict: from inference_cli.lib.workflows import local_image_adapter image_path = str(tmp_path / "frame.png") @@ -448,7 +348,7 @@ def _run_cli_root(tmp_path, init_params=None) -> dict: # A real executor: the engine runs its steps on the executor it is given. with ThreadPoolExecutor(max_workers=1) as executor: return local_image_adapter._run_workflow_for_single_image_with_inference( - model_manager=MagicMock(), + model_manager=model_manager, image_path=image_path, workflow_specification=BLUR_WORKFLOW, workflow_id=None, @@ -464,8 +364,9 @@ def _run_cli_root(tmp_path, init_params=None) -> dict: def test_cli_root_runs_a_real_engine_with_the_guarded_codec( tmp_path, forwarded_engine_init ) -> None: - result = _run_cli_root(tmp_path) - _assert_one_object_on_both_paths(forwarded_engine_init, GUARDED_IMAGE_CODEC) + manager = MagicMock() + result = _run_cli_root(tmp_path, manager) + _assert_bound_services(forwarded_engine_init, GUARDED_IMAGE_CODEC, manager) assert _decode_serialised_image(result["blurred"]).shape == (16, 24, 3) @@ -476,9 +377,12 @@ def test_cli_override_moves_both_paths_together( # the dict is built, so a codec written before that point would change # Path A only. Binding after the merge keeps the two paths identical, and # the real engine still compiles and runs with the override. + manager = MagicMock() override = WorkflowsLocalImageCodec() - result = _run_cli_root(tmp_path, init_params={CODEC_INIT_PARAMETER: override}) - _assert_one_object_on_both_paths(forwarded_engine_init, override) + result = _run_cli_root( + tmp_path, manager, init_params={CODEC_INIT_PARAMETER: override} + ) + _assert_bound_services(forwarded_engine_init, override, manager) assert _decode_serialised_image(result["blurred"]).shape == (16, 24, 3) @@ -488,7 +392,9 @@ def test_cli_override_conflicting_with_an_install_is_refused_before_the_engine_s install_guarded_image_codec() with pytest.raises(WorkflowEnvironmentConfigurationError): _run_cli_root( - tmp_path, init_params={CODEC_INIT_PARAMETER: WorkflowsLocalImageCodec()} + tmp_path, + MagicMock(), + init_params={CODEC_INIT_PARAMETER: WorkflowsLocalImageCodec()}, ) assert ( forwarded_engine_init["init_parameters"] == [] diff --git a/tests/inference/unit_tests/core/interfaces/test_step_error_handler_selection.py b/tests/inference/unit_tests/core/interfaces/test_step_error_handler_selection.py index ce2ee99ea1..84da563d30 100644 --- a/tests/inference/unit_tests/core/interfaces/test_step_error_handler_selection.py +++ b/tests/inference/unit_tests/core/interfaces/test_step_error_handler_selection.py @@ -1,6 +1,3 @@ -import ast -from pathlib import Path - import pytest from inference.core.exceptions import FeatureDeprecatedError @@ -16,13 +13,9 @@ from inference.core.workflows.execution_engine.core import ExecutionEngine from inference.core.workflows.execution_engine.v1 import core as ee_core -# tests/inference/unit_tests/core/interfaces/ -> five levels up is the repo root -REPO_ROOT = Path(__file__).resolve().parents[5] -COMPOSITION_ROOTS = [ - "inference/core/interfaces/http/http_api.py", - "inference/core/interfaces/stream/inference_pipeline.py", - "inference_cli/lib/workflows/local_image_adapter.py", -] +# The handler each composition root passes is asserted at runtime in +# `test_image_codec_binding.py` (server/CLI roots) and +# `test_direct_caller_bindings.py` (the two scripts). def test_server_default_is_the_extended_handler(monkeypatch) -> None: @@ -66,24 +59,3 @@ def test_direct_engine_default_maps_nothing_while_server_handler_maps_deprecatio resolve_step_error_handler()("step-id", error) assert captured.value.status_code == 410 assert captured.value.inner_error is error - - -def test_every_server_composition_root_passes_a_handler() -> None: - # The default argument is bound at definition time inside workflows, so - # the server must pass its choice explicitly at every ExecutionEngine.init. - for relative in COMPOSITION_ROOTS: - tree = ast.parse((REPO_ROOT / relative).read_text(encoding="utf-8")) - calls = [ - node - for node in ast.walk(tree) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "init" - and getattr(node.func.value, "id", None) == "ExecutionEngine" - ] - assert calls, relative - for call in calls: - assert any(k.arg == "step_error_handler" for k in call.keywords), ( - relative, - call.lineno, - ) diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_composition_roots.py b/tests/inference/unit_tests/core/interfaces/test_workflows_composition_roots.py index dd6daa63ef..051031d280 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_composition_roots.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_composition_roots.py @@ -1,11 +1,14 @@ -"""Every composition root, and every direct Python caller this repo maintains, -must bind the adapter, not the raw manager. +"""The inventory of engine construction sites outside workflows. -Round-1 defect 8: a name-occurrence check passes on an unused import while all -four raw-manager assignments stay unchanged. +This is the ONE inventory guard: it *discovers* `ExecutionEngine.init` sites +across the production tree (server, CLI, and the two maintained direct-caller +scripts) rather than trusting a hard-coded list, so a new root in a new file +cannot go unnoticed - it fails the count below. -The two script callers are inventoried here and driven for real in -`test_direct_caller_bindings.py`. +What each site binds is proven at runtime, not from source text: the four +server/CLI roots in `test_image_codec_binding.py` (codec, configuration, +observer, platform objects, models provider, step error handler), the two +scripts in `test_direct_caller_bindings.py`. """ import ast @@ -13,125 +16,46 @@ # tests/inference/unit_tests/core/interfaces/ -> parents[5] is the repo root REPO_ROOT = Path(__file__).resolve().parents[5] +SEARCH_ROOTS = ("inference", "inference_cli", "development", "examples") +# The engine itself constructs engines (nested workflows, tests of the engine); +# only *server* construction sites are composition roots. +EXCLUDED_SUBTREE = REPO_ROOT / "inference" / "core" / "workflows" -from inference.core.workflows.core_steps.common.entities import StepExecutionMode - -ROOTS = { +EXPECTED_ROOTS = { "inference/core/interfaces/http/http_api.py": 2, "inference/core/interfaces/stream/inference_pipeline.py": 1, "inference_cli/lib/workflows/local_image_adapter.py": 1, "development/stream_interface/benchmark_engine_throughput.py": 1, "examples/run_perspective_correction.py": 1, } -KEY = "workflows_core.model_manager" -ADAPTER = "ModelManagerModelsProvider" - -def _bound_values(tree: ast.AST) -> list: - """Every expression bound to the `workflows_core.model_manager` key. - Covers both shapes in the tree: a dict literal entry (bare or wrapped in - `install_workflows_platform_bindings({...})`), and - `params["workflows_core.model_manager"] = `. - """ - values = [] - for node in ast.walk(tree): - if isinstance(node, ast.Dict): - for key, value in zip(node.keys, node.values): - if isinstance(key, ast.Constant) and key.value == KEY: - values.append(value) - elif isinstance(node, ast.Assign): - for target in node.targets: - if ( - isinstance(target, ast.Subscript) - and isinstance(target.slice, ast.Constant) - and target.slice.value == KEY - ): - values.append(node.value) - return values +def _python_files(): + for root in SEARCH_ROOTS: + for path in sorted((REPO_ROOT / root).rglob("*.py")): + if EXCLUDED_SUBTREE in path.parents: + continue + yield path -def _is_adapter_call(value: ast.AST) -> bool: +def _is_engine_init(node: ast.AST) -> bool: return ( - isinstance(value, ast.Call) - and isinstance(value.func, ast.Name) - and value.func.id == ADAPTER - and len(value.args) == 1 - and isinstance(value.args[0], ast.Name) - and value.args[0].id == "model_manager" + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "init" + and getattr(node.func.value, "id", None) == "ExecutionEngine" ) -def test_every_binding_wraps_the_manager_in_the_adapter() -> None: - total = 0 - for relative, expected_engine_inits in ROOTS.items(): - path = REPO_ROOT / relative - assert path.is_file(), path - source = path.read_text(encoding="utf-8") - tree = ast.parse(source) - values = _bound_values(tree) - assert values, f"{relative}: no `{KEY}` binding found" - for value in values: - assert _is_adapter_call(value), ( - f"{relative}: `{KEY}` is bound to " - f"{ast.dump(value)[:120]}, not {ADAPTER}(model_manager)" - ) - engine_inits = sum( - 1 - for node in ast.walk(tree) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "init" - and isinstance(node.func.value, ast.Name) - and node.func.value.id == "ExecutionEngine" - ) - assert engine_inits == expected_engine_inits, (relative, engine_inits) - assert len(values) == engine_inits, (relative, len(values), engine_inits) - total += len(values) - assert total == 6, total - +def _discovered_engine_init_counts() -> dict: + counts = {} + for path in _python_files(): + tree = ast.parse(path.read_text(encoding="utf-8")) + n = sum(1 for node in ast.walk(tree) if _is_engine_init(node)) + if n: + counts[str(path.relative_to(REPO_ROOT))] = n + return counts -def test_a_step_constructed_through_the_engine_receives_the_adapter() -> None: - """Round-1 defect 8 / round-2: stopping at `retrieve_init_parameters_values` - proves the lookup, not the construction. This goes through - `initialise_step` (`steps_initialiser.py:53-86`), which is what the compiler - calls, and asserts the *constructed block instance* holds the adapter.""" - from unittest.mock import MagicMock - from inference.core.interfaces.workflows_models_provider import ( - ModelManagerModelsProvider, - ) - from inference.core.workflows.core_steps.models.roboflow.object_detection.v1 import ( - BlockManifest, - RoboflowObjectDetectionModelBlockV1, - ) - from inference.core.workflows.execution_engine.v1.compiler.entities import ( - BlockSpecification, - ) - from inference.core.workflows.execution_engine.v1.compiler.steps_initialiser import ( - initialise_step, - ) - - manager = MagicMock() - provider = ModelManagerModelsProvider(manager) - manifest = BlockManifest.model_construct( - name="detection", type="roboflow_core/roboflow_object_detection_model@v1" - ) - specification = BlockSpecification( - block_source="workflows_core", - identifier="roboflow_core/roboflow_object_detection_model@v1", - block_class=RoboflowObjectDetectionModelBlockV1, - manifest_class=BlockManifest, - ) - initialised = initialise_step( - step_manifest=manifest, - block_specification=specification, - explicit_init_parameters={ - "workflows_core.model_manager": provider, - "workflows_core.api_key": "key", - "workflows_core.step_execution_mode": StepExecutionMode.LOCAL, - }, - initializers={}, - ) - assert initialised.step._model_manager is provider - assert initialised.step._model_manager._model_manager is manager +def test_the_set_of_composition_roots_is_exactly_the_expected_one() -> None: + assert _discovered_engine_init_counts() == EXPECTED_ROOTS diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py b/tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py index 76785d1470..962596786e 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_configuration.py @@ -11,7 +11,6 @@ import cv2 import numpy as np import pytest -from fastapi.testclient import TestClient # Module level on purpose (round-5 defect 1, D5 rule): the facade binds its # constants at its FIRST import from whatever the registry holds at that @@ -26,9 +25,6 @@ server_workflows_configuration, ) from inference.core.workflows.execution_engine.core import ExecutionEngine -from tests.inference.unit_tests.core.interfaces.http.test_http_api import ( - _build_plain_interface, -) REPO_ROOT = Path(__file__).resolve().parents[5] WORKFLOW_SPECIFICATION = {"version": "1.0", "inputs": [], "steps": [], "outputs": []} @@ -170,11 +166,6 @@ ("INFERENCE_DEBUG_OUTPUT_DIR", lambda c: c.debug.output_dir), ] -ROOTS = { - "inference/core/interfaces/http/http_api.py": 2, - "inference/core/interfaces/stream/inference_pipeline.py": 1, - "inference_cli/lib/workflows/local_image_adapter.py": 1, -} CONFIGURATION_KEY = "workflows_core.configuration" @@ -220,26 +211,6 @@ def test_every_name_workflows_imports_from_the_facade_is_exported() -> None: assert requested <= exported, sorted(requested - exported) -def test_every_symbol_still_imported_from_env_is_in_the_field_table() -> None: - # Vacuous once Task 5.7 lands; until then it proves nothing is left behind. - workflows_root = REPO_ROOT / "inference" / "core" / "workflows" - deferred = ( - workflows_root / "core_steps" / "sinks" / "roboflow", - workflows_root / "core_steps" / "integrations" / "roboflow", - ) - remaining = set() - for path in sorted(workflows_root.rglob("*.py")): - if "__pycache__" in str(path) or any(d in path.parents for d in deferred): - continue - tree = ast.parse(path.read_bytes().decode("utf-8"), filename=str(path)) - for node in ast.walk(tree): - if isinstance(node, ast.ImportFrom) and node.module == "inference.core.env": - remaining.update(alias.name for alias in node.names) - assert remaining <= {name for name, _ in FIELDS}, sorted( - remaining - {name for name, _ in FIELDS} - ) - - # -------------------------------------------------------------------------- # Field-by-field parity with inference.core.env # -------------------------------------------------------------------------- @@ -334,219 +305,14 @@ def test_importing_inference_core_installs_before_any_workflows_module_loads() - # -------------------------------------------------------------------------- -# Composition-root wiring - AST-precise, not "the string appears somewhere" +# Composition-root wiring - caller overrides +# +# The default binding at each root is asserted against a REAL engine in +# `test_image_codec_binding.py`; what is left here is the `setdefault` +# contract, with the engine mocked. # -------------------------------------------------------------------------- -def _init_parameter_dicts_reaching_engine_init(source: str): - """For every `ExecutionEngine.init(...)` call, return the set of literal - init-parameter keys its `init_parameters=` argument carries. - - Resolves the argument to a Name, then collects every `{...}` literal - assigned to that name - DIRECTLY, or as the first argument of a helper - call such as Phase 9's `install_workflows_platform_bindings({...})` - (round-3 defect 1: under R-U both HTTP literals are already wrapped) - - and every `name["key"] = ...` / `name.setdefault("key", ...)` in the same - enclosing function. Round-1 defect 7: a substring search anywhere in the - file passes even when one of the two HTTP roots is missing the key, or - when the key only appears in a comment. - """ - tree = ast.parse(source) - parents = {} - for node in ast.walk(tree): - for child in ast.iter_child_nodes(node): - parents[child] = node - - def enclosing_function(node): - while node in parents: - node = parents[node] - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Module)): - return node - return None - - def literal_of(value): - if isinstance(value, ast.Dict): - return value - if ( - isinstance(value, ast.Call) - and value.args - and isinstance(value.args[0], ast.Dict) - ): - return value.args[0] - return None - - results = [] - for node in ast.walk(tree): - if not ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "init" - and isinstance(node.func.value, ast.Name) - and node.func.value.id == "ExecutionEngine" - ): - continue - argument = next( - (kw.value for kw in node.keywords if kw.arg == "init_parameters"), None - ) - assert isinstance(argument, ast.Name), ast.dump(node) - scope = enclosing_function(node) - keys = set() - for inner in ast.walk(scope): - if isinstance(inner, ast.Assign): - literal = literal_of(inner.value) - for target in inner.targets: - if ( - isinstance(target, ast.Name) - and target.id == argument.id - and literal is not None - ): - keys.update( - k.value for k in literal.keys if isinstance(k, ast.Constant) - ) - if ( - isinstance(target, ast.Subscript) - and isinstance(target.value, ast.Name) - and target.value.id == argument.id - and isinstance(target.slice, ast.Constant) - ): - keys.add(target.slice.value) - if ( - isinstance(inner, ast.Call) - and isinstance(inner.func, ast.Attribute) - and inner.func.attr == "setdefault" - and isinstance(inner.func.value, ast.Name) - and inner.func.value.id == argument.id - and inner.args - and isinstance(inner.args[0], ast.Constant) - ): - keys.add(inner.args[0].value) - results.append(keys) - return results - - -def test_the_root_scanner_reads_a_wrapped_and_a_plain_literal_alike() -> None: - """Round-3 defect 1: under R-U Phase 9 has already wrapped both HTTP - literals in `install_workflows_platform_bindings({...})` - (DECONTAMINATION.PLAN.PHASE-9.MD Task 9.3 Step 10), and the round-2 scanner - returned `[set(), set()]` for that shape. Five synthetic roots: plain - literal, wrapped literal, subscript assignment, `setdefault`, and a wrapped - literal WITHOUT the key - which must still be reported missing.""" - source = ( - "def plain():\n" - " params = {'workflows_core.configuration': 1}\n" - " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" - "def wrapped():\n" - " params = install_workflows_platform_bindings({\n" - " 'workflows_core.api_key': 1,\n" - " 'workflows_core.configuration': 1,\n" - " })\n" - " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" - "def assigned(params):\n" - " params['workflows_core.configuration'] = 1\n" - " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" - "def defaulted(params):\n" - " params.setdefault('workflows_core.configuration', 1)\n" - " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" - "def missing():\n" - " params = install_workflows_platform_bindings({'workflows_core.api_key': 1})\n" - " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" - ) - per_call = _init_parameter_dicts_reaching_engine_init(source) - assert [CONFIGURATION_KEY in keys for keys in per_call] == [ - True, - True, - True, - True, - False, - ] - - -@pytest.mark.parametrize("relative, expected_calls", sorted(ROOTS.items())) -def test_every_engine_call_at_every_root_carries_the_configuration( - relative, expected_calls -) -> None: - source = (REPO_ROOT / relative).read_text(encoding="utf-8") - per_call_keys = _init_parameter_dicts_reaching_engine_init(source) - assert len(per_call_keys) == expected_calls, relative - for keys in per_call_keys: - assert CONFIGURATION_KEY in keys, (relative, sorted(keys)) - - -def test_http_run_route_binds_the_server_configuration(monkeypatch) -> None: - """Execution proof for HTTP root #1 (`http_api.py:1597`). - - Round-2 defect 2: the AST test alone accepts - `"workflows_core.configuration": None`. This drives the real route with - `_build_plain_interface` + `TestClient` - the harness - `tests/inference/unit_tests/core/interfaces/http/test_http_api.py:1766` - already provides - and asserts the captured object's IDENTITY. - """ - import inference.core.interfaces.http.http_api as http_api - - interface, _ = _build_plain_interface(monkeypatch) - engine = MagicMock() - engine.run.return_value = [] - execution_engine_mock = MagicMock() - execution_engine_mock.init.return_value = engine - monkeypatch.setattr(http_api, "ExecutionEngine", execution_engine_mock) - - with TestClient(interface.app) as client: - response = client.post( - "/workflows/run", - headers={"Authorization": "Bearer header-key"}, - json={"specification": WORKFLOW_SPECIFICATION, "inputs": {}}, - ) - - assert response.status_code == 200, response.text - init_parameters = execution_engine_mock.init.call_args.kwargs["init_parameters"] - assert init_parameters[CONFIGURATION_KEY] is server_workflows_configuration() - - -def test_http_validate_route_binds_the_server_configuration(monkeypatch) -> None: - """Execution proof for HTTP root #2 (`http_api.py:2546`).""" - import inference.core.interfaces.http.http_api as http_api - - interface, _ = _build_plain_interface(monkeypatch) - execution_engine_mock = MagicMock() - monkeypatch.setattr(http_api, "ExecutionEngine", execution_engine_mock) - - with TestClient(interface.app) as client: - response = client.post( - "/workflows/validate?api_key=some-key", json=WORKFLOW_SPECIFICATION - ) - - assert response.status_code == 200, response.text - init_parameters = execution_engine_mock.init.call_args.kwargs["init_parameters"] - assert init_parameters[CONFIGURATION_KEY] is server_workflows_configuration() - - -def test_the_pipeline_binds_the_server_configuration(monkeypatch) -> None: - """Execution proof for the pipeline root (`inference_pipeline.py:751`). - - Round-2 defect 2: the round-1 test patched `inference_pipeline.ExecutionEngine`, - which does not exist - the name is imported INSIDE `init_with_workflow` - (`inference_pipeline.py:704`), so the test died with `AttributeError` - before reaching its assertion. Patching `init` on the DEFINING class works - regardless of where the name is imported. - """ - from inference.core.interfaces.stream.inference_pipeline import InferencePipeline - - execution_engine_init = MagicMock(return_value=MagicMock()) - monkeypatch.setattr(ExecutionEngine, "init", execution_engine_init) - monkeypatch.setattr( - InferencePipeline, "init_with_custom_logic", MagicMock(return_value=MagicMock()) - ) - - InferencePipeline.init_with_workflow( - video_reference="video.mp4", - workflow_specification={"version": "1.0"}, - model_manager=MagicMock(), - ) - - init_parameters = execution_engine_init.call_args.kwargs["init_parameters"] - assert init_parameters[CONFIGURATION_KEY] is server_workflows_configuration() - - def test_the_pipeline_preserves_a_caller_supplied_configuration(monkeypatch) -> None: """`setdefault`, not assignment: a caller-supplied object must reach the engine so the conflict check - not this file - decides whether it is @@ -602,14 +368,6 @@ def _capturing_init(**kwargs): return captured -def test_the_cli_root_binds_the_server_configuration(tmp_path, monkeypatch) -> None: - captured = _run_cli_root(tmp_path, monkeypatch) - assert ( - captured["init_parameters"][CONFIGURATION_KEY] - is server_workflows_configuration() - ) - - def test_the_cli_root_preserves_a_caller_supplied_configuration( tmp_path, monkeypatch ) -> None: diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer_binding.py b/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer_binding.py index 6ea5af3277..afeca2145e 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer_binding.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_execution_observer_binding.py @@ -1,264 +1,23 @@ -"""Every server composition root must bind the execution observer. +"""The registered observer default, and the protocol the server's observer meets. The engine's default is the workflows-local `NullExecutionObserver`, which records nothing - so a root that forgets the binding keeps working, keeps its -tests green, and silently stops billing. - -Two independent guards. The structural one *discovers* engine construction -sites across the production tree rather than trusting a hard-coded list, and -replays each call's own `init_parameters` bindings, so neither a new root in a -new file, nor a stray matching string in an unused dictionary, nor a -dictionary that was overwritten before the call can fool it. The runtime ones -drive each of the four roots and assert the observer that actually reaches -`ExecutionEngine.init`. +tests green, and silently stops billing. What each root actually binds is +asserted against a real engine in `test_image_codec_binding.py` (the four +server/CLI roots) and `test_direct_caller_bindings.py` (the two maintained +scripts); the inventory of roots lives in +`test_workflows_composition_roots.py`. """ -import ast -from pathlib import Path -from unittest.mock import MagicMock - -import pytest - from inference.core.interfaces.workflows_execution_observer import ( UsageTrackingExecutionObserver, ) from inference.core.workflows.core_steps.loader import REGISTERED_INITIALIZERS -from inference.core.workflows.execution_engine.core import ExecutionEngine from inference.core.workflows.prototypes.observer import ( ExecutionObserver, NullExecutionObserver, ) -# tests/inference/unit_tests/core/interfaces/ -> five levels up is the repo root -REPO_ROOT = Path(__file__).resolve().parents[5] -SEARCH_ROOTS = ("inference", "inference_cli") -# The engine itself constructs engines (nested workflows, tests of the engine); -# only *server* construction sites are composition roots. -EXCLUDED_SUBTREE = REPO_ROOT / "inference" / "core" / "workflows" -BINDING_KEY = "workflows_core.execution_observer" - -# Every production site that builds an execution engine, and how many times. -# A new entry here without a binding fails; a new site not listed here fails. -EXPECTED_ROOTS = { - "inference/core/interfaces/http/http_api.py": 2, - "inference/core/interfaces/stream/inference_pipeline.py": 1, - "inference_cli/lib/workflows/local_image_adapter.py": 1, -} - - -def _python_files(): - for root in SEARCH_ROOTS: - for path in sorted((REPO_ROOT / root).rglob("*.py")): - if EXCLUDED_SUBTREE in path.parents: - continue - yield path - - -def _is_engine_init(node: ast.AST) -> bool: - return ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "init" - and getattr(node.func.value, "id", None) == "ExecutionEngine" - ) - - -def _parents(tree: ast.AST) -> dict: - parents = {} - for node in ast.walk(tree): - for child in ast.iter_child_nodes(node): - parents[child] = node - return parents - - -def _enclosing_scope(node: ast.AST, parents: dict) -> ast.AST: - current = parents.get(node) - while current is not None and not isinstance( - current, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Module) - ): - current = parents.get(current) - return current - - -def _dict_literal_keys(node: ast.Dict) -> set: - return { - key.value - for key in node.keys - if isinstance(key, ast.Constant) and isinstance(key.value, str) - } - - -def _literal_dict_in(value: ast.AST): - """The dict literal a binding assignment builds, if it builds one. - - Either the literal itself, or a literal handed to a wrapper call as its - first positional argument - the shape Phase 9 gives the HTTP roots, - `install_workflows_platform_bindings({...})`. Anything else (a name, a - call without a literal, a comprehension) is opaque: the helper then knows - nothing about the keys, and says so by starting from an empty set. - """ - if isinstance(value, ast.Dict): - return value - if ( - isinstance(value, ast.Call) - and value.args - and isinstance(value.args[0], ast.Dict) - ): - return value.args[0] - return None - - -def _keys_bound_to_name( - scope: ast.AST, parents: dict, name: str, before_lineno: int -) -> set: - """String keys `name` holds when line `before_lineno` runs. - - Replays the assignments to `name` in this scope, in line order, up to the - engine construction: a rebinding (`name = {...}`, `name = wrapper({...})`, - `name = anything_else`) replaces whatever the name held, and a subscript - assignment (`name["k"] = ...`) adds a key. Three restrictions matter. - Without the scope check, an unrelated nested function that builds its own - dictionary under the same variable name satisfies the assertion for an - outer call that binds nothing. Without the line check, an assignment made - after the engine was constructed counts. Without the replay order, a - dictionary that was overwritten before the call (`params = {...binding...}; - params = {}`) still reports the binding it lost. - """ - assignments = sorted( - ( - node - for node in ast.walk(scope) - if isinstance(node, ast.Assign) - and _enclosing_scope(node, parents) is scope - and node.lineno < before_lineno - ), - key=lambda node: node.lineno, - ) - keys = set() - for node in assignments: - for target in node.targets: - if isinstance(target, ast.Name) and target.id == name: - literal = _literal_dict_in(node.value) - keys = _dict_literal_keys(literal) if literal is not None else set() - elif ( - isinstance(target, ast.Subscript) - and isinstance(target.value, ast.Name) - and target.value.id == name - and isinstance(target.slice, ast.Constant) - and isinstance(target.slice.value, str) - ): - keys.add(target.slice.value) - return keys - - -def _init_parameters_keys(call: ast.Call, scope: ast.AST, parents: dict) -> set: - argument = next( - (kw.value for kw in call.keywords if kw.arg == "init_parameters"), None - ) - if argument is None: - return set() - if isinstance(argument, ast.Name): - return _keys_bound_to_name(scope, parents, argument.id, call.lineno) - literal = _literal_dict_in(argument) - return _dict_literal_keys(literal) if literal is not None else set() - - -def _discovered_engine_init_calls(): - """(relative path, call node, enclosing scope, parent map) per production site.""" - found = [] - for path in _python_files(): - tree = ast.parse(path.read_text(encoding="utf-8")) - parents = _parents(tree) - for node in ast.walk(tree): - if _is_engine_init(node): - found.append( - ( - str(path.relative_to(REPO_ROOT)), - node, - _enclosing_scope(node, parents), - parents, - ) - ) - return found - - -def _keys_for_source(source: str) -> set: - """Run the structural helper over a synthetic root.""" - tree = ast.parse(source) - parents = _parents(tree) - call = next(node for node in ast.walk(tree) if _is_engine_init(node)) - return _init_parameters_keys(call, _enclosing_scope(call, parents), parents) - - -def test_the_set_of_composition_roots_is_exactly_the_expected_one() -> None: - counts = {} - for relative, _, _, _ in _discovered_engine_init_calls(): - counts[relative] = counts.get(relative, 0) + 1 - assert counts == EXPECTED_ROOTS - - -def test_every_discovered_root_binds_the_observer_in_its_own_parameters() -> None: - for relative, call, scope, parents in _discovered_engine_init_calls(): - keys = _init_parameters_keys(call, scope, parents) - assert BINDING_KEY in keys, (relative, call.lineno, sorted(keys)) - - -def test_the_structural_helper_rejects_a_binding_from_another_scope() -> None: - """A nested function's own dictionary must not satisfy an outer call.""" - source = ( - "def root():\n" - " params = {}\n" - " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" - "\n" - " def unrelated():\n" - ' params = {"workflows_core.execution_observer": None}\n' - " return params\n" - ) - assert _keys_for_source(source) == set() - - -def test_the_structural_helper_ignores_an_overwritten_dictionary() -> None: - """`params = {binding}; params = {}` binds nothing - the last rebinding wins.""" - source = ( - "def root():\n" - ' params = {"workflows_core.execution_observer": None}\n' - " params = {}\n" - " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" - ) - assert _keys_for_source(source) == set() - # ...and a key set after the rebinding, before the call, does count. - source_with_subscript = ( - "def root():\n" - ' params = {"workflows_core.execution_observer": None}\n' - " params = {}\n" - ' params["workflows_core.api_key"] = "k"\n' - " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" - ) - assert _keys_for_source(source_with_subscript) == {"workflows_core.api_key"} - - -def test_the_structural_helper_reads_a_literal_handed_to_a_wrapper_call() -> None: - """Phase 9 wraps the HTTP literals: `install_workflows_platform_bindings({...})`.""" - source = ( - "def root():\n" - " params = install_workflows_platform_bindings(\n" - ' {"workflows_core.api_key": "k", "workflows_core.execution_observer": None}\n' - " )\n" - " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" - ) - assert _keys_for_source(source) == { - "workflows_core.api_key", - "workflows_core.execution_observer", - } - # A wrapper that is handed a *name* is opaque: nothing can be claimed. - opaque = ( - "def root():\n" - ' params = {"workflows_core.execution_observer": None}\n' - " params = install_workflows_platform_bindings(params)\n" - " ExecutionEngine.init(workflow_definition={}, init_parameters=params)\n" - ) - assert _keys_for_source(opaque) == set() - def test_the_registered_default_is_the_null_observer() -> None: # Blocks that declare the parameter and get no host binding resolve to a @@ -271,129 +30,3 @@ def test_the_registered_default_is_the_null_observer() -> None: def test_the_bound_observer_and_the_default_share_the_protocol() -> None: assert isinstance(UsageTrackingExecutionObserver(), ExecutionObserver) assert isinstance(REGISTERED_INITIALIZERS["execution_observer"], ExecutionObserver) - - -# -------------------------------------------------------------------------- -# Runtime: what actually reaches ExecutionEngine.init at each of the four roots -# -------------------------------------------------------------------------- - - -@pytest.fixture -def captured_engine_init(monkeypatch): - """Capture `init_parameters` and return a MagicMock engine.""" - calls = [] - - def fake_init(**kwargs): - calls.append(kwargs) - return MagicMock() - - monkeypatch.setattr(ExecutionEngine, "init", fake_init) - return calls - - -def _bound_observer(kwargs: dict): - return kwargs["init_parameters"][BINDING_KEY] - - -class _DummyInstrumentator: - def __init__(self, app, model_manager, endpoint="/metrics"): - self.app = app - self.model_manager = model_manager - self.endpoint = endpoint - - def set_stream_manager_client(self, stream_manager_client) -> None: - self.stream_manager_client = stream_manager_client - - -def _http_client(monkeypatch): - from starlette.testclient import TestClient - - import inference.core.interfaces.http.http_api as http_api - - monkeypatch.setattr(http_api, "InferenceInstrumentator", _DummyInstrumentator) - model_manager = MagicMock() - model_manager.pingback = None - model_manager.num_errors = 0 - return TestClient(http_api.HttpInterface(model_manager=model_manager).app) - - -_TRIVIAL_WORKFLOW = { - "version": "1.0", - "inputs": [{"type": "WorkflowParameter", "name": "value"}], - "steps": [], - "outputs": [], -} - - -def test_http_run_root_binds_the_observer(monkeypatch, captured_engine_init) -> None: - client = _http_client(monkeypatch) - client.post( - "/workflows/run", - json={ - "api_key": "binding-key", - "specification": _TRIVIAL_WORKFLOW, - "inputs": {"value": 1}, - }, - ) - assert captured_engine_init, "the route never reached ExecutionEngine.init" - assert isinstance( - _bound_observer(captured_engine_init[-1]), UsageTrackingExecutionObserver - ) - - -def test_http_validate_root_binds_the_observer( - monkeypatch, captured_engine_init -) -> None: - client = _http_client(monkeypatch) - client.post("/workflows/validate?api_key=binding-key", json=_TRIVIAL_WORKFLOW) - assert captured_engine_init, "the route never reached ExecutionEngine.init" - assert isinstance( - _bound_observer(captured_engine_init[-1]), UsageTrackingExecutionObserver - ) - - -def test_pipeline_root_binds_the_observer(monkeypatch, captured_engine_init) -> None: - from inference.core.interfaces.stream.inference_pipeline import InferencePipeline - - monkeypatch.setattr( - InferencePipeline, "init_with_custom_logic", MagicMock(return_value=MagicMock()) - ) - InferencePipeline.init_with_workflow( - video_reference="video.mp4", - workflow_specification={"version": "1.0"}, - model_manager=MagicMock(), - ) - assert captured_engine_init, "init_with_workflow never reached ExecutionEngine.init" - assert isinstance( - _bound_observer(captured_engine_init[-1]), UsageTrackingExecutionObserver - ) - - -def test_cli_root_binds_the_observer( - monkeypatch, tmp_path, captured_engine_init -) -> None: - from concurrent.futures import ThreadPoolExecutor - - import numpy as np - - from inference_cli.lib.workflows import local_image_adapter - - monkeypatch.setattr( - local_image_adapter.cv2, "imread", lambda _: np.zeros((2, 2, 3)) - ) - with ThreadPoolExecutor(max_workers=1) as pool: - local_image_adapter._run_workflow_for_single_image_with_inference( - model_manager=MagicMock(), - image_path=str(tmp_path / "image.jpg"), - workflow_specification={"version": "1.0"}, - workflow_id=None, - image_input_name="image", - workflow_parameters=None, - api_key="binding-key", - thread_pool_executor=pool, - max_concurrent_workflows_steps=1, - ) - assert captured_engine_init, "the CLI adapter never reached ExecutionEngine.init" - assert isinstance( - _bound_observer(captured_engine_init[-1]), UsageTrackingExecutionObserver - ) diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_platform_bindings.py b/tests/inference/unit_tests/core/interfaces/test_workflows_platform_bindings.py index 7e341d3827..4bf8b6f493 100644 --- a/tests/inference/unit_tests/core/interfaces/test_workflows_platform_bindings.py +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_platform_bindings.py @@ -1,41 +1,20 @@ -"""Every composition root must hand the platform bindings to ExecutionEngine.init. +"""A caller-supplied resolver must survive to ExecutionEngine.init. -A root that forgets one leaves blocks on the standalone default, which refuses -to talk to the Roboflow API - and there is no failing test at the block level -for that, because the default only raises when the step runs. So the values are -observed where they actually arrive. +`install_workflows_platform_bindings` uses `setdefault`, so the roots that +forward a caller's dictionary must not overwrite what the caller put there - +while still installing the rest of the server's platform objects. + +The default bindings each root installs are asserted against a real engine in +`test_image_codec_binding.py` (the four server/CLI roots) and +`test_direct_caller_bindings.py` (the two scripts); the inventory of roots is +`test_workflows_composition_roots.py`. """ -import ast -import pathlib -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest -from starlette.testclient import TestClient - -from inference.core.cache import cache as server_cache -from inference.core.interfaces.roboflow_platform_client import ( - SERVER_PLATFORM_CLIENT, - SERVER_WORKSPACE_RESOLVER, - default_inner_workflow_spec_resolver, -) - -REPO_ROOT = pathlib.Path(__file__).resolve().parents[5] - -REQUIRED = { - "workflows_core.cache": server_cache, - "workflows_core.platform_client": SERVER_PLATFORM_CLIENT, - "workflows_core.workspace_resolver": SERVER_WORKSPACE_RESOLVER, - "workflows_core.inner_workflow_spec_resolver": default_inner_workflow_spec_resolver, -} - -# One entry per file: the number of ExecutionEngine.init call sites the runtime -# cases below cover. A new root shows up here as a mismatch. -ROOT_SITES = { - "inference/core/interfaces/http/http_api.py": 2, - "inference/core/interfaces/stream/inference_pipeline.py": 1, - "inference_cli/lib/workflows/local_image_adapter.py": 1, -} + +from inference.core.interfaces.roboflow_platform_client import SERVER_PLATFORM_CLIENT TRIVIAL_WORKFLOW = {"version": "1.0", "inputs": [], "steps": [], "outputs": []} @@ -50,130 +29,6 @@ def _capturing_init(**kwargs): raise _Captured(kwargs.get("init_parameters")) -def _assert_bindings(init_parameters: dict, site: str) -> None: - assert init_parameters is not None, f"{site} passed no init_parameters" - for key, expected in REQUIRED.items(): - assert key in init_parameters, f"{site} did not pass {key}" - assert ( - init_parameters[key] is expected - ), f"{site} passed a different object for {key}" - - -def test_ast_site_count_matches_the_runtime_cases() -> None: - for relative_path, expected_sites in ROOT_SITES.items(): - tree = ast.parse((REPO_ROOT / relative_path).read_text(encoding="utf-8")) - sites = [ - node - for node in ast.walk(tree) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "init" - and isinstance(node.func.value, ast.Name) - and node.func.value.id == "ExecutionEngine" - ] - assert len(sites) == expected_sites, ( - f"{relative_path}: {len(sites)} ExecutionEngine.init call sites, " - f"expected {expected_sites} - add a runtime case for the new root" - ) - - -class _DummyInstrumentator: - def __init__(self, app, model_manager, endpoint="/metrics"): - self.app = app - self.model_manager = model_manager - self.endpoint = endpoint - - def set_stream_manager_client(self, stream_manager_client) -> None: - self.stream_manager_client = stream_manager_client - - -def _build_test_client(monkeypatch) -> TestClient: - import inference.core.interfaces.http.http_api as http_api - - monkeypatch.setattr(http_api, "InferenceInstrumentator", _DummyInstrumentator) - monkeypatch.setattr( - http_api.usage_collector, "async_push_usage_payloads", AsyncMock() - ) - model_manager = MagicMock() - model_manager.pingback = None - model_manager.num_errors = 0 - return TestClient(http_api.HttpInterface(model_manager=model_manager).app) - - -def _http_case(monkeypatch, path, json_body, site): - import inference.core.interfaces.http.http_api as http_api - - captured = {} - - class _Engine: - @staticmethod - def init(**kwargs): - captured["init_parameters"] = kwargs.get("init_parameters") - raise _Captured(kwargs.get("init_parameters")) - - monkeypatch.setattr(http_api, "ExecutionEngine", _Engine) - _build_test_client(monkeypatch).post(path, json=json_body) - _assert_bindings(captured.get("init_parameters"), site) - - -def test_http_workflow_run_root_passes_the_bindings(monkeypatch) -> None: - _http_case( - monkeypatch, - "/workflows/run", - {"specification": TRIVIAL_WORKFLOW, "inputs": {}}, - "http_api /workflows/run", - ) - - -def test_http_workflow_validate_root_passes_the_bindings(monkeypatch) -> None: - _http_case( - monkeypatch, - "/workflows/validate", - TRIVIAL_WORKFLOW, - "http_api /workflows/validate", - ) - - -def test_cli_root_passes_the_bindings(monkeypatch) -> None: - from concurrent.futures import ThreadPoolExecutor - - import inference_cli.lib.workflows.local_image_adapter as adapter - - monkeypatch.setattr(adapter.ExecutionEngine, "init", staticmethod(_capturing_init)) - with ThreadPoolExecutor(max_workers=1) as pool, pytest.raises(_Captured) as error: - adapter._run_workflow_for_single_image_with_inference( - model_manager=MagicMock(), - image_path="unused.jpg", - workflow_specification=TRIVIAL_WORKFLOW, - workflow_id=None, - image_input_name="image", - workflow_parameters=None, - api_key="k", - thread_pool_executor=pool, - max_concurrent_workflows_steps=1, - ) - _assert_bindings(error.value.init_parameters, "inference_cli local_image_adapter") - - -def test_pipeline_root_passes_the_bindings(monkeypatch) -> None: - import inference.core.workflows.execution_engine.core as engine_module - from inference.core.interfaces.stream.inference_pipeline import InferencePipeline - - monkeypatch.setattr( - engine_module.ExecutionEngine, "init", staticmethod(_capturing_init) - ) - with pytest.raises(_Captured) as error: - InferencePipeline.init_with_workflow( - video_reference="unused.mp4", - workflow_specification=TRIVIAL_WORKFLOW, - api_key="k", - model_manager=MagicMock(), - ) - _assert_bindings( - error.value.init_parameters, "InferencePipeline.init_with_workflow" - ) - - def test_pipeline_does_not_overwrite_a_caller_supplied_resolver(monkeypatch) -> None: import inference.core.workflows.execution_engine.core as engine_module from inference.core.interfaces.stream.inference_pipeline import InferencePipeline diff --git a/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py b/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py index 68b97311a6..69f0415a62 100644 --- a/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py +++ b/tests/workflows/unit_tests/core_steps/common/test_platform_client_injection.py @@ -5,13 +5,16 @@ falls back to the offline default that refuses every call. The Qwen blocks are here because they override both the constructor and the declaration of the OpenRouter base. + +The package-wide "no workflows module imports the Roboflow proxy helper" checks +that used to live here are subsumed by the centralized zero-violations +decontamination lint (`tests/workflows/unit_tests/test_decontamination_lint.py`), +which scans every `inference.*` import under `inference/core/workflows`, not +just this one module. """ -import ast import importlib import inspect -import pathlib -from functools import partial import pytest @@ -46,9 +49,6 @@ (".models.foundation.qwen_vlm.v3", "QwenVlmBlockV3"), (".models.foundation.qwen_vlm.v4", "QwenVlmBlockV4"), ] -WORKFLOWS_ROOT = ( - pathlib.Path(__file__).resolve().parents[5] / "inference" / "core" / "workflows" -) def _load(module_suffix: str, class_name: str): @@ -73,43 +73,6 @@ def test_block_stores_the_injected_client(module_suffix, class_name): assert block_class(**kwargs)._platform_client is sentinel -def test_no_workflows_module_imports_the_roboflow_proxy_helper() -> None: - """Symbol-specific: 16 files still legitimately import other names from - `roboflow_api` until Tasks 9.5-9.6 (the 14 header users and the two engine - files). Only `post_to_roboflow_api` is gone. - """ - offenders = [] - for path in WORKFLOWS_ROOT.rglob("*.py"): - tree = ast.parse(path.read_text(encoding="utf-8")) - for node in ast.walk(tree): - if ( - isinstance(node, ast.ImportFrom) - and node.module == "inference.core.roboflow_api" - ): - if any(a.name == "post_to_roboflow_api" for a in node.names): - offenders.append(f"{path}:{node.lineno}") - assert not offenders, offenders - - -def test_concurrent_blocks_keep_their_own_clients() -> None: - """The client travels inside the `partial`, not in shared state: - `common/utils.run_in_parallel` hands the partials to a thread pool.""" - from inference.core.workflows.core_steps.common.utils import run_in_parallel - - def helper(roboflow_api_key, platform_client): - return platform_client - - a, b = RecordingPlatformClient(), RecordingPlatformClient() - results = run_in_parallel( - tasks=[ - partial(helper, roboflow_api_key="a", platform_client=a), - partial(helper, roboflow_api_key="b", platform_client=b), - ], - max_workers=2, - ) - assert results == [a, b] - - def test_openai_managed_key_path_uses_the_injected_client() -> None: """The whole chain: block -> partial -> thread pool -> `_execute_proxied_openai_request` -> `platform_client.post`.""" @@ -132,40 +95,6 @@ def test_openai_managed_key_path_uses_the_injected_client() -> None: assert result -def test_qwen_forwards_the_client_to_the_openrouter_base() -> None: - from inference.core.workflows.core_steps.models.foundation.qwen_vlm.v1 import ( - QwenVlmBlockV1, - ) - - sentinel = RecordingPlatformClient() - block = QwenVlmBlockV1( - model_manager=None, - api_key=None, - step_execution_mode=None, - platform_client=sentinel, - ) - assert block._platform_client is sentinel - - -def test_no_workflows_module_imports_the_roboflow_api_client_at_all() -> None: - """True from Task 9.6 onward: the last two importers were - `block_scaffolding.py` and `reference_resolution.py`.""" - offenders = [] - for path in WORKFLOWS_ROOT.rglob("*.py"): - tree = ast.parse(path.read_text(encoding="utf-8")) - for node in ast.walk(tree): - if ( - isinstance(node, ast.ImportFrom) - and node.module == "inference.core.roboflow_api" - ): - offenders.append(f"{path}:{node.lineno}") - if isinstance(node, ast.Import): - for alias in node.names: - if alias.name.startswith("inference.core.roboflow_api"): - offenders.append(f"{path}:{node.lineno}") - assert not offenders, offenders - - @pytest.mark.parametrize( "module_suffix,class_name,model_version", [ diff --git a/tests/workflows/unit_tests/decontamination_baseline.txt b/tests/workflows/unit_tests/decontamination_baseline.txt deleted file mode 100644 index 919bb564ce..0000000000 --- a/tests/workflows/unit_tests/decontamination_baseline.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Remaining `inference.*` imports inside inference/core/workflows. -# This list may only shrink. See DECONTAMINATION.PLAN.MD. -# Count: 0 - diff --git a/tests/workflows/unit_tests/execution_engine/test_observer_resolution.py b/tests/workflows/unit_tests/execution_engine/test_observer_resolution.py index 3506eb212e..1eddadc384 100644 --- a/tests/workflows/unit_tests/execution_engine/test_observer_resolution.py +++ b/tests/workflows/unit_tests/execution_engine/test_observer_resolution.py @@ -10,8 +10,6 @@ inherit the first one's observer. Nothing here imports the server. """ -import pytest - from inference.core.workflows.execution_engine.core import ExecutionEngine from inference.core.workflows.execution_engine.v1.core import ( _resolve_execution_observer, @@ -35,53 +33,9 @@ # --- the resolver on plain dictionaries ----------------------------------- -def test_resolver_returns_the_null_observer_when_nothing_is_bound() -> None: - parameters = {"workflows_core.api_key": "k"} - - observer = _resolve_execution_observer(parameters) - - assert observer is NULL_EXECUTION_OBSERVER - assert parameters[CORE_KEY] is NULL_EXECUTION_OBSERVER - assert parameters[DYNAMIC_KEY] is NULL_EXECUTION_OBSERVER - - -def test_resolver_publishes_a_namespaced_object_under_both_keys() -> None: - bound = NullExecutionObserver() - parameters = {CORE_KEY: bound} - - assert _resolve_execution_observer(parameters) is bound - assert parameters[CORE_KEY] is bound - assert parameters[DYNAMIC_KEY] is bound - - -def test_resolver_accepts_a_bare_key() -> None: - # `_retrieve_init_parameter` accepts the un-namespaced name, so a host may - # bind it that way; dynamic blocks must not be left with the null observer. - bound = NullExecutionObserver() - parameters = {"execution_observer": bound} - - assert _resolve_execution_observer(parameters) is bound - assert parameters[CORE_KEY] is bound - assert parameters[DYNAMIC_KEY] is bound - - -def test_resolver_calls_a_factory_once_and_publishes_the_instance() -> None: - made = [] - - def factory(): - made.append(NullExecutionObserver()) - return made[-1] - - parameters = {CORE_KEY: factory} - - observer = _resolve_execution_observer(parameters) - - assert made == [observer] - assert parameters[CORE_KEY] is observer - assert parameters[DYNAMIC_KEY] is observer - - def test_resolver_keeps_an_explicit_dynamic_override() -> None: + # Unique edge not covered by the engine-level tests below: DYNAMIC_KEY + # bound to a CALLABLE, resolved independently of CORE_KEY. core, dynamic = NullExecutionObserver(), NullExecutionObserver() parameters = {CORE_KEY: core, DYNAMIC_KEY: lambda: dynamic} @@ -100,34 +54,6 @@ def _engine(init_parameters: dict): )._engine -def test_the_engine_defaults_to_the_null_observer() -> None: - engine = _engine({"workflows_core.api_key": "k"}) - - assert engine._execution_observer is NULL_EXECUTION_OBSERVER - - -@pytest.mark.parametrize("key", [CORE_KEY, "execution_observer"]) -def test_the_engine_holds_the_bound_observer(key: str) -> None: - bound = NullExecutionObserver() - - engine = _engine({"workflows_core.api_key": "k", key: bound}) - - assert engine._execution_observer is bound - - -def test_the_engine_holds_the_instance_a_factory_made() -> None: - made = [] - - def factory(): - made.append(NullExecutionObserver()) - return made[-1] - - engine = _engine({CORE_KEY: factory}) - - assert len(made) == 1 - assert engine._execution_observer is made[0] - - def test_engine_initialisation_leaves_the_callers_parameters_untouched() -> None: """The caller's dictionary is not the engine's scratch space. @@ -192,15 +118,6 @@ def __len__(self) -> int: return 0 -def test_a_false_valued_observer_survives_engine_construction() -> None: - bound = _FalseValuedObserver() - assert not bound # the premise: a valid observer that is falsy - - engine = _engine({"workflows_core.api_key": "k", CORE_KEY: bound}) - - assert engine._execution_observer is bound - - # --- dynamic blocks (Task 6) ------------------------------------------------- _IDENTITY_BLOCK = """ diff --git a/tests/workflows/unit_tests/prototypes/platform_client_double.py b/tests/workflows/unit_tests/prototypes/platform_client_double.py index 2cfb925606..d727acb34d 100644 --- a/tests/workflows/unit_tests/prototypes/platform_client_double.py +++ b/tests/workflows/unit_tests/prototypes/platform_client_double.py @@ -83,11 +83,3 @@ def reset(self) -> None: self.weights_calls.clear() self.wrapped.clear() self.post_mock.reset_mock(return_value=True, side_effect=True) - - def set_post_response(self, value: Any) -> None: - """An exception instance is raised by `post`; anything else is returned.""" - if isinstance(value, BaseException): - self.post_mock.side_effect = value - else: - self.post_mock.side_effect = None - self.post_mock.return_value = value diff --git a/tests/workflows/unit_tests/prototypes/test_platform_client.py b/tests/workflows/unit_tests/prototypes/test_platform_client.py index ed4f1ce375..883a6ac3a0 100644 --- a/tests/workflows/unit_tests/prototypes/test_platform_client.py +++ b/tests/workflows/unit_tests/prototypes/test_platform_client.py @@ -17,9 +17,6 @@ OfflineRoboflowPlatformClient, RoboflowPlatformClient, ) -from tests.workflows.unit_tests.prototypes.platform_client_double import ( - RecordingPlatformClient, -) def test_the_shared_offline_instance_is_the_offline_client() -> None: @@ -179,18 +176,6 @@ def test_server_adapter_header_policy_matches_the_server(monkeypatch) -> None: } -def test_set_post_response_clears_a_previously_seeded_exception() -> None: - # R9-H: seeding an error and then a success must not keep raising. - client = RecordingPlatformClient() - - client.set_post_response(RuntimeError("boom")) - with pytest.raises(RuntimeError, match="boom"): - client.post(endpoint="apiproxy/openai", api_key="k") - - client.set_post_response({"ok": True}) - assert client.post(endpoint="apiproxy/openai", api_key="k") == {"ok": True} - - def test_the_two_api_key_redaction_implementations_agree() -> None: # D3: the workflows caller of `api_key_safe_raise_for_status` moves to the # plugin, so no copy is made - pin the equivalence that rests on. diff --git a/tests/workflows/unit_tests/prototypes/test_platform_errors.py b/tests/workflows/unit_tests/prototypes/test_platform_errors.py index 2b919f682e..b8684e9364 100644 --- a/tests/workflows/unit_tests/prototypes/test_platform_errors.py +++ b/tests/workflows/unit_tests/prototypes/test_platform_errors.py @@ -5,6 +5,14 @@ subclass of RoboflowAPIUnsuccessfulRequestError; 403 -> RoboflowAPIForbiddenError; any other 4xx/5xx -> RoboflowAPIUnsuccessfulRequestError), and blocks catch them - so identity, not just name, has to survive. + +The general "no workflows module imports the server exceptions" scan that used +to live here is subsumed by the centralized zero-violations decontamination +lint (`tests/workflows/unit_tests/test_decontamination_lint.py`), which scans +every `inference.*` import under `inference/core/workflows`. The stronger, +module-specific `test_the_module_imports_only_typing` below stays: it asserts +`platform_errors.py` imports NOTHING but `typing`, which the general lint does +not check. """ import ast @@ -148,19 +156,3 @@ def test_a_generic_unsuccessful_request_is_still_unmapped() -> None: ) is None ) - - -def test_no_workflows_module_imports_the_server_exceptions() -> None: - workflows_root = ( - pathlib.Path(__file__).resolve().parents[4] / "inference" / "core" / "workflows" - ) - offenders = [] - for path in workflows_root.rglob("*.py"): - tree = ast.parse(path.read_text(encoding="utf-8")) - for node in ast.walk(tree): - if ( - isinstance(node, ast.ImportFrom) - and node.module == "inference.core.exceptions" - ): - offenders.append(f"{path}:{node.lineno}") - assert not offenders, offenders diff --git a/tests/workflows/unit_tests/test_decontamination_lint.py b/tests/workflows/unit_tests/test_decontamination_lint.py index c8fac28a03..20cb816a06 100644 --- a/tests/workflows/unit_tests/test_decontamination_lint.py +++ b/tests/workflows/unit_tests/test_decontamination_lint.py @@ -1,37 +1,25 @@ """Locks in Workflows decontamination progress. -`inference/core/workflows` must stop importing the `inference` server package -(it stays in place - see DECONTAMINATION.PLAN.MD). The remaining violations -are listed in `decontamination_baseline.txt`; this test fails if a new one -appears, and also if a listed one disappears without the baseline being -updated - so the list can only shrink. +`inference/core/workflows` must not import the `inference` server package (it +stays in place - see DECONTAMINATION.PLAN.MD). This scans every `*.py` under +`inference/core/workflows` for `inference.*` imports outside +`inference.core.workflows` itself, including relative imports and +function-local imports, and asserts there are none left. This test is necessary but not sufficient: the string scan only sees single-line quoted `from|import inference...` literals, so a triple-quoted multi-line code template is invisible to it, and so is `importlib.import_module()`. The Phase 13 isolation probe is the backstop for what this lint misses. - -Create the baseline the first time, and regenerate it after removing -violations, with the same command: - UPDATE_DECONTAMINATION_BASELINE=1 pytest \ - tests/workflows/unit_tests/test_decontamination_lint.py - -Once a baseline exists, the regeneration path REFUSES to write when new -violations are present, so it cannot be used to bless a regression. Never -delete the baseline to "reset" it - regenerating from a missing baseline -recreates it from whatever is present, silently absorbing any regression. """ import ast -import os import re from pathlib import Path -from typing import Optional, Set, Tuple +from typing import Set, Tuple REPO_ROOT = Path(__file__).resolve().parents[3] WORKFLOWS_ROOT = REPO_ROOT / "inference" / "core" / "workflows" -BASELINE_PATH = Path(__file__).parent / "decontamination_baseline.txt" # `inference_models` and `inference_sdk` are separately published # distributions, not part of the server package - they are allowed. @@ -65,7 +53,7 @@ def _resolve_relative(module: str, level: int, path: Path) -> str: A `from ...core.env import X` inside workflows climbs out of the package and is exactly as contaminating as the absolute form, so it must resolve - to the same string the baseline records. + to the same string as an absolute-form violation. """ pkg_parts = path.relative_to(REPO_ROOT).with_suffix("").parts[:-1] base = pkg_parts[: len(pkg_parts) - (level - 1)] if level > 1 else pkg_parts @@ -102,86 +90,9 @@ def collect_violations() -> Set[Tuple[str, str]]: return violations -def _updating() -> bool: - return os.getenv("UPDATE_DECONTAMINATION_BASELINE", "").strip().lower() in { - "1", - "true", - } - - -def _read_baseline() -> Optional[Set[Tuple[str, str]]]: - # `None` (absent) is distinct from an empty set (nothing left to remove): - # an absent baseline must be creatable, an empty one must be enforced. - if not BASELINE_PATH.exists(): - return None - declared_count: Optional[int] = None - entries = set() - for line in BASELINE_PATH.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line: - continue - if line.startswith("#"): - count_match = re.match(r"#\s*Count:\s*(\d+)", line) - if count_match: - declared_count = int(count_match.group(1)) - continue - fields = line.split("\t") - assert ( - len(fields) == 2 - ), f"Malformed baseline row (expected 'pathmodule'): {line!r}" - path, module = fields - entries.add((path, module)) - if declared_count is not None and not _updating(): - assert declared_count == len(entries), ( - f"{BASELINE_PATH} header declares Count: {declared_count} but " - f"{len(entries)} rows were parsed - regenerate with " - "UPDATE_DECONTAMINATION_BASELINE=1" - ) - return entries - - -def _write_baseline(violations: Set[Tuple[str, str]]) -> None: - lines = [f"{path}\t{module}" for path, module in sorted(violations)] - header = ( - "# Remaining `inference.*` imports inside inference/core/workflows.\n" - "# This list may only shrink. See DECONTAMINATION.PLAN.MD.\n" - f"# Count: {len(lines)}\n" - ) - BASELINE_PATH.write_text(header + "\n".join(lines) + "\n", encoding="utf-8") - - -def test_no_new_inference_imports_in_workflows() -> None: - actual = collect_violations() - baseline = _read_baseline() - updating = _updating() - if baseline is None: - # First run: nothing to compare against, so creation is the only - # sensible action - but only when asked for explicitly, so a deleted - # baseline cannot be silently re-blessed by an ordinary test run. - assert updating, ( - f"{BASELINE_PATH} is missing. Create it with " - "UPDATE_DECONTAMINATION_BASELINE=1 and commit it." - ) - _write_baseline(actual) - return - added = sorted(actual - baseline) - removed = sorted(baseline - actual) - if updating: - # Regeneration is for recording progress, never for absorbing a - # regression - refuse to write while anything new is present. - assert not added, ( - "Refusing to regenerate the baseline: these are NEW violations, " - "not resolved ones:\n " + "\n ".join(f"{p} -> {m}" for p, m in added) - ) - _write_baseline(actual) - return - assert ( - not added - ), "New `inference.*` imports inside inference/core/workflows:\n " + "\n ".join( - f"{p} -> {m}" for p, m in added - ) - assert not removed, ( - "These baseline entries are gone - good. Regenerate the baseline with " - "UPDATE_DECONTAMINATION_BASELINE=1 and commit it:\n " - + "\n ".join(f"{p} -> {m}" for p, m in removed) +def test_no_inference_imports_in_workflows() -> None: + violations = collect_violations() + assert not violations, ( + "`inference.core.workflows` must not import the `inference` server " + "package:\n " + "\n ".join(f"{p} -> {m}" for p, m in sorted(violations)) ) diff --git a/tests/workflows/unit_tests/test_no_server_env_imports.py b/tests/workflows/unit_tests/test_no_server_env_imports.py index 46bf0c568e..57a211dcdf 100644 --- a/tests/workflows/unit_tests/test_no_server_env_imports.py +++ b/tests/workflows/unit_tests/test_no_server_env_imports.py @@ -1,7 +1,8 @@ """No module Phase 5 owns may import `inference.core.env`. -This is narrower and louder than the decontamination lint, whose baseline -tolerates whatever is listed. It reuses the lint's own `collect_violations()` +This is narrower and louder than the decontamination lint, which rejects every +server dependency across the owned tree. It reuses the lint's own +`collect_violations()` so relative imports (`from ...core.env import X`), function-local imports and the exec'd-string form are all handled by the one implementation that already gets them right - and so a mere docstring mention of the module name is NOT a diff --git a/tests/workflows/unit_tests/test_observer_decontamination.py b/tests/workflows/unit_tests/test_observer_decontamination.py index 582c6b4ce3..1fbd79ae28 100644 --- a/tests/workflows/unit_tests/test_observer_decontamination.py +++ b/tests/workflows/unit_tests/test_observer_decontamination.py @@ -1,80 +1,23 @@ -"""What Phase 6 removed, asserted by row and by AST - never by grep. - -Two natural-looking greps cannot return zero and would make a false gate: both -SAM `v1_tensor.py` siblings mention `usage_collector` in prose comments -explaining why they are *not* metered. So the statements below are (a) these -exact eight `(path, module)` pairs are absent from the lint baseline, and (b) -no call under `execution_engine/` passes the three `usage_*` keyword arguments -any more - `usage_workflow_id` legitimately survives as a local variable in -`v1/core.py`, so only the *keyword* can be checked. - -This file deliberately asserts **nothing about rows other phases own**. An -earlier draft required `executor/core.py`'s `inference.core.env` row to be -present; Phase 5 removes that row, so the assertion would have made one phase -order or the other fail. "Nothing else was swept up" is checked instead by the -shared baseline-diff checker in Global Constraints, which compares against this -phase's own start commit and is therefore order-independent. +"""AST-level guard: no call under `execution_engine/` passes the three legacy +`usage_*` keyword arguments any more. + +A natural-looking grep for `usage_collector`/`usage_tracking` cannot be used +here: both SAM `v1_tensor.py` siblings mention `usage_collector` in prose +comments explaining why they are *not* metered. `usage_workflow_id` +legitimately survives as a local variable in `v1/core.py`, so only the +*keyword argument* form can be checked, not the name. """ import ast from pathlib import Path -import pytest - # tests/workflows/unit_tests/ -> three levels up is the repo root REPO_ROOT = Path(__file__).resolve().parents[3] -BASELINE = ( - REPO_ROOT / "tests" / "workflows" / "unit_tests" / "decontamination_baseline.txt" -) ENGINE_ROOT = REPO_ROOT / "inference" / "core" / "workflows" / "execution_engine" -ROWS_OWNED_BY_PHASE_6 = [ - ( - "inference/core/workflows/core_steps/models/foundation/segment_anything2_video/v1.py", - "inference.usage_tracking.collector", - ), - ( - "inference/core/workflows/core_steps/models/foundation/segment_anything3_video/v1.py", - "inference.usage_tracking.collector", - ), - ( - "inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py", - "inference.usage_tracking.block_execution", - ), - ( - "inference/core/workflows/execution_engine/v1/dynamic_blocks/block_scaffolding.py", - "inference.usage_tracking.collector", - ), - ( - "inference/core/workflows/execution_engine/v1/dynamic_blocks/modal_executor.py", - "inference.usage_tracking.block_execution", - ), - ( - "inference/core/workflows/execution_engine/v1/executor/core.py", - "inference.core.telemetry", - ), - ( - "inference/core/workflows/execution_engine/v1/executor/core.py", - "inference.usage_tracking.collector", - ), - ( - "inference/core/workflows/execution_engine/v1/executor/core.py", - "inference.usage_tracking.stream_session", - ), -] - REMOVED_USAGE_KEYWORDS = {"usage_fps", "usage_workflow_id", "usage_workflow_preview"} -def _baseline_rows() -> set: - return set(BASELINE.read_text(encoding="utf-8").splitlines()) - - -@pytest.mark.parametrize("path, module", ROWS_OWNED_BY_PHASE_6) -def test_phase_6_row_is_gone_from_the_baseline(path: str, module: str) -> None: - assert f"{path}\t{module}" not in _baseline_rows() - - def test_the_engine_no_longer_passes_usage_keyword_arguments() -> None: offenders = [] for path in sorted(ENGINE_ROOT.rglob("*.py")): From 4e2cc8a34334ff364dbccda274b437c96481e8b6 Mon Sep 17 00:00:00 2001 From: Grzegorz Klimaszewski <166530809+grzegorz-roboflow@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:47:29 +0200 Subject: [PATCH 86/86] Preserve ModelManager compatibility in Workflows --- examples/run_perspective_correction.py | 35 +- .../interfaces/workflows_models_provider.py | 90 +++- .../workflows_step_error_handlers.py | 10 +- inference/core/managers/base.py | 26 + .../core/workflows/execution_engine/core.py | 7 +- .../workflows/execution_engine/v1/core.py | 49 +- .../v1/step_error_handlers.py | 13 +- scripts/workflows_isolation_probe.py | 33 +- .../test_workflows_model_manager_hook.py | 488 ++++++++++++++++++ .../test_model_manager_bind_hook.py | 211 ++++++++ 10 files changed, 932 insertions(+), 30 deletions(-) create mode 100644 tests/inference/unit_tests/core/interfaces/test_workflows_model_manager_hook.py create mode 100644 tests/workflows/unit_tests/execution_engine/test_model_manager_bind_hook.py diff --git a/examples/run_perspective_correction.py b/examples/run_perspective_correction.py index 52987e71f3..c2e10b1838 100644 --- a/examples/run_perspective_correction.py +++ b/examples/run_perspective_correction.py @@ -1,15 +1,31 @@ """Perspective-correction demo, and the reference for what a direct Python -caller has to bind. +caller may bind. + +Two supported wirings, permanent. Pick either: + +1. Pass a raw `ModelManager` (or `ModelManagerDecorator`) as + `workflows_core.model_manager`. `ExecutionEngine.init` invokes the + manager's `__workflows_bind__` hook, which installs the historical server + bindings (platform client / cache / workspace / inner-workflow resolver, + image codec, usage-tracking observer, process configuration and the + server's step-error handler). Explicit keys the caller already set + survive. + +2. Pass an explicit `ModelManagerModelsProvider` (or any other + `ModelsProvider` implementation) as `workflows_core.model_manager` and + bind the rest of the services yourself. Suits standalone / custom-host + integrations that keep provider object identity. + +This applies to passing a manager INTO the engine, not to direct +construction of individual workflow block classes. The script below uses +form (2); form (1) works without any of the extra `workflows_core.*` calls +made here. The Execution Engine takes the server's capabilities as explicit -`workflows_core.*` init parameters instead of reaching for them itself, and its -standalone defaults refuse or no-op whatever is missing. A script that calls -`ExecutionEngine.init` therefore installs the same services the HTTP, stream and -CLI roots install: +`workflows_core.*` init parameters instead of reaching for them itself. Its +standalone defaults refuse or no-op whatever is missing; the services below +restore them for a caller who is wiring things by hand: -* `ModelManagerModelsProvider(model_manager)` - blocks call the provider port - (`run_object_detection`, `run_instance_segmentation`, ...), which a raw - `ModelManager` does not implement. Passing the manager itself now fails. * `install_workflows_platform_bindings` - Roboflow-managed VLM and notification proxy calls, workflows referenced by ID, workspace identity for authenticated Modal execution, and the shared cache behind sink cooldown/dedup. Unbound, the @@ -25,9 +41,6 @@ * `server_workflows_configuration()` - the object the rest of the process uses, so a mis-wire is reported instead of silently diverging. -`workflows_core.api_key` still authenticates the calls a block makes, but an API -key alone no longer supplies any of the above. - The usage categories are separate scopes, not substitutes: `request` (one HTTP handler call), `workflows` (one observed engine run, with workflow identity, FPS and preview), `workflow_block` (one custom-Python block execution) and `model` diff --git a/inference/core/interfaces/workflows_models_provider.py b/inference/core/interfaces/workflows_models_provider.py index 97c4f8ff2a..d28f68282a 100644 --- a/inference/core/interfaces/workflows_models_provider.py +++ b/inference/core/interfaces/workflows_models_provider.py @@ -19,8 +19,9 @@ the blocks used (build -> register -> infer). Bound at the four composition roots as -`init_parameters["workflows_core.model_manager"]`, and by the test fixtures -that used to inject a raw manager (Task 11.7 Step 6b). +`init_parameters["workflows_core.model_manager"]`, and installed by +`bind_model_manager_to_workflows` when a raw `ModelManager` is handed to +`ExecutionEngine.init` (the permanent raw-manager compatibility path). Phase 12 note: a second implementation backed by `inference_sdk` (`InferenceHTTPClientModelsProvider`) would satisfy the same port for REMOTE @@ -629,3 +630,88 @@ def _dump(responses: List[Any]) -> List[dict]: ) for response in responses ] + + +def bind_model_manager_to_workflows( + *, + model_manager: ModelManager, + init_parameters: Dict[str, Any], + step_error_handler: Any, +) -> Any: + """Install the server's `workflows_core.*` bindings for a raw ModelManager. + + Invoked from `ModelManager.__workflows_bind__` on the engine's PRIVATE + `init_parameters` copy and returns the effective `step_error_handler`. + + Contract: + * `workflows_core.model_manager` is always overwritten with a fresh + `ModelManagerModelsProvider`; the returned provider has no + `__workflows_bind__`, so reusing this manager across engines cannot + double-wrap it. + * A bare `model_manager` is left untouched (it may belong to a plugin). + * `install_workflows_platform_bindings` / `setdefault`-style fills only + keys the caller did not set, preserving explicit and dynamic-block + overrides. + * A bare `execution_observer` is respected: only when NEITHER the + namespaced key NOR the bare name is set do we install the usage-tracking + observer, so `_resolve_execution_observer` still sees the caller's bare + binding. + * `workflows_core.configuration` is validated (when the caller supplied + one) BEFORE `bind_image_codec` touches the process-global codec + registry, so a mismatched configuration cannot leave a partially + installed codec behind. Bare / plugin-namespaced `configuration` is not + the core configuration and is deliberately left untouched. + * Step-error handler: `OMITTED_STEP_ERROR_HANDLER` resolves to the + server default; a known name resolves through `SERVER_STEP_ERROR_HANDLERS`; + any other string, callable or explicit `None` retains its identity. + """ + from inference.core.interfaces.roboflow_platform_client import ( + install_workflows_platform_bindings, + ) + from inference.core.interfaces.workflows_configuration import ( + server_workflows_configuration, + ) + from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, + ) + from inference.core.interfaces.workflows_image_codec import bind_image_codec + from inference.core.interfaces.workflows_step_error_handlers import ( + SERVER_STEP_ERROR_HANDLERS, + resolve_step_error_handler, + ) + from inference.core.workflows.configuration import ( + ensure_process_configuration_matches, + ) + from inference.core.workflows.execution_engine.v1.core import ( + OMITTED_STEP_ERROR_HANDLER, + ) + + init_parameters["workflows_core.model_manager"] = ModelManagerModelsProvider( + model_manager + ) + install_workflows_platform_bindings(init_parameters) + # Validate the caller's configuration BEFORE touching the process-wide + # codec registry: `bind_image_codec` calls `set_image_codec`, and a + # subsequent configuration mismatch would leave the codec installed. + # V1.init still runs its own post-hook validation so an arbitrary host + # hook cannot bypass the assertion. + if "workflows_core.configuration" in init_parameters: + ensure_process_configuration_matches( + init_parameters["workflows_core.configuration"] + ) + bind_image_codec(init_parameters) + if ( + "workflows_core.execution_observer" not in init_parameters + and "execution_observer" not in init_parameters + ): + init_parameters["workflows_core.execution_observer"] = ( + UsageTrackingExecutionObserver() + ) + init_parameters.setdefault( + "workflows_core.configuration", server_workflows_configuration() + ) + if step_error_handler is OMITTED_STEP_ERROR_HANDLER: + return resolve_step_error_handler() + if isinstance(step_error_handler, str): + return SERVER_STEP_ERROR_HANDLERS.get(step_error_handler, step_error_handler) + return step_error_handler diff --git a/inference/core/interfaces/workflows_step_error_handlers.py b/inference/core/interfaces/workflows_step_error_handlers.py index 609c920217..e0b9a20ff1 100644 --- a/inference/core/interfaces/workflows_step_error_handlers.py +++ b/inference/core/interfaces/workflows_step_error_handlers.py @@ -264,10 +264,12 @@ def resolve_step_error_handler( ) -> Union[str, Callable[[str, Exception], None]]: """Pick the handler the server passes to ``ExecutionEngine.init``. - The engine's own default is bound at function-definition time, so the - server must pass its choice explicitly at every composition root. Unknown - names are returned unchanged: the engine raises - ``WorkflowEnvironmentConfigurationError`` for them, exactly as before. + Composition roots pass the resolved handler explicitly. Direct Python + callers that hand a raw ``ModelManager`` to the engine also receive this + handler transparently through the ``__workflows_bind__`` compatibility + contract, so no additional wiring is required for the standard case. + Unknown names are returned unchanged: the engine raises + ``WorkflowEnvironmentConfigurationError`` for them. """ name = name or os.getenv( "DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER", "extended_roboflow_errors" diff --git a/inference/core/managers/base.py b/inference/core/managers/base.py index ee0aa06d2e..c51c785c1b 100644 --- a/inference/core/managers/base.py +++ b/inference/core/managers/base.py @@ -94,6 +94,32 @@ def init_pingback(self): self.pingback = PingbackInfo(self) self.pingback.start() + def __workflows_bind__( + self, + init_parameters: Dict[str, Any], + step_error_handler: Any, + ) -> Any: + """Class-level Workflows compatibility hook for a raw ModelManager. + + `ExecutionEngine.init` invokes this when its effective + `model_manager` is a raw `ModelManager` (or a subclass such as + `ModelManagerDecorator`); it wraps `self` with + `ModelManagerModelsProvider` and installs the historical server + services into the engine's private `init_parameters`, returning the + effective `step_error_handler`. The server helper is imported lazily + so `inference/core/workflows` does not gain a reverse import of this + module. + """ + from inference.core.interfaces.workflows_models_provider import ( + bind_model_manager_to_workflows, + ) + + return bind_model_manager_to_workflows( + model_manager=self, + init_parameters=init_parameters, + step_error_handler=step_error_handler, + ) + def add_model( self, model_id: str, diff --git a/inference/core/workflows/execution_engine/core.py b/inference/core/workflows/execution_engine/core.py index a1dce12d8f..495f7ba942 100644 --- a/inference/core/workflows/execution_engine/core.py +++ b/inference/core/workflows/execution_engine/core.py @@ -14,9 +14,10 @@ ) from inference.core.workflows.execution_engine.profiling.core import WorkflowsProfiler from inference.core.workflows.execution_engine.v1.core import ( - DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER, EXECUTION_ENGINE_V1_VERSION, + OMITTED_STEP_ERROR_HANDLER, ExecutionEngineV1, + _OmittedStepErrorHandler, ) REGISTERED_ENGINES = { @@ -41,8 +42,8 @@ def init( profiler: Optional[WorkflowsProfiler] = None, executor: Optional[ThreadPoolExecutor] = None, step_error_handler: Optional[ - Union[str, Callable[[str, Exception], None]] - ] = DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER, + Union[str, Callable[[str, Exception], None], _OmittedStepErrorHandler] + ] = OMITTED_STEP_ERROR_HANDLER, dependencies_pre_init: Optional[List[str]] = None, ) -> "ExecutionEngine": requested_engine_version = retrieve_requested_execution_engine_version( diff --git a/inference/core/workflows/execution_engine/v1/core.py b/inference/core/workflows/execution_engine/v1/core.py index c244ef6cd7..259e7901d6 100644 --- a/inference/core/workflows/execution_engine/v1/core.py +++ b/inference/core/workflows/execution_engine/v1/core.py @@ -74,6 +74,25 @@ "legacy": legacy_step_error_handler, } + +class _OmittedStepErrorHandler: + """Sentinel type: caller did not pass a ``step_error_handler`` argument. + + Distinct from ``None`` (explicit "no handler"), any string or callable. + A host bind hook uses it to distinguish "omitted" from "explicit" and + substitute its own default; if no hook fires it collapses to + ``DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER``. + """ + + __slots__ = () + + def __repr__(self) -> str: # pragma: no cover - debugging aid + return "OMITTED_STEP_ERROR_HANDLER" + + +OMITTED_STEP_ERROR_HANDLER = _OmittedStepErrorHandler() + + # The ONLY key the process-consistency check looks at. Deliberately NOT # `_retrieve_init_parameter`, which falls back to the BARE name and invokes # callables: a plugin's own `configuration` init parameter - bare, or under @@ -403,8 +422,8 @@ def init( profiler: Optional[WorkflowsProfiler] = None, executor: Optional[ThreadPoolExecutor] = None, step_error_handler: Optional[ - Union[str, Callable[[str, Exception], None]] - ] = DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER, + Union[str, Callable[[str, Exception], None], _OmittedStepErrorHandler] + ] = OMITTED_STEP_ERROR_HANDLER, dependencies_pre_init: Optional[List[str]] = None, ) -> "ExecutionEngineV1": # The engine mutates this dict (dynamic-block mirrors below) and the @@ -412,6 +431,32 @@ def init( # reuses its dictionary across engines never sees, or re-supplies, a # value this engine derived. init_parameters = dict(init_parameters or {}) + # Raw-ModelManager compatibility: if the effective model_manager + # exposes a class-level ``__workflows_bind__``, let the host fill the + # missing ``workflows_core.*`` services into the private dictionary + # BEFORE configuration validation, dynamic-block mirroring, observer + # resolution, compilation and preloading. Namespaced key wins over + # the bare name (including an explicit ``None`` - disables the hook); + # lookup is on the TYPE (walks MRO) so a permissive ``MagicMock`` or + # instance-only attribute does not opt in. Callable values are NOT + # invoked: block-initializer semantics stay unchanged. + if "workflows_core.model_manager" in init_parameters: + effective_model_manager = init_parameters["workflows_core.model_manager"] + else: + effective_model_manager = init_parameters.get("model_manager") + bind_hook = ( + getattr(type(effective_model_manager), "__workflows_bind__", None) + if effective_model_manager is not None + else None + ) + if bind_hook is not None: + step_error_handler = bind_hook( + effective_model_manager, init_parameters, step_error_handler + ) + # Sentinel collapse only after the hook had its chance. Explicit + # ``None``, callable, or string values retain their identity. + if isinstance(step_error_handler, _OmittedStepErrorHandler): + step_error_handler = DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER # Before compilation, so a warm COMPILATION_CACHE (compiler/core.py:64) # cannot skip the check with it. The configuration is process-wide; a # per-engine object that differs anywhere means the process is diff --git a/inference/core/workflows/execution_engine/v1/step_error_handlers.py b/inference/core/workflows/execution_engine/v1/step_error_handlers.py index c339f4ea9c..54092e1022 100644 --- a/inference/core/workflows/execution_engine/v1/step_error_handlers.py +++ b/inference/core/workflows/execution_engine/v1/step_error_handlers.py @@ -1,12 +1,11 @@ """Step error handlers owned by the Workflows engine. -The engine ships only the mapping-free default below. A direct -``ExecutionEngine.init(...)`` caller that wants the Roboflow exception mappings -(``FeatureDeprecatedError`` to HTTP 410, model-access and remote-execution -status codes) must pass the server handler explicitly via ``step_error_handler`` --- it lives in ``inference.core.interfaces.workflows_step_error_handlers`` and -is selected by ``resolve_step_error_handler()``, which every server composition -root already does. +The engine ships only the mapping-free default below. Raw-manager callers +(``ExecutionEngine.init`` with a ``ModelManager``) receive the server handler +transparently via the ``__workflows_bind__`` host hook; no explicit wiring is +needed. Standalone or provider-only callers may pass an explicit handler via +``step_error_handler``; the server handler lives in +``inference.core.interfaces.workflows_step_error_handlers``. """ diff --git a/scripts/workflows_isolation_probe.py b/scripts/workflows_isolation_probe.py index 368b3dc9ba..3420ad20d3 100644 --- a/scripts/workflows_isolation_probe.py +++ b/scripts/workflows_isolation_probe.py @@ -241,7 +241,7 @@ def _image_input(): def run_model_free_workflow(): # Blur, then render a label with a packaged font - a real offline render, - # in both representations. + # in both representations. Init parameters are empty: the no-hook path. from inference.core.workflows.execution_engine.core import ExecutionEngine engine = ExecutionEngine.init(workflow_definition=MODEL_FREE_WORKFLOW, init_parameters={}) runtime = {"image": _image_input(), "detections": SYNTHETIC_DETECTIONS} @@ -254,6 +254,35 @@ def run_model_free_workflow(): assert "labelled" in serialised and "blurred" in serialised, serialised +def run_workflow_with_host_hook(): + # A tiny neutral host object with the class-level ``__workflows_bind__`` + # contract must be honoured by the engine without pulling in any server + # module. Runs a workflow that does not need a `ModelsProvider`, so the + # hook can leave the binding as ``None`` and the workflow still executes. + from inference.core.workflows.execution_engine.core import ExecutionEngine + + class _NeutralHost: + received = None + + def __workflows_bind__(self, init_parameters, step_error_handler): + _NeutralHost.received = { + "keys": sorted(init_parameters), + "handler": step_error_handler, + } + init_parameters["workflows_core.model_manager"] = None + return step_error_handler + + host = _NeutralHost() + engine = ExecutionEngine.init( + workflow_definition=MODEL_FREE_WORKFLOW, + init_parameters={"workflows_core.model_manager": host}, + ) + runtime = {"image": _image_input(), "detections": SYNTHETIC_DETECTIONS} + result = engine.run(runtime_parameters=runtime, serialize_results=False)[0] + assert _NeutralHost.received is not None, "the class-level hook never fired" + assert result["labelled"].numpy_image.any(), "hook path did not reach block execution" + + def run_dynamic_block_workflow(): # Exercises block_scaffolding's generated code - where the exec'd # `from inference.core.env import ...` string used to live (Phase 5). @@ -326,6 +355,7 @@ def no_blocked_import_attempts(): "import_everything": import_everything, "load_blocks": load_blocks, "run_model_free_workflow": run_model_free_workflow, + "run_workflow_with_host_hook": run_workflow_with_host_hook, "run_dynamic_block_workflow": run_dynamic_block_workflow, "standalone_logging": standalone_logging, "fonts_offline": fonts_offline, # deletes the assets - keep it after the render @@ -349,6 +379,7 @@ def no_blocked_import_attempts(): "import_everything", "load_blocks", "run_model_free_workflow", + "run_workflow_with_host_hook", "run_dynamic_block_workflow", "standalone_logging", "fonts_offline", diff --git a/tests/inference/unit_tests/core/interfaces/test_workflows_model_manager_hook.py b/tests/inference/unit_tests/core/interfaces/test_workflows_model_manager_hook.py new file mode 100644 index 0000000000..0f742655c4 --- /dev/null +++ b/tests/inference/unit_tests/core/interfaces/test_workflows_model_manager_hook.py @@ -0,0 +1,488 @@ +"""Raw `ModelManager` compatibility: `ExecutionEngine.init(...)` may take a +manager (or a `ModelManagerDecorator` around it) as +`workflows_core.model_manager` and receive the historical server bindings for +free through the class-level `__workflows_bind__` hook. + +Companion coverage: + +* pure hook shape without server imports: + `tests/workflows/unit_tests/execution_engine/test_model_manager_bind_hook.py` +* explicit-provider path at composition roots and image codec matrix: + `test_direct_caller_bindings.py`, `test_image_codec_binding.py` +* per-service depth (usage rows, platform bindings, handler selection) lives + in the plan's named suites - this file does not duplicate them. +""" + +import dataclasses +from typing import Any, Optional +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from inference.core.entities.requests.inference import ObjectDetectionInferenceRequest +from inference.core.entities.responses.inference import ( + InferenceResponseImage, + ObjectDetectionInferenceResponse, + ObjectDetectionPrediction, +) +from inference.core.interfaces.roboflow_platform_client import ( + SERVER_PLATFORM_CLIENT, + SERVER_WORKSPACE_RESOLVER, + default_inner_workflow_spec_resolver, + workflows_platform_bindings, +) +from inference.core.interfaces.workflows_configuration import ( + server_workflows_configuration, +) +from inference.core.interfaces.workflows_execution_observer import ( + UsageTrackingExecutionObserver, +) +from inference.core.interfaces.workflows_image_codec import GUARDED_IMAGE_CODEC +from inference.core.interfaces.workflows_models_provider import ( + ModelManagerModelsProvider, +) +from inference.core.interfaces.workflows_step_error_handlers import ( + extended_roboflow_errors_handler, + legacy_step_error_handler, + resolve_step_error_handler, +) +from inference.core.managers.base import ModelManager +from inference.core.managers.decorators.base import ModelManagerDecorator +from inference.core.workflows.core_steps.common.entities import StepExecutionMode +from inference.core.workflows.errors import WorkflowEnvironmentConfigurationError +from inference.core.workflows.execution_engine.core import ExecutionEngine +from inference.core.workflows.execution_engine.v1.core import ExecutionEngineV1 +from inference.core.workflows.prototypes.image_codec import ( + WorkflowsLocalImageCodec, + get_image_codec, + reset_image_codec, +) + +TRIVIAL_WORKFLOW = {"version": "1.0", "inputs": [], "steps": [], "outputs": []} +API_KEY = "raw-manager-key" +MODEL_ID = "some-project/1" + +OBJECT_DETECTION_WORKFLOW = { + "version": "1.0", + "inputs": [ + {"type": "WorkflowImage", "name": "image"}, + {"type": "WorkflowParameter", "name": "model_id"}, + ], + "steps": [ + { + "type": "ObjectDetectionModel", + "name": "model", + "image": "$inputs.image", + "model_id": "$inputs.model_id", + "confidence": 0.4, + } + ], + "outputs": [ + { + "type": "JsonField", + "name": "predictions", + "selector": "$steps.model.predictions", + } + ], +} + + +@pytest.fixture(autouse=True) +def _clean_codec_registry(): + # `bind_model_manager_to_workflows` installs the guarded codec process-wide; + # leaving that state behind would make later tests' installs conflict, as + # documented on `set_image_codec`. + reset_image_codec() + yield + reset_image_codec() + + +class _RawTestManager(ModelManager): + """Minimal real subclass of `ModelManager` so `type(mm)` inherits the hook. + + A `MagicMock(spec=ModelManager)` would NOT trigger it: the mock's type is + `MagicMock`, and lookup is class-level to keep permissive mocks from opting + in. Skips the full manager init and stubs only what the tests reach - and + deliberately not the `run_*` methods the provider exists to add. + """ + + def __init__(self, inference_response: Optional[Any] = None) -> None: # noqa: D401 + # Skip `ModelManager.__init__` (no registry, no shared blob cache). + response = inference_response or ObjectDetectionInferenceResponse( + image=InferenceResponseImage(width=64, height=64), predictions=[] + ) + self.add_model = MagicMock() # type: ignore[assignment] + self.infer_from_request_sync = MagicMock( # type: ignore[assignment] + return_value=response + ) + # `ModelManagerDecorator.add_model` short-circuits when `model_id in + # self` and forwards to `record_request_metadata`; the real method + # reads state the skipped `__init__` never set, so we stub it. + self.record_request_metadata = MagicMock() # type: ignore[assignment] + + def __contains__(self, model_id: str) -> bool: + return True + + +def _fake_manager() -> _RawTestManager: + manager = _RawTestManager() + assert not hasattr(manager, "run_object_detection") + return manager + + +def _v1(engine: ExecutionEngine) -> ExecutionEngineV1: + inner = engine._engine + assert isinstance(inner, ExecutionEngineV1) + return inner + + +def _stored(engine: ExecutionEngine) -> dict: + return _v1(engine)._compiled_workflow.init_parameters + + +# Wrap-and-fill: identity of every service the hook installs. + + +def test_raw_manager_becomes_provider_with_server_bindings(): + manager = _fake_manager() + engine = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={ + "workflows_core.model_manager": manager, + "workflows_core.api_key": API_KEY, + }, + ) + parameters = _stored(engine) + + provider = parameters["workflows_core.model_manager"] + assert isinstance(provider, ModelManagerModelsProvider) + assert provider._model_manager is manager + + for key, value in workflows_platform_bindings().items(): + assert parameters[key] is value, key + assert parameters["workflows_core.platform_client"] is SERVER_PLATFORM_CLIENT + assert parameters["workflows_core.workspace_resolver"] is SERVER_WORKSPACE_RESOLVER + assert ( + parameters["workflows_core.inner_workflow_spec_resolver"] + is default_inner_workflow_spec_resolver + ) + + assert parameters["workflows_core.image_codec"] is GUARDED_IMAGE_CODEC + assert get_image_codec() is GUARDED_IMAGE_CODEC + + assert isinstance( + parameters["workflows_core.execution_observer"], UsageTrackingExecutionObserver + ) + assert parameters["workflows_core.configuration"] is ( + server_workflows_configuration() + ) + assert _v1(engine)._step_error_handler is resolve_step_error_handler() + + +def test_decorated_manager_is_wrapped_not_unwrapped(): + # Wrap `self`, not the inner manager: the decorator's cache / locking / + # active-learning overrides must survive. + inner = _fake_manager() + decorated = ModelManagerDecorator(model_manager=inner) + engine = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={"workflows_core.model_manager": decorated}, + ) + provider = _stored(engine)["workflows_core.model_manager"] + assert isinstance(provider, ModelManagerModelsProvider) + assert provider._model_manager is decorated + + +# Explicit values / overrides survive; lifetime is per-engine. + + +def test_explicit_platform_client_survives_hook(): + manager = _fake_manager() + platform_client = object() + engine = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={ + "workflows_core.model_manager": manager, + "workflows_core.platform_client": platform_client, + }, + ) + assert _stored(engine)["workflows_core.platform_client"] is platform_client + + +def test_explicit_configuration_survives_hook(): + manager = _fake_manager() + explicit_config = server_workflows_configuration() + engine = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={ + "workflows_core.model_manager": manager, + "workflows_core.configuration": explicit_config, + }, + ) + assert _stored(engine)["workflows_core.configuration"] is explicit_config + + +def test_bare_execution_observer_survives_hook(): + # A bare `execution_observer` reaches `_resolve_execution_observer` + # and is republished under the namespaced key; a hook-installed namespaced + # default would silently hide it. + manager = _fake_manager() + observer = object() + engine = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={ + "workflows_core.model_manager": manager, + "execution_observer": observer, + }, + ) + parameters = _stored(engine) + assert parameters["workflows_core.execution_observer"] is observer + assert parameters["execution_observer"] is observer + + +def test_dynamic_block_execution_observer_override_survives_hook(): + # A dynamic-block override wins for custom-Python blocks without + # replacing the server default for core blocks. + manager = _fake_manager() + dynamic_observer = object() + engine = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={ + "workflows_core.model_manager": manager, + "dynamic_workflows_blocks.execution_observer": dynamic_observer, + }, + ) + parameters = _stored(engine) + assert parameters["dynamic_workflows_blocks.execution_observer"] is dynamic_observer + assert isinstance( + parameters["workflows_core.execution_observer"], UsageTrackingExecutionObserver + ) + assert parameters["workflows_core.execution_observer"] is not dynamic_observer + + +def test_bare_observer_factory_is_called_once_per_engine(): + manager = _fake_manager() + + class _Observer: + pass + + build_calls = {"count": 0} + + def _factory(): + build_calls["count"] += 1 + return _Observer() + + engine_a = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={ + "workflows_core.model_manager": manager, + "execution_observer": _factory, + }, + ) + engine_b = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={ + "workflows_core.model_manager": manager, + "execution_observer": _factory, + }, + ) + # `_resolve_execution_observer` calls callables exactly once per engine. + assert build_calls["count"] == 2 + obs_a = _stored(engine_a)["workflows_core.execution_observer"] + obs_b = _stored(engine_b)["workflows_core.execution_observer"] + assert isinstance(obs_a, _Observer) and isinstance(obs_b, _Observer) + assert obs_a is not obs_b + + +def test_reuse_of_manager_and_dict_across_engines_stays_isolated(): + manager = _fake_manager() + caller_dict = { + "workflows_core.model_manager": manager, + "workflows_core.api_key": API_KEY, + } + engine_a = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, init_parameters=caller_dict + ) + engine_b = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, init_parameters=caller_dict + ) + provider_a = _stored(engine_a)["workflows_core.model_manager"] + provider_b = _stored(engine_b)["workflows_core.model_manager"] + assert isinstance(provider_a, ModelManagerModelsProvider) + assert isinstance(provider_b, ModelManagerModelsProvider) + assert provider_a is not provider_b + assert provider_a._model_manager is manager + assert provider_b._model_manager is manager + # A fresh usage-tracking observer per engine. + assert ( + _stored(engine_a)["workflows_core.execution_observer"] + is not _stored(engine_b)["workflows_core.execution_observer"] + ) + # Caller dict was not mutated. + assert set(caller_dict) == { + "workflows_core.model_manager", + "workflows_core.api_key", + } + + +# Configuration mismatch: refused BEFORE codec is installed. + + +def test_mismatched_configuration_is_rejected_before_codec_is_installed(): + manager = _fake_manager() + server_config = server_workflows_configuration() + wrong_config = dataclasses.replace( + server_config, + engine=dataclasses.replace( + server_config.engine, + max_inner_workflow_depth=server_config.engine.max_inner_workflow_depth + + 999, + ), + ) + custom_codec = WorkflowsLocalImageCodec() + codec_before = get_image_codec() + with pytest.raises(WorkflowEnvironmentConfigurationError): + ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={ + "workflows_core.model_manager": manager, + "workflows_core.configuration": wrong_config, + "workflows_core.image_codec": custom_codec, + }, + ) + # Codec registry must NOT have been touched by the failed init. + assert get_image_codec() is codec_before + engine = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={"workflows_core.model_manager": manager}, + ) + assert _stored(engine)["workflows_core.image_codec"] is GUARDED_IMAGE_CODEC + + +# step_error_handler semantics with a concrete server manager. + + +@pytest.mark.parametrize( + "kwargs, expected_handler", + [ + ({}, extended_roboflow_errors_handler), + ({"step_error_handler": "legacy"}, legacy_step_error_handler), + ( + {"step_error_handler": "extended_roboflow_errors"}, + extended_roboflow_errors_handler, + ), + ({"step_error_handler": None}, None), + ], +) +def test_handler_semantics(monkeypatch, kwargs, expected_handler): + monkeypatch.delenv("DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER", raising=False) + manager = _fake_manager() + engine = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={"workflows_core.model_manager": manager}, + **kwargs, + ) + assert _v1(engine)._step_error_handler is expected_handler + + +def test_explicit_callable_handler_keeps_identity(): + manager = _fake_manager() + + def _my_handler(step_name: str, error: Exception) -> None: + return None + + engine = ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={"workflows_core.model_manager": manager}, + step_error_handler=_my_handler, + ) + assert _v1(engine)._step_error_handler is _my_handler + + +def test_unknown_handler_name_with_raw_manager_still_raises(): + manager = _fake_manager() + with pytest.raises(WorkflowEnvironmentConfigurationError): + ExecutionEngine.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={"workflows_core.model_manager": manager}, + step_error_handler="does-not-exist", + ) + + +# End-to-end: engine.run reaches the raw manager through the hook-wired provider. + + +def _detection_response() -> ObjectDetectionInferenceResponse: + return ObjectDetectionInferenceResponse( + image=InferenceResponseImage(width=64, height=64), + predictions=[ + ObjectDetectionPrediction( + x=32.0, + y=32.0, + width=10.0, + height=10.0, + confidence=0.9, + **{"class": "raw-manager", "class_id": 0}, + ) + ], + ) + + +@pytest.mark.parametrize("wrapped", [False, True]) +def test_engine_run_reaches_raw_manager_through_wired_provider(wrapped): + # Compatibility contract: engine.run for a real workflow, with raw AND + # decorated managers, reaches the manager via the hook-installed provider. + inner = _RawTestManager(inference_response=_detection_response()) + manager = ModelManagerDecorator(model_manager=inner) if wrapped else inner + engine = ExecutionEngine.init( + workflow_definition=OBJECT_DETECTION_WORKFLOW, + init_parameters={ + "workflows_core.model_manager": manager, + "workflows_core.api_key": API_KEY, + "workflows_core.step_execution_mode": StepExecutionMode.LOCAL, + }, + ) + provider = _stored(engine)["workflows_core.model_manager"] + assert isinstance(provider, ModelManagerModelsProvider) + assert provider._model_manager is manager + # The image codec installed by the hook drives both the engine's + # deserializer and later reference re-loads: we exercise the deserializer + # via engine.run's numpy input. + image = np.zeros((64, 64, 3), dtype=np.uint8) + result = engine.run( + runtime_parameters={ + "image": image, + "model_id": MODEL_ID, + } + ) + # The decorator short-circuits to `record_request_metadata` when the + # model is already present; the raw manager takes `add_model`. Either + # way, the translated request must reach the inner manager. + if wrapped: + inner.record_request_metadata.assert_called_once() + else: + inner.add_model.assert_called_once_with(model_id=MODEL_ID, api_key=API_KEY) + call = inner.infer_from_request_sync.call_args + request = call.kwargs.get("request") or call.args[1] + assert isinstance(request, ObjectDetectionInferenceRequest) + assert request.model_id == MODEL_ID + assert request.api_key == API_KEY + predictions = result[0]["predictions"] + assert len(predictions) == 1 + + +# V1 entry point check. + + +def test_v1_direct_entry_point_also_fires_hook(): + manager = _fake_manager() + v1_engine = ExecutionEngineV1.init( + workflow_definition=TRIVIAL_WORKFLOW, + init_parameters={"workflows_core.model_manager": manager}, + ) + parameters = v1_engine._compiled_workflow.init_parameters + provider = parameters["workflows_core.model_manager"] + assert isinstance(provider, ModelManagerModelsProvider) + assert provider._model_manager is manager + assert v1_engine._step_error_handler is resolve_step_error_handler() diff --git a/tests/workflows/unit_tests/execution_engine/test_model_manager_bind_hook.py b/tests/workflows/unit_tests/execution_engine/test_model_manager_bind_hook.py new file mode 100644 index 0000000000..3799b5265b --- /dev/null +++ b/tests/workflows/unit_tests/execution_engine/test_model_manager_bind_hook.py @@ -0,0 +1,211 @@ +"""The `__workflows_bind__` hook contract, exercised without server imports. + +The engine invokes an optional class-level `__workflows_bind__` on the +effective ``workflows_core.model_manager`` (namespaced-key precedence, MRO +lookup) to let a host fill in bindings a raw manager cannot provide. Nothing +here imports ``inference.core.interfaces.*`` or ``inference.core.managers.*`` +- a hook that only works because the server is already imported would still +pass on the server-side suite; here the workflows package stands alone. +""" + +from types import MethodType +from unittest.mock import MagicMock + +import pytest + +from inference.core.workflows.execution_engine.core import ExecutionEngine +from inference.core.workflows.execution_engine.v1.core import ( + DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER, + OMITTED_STEP_ERROR_HANDLER, + REGISTERED_STEP_ERROR_HANDLERS, + ExecutionEngineV1, +) + +_TRIVIAL_WORKFLOW = {"version": "1.0", "inputs": [], "steps": [], "outputs": []} + + +class _HostManager: + """Raw-manager stand-in exposing the class-level hook.""" + + def __init__(self, effective_handler=OMITTED_STEP_ERROR_HANDLER): + self._effective_handler = effective_handler + self.received_step_error_handler = None + + def __workflows_bind__(self, init_parameters, step_error_handler): + self.received_step_error_handler = step_error_handler + init_parameters["workflows_core.model_manager"] = _StubProvider(self) + init_parameters.setdefault("workflows_core.custom_marker", "installed") + return self._effective_handler + + +class _StubProvider: + """Provider written back by the hook. No ``__workflows_bind__``: reusing + the same manager across engines cannot re-fire, and an explicit provider + supplied by the caller reaches the engine unchanged.""" + + def __init__(self, model_manager): + self.model_manager = model_manager + + +def _v1(engine): + return engine._engine # type: ignore[attr-defined] + + +def _stored(engine): + return _v1(engine)._compiled_workflow.init_parameters + + +# Effective-manager lookup: namespaced/bare precedence, class-level, no invoke. + + +@pytest.mark.parametrize( + "init_parameters", + [ + # Namespaced key wins over bare. + {"workflows_core.model_manager": _HostManager(), "model_manager": object()}, + # Bare key alone still triggers the hook; the bare binding survives. + {"model_manager": _HostManager()}, + ], +) +def test_hook_fires_for_effective_manager(init_parameters): + bare_before = init_parameters.get("model_manager") + engine = ExecutionEngine.init( + workflow_definition=_TRIVIAL_WORKFLOW, init_parameters=init_parameters + ) + stored = _stored(engine) + assert isinstance(stored["workflows_core.model_manager"], _StubProvider) + if bare_before is not None: + assert stored["model_manager"] is bare_before + + +def test_explicit_none_on_namespaced_key_disables_hook_over_bare(): + # An explicit ``None`` means "no manager"; the bare binding must NOT be + # promoted by the hook path. + bare = _HostManager() + engine = ExecutionEngine.init( + workflow_definition=_TRIVIAL_WORKFLOW, + init_parameters={ + "workflows_core.model_manager": None, + "model_manager": bare, + }, + ) + stored = _stored(engine) + assert stored["workflows_core.model_manager"] is None + assert stored["model_manager"] is bare + + +def test_permissive_getattr_does_not_opt_in(): + # The mock's TYPE is ``MagicMock`` (no ``__workflows_bind__``); instance + # attribute access is permissive but the class-level lookup must miss. + permissive = MagicMock() + engine = ExecutionEngine.init( + workflow_definition=_TRIVIAL_WORKFLOW, + init_parameters={"workflows_core.model_manager": permissive}, + ) + assert _stored(engine)["workflows_core.model_manager"] is permissive + + +def test_instance_only_attribute_does_not_opt_in(): + fresh = _HostManager() + + class _Bare: + pass + + bare = _Bare() + bare.__workflows_bind__ = MethodType( # type: ignore[attr-defined] + _HostManager.__workflows_bind__, fresh + ) + engine = ExecutionEngine.init( + workflow_definition=_TRIVIAL_WORKFLOW, + init_parameters={"workflows_core.model_manager": bare}, + ) + assert _stored(engine)["workflows_core.model_manager"] is bare + + +def test_inherited_hook_still_fires(): + # ``ModelManagerDecorator`` subclasses ``ModelManager``; a subclass that + # inherits ``__workflows_bind__`` must still opt in. + class _Sub(_HostManager): + pass + + sub = _Sub() + engine = ExecutionEngine.init( + workflow_definition=_TRIVIAL_WORKFLOW, + init_parameters={"workflows_core.model_manager": sub}, + ) + assert isinstance(_stored(engine)["workflows_core.model_manager"], _StubProvider) + + +# step_error_handler semantics through the hook seam. + + +def test_engine_uses_hook_returned_handler_for_omitted_argument(): + def handler(step_name, error): + return None + + manager = _HostManager(effective_handler=handler) + engine = ExecutionEngine.init( + workflow_definition=_TRIVIAL_WORKFLOW, + init_parameters={"workflows_core.model_manager": manager}, + ) + assert manager.received_step_error_handler is OMITTED_STEP_ERROR_HANDLER + assert _v1(engine)._step_error_handler is handler + + +def test_v1_direct_entry_point_also_fires_hook_and_uses_returned_handler(): + def handler(step_name, error): + return None + + manager = _HostManager(effective_handler=handler) + v1_engine = ExecutionEngineV1.init( + workflow_definition=_TRIVIAL_WORKFLOW, + init_parameters={"workflows_core.model_manager": manager}, + ) + assert v1_engine._step_error_handler is handler + + +def test_engine_forwards_explicit_callable_to_hook_and_preserves_identity(): + def explicit(step_name, error): + return None + + manager = _HostManager(effective_handler=explicit) + engine = ExecutionEngine.init( + workflow_definition=_TRIVIAL_WORKFLOW, + init_parameters={"workflows_core.model_manager": manager}, + step_error_handler=explicit, + ) + assert manager.received_step_error_handler is explicit + assert _v1(engine)._step_error_handler is explicit + + +def test_standalone_default_applies_when_no_hook_and_omitted(): + engine = ExecutionEngine.init(workflow_definition=_TRIVIAL_WORKFLOW) + expected = REGISTERED_STEP_ERROR_HANDLERS[DEFAULT_WORKFLOWS_STEP_ERROR_HANDLER] + assert _v1(engine)._step_error_handler is expected + + +# Non-hook providers, private dict, per-engine lifetime. + + +def test_explicit_provider_reaches_engine_unchanged(): + provider = _StubProvider(model_manager=object()) + caller_dict = { + "workflows_core.model_manager": provider, + "workflows_core.custom_marker": "caller-value", + } + engine = ExecutionEngine.init( + workflow_definition=_TRIVIAL_WORKFLOW, init_parameters=caller_dict + ) + stored = _stored(engine) + assert stored["workflows_core.model_manager"] is provider + assert stored["workflows_core.custom_marker"] == "caller-value" + + +def test_caller_dictionary_is_never_mutated(): + manager = _HostManager() + caller_dict = {"workflows_core.model_manager": manager} + ExecutionEngine.init( + workflow_definition=_TRIVIAL_WORKFLOW, init_parameters=caller_dict + ) + assert set(caller_dict) == {"workflows_core.model_manager"} + assert caller_dict["workflows_core.model_manager"] is manager