Spaces:
Running
Running
| # scikitplot/_externals/_sphinx_ext/_sphinx_ai_assistant/_hf_spaces_proxy/_utils/_shared_logic.py | |
| # | |
| # flake8: noqa: D213 | |
| # | |
| # Authors: The scikit-plots developers | |
| # SPDX-License-Identifier: BSD-3-Clause | |
| # _shared_logic.py v7.0.0 | |
| # | |
| # Single source of truth for shared constants, pure helper functions, and | |
| # type aliases used by the deployed proxy (_hf_spaces_proxy/app.py) and the | |
| # local development proxy (dev_proxy.py). | |
| # | |
| # Import discipline | |
| # ----------------- | |
| # Only the Python standard library is imported here. httpx, fastapi, and | |
| # torch are NOT imported so this module can be sourced by stdlib-only tools | |
| # (dev_proxy) and tested in isolation without any network or GPU environment. | |
| # | |
| # Routing paths (v6.0.0) | |
| # ---------------------- | |
| # Three ordered routing paths β each with its own configurable read timeout: | |
| # | |
| # Path 1 β BACKEND_URL set (explicit override) | |
| # Forward to BACKEND_URL. Only BACKEND_AUTH_TOKEN may be attached by callers. | |
| # Read timeout: proxy_timeout kwarg (env: PROXY_TIMEOUT, default 600 s). | |
| # | |
| # Path 2 β Model namespace in HF_SPACES_MODEL_NAMESPACES | |
| # Model owner (e.g. "scikit-plots") matches a custom namespace. | |
| # Forward to HF_SPACES_MODEL_URL (the ai-model HF Space, CPU inference). | |
| # These models have no HF Inference Provider β direct HF API returns 404/503. | |
| # Read timeout: path2_read_timeout kwarg (env: PATH2_TIMEOUT, default 600 s). | |
| # CPU inference on a 7B model takes 4-5 minutes; 600 s gives safe headroom. | |
| # | |
| # Path 3 β Standard HF Inference API (default) | |
| # Model has a registered HF Inference Provider (openai/*, Qwen/*, etc.). | |
| # Forward to HF_BASE/{model}/v1/chat/completions with HF_TOKEN. | |
| # Read timeout: path3_read_timeout kwarg (env: PATH3_TIMEOUT, default 120 s). | |
| # HF Serverless API (GPU-backed) normally responds within 30-90 s. | |
| # | |
| # Breaking changes v4.0.0 β v5.0.0 | |
| # ---------------------------------- | |
| # + DEFAULT_PROXY_TIMEOUT raised from 120 s to 600 s. | |
| # Root cause: 120 s was shorter than the 4-5 min CPU inference on the | |
| # ai-model HF Space, causing every request to return a network error. | |
| # + DEFAULT_PATH2_READ_TIMEOUT added (600 s) β ai-model space per-path timeout. | |
| # + DEFAULT_PATH3_READ_TIMEOUT added (120 s) β HF API per-path timeout. | |
| # + _resolve_upstream_url now accepts path2_read_timeout, path3_read_timeout, | |
| # and proxy_timeout keyword-only parameters. | |
| # + _resolve_upstream_url return type changed from tuple[str, dict] to | |
| # tuple[str, dict, float] β the third element is the per-path read timeout. | |
| # Callers must unpack all three values. | |
| # + load_proxy_env extended with path2_read_timeout and path3_read_timeout. | |
| # | |
| # Breaking changes v5.0.0 β v6.0.0 | |
| # ---------------------------------- | |
| # + DEFAULT_HF_BASE changed from ``https://api-inference.huggingface.co/models`` | |
| # to ``https://router.huggingface.co``. | |
| # Root cause: api-inference.huggingface.co was DNS-unresolvable ([Errno -5] | |
| # EAI_NODATA / EAI_NONAME) from within HF Docker Spaces. | |
| # router.huggingface.co is the current HF Inference Providers endpoint and | |
| # resolves correctly in all deployment environments. | |
| # Callers who hard-code ``HF_BASE`` to the old hostname must migrate to | |
| # the new router URL. | |
| # | |
| # New in v6.1.0 β Three-type HF token system | |
| # ------------------------------------------- | |
| # + ``HFTokenType`` literal type alias added: ``"fine-grained" | "read" | | |
| # ``"write" | "unknown"``. Maps directly to the three token types exposed | |
| # in HF Settings β Tokens. | |
| # + ``HF_TOKEN_TYPE_*`` string constants and ``HF_INFERENCE_TOKEN_TYPES`` / | |
| # ``HF_WRITE_TOKEN_TYPES`` frozensets added for type-safe comparisons. | |
| # + ``_classify_token_type()`` β classify a token by explicit env-var | |
| # declaration (``HF_TOKEN_TYPE``, ``HF_WRITE_TOKEN_TYPE``) with a length- | |
| # based heuristic fallback. | |
| # + ``_token_suitable_for_inference()`` / ``_token_suitable_for_writes()`` | |
| # predicates for principle-of-least-privilege validation. | |
| # + ``_validate_token_config()`` β returns actionable WARNING / ERROR strings | |
| # for token-type mismatches detected at startup. | |
| # + ``_token_log_fragment()`` gains an optional ``token_type`` parameter so | |
| # log lines include the token type (e.g. ``hf_abcde...1234 (read)``). | |
| # + ``load_proxy_env()`` extended with ``hf_token_type`` and | |
| # ``hf_write_token_type`` keys read from the matching env vars. | |
| # + ``_safe_float`` added to ``__all__`` (was importable but unadvertised). | |
| """ | |
| Shared utilities for the sphinx-ai-assistant proxy solutions. | |
| This module provides pure, stateless helper functions and typed constants | |
| that are common to all server-side proxy implementations. It has **no** | |
| runtime dependencies beyond the Python standard library. | |
| Public API: | |
| PROXY_VERSION : str | |
| Proxy release version string. | |
| DEFAULT_HF_BASE : str | |
| HuggingFace Serverless Inference API base URL. | |
| DEFAULT_MODEL : str | |
| Fallback model ID when the request body omits ``model``. | |
| DEFAULT_PROXY_TIMEOUT : int | |
| Global upstream read timeout in seconds (Path 1 / backward-compat). | |
| DEFAULT_PATH2_READ_TIMEOUT : float | |
| Per-path read timeout for Path 2 (ai-model space, CPU inference). | |
| DEFAULT_PATH3_READ_TIMEOUT : float | |
| Per-path read timeout for Path 3 (HF Serverless Inference API). | |
| DEFAULT_MAX_BODY_BYTES : int | |
| Maximum accepted request body size. | |
| DEFAULT_HF_SPACES_MODEL_URL : str | |
| Default URL for the custom ai-model HF Space (Path 2). | |
| DEFAULT_HF_SPACES_MODEL_NAMESPACES : tuple[str, ...] | |
| Default model owner namespaces routed to the model Space (Path 2). | |
| _safe_int : callable | |
| Parse an integer environment variable with a safe fallback. | |
| _parse_model : callable | |
| Extract the ``model`` field from a raw JSON request body. | |
| _is_custom_model_namespace : callable | |
| Return True when a model's owner namespace is in the custom list. | |
| _build_cors_headers : callable | |
| Return the CORS response-header mapping. | |
| _token_log_fragment : callable | |
| Produce a safely-truncated token string for log output. | |
| _resolve_upstream_url : callable | |
| Centralised three-path routing: choose upstream URL, auth headers, | |
| and per-path read timeout. | |
| _validate_env : callable | |
| Fail-fast startup check with actionable error messages. | |
| load_proxy_env : callable | |
| Read all proxy-relevant environment variables into a typed dict. | |
| Notes | |
| ----- | |
| **Developer note** β All functions are pure (no side effects, no I/O). | |
| Tests can import this module without a running event loop or any network. | |
| The proxy (FastAPI / asyncio) and dev_proxy (stdlib HTTPServer) both import | |
| from here so that routing and CORS logic are *never* duplicated. | |
| **Breaking change v5.0.0** β ``_resolve_upstream_url`` now returns a | |
| 3-tuple ``(url, headers, read_timeout_s: float)`` instead of the previous | |
| 2-tuple ``(url, headers)``. All callers must unpack the third element or | |
| the per-path timeout falls through to the old flat-timeout behaviour. | |
| **Breaking change v6.0.0** β :data:`DEFAULT_HF_BASE` migrated from | |
| ``https://api-inference.huggingface.co/models`` to | |
| ``https://router.huggingface.co``. The old hostname was DNS-unresolvable | |
| ([Errno -5] EAI_NONAME) from within HF Docker Spaces. Deployments that | |
| override ``HF_BASE`` to the legacy hostname must update their configuration. | |
| **Security note** β :func:`_token_log_fragment` ensures the full API token | |
| never appears in log output. Never widen the exposed fragment beyond the | |
| current 8+4 character window without reviewing log-aggregation policy first. | |
| **Versioning note** β Bump :data:`PROXY_VERSION` on every breaking change so | |
| deployed Spaces and log aggregators can correlate errors to a specific release. | |
| """ | |
| from __future__ import annotations | |
| import ipaddress | |
| import json | |
| import logging | |
| import os | |
| import re | |
| from typing import Any, Literal | |
| from urllib.parse import urlsplit | |
| try: | |
| from ._telemetry import sanitize_log_text | |
| except ImportError: # standalone HF Space deployment | |
| from _utils._telemetry import sanitize_log_text | |
| logger = logging.getLogger(__name__) | |
| __all__ = [ # noqa: RUF022 | |
| # Version | |
| "PROXY_VERSION", | |
| # Constants β routing / timeout | |
| "DEFAULT_HF_BASE", | |
| "DEFAULT_HF_PROVIDER_MODELS", | |
| "DEFAULT_HF_SPACES_MODEL_NAMESPACES", | |
| "DEFAULT_HF_SPACES_MODEL_URL", | |
| "DEFAULT_MAX_BODY_BYTES", | |
| "DEFAULT_MODEL", | |
| "DEFAULT_PATH2_READ_TIMEOUT", | |
| "DEFAULT_PATH3_READ_TIMEOUT", | |
| "DEFAULT_PROXY_TIMEOUT", | |
| # Constants β token type system (v6.1.0) | |
| "HFTokenType", | |
| "HF_TOKEN_TYPE_FINE_GRAINED", | |
| "HF_TOKEN_TYPE_READ", | |
| "HF_TOKEN_TYPE_WRITE", | |
| "HF_TOKEN_TYPE_UNKNOWN", | |
| "HF_INFERENCE_TOKEN_TYPES", | |
| "HF_WRITE_TOKEN_TYPES", | |
| # Helpers β general | |
| "_build_cors_headers", | |
| "_is_custom_model_namespace", | |
| "_parse_model", | |
| "_safe_float", | |
| "_safe_int", | |
| "_token_log_fragment", | |
| # Privacy / log-redaction (v6.2.0) | |
| "_REDACT_PATTERNS", | |
| "_RedactingFilter", | |
| "_mask_ip", | |
| # Helpers β token type system (v6.1.0) | |
| "_classify_token_type", | |
| "_token_suitable_for_inference", | |
| "_token_suitable_for_writes", | |
| "_validate_token_config", | |
| # Helpers β routing / env | |
| "_resolve_upstream_url", | |
| "_validate_credential_destination", | |
| "_validate_env", | |
| "load_proxy_env", | |
| ] | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Module-level constants | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| #: Proxy release version β bump on every breaking change. | |
| PROXY_VERSION: str = "7.4.0" | |
| #: HuggingFace Inference Providers router base URL (no trailing slash). | |
| #: Only used for Path 3 (standard provider models) when ``BACKEND_URL`` is | |
| #: empty and the model namespace is not in ``HF_SPACES_MODEL_NAMESPACES``. | |
| #: | |
| #: Migrated from ``https://api-inference.huggingface.co/models`` (v5.0.0) to | |
| #: ``https://router.huggingface.co`` (v6.0.0). | |
| #: Root cause: api-inference.huggingface.co was DNS-unresolvable ([Errno -5] | |
| #: EAI_NODATA / EAI_NONAME) from within HF Docker Spaces; the router hostname | |
| #: resolves correctly and is the current HF Inference Providers endpoint. | |
| DEFAULT_HF_BASE: str = "https://router.huggingface.co" | |
| #: Public Hugging Face Inference Provider models advertised by the bundled | |
| #: example configuration. Keep this default synchronized with the Cloudflare | |
| #: Worker so both bundled proxies accept the same public model choices. | |
| #: Operators can replace the exact set with ``ALLOWED_MODELS``. | |
| DEFAULT_HF_PROVIDER_MODELS: tuple[str, ...] = ( | |
| "Qwen/Qwen2.5-Coder-7B-Instruct", | |
| "Qwen/Qwen2.5-Coder-32B-Instruct", | |
| "openai/gpt-oss-20b", | |
| ) | |
| #: Fallback model ID when the request body omits the ``model`` field. | |
| #: Must have a registered HF Inference Provider for Path 3. | |
| DEFAULT_MODEL: str = "scikit-plots/Qwen2.5-Coder-7B-Instruct" | |
| #: Global upstream read timeout in seconds (used for Path 1 / backward compat). | |
| #: | |
| #: Raised from 120 s (v4.0.0) to 600 s (v5.0.0). | |
| #: | |
| #: Root cause of the increase: the ai-model HF Space runs a 7B model on CPU | |
| #: basic hardware. Cold-start inference (model loading + generation) takes | |
| #: 4-5 minutes. The 120 s ceiling caused every request to the ai-model Space | |
| #: to return ``httpx.ReadTimeout``, which the browser reported as | |
| #: "Sorry, something went wrong: network error". | |
| DEFAULT_PROXY_TIMEOUT: int = 600 | |
| #: Per-path read timeout for Path 2 (ai-model HF Space, CPU inference). | |
| #: | |
| #: CPU inference on a 7B model takes 4-5 minutes. 600 s gives 1 minute of | |
| #: additional headroom for cold-start model loading (~50 s tokenizer + | |
| #: ~50 s model load + ~4.5 min generation on the first request). | |
| DEFAULT_PATH2_READ_TIMEOUT: float = 600.0 | |
| #: Per-path read timeout for Path 3 (HF Serverless Inference API). | |
| #: | |
| #: The HF Serverless API runs inference on GPU hardware. Most responses | |
| #: arrive within 30-90 s. 120 s gives a comfortable margin. | |
| DEFAULT_PATH3_READ_TIMEOUT: float = 120.0 | |
| #: Maximum accepted request body size in bytes (10 MiB). | |
| #: Prevents memory exhaustion from maliciously oversized POST bodies. | |
| DEFAULT_MAX_BODY_BYTES: int = 10 * 1024 * 1024 # 10 MiB | |
| #: Default URL for the custom ai-model HF Space (Path 2). | |
| #: Requests for models whose namespace is in ``DEFAULT_HF_SPACES_MODEL_NAMESPACES`` | |
| #: are forwarded here instead of the HF Serverless Inference API. | |
| #: Overridable via the ``HF_SPACES_MODEL_URL`` environment variable. | |
| DEFAULT_HF_SPACES_MODEL_URL: str = ( | |
| "https://scikit-plots-ai-model.hf.space/v1/chat/completions" | |
| ) | |
| #: Default model owner namespaces routed to :data:`DEFAULT_HF_SPACES_MODEL_URL`. | |
| #: Models whose owner (the part before ``/``) matches any entry in this tuple | |
| #: are routed to the ai-model Space (Path 2) rather than the HF API (Path 3). | |
| #: Overridable via the ``HF_SPACES_MODEL_NAMESPACES`` environment variable. | |
| DEFAULT_HF_SPACES_MODEL_NAMESPACES: tuple[str, ...] = ("scikit-plots",) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # HuggingFace token type system (v6.1.0) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # | |
| # HuggingFace exposes exactly three token types in | |
| # https://huggingface.co/settings/tokens: | |
| # | |
| # β Fine-grained β New-style token. Permissions set at creation time: | |
| # choose any combination of per-repo access levels and | |
| # API capabilities. Recommended for production because | |
| # each token carries only the minimum required scope. | |
| # | |
| # β‘ Read (classic) β Legacy read-only token. Grants read access to all | |
| # public repos and any private repos you can access. | |
| # Always includes the Serverless Inference API capability. | |
| # Cannot push commits or create repos. | |
| # | |
| # β’ Write (classic)β Legacy read+write token. All read permissions plus | |
| # the ability to push commits, create repos, manage | |
| # members, etc. Over-privileged for inference-only use. | |
| # | |
| # Mapping to proxy env vars | |
| # βββββββββββββββββββββββββ | |
| # HF_TOKEN β inference token (Path 2 private Space + Path 3 HF API). | |
| # Best practice: fine-grained with inference-api scope only, | |
| # OR classic read. Never use a write token here. | |
| # | |
| # HF_DATASET_TOKEN β preferred dataset-persistence token. Best practice: | |
| # fine-grained scoped to ONE dataset repo. Classic Write | |
| # also works; classic Read never does. | |
| # HF_WRITE_TOKEN β historical alias for HF_DATASET_TOKEN. | |
| # | |
| # Optional type-declaration env vars (Space β Settings β Repository secrets): | |
| # HF_TOKEN_TYPE = fine-grained | read | write (default: auto-detect) | |
| # HF_DATASET_TOKEN_TYPE = fine-grained | read | write (preferred) | |
| # HF_WRITE_TOKEN_TYPE = fine-grained | read | write (legacy alias) | |
| # | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| #: Literal type for HuggingFace token type labels. | |
| #: Use as type annotation and for exhaustive ``isinstance``-free comparisons. | |
| HFTokenType = Literal["fine-grained", "read", "write", "unknown"] | |
| #: New-style fine-grained HF token. Permissions defined at creation time. | |
| #: Declare via env var: ``HF_TOKEN_TYPE=fine-grained``. | |
| HF_TOKEN_TYPE_FINE_GRAINED: str = "fine-grained" # noqa: S105 | |
| #: Classic HF read token. Read + Inference API; no write capability. | |
| #: Declare via env var: ``HF_TOKEN_TYPE=read``. | |
| HF_TOKEN_TYPE_READ: str = "read" # noqa: S105 | |
| #: Classic HF write token. All read permissions + repo push capability. | |
| #: Declare via env var: ``HF_TOKEN_TYPE=write`` or ``HF_WRITE_TOKEN_TYPE=write``. | |
| HF_TOKEN_TYPE_WRITE: str = "write" # noqa: S105 | |
| #: Sentinel: token type not declared and could not be inferred. | |
| #: Runtime operations are not blocked, but :func:`_validate_token_config` omits | |
| #: least-privilege warnings because the type is unknown. | |
| HF_TOKEN_TYPE_UNKNOWN: str = "unknown" # noqa: S105 | |
| #: Token types that are appropriate for HF Serverless Inference API calls | |
| #: (Path 3) and private HF Space access (Path 2). | |
| #: | |
| #: Classic write tokens ARE technically capable of inference (write β read), | |
| #: but are excluded from this set so :func:`_validate_token_config` can emit | |
| #: a startup warning when a write token is used where a read / fine-grained | |
| #: token is the correct choice. The ``"unknown"`` sentinel is included so | |
| #: that un-declared tokens do not trigger false-positive warnings. | |
| HF_INFERENCE_TOKEN_TYPES: frozenset[str] = frozenset( | |
| { | |
| HF_TOKEN_TYPE_FINE_GRAINED, | |
| HF_TOKEN_TYPE_READ, | |
| HF_TOKEN_TYPE_UNKNOWN, | |
| } | |
| ) | |
| #: Token types that can push commits to HuggingFace repos and datasets. | |
| #: | |
| #: Classic read tokens **cannot** write β any ``HfApi.create_commit`` call | |
| #: returns HTTP 403 / 401. ``"unknown"`` is excluded so that | |
| #: :func:`_validate_token_config` can flag a read token configured as the write | |
| #: token as a hard error rather than silently failing at request time. | |
| HF_WRITE_TOKEN_TYPES: frozenset[str] = frozenset( | |
| { | |
| HF_TOKEN_TYPE_FINE_GRAINED, | |
| HF_TOKEN_TYPE_WRITE, | |
| } | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Pure helper functions | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _safe_int(value: str | None, default: int) -> int: | |
| """ | |
| Parse *value* as an integer, returning *default* on any failure. | |
| Parameters | |
| ---------- | |
| value : str or None | |
| String to parse. Typically the raw value of an environment variable | |
| (may be ``None`` when the variable is absent). | |
| default : int | |
| Returned when *value* is ``None``, empty, or cannot be converted. | |
| Returns | |
| ------- | |
| int | |
| Parsed integer, or *default* on any ``ValueError`` / ``TypeError``. | |
| Notes | |
| ----- | |
| **Developer note** β This function is intentionally never-raise. | |
| A misconfigured ``PROXY_TIMEOUT`` or ``MAX_BODY_BYTES`` must not prevent | |
| the proxy from starting β the safe default is better than a crash. | |
| Examples | |
| -------- | |
| >>> _safe_int("120", 60) | |
| 120 | |
| >>> _safe_int("not-a-number", 60) | |
| 60 | |
| >>> _safe_int(None, 60) | |
| 60 | |
| >>> _safe_int("", 60) | |
| 60 | |
| """ | |
| if value is None: | |
| return default | |
| try: | |
| return int(value) | |
| except (ValueError, TypeError): | |
| return default | |
| def _safe_float(value: str | None, default: float) -> float: | |
| """ | |
| Parse *value* as a float, returning *default* on any failure. | |
| Parameters | |
| ---------- | |
| value : str or None | |
| String to parse. Typically the raw value of an environment variable. | |
| default : float | |
| Returned when *value* is ``None``, empty, or cannot be converted. | |
| Returns | |
| ------- | |
| float | |
| Parsed float, or *default* on any ``ValueError`` / ``TypeError``. | |
| Notes | |
| ----- | |
| **Developer note** β Like :func:`_safe_int`, this is intentionally | |
| never-raise. A misconfigured ``PATH2_TIMEOUT`` or ``PATH3_TIMEOUT`` | |
| must not crash the proxy at startup. | |
| Examples | |
| -------- | |
| >>> _safe_float("600.0", 120.0) | |
| 600.0 | |
| >>> _safe_float("bad", 120.0) | |
| 120.0 | |
| >>> _safe_float(None, 120.0) | |
| 120.0 | |
| """ | |
| if value is None: | |
| return default | |
| try: | |
| return float(value) | |
| except (ValueError, TypeError): | |
| return default | |
| def _parse_model(body: bytes, default: str = DEFAULT_MODEL) -> str: | |
| """ | |
| Extract the ``model`` field from a raw JSON request body. | |
| Parameters | |
| ---------- | |
| body : bytes | |
| Raw HTTP request body forwarded from the browser. Expected to be | |
| valid JSON but the function never raises on malformed input. | |
| default : str, optional | |
| Fallback model ID when the field is absent or the body cannot be | |
| decoded. Defaults to :data:`DEFAULT_MODEL`. | |
| Returns | |
| ------- | |
| str | |
| The ``model`` value from the body, or *default* if the field is | |
| absent, empty, or the body is not valid JSON. | |
| Notes | |
| ----- | |
| **Developer note** β This function is intentionally never-raise. | |
| A malformed body must not crash the proxy; the upstream model backend | |
| will return a meaningful error that the browser can display. | |
| Examples | |
| -------- | |
| >>> _parse_model(b'{"model": "Qwen/Qwen2.5-Coder-7B-Instruct"}') | |
| 'Qwen/Qwen2.5-Coder-7B-Instruct' | |
| >>> _parse_model(b"{}") | |
| 'scikit-plots/Qwen2.5-Coder-7B-Instruct' | |
| >>> _parse_model(b"not-json") | |
| 'scikit-plots/Qwen2.5-Coder-7B-Instruct' | |
| >>> _parse_model(b'{"model": " "}') | |
| 'scikit-plots/Qwen2.5-Coder-7B-Instruct' | |
| """ | |
| try: | |
| data: Any = json.loads(body) | |
| candidate = str(data.get("model", "")).strip() | |
| return candidate or default | |
| except (json.JSONDecodeError, ValueError, AttributeError, TypeError): | |
| return default | |
| def _is_custom_model_namespace( | |
| model: str, | |
| namespaces: tuple[str, ...] | list[str], | |
| ) -> bool: | |
| """ | |
| Return ``True`` when the model owner namespace is in *namespaces*. | |
| The owner is the portion of the model ID before the first ``/``. | |
| An optional HF Router variant suffix (e.g. ``:fastest``) is stripped | |
| before comparison so ``"scikit-plots/Qwen2.5-Coder-7B-Instruct:fastest"`` | |
| is correctly identified as belonging to the ``"scikit-plots"`` namespace. | |
| Parameters | |
| ---------- | |
| model : str | |
| Model ID string, e.g. ``"scikit-plots/Qwen2.5-Coder-7B-Instruct"`` | |
| or ``"openai/gpt-oss-20b:fastest"``. | |
| namespaces : tuple[str, ...] or list[str] | |
| Iterable of owner namespace strings to match against (case-insensitive). | |
| Typically :data:`DEFAULT_HF_SPACES_MODEL_NAMESPACES` or parsed from | |
| the ``HF_SPACES_MODEL_NAMESPACES`` environment variable. | |
| Returns | |
| ------- | |
| bool | |
| ``True`` when the model owner is in *namespaces*, ``False`` otherwise. | |
| Notes | |
| ----- | |
| **Developer note** β Comparison is case-insensitive and strips leading / | |
| trailing whitespace from both the model owner and each namespace entry. | |
| A model string without a ``/`` separator (i.e. no namespace component) | |
| always returns ``False``; such IDs are routed to Path 3 (HF Inference API). | |
| Examples | |
| -------- | |
| >>> _is_custom_model_namespace( | |
| ... "scikit-plots/Qwen2.5-Coder-7B-Instruct", | |
| ... ("scikit-plots",), | |
| ... ) | |
| True | |
| >>> _is_custom_model_namespace( | |
| ... "scikit-plots/Qwen2.5-Coder-7B-Instruct:fastest", | |
| ... ("scikit-plots",), | |
| ... ) | |
| True | |
| >>> _is_custom_model_namespace("openai/gpt-oss-20b", ("scikit-plots",)) | |
| False | |
| >>> _is_custom_model_namespace("no-slash-model", ("scikit-plots",)) | |
| False | |
| """ | |
| base = model.split(":", maxsplit=1)[0].strip() | |
| if not base or "/" not in base: | |
| return False | |
| owner = base.split("/", 1)[0].lower().strip() | |
| normalised = {ns.lower().strip() for ns in namespaces if ns.strip()} | |
| return owner in normalised | |
| def _build_cors_headers(allowed_origin: str = "*") -> dict[str, str]: | |
| """ | |
| Return the standard CORS response-header mapping. | |
| Parameters | |
| ---------- | |
| allowed_origin : str, optional | |
| Value for the ``Access-Control-Allow-Origin`` header. | |
| Defaults to ``"*"`` (allow all origins). | |
| Returns | |
| ------- | |
| dict[str, str] | |
| CORS response headers. | |
| Examples | |
| -------- | |
| >>> headers = _build_cors_headers() | |
| >>> headers["Access-Control-Allow-Origin"] | |
| '*' | |
| """ | |
| return { | |
| "Access-Control-Allow-Origin": allowed_origin, | |
| "Access-Control-Allow-Methods": "POST, OPTIONS", | |
| "Access-Control-Allow-Headers": "Content-Type", | |
| } | |
| def _token_log_fragment(token: str, token_type: str = "") -> str: | |
| """Return non-secret token configuration state for legacy log call sites. | |
| The historical implementation exposed an 8+4 character credential | |
| fragment. Run 5 deliberately removes that behavior: partial credentials | |
| are still credentials and may become identifying/correlatable in retained | |
| logs. Keep the helper name for source compatibility, but return only | |
| presence and optional type metadata. | |
| """ | |
| if not token: | |
| return "<not-set>" | |
| label = str(token_type or "").strip().lower() | |
| return f"<set> ({label})" if label and label != "unknown" else "<set>" | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Privacy / log-redaction helpers (v6.2.0) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # | |
| # Design rationale | |
| # ---------------- | |
| # Two complementary layers protect PII in log output: | |
| # | |
| # Layer 1 β call-site masking via :func:`_mask_ip` | |
| # Every ``json.dumps({..., "ip": ...})`` call in ``app.py`` passes | |
| # ``client_ip`` through :func:`_mask_ip` before it is serialised. | |
| # This is the PRIMARY control: the raw IP never enters the log string. | |
| # | |
| # Layer 2 β defence-in-depth via :class:`_RedactingFilter` | |
| # Attached to the root logging handler. Applies :data:`_REDACT_PATTERNS` | |
| # to the fully formatted message BEFORE it is emitted. Catches: | |
| # β’ HF token strings leaked via exception messages from | |
| # ``huggingface_hub`` (e.g. ``snapshot_download`` auth failures). | |
| # β’ IPv4 addresses emitted by third-party library loggers (httpx, | |
| # uvicorn) that bypass the call-site masking. | |
| # β’ Any future code that forgets to call :func:`_mask_ip` first. | |
| # | |
| # IPv6 is handled exclusively at Layer 1 (:func:`_mask_ip`). A generic | |
| # IPv6 regex in Layer 2 has unacceptable false-positive rates (e.g. it | |
| # would match ``12:34:56:78`` in log timestamps or MAC addresses). | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _mask_ip(ip: str) -> str: | |
| """Mask a client IP address for privacy-safe log output. | |
| Preserves enough network context for rate-limit and abuse analysis while | |
| zeroing the host portion that identifies the individual user. | |
| * **IPv4** β zero the last octet, retaining the /24 subnet. | |
| ``"192.168.1.100"`` β ``"192.168.1.0"`` | |
| * **IPv6** β zero the interface identifier (last 64 bits), retaining | |
| the /64 prefix. ``"2001:db8:85a3::8a2e:370:7334"`` β ``"2001:db8:85a3::"`` | |
| * **IPv6 scope suffix** (e.g. ``"fe80::1%eth0"``) β stripped before | |
| parsing (Python's :mod:`ipaddress` does not accept scope identifiers). | |
| * **Non-IP strings** β returned as ``"<ip-redacted>"``. | |
| * **Sentinel** ``"unknown"`` β returned unchanged (already non-identifying). | |
| Parameters | |
| ---------- | |
| ip : str | |
| Client IP string extracted from the HTTP request headers. | |
| May be ``"unknown"`` when the proxy header is absent. | |
| Returns | |
| ------- | |
| str | |
| Masked IP suitable for structured log output. This function is | |
| intentionally never-raise β any :exc:`ValueError` from | |
| :mod:`ipaddress` is caught and replaced by the safe fallback. | |
| Notes | |
| ----- | |
| **Security note** β This is the canonical privacy gate for all IP values | |
| written to log records in ``app.py``. Every ``json.dumps({..., "ip": β¦})`` | |
| call must pass ``client_ip`` through :func:`_mask_ip` before serialising. | |
| Callers must **not** write raw ``client_ip`` values to any log record. | |
| **Developer note** β Uses :mod:`ipaddress` from the Python standard | |
| library; no third-party dependencies are introduced. | |
| Examples | |
| -------- | |
| >>> _mask_ip("192.168.1.100") | |
| '192.168.1.0' | |
| >>> _mask_ip("10.0.0.255") | |
| '10.0.0.0' | |
| >>> _mask_ip("2001:db8:85a3::8a2e:370:7334") | |
| '2001:db8:85a3::' | |
| >>> _mask_ip("fe80::1%eth0") | |
| 'fe80::' | |
| >>> _mask_ip("unknown") | |
| 'unknown' | |
| >>> _mask_ip("not-an-ip") | |
| '<ip-redacted>' | |
| """ | |
| if ip in ("unknown", ""): | |
| return ip | |
| try: | |
| # Strip IPv6 zone/scope identifier (e.g. "%eth0") β ipaddress rejects it. | |
| clean: str = ip.split("%", 1)[0].strip() | |
| addr = ipaddress.ip_address(clean) | |
| if isinstance(addr, ipaddress.IPv4Address): | |
| # Retain /24 (first three octets); zero the host octet. | |
| return str(ipaddress.ip_network(f"{addr}/24", strict=False).network_address) | |
| # IPv6: retain /64 prefix; zero the 64-bit interface identifier. | |
| return str(ipaddress.ip_network(f"{addr}/64", strict=False).network_address) | |
| except ValueError: | |
| return "<ip-redacted>" | |
| #: Ordered list of ``(compiled_pattern, replacement)`` tuples applied by | |
| #: :class:`_RedactingFilter` to every log record before emission. | |
| #: | |
| #: **Pattern order matters** β patterns are applied left-to-right; more | |
| #: specific patterns must precede catch-all patterns. There is no overlap | |
| #: between the current patterns, but this convention must be maintained when | |
| #: extending this list. | |
| #: | |
| #: IPv6 addresses are intentionally **absent** β they are handled at the | |
| #: call-site by :func:`_mask_ip` (Layer 1). A generic IPv6 regex in a | |
| #: global filter produces too many false positives (hex timestamps, MAC | |
| #: addresses, Docker overlay IDs) to be safe in a production log stream. | |
| _REDACT_PATTERNS: list[tuple[re.Pattern[str], str]] = [ | |
| # HuggingFace API tokens β ``hf_`` prefix followed by β₯ 4 alphanumeric | |
| # characters. Classic tokens are ~34 chars; fine-grained tokens are β₯ 52. | |
| # The {4,} lower bound avoids matching ``hf_`` in legitimate identifiers | |
| # (e.g. Python identifiers that start with ``hf_``) while still catching | |
| # any partial token fragment that huggingface_hub may embed in an error | |
| # message. | |
| (re.compile(r"\bhf_[a-zA-Z0-9]{4,}\b"), "<token-redacted>"), | |
| # IPv4 addresses β strict dotted-decimal notation with per-octet range | |
| # validation (0-255). Word boundaries prevent partial matches inside | |
| # longer numeric strings. This pattern catches IPv4 strings emitted by | |
| # third-party loggers (httpx, uvicorn) that bypass :func:`_mask_ip`. | |
| ( | |
| re.compile( | |
| r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}" | |
| r"(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b" | |
| ), | |
| "<ipv4-redacted>", | |
| ), | |
| ] | |
| class _RedactingFilter(logging.Filter): | |
| """Scrub sensitive values from log records before emission. | |
| Applies the regex patterns in :data:`_REDACT_PATTERNS` to the fully | |
| formatted log message, replacing HuggingFace API tokens and raw IPv4 | |
| addresses with opaque placeholders. | |
| This class is the **defence-in-depth layer** (Layer 2). The primary | |
| control is :func:`_mask_ip` at each call site (Layer 1). The filter | |
| catches values that slip through Layer 1 β most importantly, HF token | |
| strings embedded in exception messages from ``huggingface_hub``. | |
| Parameters | |
| ---------- | |
| name : str, optional | |
| Filter name forwarded to :class:`logging.Filter`. Default ``""``. | |
| Notes | |
| ----- | |
| **Security note** β This filter materialises the fully formatted message | |
| via :meth:`logging.LogRecord.getMessage`, applies every pattern in | |
| :data:`_REDACT_PATTERNS`, then replaces :attr:`~logging.LogRecord.msg` | |
| with the scrubbed result and clears :attr:`~logging.LogRecord.args`. | |
| Clearing ``args`` prevents downstream handlers from re-applying ``%`` | |
| formatting to a string that no longer contains positional placeholders. | |
| **Developer note** β Attach to the root handler immediately after | |
| construction so **every** handler in the process benefits:: | |
| handler = logging.StreamHandler() | |
| handler.addFilter(_RedactingFilter()) | |
| logging.root.handlers = [handler] | |
| To extend the redaction vocabulary, append a ``(pattern, replacement)`` | |
| tuple to :data:`_REDACT_PATTERNS`. | |
| Examples | |
| -------- | |
| >>> import logging | |
| >>> f = _RedactingFilter() | |
| >>> rec = logging.makeLogRecord( | |
| ... {"msg": "token=hf_abc1234defg5678 ip=10.0.1.99", "args": ()} | |
| ... ) | |
| >>> f.filter(rec) | |
| True | |
| >>> rec.msg | |
| 'token=<token-redacted> ip=<ipv4-redacted>' | |
| """ | |
| def filter(self, record: logging.LogRecord) -> bool: # noqa: A003 | |
| """Redact sensitive patterns from *record*'s formatted message. | |
| Parameters | |
| ---------- | |
| record : logging.LogRecord | |
| Log record to inspect and mutate in-place. | |
| Returns | |
| ------- | |
| bool | |
| Always ``True`` β this filter never suppresses records, only | |
| scrubs their message content. | |
| """ | |
| # Materialise the full %-formatted string first, then scrub it. | |
| msg: str = sanitize_log_text(record.getMessage()) | |
| # Write the scrubbed text back and clear args so that any subsequent | |
| # call to getMessage() returns the already-scrubbed string without | |
| # attempting to re-apply % formatting. | |
| record.msg = msg | |
| record.args = () | |
| return True | |
| def _classify_token_type( | |
| token: str, | |
| declared_type: str | None = None, | |
| ) -> HFTokenType: | |
| """ | |
| Classify a HuggingFace token by its declared type or format heuristics. | |
| Token type classification is used at startup by :func:`_validate_token_config` | |
| to enforce the principle of least privilege before any requests arrive. | |
| Parameters | |
| ---------- | |
| token : str | |
| The HuggingFace API token string. | |
| declared_type : str or None, optional | |
| Explicitly declared type from an environment variable | |
| (``HF_TOKEN_TYPE`` or ``HF_WRITE_TOKEN_TYPE``). | |
| Accepted values: ``"fine-grained"``, ``"read"``, ``"write"`` | |
| (and minor formatting variants: ``"finegrained"``, | |
| ``"fine_grained"``). When provided and recognized, it takes | |
| precedence over all heuristics. | |
| Returns | |
| ------- | |
| HFTokenType | |
| One of ``"fine-grained"``, ``"read"``, ``"write"``, or ``"unknown"``. | |
| Notes | |
| ----- | |
| **Security note** β Token type cannot be verified without an authenticated | |
| call to the HF API (``GET https://huggingface.co/api/whoami-v2``). This | |
| function applies lightweight format heuristics only. For production | |
| deployments, always declare the type explicitly via ``HF_TOKEN_TYPE`` / | |
| ``HF_WRITE_TOKEN_TYPE`` so :func:`_validate_token_config` can enforce | |
| least-privilege at startup without any network calls. | |
| **Developer note** β As of 2025, classic HF tokens are approximately 34 | |
| characters total (``hf_`` prefix + 30 alphanumeric chars). Fine-grained | |
| tokens are substantially longer (β₯ 52 characters total as of the HF 2025 | |
| token format). This length heuristic is imprecise and subject to silent | |
| change by HF; explicit declaration via env vars is always preferred. | |
| Examples | |
| -------- | |
| Explicit declaration takes precedence over heuristics: | |
| >>> _classify_token_type("hf_" + "a" * 30, declared_type="read") | |
| 'read' | |
| >>> _classify_token_type("hf_" + "a" * 30, declared_type="write") | |
| 'write' | |
| Heuristic: token β₯ 52 chars β fine-grained: | |
| >>> _classify_token_type("hf_" + "a" * 50) | |
| 'fine-grained' | |
| Short classic token without declaration β unknown: | |
| >>> _classify_token_type("hf_" + "a" * 28) | |
| 'unknown' | |
| Empty or malformed token β unknown: | |
| >>> _classify_token_type("") | |
| 'unknown' | |
| """ | |
| # Normalise accepted declared-type values (tolerate minor formatting variants). | |
| _declared_map: dict[str, HFTokenType] = { | |
| "fine-grained": "fine-grained", | |
| "finegrained": "fine-grained", | |
| "fine_grained": "fine-grained", | |
| "read": "read", | |
| "write": "write", | |
| } | |
| if declared_type: | |
| normalised = _declared_map.get(declared_type.lower().strip()) | |
| if normalised is not None: | |
| return normalised | |
| # Validate basic token format β all HF tokens start with "hf_". | |
| if not token or not token.startswith("hf_") or len(token) < 10: # noqa: PLR2004 | |
| return "unknown" | |
| # Heuristic: fine-grained tokens are substantially longer than classic tokens. | |
| # Classic tokens: ~34 chars total. Fine-grained tokens: β₯ 52 chars (HF 2025). | |
| # Best-effort only; explicit declaration via env vars is always preferred. | |
| if len(token) >= 52: # noqa: PLR2004 | |
| return "fine-grained" | |
| # Cannot distinguish classic read vs write by token string alone. | |
| return "unknown" | |
| def _token_suitable_for_inference(token_type: str) -> bool: | |
| """ | |
| Return ``True`` when *token_type* is appropriate for HF Inference API calls. | |
| This predicate guards inference paths (Path 2 private Space access and | |
| Path 3 HF Serverless API). Returning ``False`` for a classic write token | |
| does not block the token at runtime β it causes :func:`_validate_token_config` | |
| to emit a startup ``WARNING`` so the operator knows they are running with | |
| more permission than necessary. | |
| Parameters | |
| ---------- | |
| token_type : str | |
| One of the ``HF_TOKEN_TYPE_*`` constants or a free-form string parsed | |
| from an environment variable. | |
| Returns | |
| ------- | |
| bool | |
| ``True`` for ``"fine-grained"``, ``"read"``, and ``"unknown"``. | |
| ``False`` for ``"write"`` (classic write token β over-privileged). | |
| Notes | |
| ----- | |
| The recommended configuration is a fine-grained token scoped exclusively | |
| to ``Make calls to the serverless Inference API``, or a classic read | |
| token. Classic write tokens carry unnecessary repo-write permission | |
| and violate the principle of least privilege. | |
| Examples | |
| -------- | |
| >>> _token_suitable_for_inference("read") | |
| True | |
| >>> _token_suitable_for_inference("fine-grained") | |
| True | |
| >>> _token_suitable_for_inference("write") | |
| False | |
| >>> _token_suitable_for_inference("unknown") | |
| True | |
| """ | |
| return token_type in HF_INFERENCE_TOKEN_TYPES | |
| def _token_suitable_for_writes(token_type: str) -> bool: | |
| """ | |
| Return ``True`` when *token_type* can authorize HuggingFace write operations. | |
| This predicate guards the ``/v1/contribute`` endpoint. Returning ``False`` | |
| for a classic read or unknown token causes :func:`_validate_token_config` | |
| to emit a startup ``ERROR`` string because the token WILL fail at | |
| ``HfApi.create_commit`` time (HTTP 403 / 401 from HF). | |
| Parameters | |
| ---------- | |
| token_type : str | |
| One of the ``HF_TOKEN_TYPE_*`` constants or a free-form string parsed | |
| from an environment variable. | |
| Returns | |
| ------- | |
| bool | |
| ``True`` for ``"fine-grained"`` and ``"write"``. | |
| ``False`` for ``"read"`` and ``"unknown"``. | |
| Notes | |
| ----- | |
| Fine-grained tokens can write **only if** write permission was granted to | |
| the target repo at token-creation time. A fine-grained token created | |
| with only inference-API scope will also fail on write operations, but the | |
| proxy cannot verify fine-grained permissions without an authenticated API | |
| call. Fine-grained tokens are therefore accepted here and any permission | |
| failures surface at operation time with a clear HTTP 503 error. | |
| Examples | |
| -------- | |
| >>> _token_suitable_for_writes("write") | |
| True | |
| >>> _token_suitable_for_writes("fine-grained") | |
| True | |
| >>> _token_suitable_for_writes("read") | |
| False | |
| >>> _token_suitable_for_writes("unknown") | |
| False | |
| """ | |
| return token_type in HF_WRITE_TOKEN_TYPES | |
| def _validate_token_config( | |
| hf_token: str, | |
| hf_write_token: str, | |
| training_dataset_repo: str = "", | |
| *, | |
| hf_token_type: str = HF_TOKEN_TYPE_UNKNOWN, | |
| hf_write_token_type: str = HF_TOKEN_TYPE_UNKNOWN, | |
| ) -> list[str]: | |
| """ | |
| Validate token types and return actionable warning / error strings. | |
| Enforces the principle of least privilege and detects token-type | |
| misconfigurations that would cause silent failures at request time. | |
| Returns a list of strings rather than raising exceptions so the proxy | |
| can start in degraded mode and surface issues through structured logs. | |
| Call this at startup **after** :func:`_validate_env` so routing is | |
| confirmed viable before type checks are run. | |
| Parameters | |
| ---------- | |
| hf_token : str | |
| HuggingFace token used for inference (``HF_TOKEN`` env var). | |
| hf_write_token : str | |
| HuggingFace token used for dataset persistence. New deployments pass the | |
| effective ``HF_DATASET_TOKEN``; legacy callers may still pass | |
| ``HF_WRITE_TOKEN``. Pass empty string when not configured. | |
| training_dataset_repo : str, optional | |
| HuggingFace Dataset repo ID (``TRAINING_DATASET_REPO`` env var). | |
| Pass empty string when ``/v1/contribute`` is not enabled. | |
| hf_token_type : str, optional | |
| Classified type for *hf_token* (from :func:`_classify_token_type`). | |
| Defaults to ``"unknown"``. | |
| hf_write_token_type : str, optional | |
| Classified type for *hf_write_token*. Defaults to ``"unknown"``. | |
| Returns | |
| ------- | |
| list[str] | |
| Zero or more diagnostic strings. Each message is prefixed with | |
| ``"WARNING:"`` or ``"ERROR:"`` so callers can log at the correct | |
| level. An empty list means the configuration passes all checks. | |
| Notes | |
| ----- | |
| **Security note** β ``"write"`` token used for inference is a WARNING | |
| (not an error) because it functions correctly at runtime. The warning | |
| exists to prompt the operator to apply least-privilege. | |
| **Security note** β ``"read"`` token used for writes is a hard ERROR: | |
| the token WILL fail on every ``HfApi.create_commit`` call. The proxy | |
| can still start (useful for operators who only need inference), but | |
| ``/v1/contribute`` will be permanently non-functional until the token is | |
| replaced. | |
| Examples | |
| -------- | |
| Clean configuration β no messages: | |
| >>> _validate_token_config("hf_readtok", "", hf_token_type="read") | |
| [] | |
| Write token for inference (overprivileged) β WARNING: | |
| >>> msgs = _validate_token_config("hf_writetok", "", hf_token_type="write") | |
| >>> any("WARNING" in m for m in msgs) | |
| True | |
| Read token for writes β ERROR: | |
| >>> msgs = _validate_token_config( | |
| ... "hf_tok", | |
| ... "hf_readtok", | |
| ... training_dataset_repo="org/dataset", | |
| ... hf_write_token_type="read", | |
| ... ) | |
| >>> any("ERROR" in m for m in msgs) | |
| True | |
| """ | |
| messages: list[str] = [] | |
| # ββ Inference token (HF_TOKEN) type check ββββββββββββββββββββββββββββββββ | |
| if hf_token and not _token_suitable_for_inference(hf_token_type): | |
| messages.append( | |
| f"WARNING: HF_TOKEN type is {hf_token_type!r} (classic write token). " | |
| "Write tokens carry unnecessary repo-push permission and violate the " | |
| "principle of least privilege for inference. " | |
| "Replace HF_TOKEN with: (a) a fine-grained token scoped to " | |
| "'Make calls to the serverless Inference API' only, or " | |
| "(b) a classic read token. " | |
| "See HF Settings β Tokens β New token β Fine-grained. " | |
| "Set HF_TOKEN_TYPE=read or HF_TOKEN_TYPE=fine-grained after replacing." | |
| ) | |
| # ββ Dataset-persistence token type check βββββββββββββββββββββββββββββββββ | |
| if hf_write_token and not _token_suitable_for_writes(hf_write_token_type): | |
| messages.append( | |
| f"ERROR: dataset persistence token type is {hf_write_token_type!r}. " | |
| "Read tokens cannot push commits to Hugging Face repositories. " | |
| "Use HF_DATASET_TOKEN with a fine-grained token scoped to write the " | |
| "target dataset repo (preferred), or a classic Write token. " | |
| "Legacy HF_WRITE_TOKEN remains supported as an alias." | |
| ) | |
| # ββ Training repo + effective write token consistency ββββββββββββββββββββ | |
| if training_dataset_repo: | |
| # Effective write token is HF_WRITE_TOKEN when set; else falls back to | |
| # HF_TOKEN. Check that the effective token type can authorize writes. | |
| effective_token = hf_write_token or hf_token | |
| effective_type = hf_write_token_type if hf_write_token else hf_token_type | |
| if effective_token and not _token_suitable_for_writes(effective_type): | |
| messages.append( | |
| "ERROR: TRAINING_DATASET_REPO is configured but the effective " | |
| "write token type " | |
| f"({effective_type!r}) cannot push to HuggingFace repositories. " | |
| "POST /v1/contribute will always fail with HTTP 503. " | |
| "Set HF_DATASET_TOKEN to a write-capable token (fine-grained with " | |
| "write access to the dataset repo, or a classic Write token). " | |
| f"Set HF_DATASET_TOKEN_TYPE accordingly." | |
| ) | |
| return messages | |
| def _resolve_upstream_url( | |
| body: bytes, | |
| *, | |
| backend_url: str, | |
| hf_token: str, | |
| backend_auth_token: str = "", | |
| hf_spaces_auth_token: str = "", | |
| hf_base: str = DEFAULT_HF_BASE, | |
| default_model: str = DEFAULT_MODEL, | |
| hf_spaces_model_url: str = DEFAULT_HF_SPACES_MODEL_URL, | |
| hf_spaces_model_namespaces: ( | |
| tuple[str, ...] | list[str] | |
| ) = DEFAULT_HF_SPACES_MODEL_NAMESPACES, | |
| proxy_timeout: float = float(DEFAULT_PROXY_TIMEOUT), | |
| path2_read_timeout: float = DEFAULT_PATH2_READ_TIMEOUT, | |
| path3_read_timeout: float = DEFAULT_PATH3_READ_TIMEOUT, | |
| ) -> tuple[str, dict[str, str], float]: | |
| """ | |
| Centralised three-path routing β choose upstream endpoint, auth headers, | |
| and per-path read timeout. | |
| Priority | |
| -------- | |
| 1. *backend_url* is non-empty β **Path 1**: explicit custom backend. | |
| Forward to *backend_url* (Docker Model Runner, Ollama, any backend). | |
| *backend_auth_token* is injected only when explicitly configured. | |
| Read timeout: *proxy_timeout* (env ``PROXY_TIMEOUT``, default 600 s). | |
| 2. Model namespace is in *hf_spaces_model_namespaces* β **Path 2**: HF model Space. | |
| Forward to *hf_spaces_model_url* (the ``scikit-plots/ai-model`` Space). | |
| CPU inference on a 7B model takes 4-5 minutes; *path2_read_timeout* | |
| (env ``PATH2_TIMEOUT``, default 600 s) prevents premature timeout. | |
| *hf_spaces_auth_token* is injected only when explicitly configured. | |
| 3. Otherwise β **Path 3**: HF Serverless Inference API (default). | |
| Build ``{hf_base}/{model}/v1/chat/completions`` and inject *hf_token* | |
| (always required for the HF API). | |
| *path3_read_timeout* (env ``PATH3_TIMEOUT``, default 120 s) is | |
| appropriate for GPU-backed HF API inference. | |
| Parameters | |
| ---------- | |
| body : bytes | |
| Raw JSON request body. Used to extract the ``model`` field for | |
| Paths 2 and 3. | |
| backend_url : str | |
| Value of the ``BACKEND_URL`` environment variable. Non-empty string | |
| triggers Path 1; empty string means "proceed to Path 2 / 3". | |
| hf_token : str | |
| HuggingFace inference token. Used only for Path 3. | |
| backend_auth_token : str, optional | |
| Dedicated bearer capability bound to Path 1 ``backend_url``. | |
| hf_spaces_auth_token : str, optional | |
| Dedicated bearer capability bound to Path 2 ``hf_spaces_model_url``. | |
| hf_base : str, optional | |
| HF Serverless Inference API base URL (no trailing slash). | |
| default_model : str, optional | |
| Fallback model ID when the body omits the ``model`` field. | |
| hf_spaces_model_url : str, optional | |
| URL of the custom ai-model HF Space (Path 2 target). | |
| hf_spaces_model_namespaces : tuple[str, ...] or list[str], optional | |
| Model owner namespaces routed to *hf_spaces_model_url*. | |
| proxy_timeout : float, optional | |
| Read timeout (seconds) for Path 1. Default: 600 s. | |
| path2_read_timeout : float, optional | |
| Read timeout (seconds) for Path 2 (ai-model Space). Default: 600 s. | |
| path3_read_timeout : float, optional | |
| Read timeout (seconds) for Path 3 (HF Serverless API). Default: 120 s. | |
| Returns | |
| ------- | |
| url : str | |
| Fully-qualified upstream endpoint URL. | |
| headers : dict[str, str] | |
| HTTP headers for the upstream POST request. | |
| read_timeout_s : float | |
| Per-path read timeout in seconds. Pass to ``httpx.Timeout(read=...)``. | |
| Notes | |
| ----- | |
| **Breaking change v5.0.0** β Return type changed from | |
| ``tuple[str, dict]`` to ``tuple[str, dict, float]``. All callers must | |
| unpack the third element. | |
| **Breaking change v6.0.0** β :data:`DEFAULT_HF_BASE` changed from | |
| ``https://api-inference.huggingface.co/models`` to | |
| ``https://router.huggingface.co``. The old hostname was DNS-unresolvable | |
| from HF Docker Spaces ([Errno -5] EAI_NONAME). | |
| **Developer note** β All routing logic lives here. To add a new backend | |
| type, add a new branch in this function. Callers (``app.py``, | |
| ``dev_proxy.py``) remain unchanged when they already unpack 3 values. | |
| Examples | |
| -------- | |
| Path 2 β scikit-plots namespace β ai-model Space: | |
| >>> url, hdrs, t = _resolve_upstream_url( | |
| ... b'{"model":"scikit-plots/Qwen2.5-Coder-7B-Instruct","messages":[]}', | |
| ... backend_url="", | |
| ... hf_token="", | |
| ... ) | |
| >>> "scikit-plots-ai-model.hf.space" in url | |
| True | |
| >>> t | |
| 600.0 | |
| Path 3 β standard HF Inference API: | |
| >>> url, hdrs, t = _resolve_upstream_url( | |
| ... b'{"model":"openai/gpt-oss-20b","messages":[]}', | |
| ... backend_url="", | |
| ... hf_token="hf_test_token_abc123", | |
| ... ) | |
| >>> "router.huggingface.co" in url | |
| True | |
| >>> t | |
| 120.0 | |
| Path 1 β explicit BACKEND_URL: | |
| >>> url, hdrs, t = _resolve_upstream_url( | |
| ... b"{}", | |
| ... backend_url="https://my-model.hf.space/v1/chat/completions", | |
| ... hf_token="", | |
| ... ) | |
| >>> url | |
| 'https://my-model.hf.space/v1/chat/completions' | |
| >>> t | |
| 600.0 | |
| """ # noqa: D205 | |
| headers: dict[str, str] = {"Content-Type": "application/json"} | |
| # ββ Path 1: explicit custom backend override ββββββββββββββββββββββββββββββ | |
| if backend_url: | |
| if backend_auth_token: | |
| headers["Authorization"] = f"Bearer {backend_auth_token}" | |
| return backend_url, headers, proxy_timeout | |
| # Extract model ID from request body (needed for Paths 2 and 3). | |
| model: str = _parse_model(body, default=default_model) | |
| # ββ Path 2: custom model namespace β HF Spaces model backend βββββββββββββ | |
| if hf_spaces_model_url and _is_custom_model_namespace( | |
| model, hf_spaces_model_namespaces | |
| ): | |
| if hf_spaces_auth_token: | |
| headers["Authorization"] = f"Bearer {hf_spaces_auth_token}" | |
| return hf_spaces_model_url, headers, path2_read_timeout | |
| # ββ Path 3: HF Serverless Inference API (provider models) βββββββββββββββββ | |
| # router.huggingface.co is a flat OpenAI-compatible endpoint. | |
| # The model is supplied in the request body (already present in `body`), | |
| # NOT embedded in the URL path. The old api-inference.huggingface.co/models | |
| # API DID embed the model in the path as /{model}/v1/chat/completions, but | |
| # router.huggingface.co uses a single endpoint for all models: | |
| # POST https://router.huggingface.co/v1/chat/completions | |
| # body: {"model": "Qwen/Qwen2.5-Coder-7B-Instruct:nscale", ...} | |
| # Embedding the model ID in the path produces a 404/422 with no log entry | |
| # because _forward passes non-2xx upstream responses through transparently. | |
| url = f"{hf_base.rstrip('/')}/v1/chat/completions" | |
| # Do not manufacture an empty ``Authorization: Bearer `` header. Besides | |
| # being useless, malformed/whitespace-only auth values may be rejected at | |
| # the local HTTP protocol layer before a request ever reaches Hugging Face. | |
| # When the token is absent, send no Authorization header and let the caller | |
| # or upstream return a normal authentication/configuration error. | |
| if hf_token: | |
| headers["Authorization"] = f"Bearer {hf_token}" | |
| return url, headers, path3_read_timeout | |
| def _validate_credential_destination( | |
| url: str, | |
| *, | |
| credential_kind: str, | |
| allow_local_http: bool = False, | |
| ) -> None: | |
| """Fail closed when a server credential could be sent to an unsafe URL. | |
| ``credential_kind`` is descriptive and never contains the credential. HF | |
| inference tokens are bound to official Hugging Face HTTPS origins; custom | |
| backend/Space tokens are separately configured and therefore bind to the | |
| exact operator-selected destination rather than reusing ``HF_TOKEN``. | |
| """ | |
| if not url: | |
| raise RuntimeError(f"{credential_kind} is configured without a destination URL") | |
| try: | |
| parts = urlsplit(url) | |
| host = (parts.hostname or "").lower().rstrip(".") | |
| port = parts.port | |
| except (TypeError, ValueError) as exc: | |
| raise RuntimeError( | |
| f"unsafe destination for {credential_kind}: malformed URL" | |
| ) from exc | |
| if parts.username or parts.password or parts.query or parts.fragment: | |
| raise RuntimeError( | |
| f"unsafe destination for {credential_kind}: userinfo/query/fragment is not allowed" | |
| ) | |
| is_local = host in {"localhost", "127.0.0.1", "::1"} | |
| if parts.scheme != "https" and not ( | |
| allow_local_http and parts.scheme == "http" and is_local | |
| ): | |
| raise RuntimeError( | |
| f"unsafe destination for {credential_kind}: HTTPS is required" | |
| ) | |
| if credential_kind == "HF_TOKEN": | |
| if host != "router.huggingface.co" and not host.endswith(".huggingface.co"): | |
| raise RuntimeError( | |
| "unsafe destination for HF_TOKEN: token is bound to official Hugging Face origins" | |
| ) | |
| if port not in (None, 443): | |
| raise RuntimeError("unsafe destination for HF_TOKEN: non-standard port") | |
| def _validate_env( | |
| backend_url: str, | |
| hf_token: str, | |
| hf_spaces_model_url: str = DEFAULT_HF_SPACES_MODEL_URL, | |
| ) -> None: | |
| """ | |
| Validate the minimum required environment at proxy startup. | |
| At least one of the three routing paths must be viable: | |
| * **Path 1** β *backend_url* is non-empty. | |
| * **Path 2** β *hf_spaces_model_url* is non-empty (serves custom namespace models). | |
| * **Path 3** β *hf_token* is non-empty (HF Inference API for provider models). | |
| Parameters | |
| ---------- | |
| backend_url : str | |
| Value of the ``BACKEND_URL`` environment variable (may be empty). | |
| hf_token : str | |
| Value of the ``HF_TOKEN`` environment variable (may be empty). | |
| hf_spaces_model_url : str, optional | |
| Value of the ``HF_SPACES_MODEL_URL`` environment variable. | |
| Raises | |
| ------ | |
| RuntimeError | |
| When all three routing paths are disabled (all parameters are empty). | |
| Examples | |
| -------- | |
| >>> _validate_env("https://my-model.hf.space/v1/chat/completions", "", "") | |
| >>> _validate_env("", "hf_mytoken", "") | |
| >>> _validate_env( | |
| ... "", "", "https://scikit-plots-ai-model.hf.space/v1/chat/completions" | |
| ... ) | |
| >>> import pytest | |
| >>> with pytest.raises(RuntimeError, match="no viable routing path"): | |
| ... _validate_env("", "", "") | |
| """ | |
| if not backend_url and not hf_token and not hf_spaces_model_url: | |
| raise RuntimeError( | |
| "Proxy configuration error: no viable routing path configured.\n\n" | |
| "Set at least ONE of the following in Space β Settings β Repository secrets:\n\n" | |
| " Option 1 β HF Inference API (standard provider models):\n" | |
| " HF_TOKEN = hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" | |
| " DEFAULT_MODEL = openai/gpt-oss-20b\n\n" | |
| " Option 2 β Custom ai-model Space (scikit-plots/* models):\n" | |
| " HF_SPACES_MODEL_URL = " | |
| "https://scikit-plots-ai-model.hf.space/v1/chat/completions\n\n" | |
| " Option 3 β Explicit custom backend (DMR, Ollama, or any backend):\n" | |
| " BACKEND_URL = http://localhost:12434/engines/llama.cpp/v1/chat/completions\n\n" | |
| "See FREE_PROXY_SOLUTIONS.md for the full path decision tree." | |
| ) | |
| def load_proxy_env() -> dict[str, Any]: | |
| """ | |
| Read all proxy-relevant environment variables and return a typed dict. | |
| Returns | |
| ------- | |
| dict[str, Any] | |
| Keys and types: | |
| ``backend_url`` : str | |
| ``hf_token`` : str | |
| ``hf_base`` : str | |
| ``default_model`` : str | |
| ``hf_spaces_model_url`` : str | |
| ``hf_spaces_model_namespaces`` : tuple[str, ...] | |
| ``proxy_timeout`` : int | |
| Global / Path 1 read timeout (env ``PROXY_TIMEOUT``). | |
| ``path2_read_timeout`` : float | |
| Path 2 read timeout (env ``PATH2_TIMEOUT``). | |
| ``path3_read_timeout`` : float | |
| Path 3 read timeout (env ``PATH3_TIMEOUT``). | |
| ``max_body_bytes`` : int | |
| ``allowed_origins`` : str | |
| ``allowed_origins_mode`` : str | |
| Raw deployment composition mode (``additive`` or ``replace``). | |
| ``hf_token_type`` : str | |
| Classified token type for *hf_token* (env ``HF_TOKEN_TYPE``). | |
| One of ``"fine-grained"``, ``"read"``, ``"write"``, ``"unknown"``. | |
| ``hf_write_token_type`` : str | |
| Classified type for the legacy ``HF_WRITE_TOKEN`` alias. | |
| ``hf_dataset_token_type`` : str | |
| Classified type for the effective dataset-persistence token. One of | |
| ``"fine-grained"``, ``"read"``, ``"write"``, ``"unknown"``. | |
| Examples | |
| -------- | |
| >>> import os | |
| >>> os.environ["PROXY_TIMEOUT"] = "600" | |
| >>> cfg = load_proxy_env() | |
| >>> cfg["proxy_timeout"] | |
| 600 | |
| >>> os.environ["PATH2_TIMEOUT"] = "900" | |
| >>> cfg = load_proxy_env() | |
| >>> cfg["path2_read_timeout"] | |
| 900.0 | |
| """ | |
| _raw_namespaces: str = os.environ.get( | |
| "HF_SPACES_MODEL_NAMESPACES", | |
| ",".join(DEFAULT_HF_SPACES_MODEL_NAMESPACES), | |
| ) | |
| _parsed_namespaces: tuple[str, ...] = ( | |
| tuple(ns.strip() for ns in _raw_namespaces.split(",") if ns.strip()) | |
| or DEFAULT_HF_SPACES_MODEL_NAMESPACES | |
| ) | |
| _hf_token: str = os.environ.get("HF_TOKEN", "").strip() | |
| _hf_dataset_token_explicit: str = os.environ.get("HF_DATASET_TOKEN", "").strip() | |
| _hf_write_token: str = os.environ.get("HF_WRITE_TOKEN", "").strip() | |
| # Classify token types from explicit declarations (preferred) or heuristics. | |
| # Explicit: set HF_TOKEN_TYPE=read|write|fine-grained in Space secrets. | |
| # Heuristic: length-based guess (fine-grained tokens are β₯ 52 chars). | |
| _hf_token_type: str = _classify_token_type( | |
| _hf_token, | |
| declared_type=os.environ.get("HF_TOKEN_TYPE"), | |
| ) | |
| _hf_write_token_type: str = _classify_token_type( | |
| _hf_write_token, | |
| declared_type=os.environ.get("HF_WRITE_TOKEN_TYPE"), | |
| ) | |
| _hf_dataset_token: str = _hf_dataset_token_explicit or _hf_write_token or _hf_token | |
| _hf_dataset_token_type: str = ( | |
| _classify_token_type( | |
| _hf_dataset_token_explicit, | |
| declared_type=os.environ.get("HF_DATASET_TOKEN_TYPE"), | |
| ) | |
| if _hf_dataset_token_explicit | |
| else (_hf_write_token_type if _hf_write_token else _hf_token_type) | |
| ) | |
| return { | |
| "backend_url": os.environ.get("BACKEND_URL", "").strip(), | |
| "hf_token": _hf_token, | |
| # Preferred dataset token + legacy alias. Never forward the effective | |
| # dataset token to model backends. | |
| "hf_write_token": _hf_write_token, | |
| "hf_dataset_token": _hf_dataset_token, | |
| # Token type metadata β used by startup validation and discovery. | |
| "hf_token_type": _hf_token_type, | |
| "hf_write_token_type": _hf_write_token_type, | |
| "hf_dataset_token_type": _hf_dataset_token_type, | |
| "hf_base": os.environ.get("HF_BASE", DEFAULT_HF_BASE).rstrip("/"), | |
| "default_model": ( | |
| os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL).strip() or DEFAULT_MODEL | |
| ), | |
| "hf_spaces_model_url": ( | |
| os.environ.get("HF_SPACES_MODEL_URL", DEFAULT_HF_SPACES_MODEL_URL).strip() | |
| ), | |
| "hf_spaces_model_namespaces": _parsed_namespaces, | |
| "proxy_timeout": _safe_int( | |
| os.environ.get("PROXY_TIMEOUT"), | |
| DEFAULT_PROXY_TIMEOUT, | |
| ), | |
| "path2_read_timeout": _safe_float( | |
| os.environ.get("PATH2_TIMEOUT"), | |
| DEFAULT_PATH2_READ_TIMEOUT, | |
| ), | |
| "path3_read_timeout": _safe_float( | |
| os.environ.get("PATH3_TIMEOUT"), | |
| DEFAULT_PATH3_READ_TIMEOUT, | |
| ), | |
| "max_body_bytes": _safe_int( | |
| os.environ.get("MAX_BODY_BYTES"), | |
| DEFAULT_MAX_BODY_BYTES, | |
| ), | |
| "allowed_origins": os.environ.get("ALLOWED_ORIGINS", "").strip(), | |
| "allowed_origins_mode": ( | |
| os.environ.get("ALLOWED_ORIGINS_MODE", "additive").strip().lower() | |
| or "additive" | |
| ), | |
| } | |