```python:inference/core/roboflow_api.py
import base64
import binascii
import hashlib
import json
import os
import re
import urllib.parse
from hashlib import sha256
from pathlib import Path
from typing import List, Optional, Tuple, Union

import backoff
import requests
from inference.core import logger
from inference.core.cache import cache
from inference.core.cache.base import BaseCache
from inference.core.entities.types import WorkspaceID
from inference.core.env import (API_BASE_URL, MD5_VERIFICATION_ENABLED,
                                MODEL_CACHE_DIR,
                                RETRY_CONNECTION_ERRORS_TO_ROBOFLOW_API,
                                ROBOFLOW_API_REQUEST_TIMEOUT,
                                ROBOFLOW_API_VERIFY_SSL,
                                TRANSIENT_ROBOFLOW_API_ERRORS,
                                TRANSIENT_ROBOFLOW_API_ERRORS_RETRIES,
                                TRANSIENT_ROBOFLOW_API_ERRORS_RETRY_INTERVAL,
                                USE_FILE_CACHE_FOR_WORKFLOWS_DEFINITIONS,
                                WORKFLOWS_DEFINITION_CACHE_EXPIRY)
from inference.core.exceptions import (MalformedWorkflowResponseError,
                                       PaymentRequiredError, RetryRequestError,
                                       RoboflowAPIForbiddenError,
                                       RoboflowAPINotAuthorizedError,
                                       RoboflowAPINotNotFoundError,
                                       RoboflowAPIUnsuccessfulRequestError,
                                       RoboflowAPIUsagePausedError)
from inference.core.utils.requests import api_key_safe_raise_for_status
from inference.core.utils.url_utils import wrap_url
from requests import Response, Timeout

NOT_FOUND_ERROR_MESSAGE = (
    "Could not find requested Roboflow resource. Check that the provided dataset and "
    "version are correct, and check that the provided Roboflow API key has the correct permissions."
)

ROBOFLOW_INFERENCE_VERSION_HEADER = "X-Roboflow-Inference-Version"
ALLOW_CHUNKED_RESPONSE_HEADER = "X-Allow-Chunked"


DEFAULT_ERROR_HANDLERS = {
    401: lambda e: raise_from_lambda(
        e,
        RoboflowAPINotAuthorizedError,
        "Unauthorized access to roboflow API - check API key. Visit "
        "https://docs.roboflow.com/api-reference/authentication#retrieve-an-api-key to learn how to retrieve one.",
    ),
    402: lambda e: raise_from_lambda(
        e,
        PaymentRequiredError,
        "Not enough credits to perform this request. Verify your workspace billing page.",
    ),
    403: lambda e: raise_from_lambda(
        e,
        RoboflowAPIForbiddenError,
        "Unauthorized access to roboflow API - check API key regarding correctness and required scopes. Visit "
        "https://docs.roboflow.com/api-reference/authentication#retrieve-an-api-key to learn how to retrieve one.",
    ),
    404: lambda e: raise_from_lambda(
        e, RoboflowAPINotNotFoundError, NOT_FOUND_ERROR_MESSAGE
    ),
    423: lambda e: raise_from_lambda(
        e,
        RoboflowAPIUsagePausedError,
        "Roboflow API usage is paused. Please contact your workspace administrator to re-enable api keys.",
    ),
}



_workflow_cache_key_memo: dict = {}


def cache_workflow_response(
    workspace_id: WorkspaceID,
    workflow_id: str,
    api_key: Optional[str],
    response: dict,
    workflow_version_id: Optional[str] = None,
):
    workflow_cache_file = get_workflow_cache_file(
        workspace_id=workspace_id,
        workflow_id=workflow_id,
        api_key=api_key,
        workflow_version_id=workflow_version_id,
    )
    workflow_cache_dir = os.path.dirname(workflow_cache_file)
    os.makedirs(workflow_cache_dir, exist_ok=True)
    with open(workflow_cache_file, "w") as f:
        json.dump(response, f)




def load_cached_workflow_response(
    workspace_id: WorkspaceID,
    workflow_id: str,
    api_key: Optional[str],
    workflow_version_id: Optional[str] = None,
) -> Optional[dict]:
    workflow_cache_file = get_workflow_cache_file(
        workspace_id=workspace_id,
        workflow_id=workflow_id,
        api_key=api_key,
        workflow_version_id=workflow_version_id,
    )
    try:
        with open(workflow_cache_file, "r") as f:
            return json.load(f)
    except FileNotFoundError:
        return None
    except Exception:
        delete_cached_workflow_response_if_exists(
            workspace_id=workspace_id,
            workflow_id=workflow_id,
            api_key=api_key,
            workflow_version_id=workflow_version_id,
        )


@wrap_roboflow_api_errors()
def get_workflow_specification(
    api_key: Optional[str],
    workspace_id: WorkspaceID,
    workflow_id: str,
    use_cache: bool = True,
    ephemeral_cache: Optional[BaseCache] = None,
    workflow_version_id: Optional[str] = None,
) -> dict:
    ephemeral_cache = ephemeral_cache or cache
    if use_cache:
        cached_entry = _retrieve_workflow_specification_from_ephemeral_cache(
            api_key=api_key,
            workspace_id=workspace_id,
            workflow_id=workflow_id,
            workflow_version_id=workflow_version_id,
            ephemeral_cache=ephemeral_cache,
        )
        if cached_entry:
            return cached_entry

    if workspace_id == "local":
        if not re.match(r"^[\w\-]+$", workflow_id):
            raise ValueError("Invalid workflow id")

        workflow_hash = sha256(workflow_id.encode()).hexdigest()
        local_file_path = (
            Path(MODEL_CACHE_DIR) / "workflow" / "local" / f"{workflow_hash}.json"
        )
        if not local_file_path.exists():
            raise FileNotFoundError(f"Local workflow file not found: {local_file_path}")

        with local_file_path.open("r", encoding="utf-8") as f:
            local_config = json.load(f)

        # Mimic the same shape as the cloud response:
        response = {"workflow": local_config}
    else:
        params = []
        if api_key is not None:
            params.append(("api_key", api_key))
        if workflow_version_id is not None:
            params.append(("workflow_version", workflow_version_id))
        api_url = _add_params_to_url(
            url=f"{API_BASE_URL}/{workspace_id}/workflows/{workflow_id}",
            params=params,
        )
        try:
            response = _get_from_url(url=api_url)
            if USE_FILE_CACHE_FOR_WORKFLOWS_DEFINITIONS:
                cache_workflow_response(
                    workspace_id=workspace_id,
                    workflow_id=workflow_id,
                    api_key=api_key,
                    response=response,
                    workflow_version_id=workflow_version_id,
                )
        except (
            requests.exceptions.ConnectionError,
            ConnectionError,
            requests.exceptions.Timeout,
        ) as error:
            if not USE_FILE_CACHE_FOR_WORKFLOWS_DEFINITIONS:
                raise error
            response = load_cached_workflow_response(
                workspace_id=workspace_id,
                workflow_id=workflow_id,
                api_key=api_key,
                workflow_version_id=workflow_version_id,
            )
            if response is None:
                raise error

    if "workflow" not in response or "config" not in response["workflow"]:
        raise MalformedWorkflowResponseError(
            "Could not find workflow specification in API response"
        )
    try:
        workflow_config = json.loads(response["workflow"]["config"])
        specification = workflow_config["specification"]
        if isinstance(specification, dict):
            specification["id"] = response["workflow"].get("id")
        if use_cache:
            _cache_workflow_specification_in_ephemeral_cache(
                api_key=api_key,
                workspace_id=workspace_id,
                workflow_id=workflow_id,
                workflow_version_id=workflow_version_id,
                specification=specification,
                ephemeral_cache=ephemeral_cache,
            )
        return specification
    except KeyError as error:
        raise MalformedWorkflowResponseError(
            "Workflow specification not found in Roboflow API response"
        ) from error
    except (ValueError, TypeError) as error:
        raise MalformedWorkflowResponseError(
            "Could not decode workflow specification in Roboflow API response"
        ) from error


def _retrieve_workflow_specification_from_ephemeral_cache(
    api_key: Optional[str],
    workspace_id: WorkspaceID,
    workflow_id: str,
    ephemeral_cache: BaseCache,
    workflow_version_id: Optional[str] = None,
) -> Optional[dict]:
    memo_key = (api_key, workspace_id, workflow_id, workflow_version_id)
    cache_key = _workflow_cache_key_memo.get(memo_key)
    if cache_key is None:
        cache_key = _prepare_workflow_response_cache_key(
            api_key=api_key,
            workspace_id=workspace_id,
            workflow_id=workflow_id,
            workflow_version_id=workflow_version_id,
        )
        _workflow_cache_key_memo[memo_key] = cache_key
    return ephemeral_cache.get(key=cache_key)


def _cache_workflow_specification_in_ephemeral_cache(
    api_key: Optional[str],
    workspace_id: WorkspaceID,
    workflow_id: str,
    specification: dict,
    ephemeral_cache: BaseCache,
    workflow_version_id: Optional[str] = None,
) -> None:
    memo_key = (api_key, workspace_id, workflow_id, workflow_version_id)
    cache_key = _workflow_cache_key_memo.get(memo_key)
    if cache_key is None:
        cache_key = _prepare_workflow_response_cache_key(
            api_key=api_key,
            workspace_id=workspace_id,
            workflow_id=workflow_id,
            workflow_version_id=workflow_version_id,
        )
        _workflow_cache_key_memo[memo_key] = cache_key
    ephemeral_cache.set(
        key=cache_key,
        value=specification,
        expire=WORKFLOWS_DEFINITION_CACHE_EXPIRY,
    )


@backoff.on_exception(
    backoff.constant,
    exception=RetryRequestError,
    max_tries=TRANSIENT_ROBOFLOW_API_ERRORS_RETRIES,
    interval=TRANSIENT_ROBOFLOW_API_ERRORS_RETRY_INTERVAL,
)
def _get_from_url(
    url: str,
    json_response: bool = True,
    headers: Optional[dict] = None,
) -> Union[Response, dict]:
    full_url = wrap_url(url)
    try:
        response = requests.get(
            full_url,
            headers=build_roboflow_api_headers(explicit_headers=headers),
            timeout=ROBOFLOW_API_REQUEST_TIMEOUT,
            verify=ROBOFLOW_API_VERIFY_SSL,
        )

    except (ConnectionError, Timeout, requests.exceptions.ConnectionError) as error:
        if RETRY_CONNECTION_ERRORS_TO_ROBOFLOW_API:
            raise RetryRequestError(
                message="Connectivity error", inner_error=error
            ) from error
        raise error
    try:
        api_key_safe_raise_for_status(response=response)
    except Exception as error:
        if response.status_code in TRANSIENT_ROBOFLOW_API_ERRORS:
            raise RetryRequestError(message=str(error), inner_error=error) from error
        raise error

    if MD5_VERIFICATION_ENABLED:
        x_goog_hash = response.headers.get("x-goog-hash")
        if x_goog_hash is None:
            logger.warning(
                f"MD5 verification enabled but response missing x-goog-hash header. "
                f"Request url: {_url_for_safe_logging(full_url)}"
            )
        else:
            md5_part = None
            for part in x_goog_hash.split(","):
                if part.strip().startswith("md5="):
                    md5_part = part.strip()[4:]
                    break
            if md5_part is not None:
                try:
                    md5_from_header = base64.b64decode(md5_part)
                except binascii.Error as decode_error:
                    raise RoboflowAPIUnsuccessfulRequestError(
                        "Invalid MD5 value in x-goog-hash header: not valid base64"
                    ) from decode_error
                if md5_from_header != hashlib.md5(response.content).digest():
                    raise RoboflowAPIUnsuccessfulRequestError(
                        "MD5 hash does not match MD5 received from x-goog-hash header"
                    )
            else:
                logger.warning(
                    f"MD5 verification enabled but x-goog-hash header has no md5= part. "
                    f"Request url: {_url_for_safe_logging(full_url)}"
                )

    if json_response:
        return response.json()
    return response


def _add_params_to_url(url: str, params: List[Tuple[str, str]]) -> str:
    if len(params) == 0:
        return url
    params_chunks = [
        f"{name}={urllib.parse.quote_plus(value)}" for name, value in params
    ]
    parameters_string = "&".join(params_chunks)
    return f"{url}?{parameters_string}"
```