celik-muhammed commited on
Commit
02d552e
Β·
verified Β·
1 Parent(s): 2121056

Upload 5 files

Browse files
Files changed (3) hide show
  1. README.md +44 -10
  2. _shared_logic.py +472 -15
  3. app.py +65 -6
README.md CHANGED
@@ -123,19 +123,23 @@ All variables are set in **Space β†’ Settings β†’ Repository secrets**.
123
  | Variable | Required? | Scope | Description |
124
  |---|---|---|---|
125
  | `HF_TOKEN` | Yes (for Path 3) | Read / inference-api | Injected as `Bearer` into Path 1 and Path 3 upstream requests. Use a **read-only** fine-grained token β€” it never needs write permission. |
 
126
  | `HF_WRITE_TOKEN` | No | Write (dataset repo only) | Dedicated write token used **only** by `POST /v1/contribute` to push JSONL files to `TRAINING_DATASET_REPO`. When set, `HF_TOKEN` remains strictly read-only. When absent, the proxy falls back to `HF_TOKEN` for writes (single-token mode, backward compatible but less secure). |
 
127
 
128
  > **Why two tokens?** Principle of least privilege. `HF_TOKEN` is forwarded
129
  > to external model backends on every inference request β€” if it carried write
130
  > permission a compromised upstream could modify your repos. `HF_WRITE_TOKEN`
131
  > never leaves the proxy and is scoped to one dataset repo.
132
 
133
- The `/` status page shows both token states without exposing values:
134
 
135
  ```json
136
  "tokens": {
137
- "hf_token_set": true,
138
- "hf_write_token_set": true,
 
 
139
  "least_privilege_mode": true
140
  },
141
  "training": {
@@ -144,6 +148,10 @@ The `/` status page shows both token states without exposing values:
144
  }
145
  ```
146
 
 
 
 
 
147
  `least_privilege_mode: false` means `HF_WRITE_TOKEN` is absent and writes fall
148
  back to `HF_TOKEN` β€” functional but not recommended for production.
149
 
@@ -155,7 +163,7 @@ back to `HF_TOKEN` β€” functional but not recommended for production.
155
  | `HF_SPACES_MODEL_URL` | No | β€” | **Path 2** β€” Custom ZeroGPU Space URL (e.g. `https://scikit-plots-ai-model.hf.space/v1/chat/completions`). Receives requests whose `model` matches `HF_SPACES_MODEL_NAMESPACES`. |
156
  | `HF_SPACES_MODEL_NAMESPACES` | No | `scikit-plots` | Comma-separated model owner prefixes routed to Path 2 (e.g. `scikit-plots,my-org`). |
157
  | `HF_BASE` | No | `https://router.huggingface.co` | **Path 3** β€” HF Serverless API base URL. Only used when Path 1 and Path 2 do not match. |
158
- | `DEFAULT_MODEL` | No | `scikit-plots/Qwen2.5-Coder-32B-Instruct` | Fallback model when the request body omits `"model"`. |
159
 
160
  ### Training data collection
161
 
@@ -224,6 +232,26 @@ read role β†’ HF_TOKEN (inference only; set in Space secrets)
224
  write role β†’ HF_WRITE_TOKEN (dataset push; set in Space secrets)
225
  ```
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  ---
228
 
229
  ## Training data setup
@@ -314,7 +342,7 @@ below.
314
  HF_SPACES_MODEL_URL = https://scikit-plots-ai-model.hf.space/v1/chat/completions
315
  HF_SPACES_MODEL_NAMESPACES = scikit-plots
316
  PATH2_TIMEOUT = 600 ← CPU cold start takes 4–5 minutes
317
- DEFAULT_MODEL = scikit-plots/Qwen2.5-Coder-32B-Instruct
318
  ```
319
 
320
  ### Path 1 β€” Local / custom backend (Docker Model Runner, Ollama)
@@ -332,14 +360,16 @@ export HF_TOKEN=hf_<optional-for-path-1>
332
  ```
333
  # Inference
334
  HF_TOKEN = hf_<read-token>
 
335
  HF_SPACES_MODEL_URL = https://scikit-plots-ai-model.hf.space/v1/chat/completions
336
  HF_SPACES_MODEL_NAMESPACES = scikit-plots
337
- DEFAULT_MODEL = scikit-plots/Qwen2.5-Coder-32B-Instruct
338
  PATH2_TIMEOUT = 600
339
  PATH3_TIMEOUT = 120
340
 
341
  # Training data collection
342
  HF_WRITE_TOKEN = hf_<write-token>
 
343
  TRAINING_DATASET_REPO = scikit-plots/ai-assistant-contributions
344
 
345
  # Security
@@ -355,7 +385,7 @@ BASE=https://scikit-plots-ai.hf.space
355
 
356
  # 1. Liveness probe
357
  curl $BASE/health
358
- # {"status":"ok","version":"6.0.0"}
359
 
360
  # 2. Full status β€” check routing, token slots, and training readiness
361
  curl $BASE/ | python3 -m json.tool
@@ -371,7 +401,7 @@ curl $BASE/v1/chat/completions \
371
  # 4. Test a chat completion (Path 2 β€” custom Space)
372
  curl $BASE/v1/chat/completions \
373
  -H "Content-Type: application/json" \
374
- -d '{"model":"scikit-plots/Qwen2.5-Coder-32B-Instruct","messages":[{"role":"user","content":"hi"}]}'
375
 
376
  # 5. Test training contribution (requires TRAINING_DATASET_REPO + write token)
377
  curl $BASE/v1/contribute \
@@ -400,7 +430,7 @@ curl $BASE/v1/contribute \
400
 
401
  ## Why mirror repos fail β€” and how Path 2 solves it
402
 
403
- `scikit-plots/gpt-oss-20b` and `scikit-plots/Qwen2.5-Coder-32B-Instruct` are
404
  **mirror repositories** β€” weights copied from the originals but **not
405
  registered with any HF Inference Provider**.
406
 
@@ -413,7 +443,7 @@ registered with any HF Inference Provider**.
413
  Path 2 intercepts requests whose `model` field starts with a configured
414
  namespace **before** they reach the HF Serverless router, and forwards them to
415
  the custom ZeroGPU Space that actually has the weights loaded. This is why
416
- `DEFAULT_MODEL = scikit-plots/Qwen2.5-Coder-32B-Instruct` works when
417
  `HF_SPACES_MODEL_NAMESPACES = scikit-plots` is set.
418
 
419
  ---
@@ -433,6 +463,10 @@ the custom ZeroGPU Space that actually has the weights loaded. This is why
433
  | `least_privilege_mode: false` on status page | `HF_WRITE_TOKEN` not set | Set `HF_WRITE_TOKEN`; proxy works but uses `HF_TOKEN` for writes (less secure) |
434
  | Container crashes: `ModuleNotFoundError: No module named '_shared_logic'` | `_shared_logic.py` missing from the Space repo | Commit `_shared_logic.py` alongside `app.py` |
435
  | 413 on large conversation export | Body exceeds `MAX_BODY_BYTES` (default 10 MB) | Increase `MAX_BODY_BYTES` in Space secrets |
 
 
 
 
436
 
437
  ---
438
 
 
123
  | Variable | Required? | Scope | Description |
124
  |---|---|---|---|
125
  | `HF_TOKEN` | Yes (for Path 3) | Read / inference-api | Injected as `Bearer` into Path 1 and Path 3 upstream requests. Use a **read-only** fine-grained token β€” it never needs write permission. |
126
+ | `HF_TOKEN_TYPE` | No | β€” | Declares the type of `HF_TOKEN`. Accepted: `fine-grained` \| `read` \| `write`. When set, startup validation enforces least-privilege without network calls. When absent, a length-based heuristic is used and type shows as `"unknown"` on the status page. Setting `"write"` triggers a startup `WARNING`. |
127
  | `HF_WRITE_TOKEN` | No | Write (dataset repo only) | Dedicated write token used **only** by `POST /v1/contribute` to push JSONL files to `TRAINING_DATASET_REPO`. When set, `HF_TOKEN` remains strictly read-only. When absent, the proxy falls back to `HF_TOKEN` for writes (single-token mode, backward compatible but less secure). |
128
+ | `HF_WRITE_TOKEN_TYPE` | No | β€” | Declares the type of `HF_WRITE_TOKEN`. Accepted: `fine-grained` \| `write`. Enables startup validation to detect a read token used for writes (which would cause every `/v1/contribute` call to fail with 403). |
129
 
130
  > **Why two tokens?** Principle of least privilege. `HF_TOKEN` is forwarded
131
  > to external model backends on every inference request β€” if it carried write
132
  > permission a compromised upstream could modify your repos. `HF_WRITE_TOKEN`
133
  > never leaves the proxy and is scoped to one dataset repo.
134
 
135
+ The `/` status page shows all token states without exposing values:
136
 
137
  ```json
138
  "tokens": {
139
+ "hf_token_set": true,
140
+ "hf_token_type": "fine-grained",
141
+ "hf_write_token_set": true,
142
+ "hf_write_token_type": "fine-grained",
143
  "least_privilege_mode": true
144
  },
145
  "training": {
 
148
  }
149
  ```
150
 
151
+ `hf_token_type: "unknown"` means `HF_TOKEN_TYPE` is not set and the length
152
+ heuristic could not determine the type β€” set `HF_TOKEN_TYPE=fine-grained` or
153
+ `HF_TOKEN_TYPE=read` in Space secrets to resolve.
154
+
155
  `least_privilege_mode: false` means `HF_WRITE_TOKEN` is absent and writes fall
156
  back to `HF_TOKEN` β€” functional but not recommended for production.
157
 
 
163
  | `HF_SPACES_MODEL_URL` | No | β€” | **Path 2** β€” Custom ZeroGPU Space URL (e.g. `https://scikit-plots-ai-model.hf.space/v1/chat/completions`). Receives requests whose `model` matches `HF_SPACES_MODEL_NAMESPACES`. |
164
  | `HF_SPACES_MODEL_NAMESPACES` | No | `scikit-plots` | Comma-separated model owner prefixes routed to Path 2 (e.g. `scikit-plots,my-org`). |
165
  | `HF_BASE` | No | `https://router.huggingface.co` | **Path 3** β€” HF Serverless API base URL. Only used when Path 1 and Path 2 do not match. |
166
+ | `DEFAULT_MODEL` | No | `scikit-plots/Qwen2.5-Coder-7B-Instruct` | Fallback model when the request body omits `"model"`. |
167
 
168
  ### Training data collection
169
 
 
232
  write role β†’ HF_WRITE_TOKEN (dataset push; set in Space secrets)
233
  ```
234
 
235
+ ### Declare token types in Space secrets
236
+
237
+ After creating the tokens, set the corresponding type variables so the proxy
238
+ can validate least-privilege at startup without network calls:
239
+
240
+ ```
241
+ # Fine-grained tokens (recommended):
242
+ HF_TOKEN_TYPE = fine-grained
243
+ HF_WRITE_TOKEN_TYPE = fine-grained
244
+
245
+ # Classic read/write tokens (legacy):
246
+ HF_TOKEN_TYPE = read
247
+ HF_WRITE_TOKEN_TYPE = write
248
+ ```
249
+
250
+ If you omit these variables the proxy applies a length-based heuristic
251
+ (tokens β‰₯ 52 chars β†’ `fine-grained`; shorter β†’ `unknown`). The startup log
252
+ and status page will show `"unknown"` for the type, which suppresses
253
+ least-privilege warnings β€” always set explicit types in production.
254
+
255
  ---
256
 
257
  ## Training data setup
 
342
  HF_SPACES_MODEL_URL = https://scikit-plots-ai-model.hf.space/v1/chat/completions
343
  HF_SPACES_MODEL_NAMESPACES = scikit-plots
344
  PATH2_TIMEOUT = 600 ← CPU cold start takes 4–5 minutes
345
+ DEFAULT_MODEL = scikit-plots/Qwen2.5-Coder-7B-Instruct
346
  ```
347
 
348
  ### Path 1 β€” Local / custom backend (Docker Model Runner, Ollama)
 
360
  ```
361
  # Inference
362
  HF_TOKEN = hf_<read-token>
363
+ HF_TOKEN_TYPE = fine-grained
364
  HF_SPACES_MODEL_URL = https://scikit-plots-ai-model.hf.space/v1/chat/completions
365
  HF_SPACES_MODEL_NAMESPACES = scikit-plots
366
+ DEFAULT_MODEL = scikit-plots/Qwen2.5-Coder-7B-Instruct
367
  PATH2_TIMEOUT = 600
368
  PATH3_TIMEOUT = 120
369
 
370
  # Training data collection
371
  HF_WRITE_TOKEN = hf_<write-token>
372
+ HF_WRITE_TOKEN_TYPE = fine-grained
373
  TRAINING_DATASET_REPO = scikit-plots/ai-assistant-contributions
374
 
375
  # Security
 
385
 
386
  # 1. Liveness probe
387
  curl $BASE/health
388
+ # {"status":"ok","version":"6.1.0"}
389
 
390
  # 2. Full status β€” check routing, token slots, and training readiness
391
  curl $BASE/ | python3 -m json.tool
 
401
  # 4. Test a chat completion (Path 2 β€” custom Space)
402
  curl $BASE/v1/chat/completions \
403
  -H "Content-Type: application/json" \
404
+ -d '{"model":"scikit-plots/Qwen2.5-Coder-7B-Instruct","messages":[{"role":"user","content":"hi"}]}'
405
 
406
  # 5. Test training contribution (requires TRAINING_DATASET_REPO + write token)
407
  curl $BASE/v1/contribute \
 
430
 
431
  ## Why mirror repos fail β€” and how Path 2 solves it
432
 
433
+ `scikit-plots/gpt-oss-20b` and `scikit-plots/Qwen2.5-Coder-7B-Instruct` are
434
  **mirror repositories** β€” weights copied from the originals but **not
435
  registered with any HF Inference Provider**.
436
 
 
443
  Path 2 intercepts requests whose `model` field starts with a configured
444
  namespace **before** they reach the HF Serverless router, and forwards them to
445
  the custom ZeroGPU Space that actually has the weights loaded. This is why
446
+ `DEFAULT_MODEL = scikit-plots/Qwen2.5-Coder-7B-Instruct` works when
447
  `HF_SPACES_MODEL_NAMESPACES = scikit-plots` is set.
448
 
449
  ---
 
463
  | `least_privilege_mode: false` on status page | `HF_WRITE_TOKEN` not set | Set `HF_WRITE_TOKEN`; proxy works but uses `HF_TOKEN` for writes (less secure) |
464
  | Container crashes: `ModuleNotFoundError: No module named '_shared_logic'` | `_shared_logic.py` missing from the Space repo | Commit `_shared_logic.py` alongside `app.py` |
465
  | 413 on large conversation export | Body exceeds `MAX_BODY_BYTES` (default 10 MB) | Increase `MAX_BODY_BYTES` in Space secrets |
466
+ | Startup log: `WARNING: Startup token-config check: HF_TOKEN has type 'write' …` | A write token is being used for inference β€” violates least-privilege | Replace `HF_TOKEN` with a read or fine-grained token scoped to Inference only; set `HF_TOKEN_TYPE=read` or `HF_TOKEN_TYPE=fine-grained` in Space secrets |
467
+ | Startup log: `WARNING: Startup token-config check: HF_WRITE_TOKEN has type 'read' …` | A read-only token is configured as the write token | Replace `HF_WRITE_TOKEN` with a write or fine-grained token that has dataset write scope; set `HF_WRITE_TOKEN_TYPE=write` or `HF_WRITE_TOKEN_TYPE=fine-grained` |
468
+ | Status page shows `hf_token_type: "unknown"` | `HF_TOKEN_TYPE` not set; length-based heuristic could not determine token type | Set `HF_TOKEN_TYPE=fine-grained` or `HF_TOKEN_TYPE=read` in Space secrets to enable least-privilege startup checks |
469
+ | Status page shows `hf_token_type: "write"` with a startup WARNING | `HF_TOKEN` is a write token β€” too many permissions for inference | Create a separate read or fine-grained token for `HF_TOKEN` and set `HF_TOKEN_TYPE=read`; keep the write token only in `HF_WRITE_TOKEN` |
470
 
471
  ---
472
 
_shared_logic.py CHANGED
@@ -1,4 +1,4 @@
1
- # _shared_logic.py v6.0.0
2
  #
3
  # Single source of truth for shared constants, pure helper functions, and
4
  # type aliases used by the deployed proxy (_hf_spaces_proxy/app.py) and the
@@ -56,6 +56,26 @@
56
  # Callers who hard-code ``HF_BASE`` to the old hostname must migrate to
57
  # the new router URL.
58
  #
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  # SPDX-License-Identifier: BSD-3-Clause
60
  # Authors: The scikit-plots developers
61
 
@@ -134,10 +154,12 @@ from __future__ import annotations
134
 
135
  import json
136
  import os
137
- from typing import Any
138
 
139
  __all__ = [
140
- # Constants
 
 
141
  "DEFAULT_HF_BASE",
142
  "DEFAULT_HF_SPACES_MODEL_NAMESPACES",
143
  "DEFAULT_HF_SPACES_MODEL_URL",
@@ -146,14 +168,28 @@ __all__ = [
146
  "DEFAULT_PATH2_READ_TIMEOUT",
147
  "DEFAULT_PATH3_READ_TIMEOUT",
148
  "DEFAULT_PROXY_TIMEOUT",
149
- "PROXY_VERSION",
150
- # Helpers
 
 
 
 
 
 
 
151
  "_build_cors_headers",
152
  "_is_custom_model_namespace",
153
  "_parse_model",
154
- "_resolve_upstream_url",
155
  "_safe_int",
156
  "_token_log_fragment",
 
 
 
 
 
 
 
157
  "_validate_env",
158
  "load_proxy_env",
159
  ]
@@ -164,7 +200,7 @@ __all__ = [
164
  # ─────────────────────────────────────────────────────────────────────────────
165
 
166
  #: Proxy release version β€” bump on every breaking change.
167
- PROXY_VERSION: str = "6.0.0"
168
 
169
  #: HuggingFace Inference Providers router base URL (no trailing slash).
170
  #: Only used for Path 3 (standard provider models) when ``BACKEND_URL`` is
@@ -179,7 +215,7 @@ DEFAULT_HF_BASE: str = "https://router.huggingface.co"
179
 
180
  #: Fallback model ID when the request body omits the ``model`` field.
181
  #: Must have a registered HF Inference Provider for Path 3.
182
- DEFAULT_MODEL: str = "scikit-plots/Qwen2.5-Coder-32B-Instruct"
183
 
184
  #: Global upstream read timeout in seconds (used for Path 1 / backward compat).
185
  #:
@@ -224,6 +260,90 @@ DEFAULT_HF_SPACES_MODEL_URL: str = (
224
  DEFAULT_HF_SPACES_MODEL_NAMESPACES: tuple[str, ...] = ("scikit-plots",)
225
 
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  # ─────────────────────────────────────────────────────────────────────────────
228
  # Pure helper functions
229
  # ─────────────────────────────────────────────────────────────────────────────
@@ -340,11 +460,11 @@ def _parse_model(body: bytes, default: str = DEFAULT_MODEL) -> str:
340
  >>> _parse_model(b'{"model": "Qwen/Qwen2.5-Coder-7B-Instruct"}')
341
  'Qwen/Qwen2.5-Coder-7B-Instruct'
342
  >>> _parse_model(b"{}")
343
- 'scikit-plots/Qwen2.5-Coder-32B-Instruct'
344
  >>> _parse_model(b"not-json")
345
- 'scikit-plots/Qwen2.5-Coder-32B-Instruct'
346
  >>> _parse_model(b'{"model": " "}')
347
- 'scikit-plots/Qwen2.5-Coder-32B-Instruct'
348
  """
349
  try:
350
  data: Any = json.loads(body)
@@ -441,7 +561,7 @@ def _build_cors_headers(allowed_origin: str = "*") -> dict[str, str]:
441
  }
442
 
443
 
444
- def _token_log_fragment(token: str) -> str:
445
  """
446
  Produce a safely-truncated token string for log output.
447
 
@@ -449,6 +569,11 @@ def _token_log_fragment(token: str) -> str:
449
  ----------
450
  token : str
451
  A HuggingFace API token (typically ``hf_xxx...``).
 
 
 
 
 
452
 
453
  Returns
454
  -------
@@ -466,13 +591,322 @@ def _token_log_fragment(token: str) -> str:
466
  --------
467
  >>> _token_log_fragment("hf_abcdefghij1234")
468
  'hf_abcde...1234'
 
 
469
  >>> _token_log_fragment("")
470
  '<not-set>'
471
  """
472
  if not token or len(token) < 12: # noqa: PLR2004
473
  return "<not-set>"
474
- return f"{token[:8]}...{token[-4:]}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
 
477
  def _resolve_upstream_url(
478
  body: bytes,
@@ -711,6 +1145,12 @@ def load_proxy_env() -> dict[str, Any]:
711
  Path 3 read timeout (env ``PATH3_TIMEOUT``).
712
  ``max_body_bytes`` : int
713
  ``allowed_origins`` : str
 
 
 
 
 
 
714
 
715
  Examples
716
  --------
@@ -736,6 +1176,18 @@ def load_proxy_env() -> dict[str, Any]:
736
  _hf_token: str = os.environ.get("HF_TOKEN", "").strip()
737
  _hf_write_token: str = os.environ.get("HF_WRITE_TOKEN", "").strip()
738
 
 
 
 
 
 
 
 
 
 
 
 
 
739
  return {
740
  "backend_url": os.environ.get("BACKEND_URL", "").strip(),
741
  "hf_token": _hf_token,
@@ -743,8 +1195,13 @@ def load_proxy_env() -> dict[str, Any]:
743
  # Principle of least privilege: set HF_WRITE_TOKEN to a write-scoped
744
  # fine-grained token so HF_TOKEN can remain read-only / inference-only.
745
  # Falls back to hf_token when absent for backward compatibility.
746
- "hf_write_token": _hf_write_token,
747
- "hf_dataset_token": _hf_write_token or _hf_token,
 
 
 
 
 
748
  "hf_base": os.environ.get("HF_BASE", DEFAULT_HF_BASE).rstrip("/"),
749
  "default_model": (
750
  os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL).strip() or DEFAULT_MODEL
 
1
+ # _shared_logic.py v6.1.0
2
  #
3
  # Single source of truth for shared constants, pure helper functions, and
4
  # type aliases used by the deployed proxy (_hf_spaces_proxy/app.py) and the
 
56
  # Callers who hard-code ``HF_BASE`` to the old hostname must migrate to
57
  # the new router URL.
58
  #
59
+ # New in v6.1.0 β€” Three-type HF token system
60
+ # -------------------------------------------
61
+ # + ``HFTokenType`` literal type alias added: ``"fine-grained" | "read" |
62
+ # ``"write" | "unknown"``. Maps directly to the three token types exposed
63
+ # in HF Settings β†’ Tokens.
64
+ # + ``HF_TOKEN_TYPE_*`` string constants and ``HF_INFERENCE_TOKEN_TYPES`` /
65
+ # ``HF_WRITE_TOKEN_TYPES`` frozensets added for type-safe comparisons.
66
+ # + ``_classify_token_type()`` β€” classify a token by explicit env-var
67
+ # declaration (``HF_TOKEN_TYPE``, ``HF_WRITE_TOKEN_TYPE``) with a length-
68
+ # based heuristic fallback.
69
+ # + ``_token_suitable_for_inference()`` / ``_token_suitable_for_writes()``
70
+ # predicates for principle-of-least-privilege validation.
71
+ # + ``_validate_token_config()`` β€” returns actionable WARNING / ERROR strings
72
+ # for token-type mismatches detected at startup.
73
+ # + ``_token_log_fragment()`` gains an optional ``token_type`` parameter so
74
+ # log lines include the token type (e.g. ``hf_abcde...1234 (read)``).
75
+ # + ``load_proxy_env()`` extended with ``hf_token_type`` and
76
+ # ``hf_write_token_type`` keys read from the matching env vars.
77
+ # + ``_safe_float`` added to ``__all__`` (was importable but unadvertised).
78
+ #
79
  # SPDX-License-Identifier: BSD-3-Clause
80
  # Authors: The scikit-plots developers
81
 
 
154
 
155
  import json
156
  import os
157
+ from typing import Any, Literal
158
 
159
  __all__ = [
160
+ # Version
161
+ "PROXY_VERSION",
162
+ # Constants β€” routing / timeout
163
  "DEFAULT_HF_BASE",
164
  "DEFAULT_HF_SPACES_MODEL_NAMESPACES",
165
  "DEFAULT_HF_SPACES_MODEL_URL",
 
168
  "DEFAULT_PATH2_READ_TIMEOUT",
169
  "DEFAULT_PATH3_READ_TIMEOUT",
170
  "DEFAULT_PROXY_TIMEOUT",
171
+ # Constants β€” token type system (v6.1.0)
172
+ "HFTokenType",
173
+ "HF_TOKEN_TYPE_FINE_GRAINED",
174
+ "HF_TOKEN_TYPE_READ",
175
+ "HF_TOKEN_TYPE_WRITE",
176
+ "HF_TOKEN_TYPE_UNKNOWN",
177
+ "HF_INFERENCE_TOKEN_TYPES",
178
+ "HF_WRITE_TOKEN_TYPES",
179
+ # Helpers β€” general
180
  "_build_cors_headers",
181
  "_is_custom_model_namespace",
182
  "_parse_model",
183
+ "_safe_float",
184
  "_safe_int",
185
  "_token_log_fragment",
186
+ # Helpers β€” token type system (v6.1.0)
187
+ "_classify_token_type",
188
+ "_token_suitable_for_inference",
189
+ "_token_suitable_for_writes",
190
+ "_validate_token_config",
191
+ # Helpers β€” routing / env
192
+ "_resolve_upstream_url",
193
  "_validate_env",
194
  "load_proxy_env",
195
  ]
 
200
  # ─────────────────────────────────────────────────────────────────────────────
201
 
202
  #: Proxy release version β€” bump on every breaking change.
203
+ PROXY_VERSION: str = "6.1.0"
204
 
205
  #: HuggingFace Inference Providers router base URL (no trailing slash).
206
  #: Only used for Path 3 (standard provider models) when ``BACKEND_URL`` is
 
215
 
216
  #: Fallback model ID when the request body omits the ``model`` field.
217
  #: Must have a registered HF Inference Provider for Path 3.
218
+ DEFAULT_MODEL: str = "scikit-plots/Qwen2.5-Coder-7B-Instruct"
219
 
220
  #: Global upstream read timeout in seconds (used for Path 1 / backward compat).
221
  #:
 
260
  DEFAULT_HF_SPACES_MODEL_NAMESPACES: tuple[str, ...] = ("scikit-plots",)
261
 
262
 
263
+ # ─────────────────────────────────────────────────────────────────────────────
264
+ # HuggingFace token type system (v6.1.0)
265
+ # ─────────────────────────────────────────────────────────────────────────────
266
+ #
267
+ # HuggingFace exposes exactly three token types in
268
+ # https://huggingface.co/settings/tokens:
269
+ #
270
+ # β‘  Fine-grained β€” New-style token. Permissions set at creation time:
271
+ # choose any combination of per-repo access levels and
272
+ # API capabilities. Recommended for production because
273
+ # each token carries only the minimum required scope.
274
+ #
275
+ # β‘‘ Read (classic) β€” Legacy read-only token. Grants read access to all
276
+ # public repos and any private repos you can access.
277
+ # Always includes the Serverless Inference API capability.
278
+ # Cannot push commits or create repos.
279
+ #
280
+ # β‘’ Write (classic)β€” Legacy read+write token. All read permissions plus
281
+ # the ability to push commits, create repos, manage
282
+ # members, etc. Over-privileged for inference-only use.
283
+ #
284
+ # Mapping to proxy env vars
285
+ # ─────────────────────────
286
+ # HF_TOKEN β€” inference token (Path 2 private Space + Path 3 HF API).
287
+ # Best practice: fine-grained with inference-api scope only,
288
+ # OR classic read. Never use a write token here.
289
+ #
290
+ # HF_WRITE_TOKEN β€” dataset write token (/v1/contribute β†’ HfApi.create_commit).
291
+ # Best practice: fine-grained scoped to ONE dataset repo,
292
+ # OR classic write. Never use a read token here.
293
+ #
294
+ # Optional type-declaration env vars (Space β†’ Settings β†’ Repository secrets):
295
+ # HF_TOKEN_TYPE = fine-grained | read | write (default: auto-detect)
296
+ # HF_WRITE_TOKEN_TYPE = fine-grained | write (default: auto-detect)
297
+ #
298
+ # ─────────────────────────────────────────────────────────────────────────────
299
+
300
+ #: Literal type for HuggingFace token type labels.
301
+ #: Use as type annotation and for exhaustive ``isinstance``-free comparisons.
302
+ HFTokenType = Literal["fine-grained", "read", "write", "unknown"]
303
+
304
+ #: New-style fine-grained HF token. Permissions defined at creation time.
305
+ #: Declare via env var: ``HF_TOKEN_TYPE=fine-grained``.
306
+ HF_TOKEN_TYPE_FINE_GRAINED: str = "fine-grained"
307
+
308
+ #: Classic HF read token. Read + Inference API; no write capability.
309
+ #: Declare via env var: ``HF_TOKEN_TYPE=read``.
310
+ HF_TOKEN_TYPE_READ: str = "read"
311
+
312
+ #: Classic HF write token. All read permissions + repo push capability.
313
+ #: Declare via env var: ``HF_TOKEN_TYPE=write`` or ``HF_WRITE_TOKEN_TYPE=write``.
314
+ HF_TOKEN_TYPE_WRITE: str = "write"
315
+
316
+ #: Sentinel: token type not declared and could not be inferred.
317
+ #: Runtime operations are not blocked, but :func:`_validate_token_config` omits
318
+ #: least-privilege warnings because the type is unknown.
319
+ HF_TOKEN_TYPE_UNKNOWN: str = "unknown"
320
+
321
+ #: Token types that are appropriate for HF Serverless Inference API calls
322
+ #: (Path 3) and private HF Space access (Path 2).
323
+ #:
324
+ #: Classic write tokens ARE technically capable of inference (write βŠ‡ read),
325
+ #: but are excluded from this set so :func:`_validate_token_config` can emit
326
+ #: a startup warning when a write token is used where a read / fine-grained
327
+ #: token is the correct choice. The ``"unknown"`` sentinel is included so
328
+ #: that un-declared tokens do not trigger false-positive warnings.
329
+ HF_INFERENCE_TOKEN_TYPES: frozenset[str] = frozenset({
330
+ HF_TOKEN_TYPE_FINE_GRAINED,
331
+ HF_TOKEN_TYPE_READ,
332
+ HF_TOKEN_TYPE_UNKNOWN,
333
+ })
334
+
335
+ #: Token types that can push commits to HuggingFace repos and datasets.
336
+ #:
337
+ #: Classic read tokens **cannot** write β€” any ``HfApi.create_commit`` call
338
+ #: returns HTTP 403 / 401. ``"unknown"`` is excluded so that
339
+ #: :func:`_validate_token_config` can flag a read token configured as the write
340
+ #: token as a hard error rather than silently failing at request time.
341
+ HF_WRITE_TOKEN_TYPES: frozenset[str] = frozenset({
342
+ HF_TOKEN_TYPE_FINE_GRAINED,
343
+ HF_TOKEN_TYPE_WRITE,
344
+ })
345
+
346
+
347
  # ─────────────────────────────────────────────────────────────────────────────
348
  # Pure helper functions
349
  # ─────────────────────────────────────────────────────────────────────────────
 
460
  >>> _parse_model(b'{"model": "Qwen/Qwen2.5-Coder-7B-Instruct"}')
461
  'Qwen/Qwen2.5-Coder-7B-Instruct'
462
  >>> _parse_model(b"{}")
463
+ 'scikit-plots/Qwen2.5-Coder-7B-Instruct'
464
  >>> _parse_model(b"not-json")
465
+ 'scikit-plots/Qwen2.5-Coder-7B-Instruct'
466
  >>> _parse_model(b'{"model": " "}')
467
+ 'scikit-plots/Qwen2.5-Coder-7B-Instruct'
468
  """
469
  try:
470
  data: Any = json.loads(body)
 
561
  }
562
 
563
 
564
+ def _token_log_fragment(token: str, token_type: str = "") -> str:
565
  """
566
  Produce a safely-truncated token string for log output.
567
 
 
569
  ----------
570
  token : str
571
  A HuggingFace API token (typically ``hf_xxx...``).
572
+ token_type : str, optional
573
+ Token type string (one of the ``HF_TOKEN_TYPE_*`` constants).
574
+ When non-empty and not ``"unknown"``, appended as a parenthetical
575
+ suffix so log lines identify the token class without exposing the
576
+ value. Example: ``hf_abcde...1234 (read)``.
577
 
578
  Returns
579
  -------
 
591
  --------
592
  >>> _token_log_fragment("hf_abcdefghij1234")
593
  'hf_abcde...1234'
594
+ >>> _token_log_fragment("hf_abcdefghij1234", token_type="read")
595
+ 'hf_abcde...1234 (read)'
596
  >>> _token_log_fragment("")
597
  '<not-set>'
598
  """
599
  if not token or len(token) < 12: # noqa: PLR2004
600
  return "<not-set>"
601
+ fragment = f"{token[:8]}...{token[-4:]}"
602
+ if token_type and token_type != HF_TOKEN_TYPE_UNKNOWN:
603
+ return f"{fragment} ({token_type})"
604
+ return fragment
605
+
606
+
607
+ def _classify_token_type(
608
+ token: str,
609
+ declared_type: str | None = None,
610
+ ) -> HFTokenType:
611
+ """
612
+ Classify a HuggingFace token by its declared type or format heuristics.
613
+
614
+ Token type classification is used at startup by :func:`_validate_token_config`
615
+ to enforce the principle of least privilege before any requests arrive.
616
+
617
+ Parameters
618
+ ----------
619
+ token : str
620
+ The HuggingFace API token string.
621
+ declared_type : str or None, optional
622
+ Explicitly declared type from an environment variable
623
+ (``HF_TOKEN_TYPE`` or ``HF_WRITE_TOKEN_TYPE``).
624
+ Accepted values: ``"fine-grained"``, ``"read"``, ``"write"``
625
+ (and minor formatting variants: ``"finegrained"``,
626
+ ``"fine_grained"``). When provided and recognized, it takes
627
+ precedence over all heuristics.
628
 
629
+ Returns
630
+ -------
631
+ HFTokenType
632
+ One of ``"fine-grained"``, ``"read"``, ``"write"``, or ``"unknown"``.
633
+
634
+ Notes
635
+ -----
636
+ **Security note** β€” Token type cannot be verified without an authenticated
637
+ call to the HF API (``GET https://huggingface.co/api/whoami-v2``). This
638
+ function applies lightweight format heuristics only. For production
639
+ deployments, always declare the type explicitly via ``HF_TOKEN_TYPE`` /
640
+ ``HF_WRITE_TOKEN_TYPE`` so :func:`_validate_token_config` can enforce
641
+ least-privilege at startup without any network calls.
642
+
643
+ **Developer note** β€” As of 2025, classic HF tokens are approximately 34
644
+ characters total (``hf_`` prefix + 30 alphanumeric chars). Fine-grained
645
+ tokens are substantially longer (β‰₯ 52 characters total as of the HF 2025
646
+ token format). This length heuristic is imprecise and subject to silent
647
+ change by HF; explicit declaration via env vars is always preferred.
648
+
649
+ Examples
650
+ --------
651
+ Explicit declaration takes precedence over heuristics:
652
+
653
+ >>> _classify_token_type("hf_" + "a" * 30, declared_type="read")
654
+ 'read'
655
+ >>> _classify_token_type("hf_" + "a" * 30, declared_type="write")
656
+ 'write'
657
+
658
+ Heuristic: token β‰₯ 52 chars β†’ fine-grained:
659
+
660
+ >>> _classify_token_type("hf_" + "a" * 50)
661
+ 'fine-grained'
662
+
663
+ Short classic token without declaration β†’ unknown:
664
+
665
+ >>> _classify_token_type("hf_" + "a" * 28)
666
+ 'unknown'
667
+
668
+ Empty or malformed token β†’ unknown:
669
+
670
+ >>> _classify_token_type("")
671
+ 'unknown'
672
+ """
673
+ # Normalise accepted declared-type values (tolerate minor formatting variants).
674
+ _declared_map: dict[str, HFTokenType] = {
675
+ "fine-grained": "fine-grained",
676
+ "finegrained": "fine-grained",
677
+ "fine_grained": "fine-grained",
678
+ "read": "read",
679
+ "write": "write",
680
+ }
681
+ if declared_type:
682
+ normalised = _declared_map.get(declared_type.lower().strip())
683
+ if normalised is not None:
684
+ return normalised
685
+
686
+ # Validate basic token format β€” all HF tokens start with "hf_".
687
+ if not token or not token.startswith("hf_") or len(token) < 10: # noqa: PLR2004
688
+ return "unknown"
689
+
690
+ # Heuristic: fine-grained tokens are substantially longer than classic tokens.
691
+ # Classic tokens: ~34 chars total. Fine-grained tokens: β‰₯ 52 chars (HF 2025).
692
+ # Best-effort only; explicit declaration via env vars is always preferred.
693
+ if len(token) >= 52: # noqa: PLR2004
694
+ return "fine-grained"
695
+
696
+ # Cannot distinguish classic read vs write by token string alone.
697
+ return "unknown"
698
+
699
+
700
+ def _token_suitable_for_inference(token_type: str) -> bool:
701
+ """
702
+ Return ``True`` when *token_type* is appropriate for HF Inference API calls.
703
+
704
+ This predicate guards inference paths (Path 2 private Space access and
705
+ Path 3 HF Serverless API). Returning ``False`` for a classic write token
706
+ does not block the token at runtime β€” it causes :func:`_validate_token_config`
707
+ to emit a startup ``WARNING`` so the operator knows they are running with
708
+ more permission than necessary.
709
+
710
+ Parameters
711
+ ----------
712
+ token_type : str
713
+ One of the ``HF_TOKEN_TYPE_*`` constants or a free-form string parsed
714
+ from an environment variable.
715
+
716
+ Returns
717
+ -------
718
+ bool
719
+ ``True`` for ``"fine-grained"``, ``"read"``, and ``"unknown"``.
720
+ ``False`` for ``"write"`` (classic write token β€” over-privileged).
721
+
722
+ Notes
723
+ -----
724
+ The recommended configuration is a fine-grained token scoped exclusively
725
+ to ``Make calls to the serverless Inference API``, or a classic read
726
+ token. Classic write tokens carry unnecessary repo-write permission
727
+ and violate the principle of least privilege.
728
+
729
+ Examples
730
+ --------
731
+ >>> _token_suitable_for_inference("read")
732
+ True
733
+ >>> _token_suitable_for_inference("fine-grained")
734
+ True
735
+ >>> _token_suitable_for_inference("write")
736
+ False
737
+ >>> _token_suitable_for_inference("unknown")
738
+ True
739
+ """
740
+ return token_type in HF_INFERENCE_TOKEN_TYPES
741
+
742
+
743
+ def _token_suitable_for_writes(token_type: str) -> bool:
744
+ """
745
+ Return ``True`` when *token_type* can authorize HuggingFace write operations.
746
+
747
+ This predicate guards the ``/v1/contribute`` endpoint. Returning ``False``
748
+ for a classic read or unknown token causes :func:`_validate_token_config`
749
+ to emit a startup ``ERROR`` string because the token WILL fail at
750
+ ``HfApi.create_commit`` time (HTTP 403 / 401 from HF).
751
+
752
+ Parameters
753
+ ----------
754
+ token_type : str
755
+ One of the ``HF_TOKEN_TYPE_*`` constants or a free-form string parsed
756
+ from an environment variable.
757
+
758
+ Returns
759
+ -------
760
+ bool
761
+ ``True`` for ``"fine-grained"`` and ``"write"``.
762
+ ``False`` for ``"read"`` and ``"unknown"``.
763
+
764
+ Notes
765
+ -----
766
+ Fine-grained tokens can write **only if** write permission was granted to
767
+ the target repo at token-creation time. A fine-grained token created
768
+ with only inference-API scope will also fail on write operations, but the
769
+ proxy cannot verify fine-grained permissions without an authenticated API
770
+ call. Fine-grained tokens are therefore accepted here and any permission
771
+ failures surface at operation time with a clear HTTP 503 error.
772
+
773
+ Examples
774
+ --------
775
+ >>> _token_suitable_for_writes("write")
776
+ True
777
+ >>> _token_suitable_for_writes("fine-grained")
778
+ True
779
+ >>> _token_suitable_for_writes("read")
780
+ False
781
+ >>> _token_suitable_for_writes("unknown")
782
+ False
783
+ """
784
+ return token_type in HF_WRITE_TOKEN_TYPES
785
+
786
+
787
+ def _validate_token_config(
788
+ hf_token: str,
789
+ hf_write_token: str,
790
+ training_dataset_repo: str = "",
791
+ *,
792
+ hf_token_type: str = HF_TOKEN_TYPE_UNKNOWN,
793
+ hf_write_token_type: str = HF_TOKEN_TYPE_UNKNOWN,
794
+ ) -> list[str]:
795
+ """
796
+ Validate token types and return actionable warning / error strings.
797
+
798
+ Enforces the principle of least privilege and detects token-type
799
+ misconfigurations that would cause silent failures at request time.
800
+ Returns a list of strings rather than raising exceptions so the proxy
801
+ can start in degraded mode and surface issues through structured logs.
802
+
803
+ Call this at startup **after** :func:`_validate_env` so routing is
804
+ confirmed viable before type checks are run.
805
+
806
+ Parameters
807
+ ----------
808
+ hf_token : str
809
+ HuggingFace token used for inference (``HF_TOKEN`` env var).
810
+ hf_write_token : str
811
+ HuggingFace token used for dataset writes (``HF_WRITE_TOKEN`` env
812
+ var). Pass empty string when not configured.
813
+ training_dataset_repo : str, optional
814
+ HuggingFace Dataset repo ID (``TRAINING_DATASET_REPO`` env var).
815
+ Pass empty string when ``/v1/contribute`` is not enabled.
816
+ hf_token_type : str, optional
817
+ Classified type for *hf_token* (from :func:`_classify_token_type`).
818
+ Defaults to ``"unknown"``.
819
+ hf_write_token_type : str, optional
820
+ Classified type for *hf_write_token*. Defaults to ``"unknown"``.
821
+
822
+ Returns
823
+ -------
824
+ list[str]
825
+ Zero or more diagnostic strings. Each message is prefixed with
826
+ ``"WARNING:"`` or ``"ERROR:"`` so callers can log at the correct
827
+ level. An empty list means the configuration passes all checks.
828
+
829
+ Notes
830
+ -----
831
+ **Security note** β€” ``"write"`` token used for inference is a WARNING
832
+ (not an error) because it functions correctly at runtime. The warning
833
+ exists to prompt the operator to apply least-privilege.
834
+
835
+ **Security note** β€” ``"read"`` token used for writes is a hard ERROR:
836
+ the token WILL fail on every ``HfApi.create_commit`` call. The proxy
837
+ can still start (useful for operators who only need inference), but
838
+ ``/v1/contribute`` will be permanently non-functional until the token is
839
+ replaced.
840
+
841
+ Examples
842
+ --------
843
+ Clean configuration β€” no messages:
844
+
845
+ >>> _validate_token_config("hf_readtok", "", hf_token_type="read")
846
+ []
847
+
848
+ Write token for inference (overprivileged) β†’ WARNING:
849
+
850
+ >>> msgs = _validate_token_config("hf_writetok", "", hf_token_type="write")
851
+ >>> any("WARNING" in m for m in msgs)
852
+ True
853
+
854
+ Read token for writes β†’ ERROR:
855
+
856
+ >>> msgs = _validate_token_config(
857
+ ... "hf_tok", "hf_readtok",
858
+ ... training_dataset_repo="org/dataset",
859
+ ... hf_write_token_type="read",
860
+ ... )
861
+ >>> any("ERROR" in m for m in msgs)
862
+ True
863
+ """
864
+ messages: list[str] = []
865
+
866
+ # ── Inference token (HF_TOKEN) type check ────────────────────────────────
867
+ if hf_token and not _token_suitable_for_inference(hf_token_type):
868
+ messages.append(
869
+ f"WARNING: HF_TOKEN type is {hf_token_type!r} (classic write token). "
870
+ "Write tokens carry unnecessary repo-push permission and violate the "
871
+ "principle of least privilege for inference. "
872
+ "Replace HF_TOKEN with: (a) a fine-grained token scoped to "
873
+ "'Make calls to the serverless Inference API' only, or "
874
+ "(b) a classic read token. "
875
+ "See HF Settings β†’ Tokens β†’ New token β†’ Fine-grained. "
876
+ "Set HF_TOKEN_TYPE=read or HF_TOKEN_TYPE=fine-grained after replacing."
877
+ )
878
+
879
+ # ── Write token (HF_WRITE_TOKEN) type check ──────────────────────────────
880
+ if hf_write_token and not _token_suitable_for_writes(hf_write_token_type):
881
+ messages.append(
882
+ f"ERROR: HF_WRITE_TOKEN type is {hf_write_token_type!r}. "
883
+ "Classic read tokens cannot push commits to HuggingFace repositories "
884
+ "(HF API returns HTTP 403 / 401). "
885
+ "Every POST /v1/contribute will fail with HTTP 503. "
886
+ "Replace HF_WRITE_TOKEN with: (a) a fine-grained token with write "
887
+ "access scoped to the training dataset repo, or (b) a classic write "
888
+ "token. "
889
+ "See HF Settings β†’ Tokens β†’ New token β†’ Fine-grained β†’ Write access."
890
+ )
891
+
892
+ # ── Training repo + effective write token consistency ────────────────────
893
+ if training_dataset_repo:
894
+ # Effective write token is HF_WRITE_TOKEN when set; else falls back to
895
+ # HF_TOKEN. Check that the effective token type can authorize writes.
896
+ effective_token = hf_write_token or hf_token
897
+ effective_type = hf_write_token_type if hf_write_token else hf_token_type
898
+ if effective_token and not _token_suitable_for_writes(effective_type):
899
+ messages.append(
900
+ f"ERROR: TRAINING_DATASET_REPO={training_dataset_repo!r} is "
901
+ "configured but the effective write token type "
902
+ f"({effective_type!r}) cannot push to HuggingFace repositories. "
903
+ "POST /v1/contribute will always fail with HTTP 503. "
904
+ "Set HF_WRITE_TOKEN to a write-capable token (fine-grained with "
905
+ "write access to the dataset repo, or a classic write token). "
906
+ f"Set HF_WRITE_TOKEN_TYPE accordingly."
907
+ )
908
+
909
+ return messages
910
 
911
  def _resolve_upstream_url(
912
  body: bytes,
 
1145
  Path 3 read timeout (env ``PATH3_TIMEOUT``).
1146
  ``max_body_bytes`` : int
1147
  ``allowed_origins`` : str
1148
+ ``hf_token_type`` : str
1149
+ Classified token type for *hf_token* (env ``HF_TOKEN_TYPE``).
1150
+ One of ``"fine-grained"``, ``"read"``, ``"write"``, ``"unknown"``.
1151
+ ``hf_write_token_type`` : str
1152
+ Classified token type for *hf_write_token* (env ``HF_WRITE_TOKEN_TYPE``).
1153
+ One of ``"fine-grained"``, ``"write"``, ``"unknown"``.
1154
 
1155
  Examples
1156
  --------
 
1176
  _hf_token: str = os.environ.get("HF_TOKEN", "").strip()
1177
  _hf_write_token: str = os.environ.get("HF_WRITE_TOKEN", "").strip()
1178
 
1179
+ # Classify token types from explicit declarations (preferred) or heuristics.
1180
+ # Explicit: set HF_TOKEN_TYPE=read|write|fine-grained in Space secrets.
1181
+ # Heuristic: length-based guess (fine-grained tokens are β‰₯ 52 chars).
1182
+ _hf_token_type: str = _classify_token_type(
1183
+ _hf_token,
1184
+ declared_type=os.environ.get("HF_TOKEN_TYPE"),
1185
+ )
1186
+ _hf_write_token_type: str = _classify_token_type(
1187
+ _hf_write_token,
1188
+ declared_type=os.environ.get("HF_WRITE_TOKEN_TYPE"),
1189
+ )
1190
+
1191
  return {
1192
  "backend_url": os.environ.get("BACKEND_URL", "").strip(),
1193
  "hf_token": _hf_token,
 
1195
  # Principle of least privilege: set HF_WRITE_TOKEN to a write-scoped
1196
  # fine-grained token so HF_TOKEN can remain read-only / inference-only.
1197
  # Falls back to hf_token when absent for backward compatibility.
1198
+ "hf_write_token": _hf_write_token,
1199
+ "hf_dataset_token": _hf_write_token or _hf_token,
1200
+ # Token type metadata β€” used by _validate_token_config at startup.
1201
+ # Operators can override auto-detection by setting HF_TOKEN_TYPE /
1202
+ # HF_WRITE_TOKEN_TYPE to "fine-grained", "read", or "write".
1203
+ "hf_token_type": _hf_token_type,
1204
+ "hf_write_token_type": _hf_write_token_type,
1205
  "hf_base": os.environ.get("HF_BASE", DEFAULT_HF_BASE).rstrip("/"),
1206
  "default_model": (
1207
  os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL).strip() or DEFAULT_MODEL
app.py CHANGED
@@ -1,4 +1,4 @@
1
- # scikit-plots/ai Β· _hf_spaces_proxy/app.py v6.0.0
2
  #
3
  # Thin OpenAI-compatible reverse proxy for sphinx-ai-assistant.
4
  #
@@ -51,13 +51,23 @@
51
  # HF_BASE HF Serverless API base URL.
52
  # Default: https://router.huggingface.co
53
  # DEFAULT_MODEL Fallback model when request body omits "model".
54
- # Default: scikit-plots/Qwen2.5-Coder-32B-Instruct
55
  # PROXY_TIMEOUT Path 1 read timeout in seconds. Default: 600.
56
  # PATH2_TIMEOUT Path 2 read timeout in seconds. Default: 600.
57
  # PATH3_TIMEOUT Path 3 read timeout in seconds. Default: 120.
58
  # PROXY_CONNECT_TIMEOUT TCP handshake timeout. Default: 10.
59
  # PROXY_WRITE_TIMEOUT Request body upload timeout. Default: 30.
60
  # PROXY_POOL_TIMEOUT Connection pool acquire timeout. Default: 10.
 
 
 
 
 
 
 
 
 
 
61
  # ALLOWED_ORIGINS Comma-separated CORS origins. Default: *.
62
  # MAX_BODY_BYTES Maximum accepted body size. Default: 10485760.
63
  #
@@ -129,11 +139,13 @@ try:
129
  DEFAULT_PATH3_READ_TIMEOUT,
130
  DEFAULT_PROXY_TIMEOUT,
131
  PROXY_VERSION,
 
132
  _resolve_upstream_url,
133
  _safe_float,
134
  _safe_int,
135
  _token_log_fragment,
136
  _validate_env,
 
137
  )
138
  except ImportError:
139
  from .._shared_logic import ( # type: ignore[import]
@@ -146,11 +158,13 @@ except ImportError:
146
  DEFAULT_PATH3_READ_TIMEOUT,
147
  DEFAULT_PROXY_TIMEOUT,
148
  PROXY_VERSION,
 
149
  _resolve_upstream_url,
150
  _safe_float,
151
  _safe_int,
152
  _token_log_fragment,
153
  _validate_env,
 
154
  )
155
 
156
 
@@ -281,6 +295,29 @@ HF_WRITE_TOKEN: str = os.environ.get("HF_WRITE_TOKEN", "").strip()
281
  #: Never forward this token to model inference backends.
282
  HF_DATASET_TOKEN: str = HF_WRITE_TOKEN or HF_TOKEN
283
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  #: HF Serverless Inference API base URL (no trailing slash).
285
  HF_BASE: str = os.environ.get("HF_BASE", DEFAULT_HF_BASE).rstrip("/")
286
 
@@ -388,6 +425,21 @@ _feedback_rl_lock = asyncio.Lock()
388
 
389
  _validate_env(BACKEND_URL, HF_TOKEN, HF_SPACES_MODEL_URL)
390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  if not BACKEND_URL and not HF_TOKEN:
392
  logger.warning(
393
  "HF_TOKEN is not set. Requests to standard HF Inference API models "
@@ -459,8 +511,8 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
459
  BACKEND_URL or None,
460
  HF_SPACES_MODEL_URL or None,
461
  list(HF_SPACES_MODEL_NAMESPACES),
462
- _token_log_fragment(HF_TOKEN),
463
- _token_log_fragment(HF_WRITE_TOKEN) if HF_WRITE_TOKEN else "<using-hf-token-fallback>",
464
  DEFAULT_MODEL,
465
  )
466
  logger.info(
@@ -878,8 +930,15 @@ async def root() -> JSONResponse:
878
  },
879
  "tokens": {
880
  # Never expose token values β€” only whether each slot is filled.
881
- "hf_token_set": bool(HF_TOKEN),
882
- "hf_write_token_set": bool(HF_WRITE_TOKEN),
 
 
 
 
 
 
 
883
  # True when HF_WRITE_TOKEN is set; False means writes fall
884
  # back to HF_TOKEN (single-token mode, less secure).
885
  "least_privilege_mode": bool(HF_WRITE_TOKEN),
 
1
+ # scikit-plots/ai Β· _hf_spaces_proxy/app.py v6.1.0
2
  #
3
  # Thin OpenAI-compatible reverse proxy for sphinx-ai-assistant.
4
  #
 
51
  # HF_BASE HF Serverless API base URL.
52
  # Default: https://router.huggingface.co
53
  # DEFAULT_MODEL Fallback model when request body omits "model".
54
+ # Default: scikit-plots/Qwen2.5-Coder-7B-Instruct
55
  # PROXY_TIMEOUT Path 1 read timeout in seconds. Default: 600.
56
  # PATH2_TIMEOUT Path 2 read timeout in seconds. Default: 600.
57
  # PATH3_TIMEOUT Path 3 read timeout in seconds. Default: 120.
58
  # PROXY_CONNECT_TIMEOUT TCP handshake timeout. Default: 10.
59
  # PROXY_WRITE_TIMEOUT Request body upload timeout. Default: 30.
60
  # PROXY_POOL_TIMEOUT Connection pool acquire timeout. Default: 10.
61
+ # HF_TOKEN_TYPE Declare the type of HF_TOKEN so startup validation can
62
+ # enforce principle-of-least-privilege without network calls.
63
+ # Accepted values: fine-grained | read | write
64
+ # When absent the proxy applies a length-based heuristic.
65
+ # Set to "read" when using a classic read token; set to
66
+ # "fine-grained" for new-style scoped tokens. Never set
67
+ # "write" β€” that triggers a startup WARNING.
68
+ # HF_WRITE_TOKEN_TYPE Declare the type of HF_WRITE_TOKEN.
69
+ # Accepted values: fine-grained | write
70
+ # Set to match the token created in HF Settings β†’ Tokens.
71
  # ALLOWED_ORIGINS Comma-separated CORS origins. Default: *.
72
  # MAX_BODY_BYTES Maximum accepted body size. Default: 10485760.
73
  #
 
139
  DEFAULT_PATH3_READ_TIMEOUT,
140
  DEFAULT_PROXY_TIMEOUT,
141
  PROXY_VERSION,
142
+ _classify_token_type,
143
  _resolve_upstream_url,
144
  _safe_float,
145
  _safe_int,
146
  _token_log_fragment,
147
  _validate_env,
148
+ _validate_token_config,
149
  )
150
  except ImportError:
151
  from .._shared_logic import ( # type: ignore[import]
 
158
  DEFAULT_PATH3_READ_TIMEOUT,
159
  DEFAULT_PROXY_TIMEOUT,
160
  PROXY_VERSION,
161
+ _classify_token_type,
162
  _resolve_upstream_url,
163
  _safe_float,
164
  _safe_int,
165
  _token_log_fragment,
166
  _validate_env,
167
+ _validate_token_config,
168
  )
169
 
170
 
 
295
  #: Never forward this token to model inference backends.
296
  HF_DATASET_TOKEN: str = HF_WRITE_TOKEN or HF_TOKEN
297
 
298
+ #: Classified type for HF_TOKEN β€” used by startup validation and log output.
299
+ #:
300
+ #: Source priority:
301
+ #: 1. Explicit ``HF_TOKEN_TYPE`` env var (``fine-grained`` | ``read`` | ``write``).
302
+ #: 2. Length-based heuristic: tokens β‰₯ 52 chars are classified ``"fine-grained"``;
303
+ #: shorter classic tokens that cannot be distinguished return ``"unknown"``.
304
+ #:
305
+ #: Set ``HF_TOKEN_TYPE=read`` or ``HF_TOKEN_TYPE=fine-grained`` in Space secrets
306
+ #: to enable accurate least-privilege startup warnings.
307
+ HF_TOKEN_TYPE: str = _classify_token_type(
308
+ HF_TOKEN,
309
+ declared_type=os.environ.get("HF_TOKEN_TYPE"),
310
+ )
311
+
312
+ #: Classified type for HF_WRITE_TOKEN.
313
+ #: Source priority mirrors HF_TOKEN_TYPE above.
314
+ #: Accepted values: ``"fine-grained"``, ``"write"``.
315
+ #: Returns ``"unknown"`` when the token is absent or type cannot be inferred.
316
+ HF_WRITE_TOKEN_TYPE: str = _classify_token_type(
317
+ HF_WRITE_TOKEN,
318
+ declared_type=os.environ.get("HF_WRITE_TOKEN_TYPE"),
319
+ )
320
+
321
  #: HF Serverless Inference API base URL (no trailing slash).
322
  HF_BASE: str = os.environ.get("HF_BASE", DEFAULT_HF_BASE).rstrip("/")
323
 
 
425
 
426
  _validate_env(BACKEND_URL, HF_TOKEN, HF_SPACES_MODEL_URL)
427
 
428
+ # Token-type least-privilege validation. Runs after _validate_env confirms
429
+ # that routing is viable. Issues are logged at WARNING or ERROR level but
430
+ # never block startup β€” the proxy starts in degraded mode so operators can
431
+ # read the log and fix the configuration without a redeploy loop.
432
+ _token_config_issues: list[str] = _validate_token_config(
433
+ HF_TOKEN,
434
+ HF_WRITE_TOKEN,
435
+ TRAINING_DATASET_REPO,
436
+ hf_token_type=HF_TOKEN_TYPE,
437
+ hf_write_token_type=HF_WRITE_TOKEN_TYPE,
438
+ )
439
+ for _issue in _token_config_issues:
440
+ _log_level = logging.ERROR if _issue.startswith("ERROR:") else logging.WARNING
441
+ logger.log(_log_level, "Startup token-config check: %s", _issue)
442
+
443
  if not BACKEND_URL and not HF_TOKEN:
444
  logger.warning(
445
  "HF_TOKEN is not set. Requests to standard HF Inference API models "
 
511
  BACKEND_URL or None,
512
  HF_SPACES_MODEL_URL or None,
513
  list(HF_SPACES_MODEL_NAMESPACES),
514
+ _token_log_fragment(HF_TOKEN, token_type=HF_TOKEN_TYPE),
515
+ _token_log_fragment(HF_WRITE_TOKEN, token_type=HF_WRITE_TOKEN_TYPE) if HF_WRITE_TOKEN else "<using-hf-token-fallback>",
516
  DEFAULT_MODEL,
517
  )
518
  logger.info(
 
930
  },
931
  "tokens": {
932
  # Never expose token values β€” only whether each slot is filled.
933
+ # hf_token_type / hf_write_token_type reflect the classified token
934
+ # class so operators can verify least-privilege configuration without
935
+ # reading Space logs. Possible values: fine-grained | read | write |
936
+ # unknown. "unknown" means the type could not be inferred β€” set
937
+ # HF_TOKEN_TYPE / HF_WRITE_TOKEN_TYPE in Space secrets to resolve.
938
+ "hf_token_set": bool(HF_TOKEN),
939
+ "hf_token_type": HF_TOKEN_TYPE,
940
+ "hf_write_token_set": bool(HF_WRITE_TOKEN),
941
+ "hf_write_token_type": HF_WRITE_TOKEN_TYPE,
942
  # True when HF_WRITE_TOKEN is set; False means writes fall
943
  # back to HF_TOKEN (single-token mode, less secure).
944
  "least_privilege_mode": bool(HF_WRITE_TOKEN),