Spaces:
Running
Running
Upload 34 files
Browse files- DATASET_COLLECTION_GUIDANCE.md +5 -0
- README.md +6 -0
- _utils/_share_contract.py +1 -1
- _utils/_shared_logic.py +1 -1
- _utils/_storage.py +213 -58
- app.py +106 -13
DATASET_COLLECTION_GUIDANCE.md
CHANGED
|
@@ -1845,3 +1845,8 @@ This closes cross-replica **coordination authority** only. The proxy does not in
|
|
| 1845 |
Redis AOF/RDB/managed-service persistence guarantees, backup retention, or disaster
|
| 1846 |
recovery policy. Keep `CONTRIBUTION_REQUIRE_DURABLE` as a separate deployment
|
| 1847 |
requirement and verify the external Redis durability contract independently.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1845 |
Redis AOF/RDB/managed-service persistence guarantees, backup retention, or disaster
|
| 1846 |
recovery policy. Keep `CONTRIBUTION_REQUIRE_DURABLE` as a separate deployment
|
| 1847 |
requirement and verify the external Redis durability contract independently.
|
| 1848 |
+
|
| 1849 |
+
|
| 1850 |
+
## Run 25 / B44 provider response boundaries
|
| 1851 |
+
|
| 1852 |
+
Storage-provider control traffic is a small metadata plane, not a bulk-download plane. Its response ceiling defaults to **4 MiB** and is hard-clamped to **16 MiB**. GitHub/GitLab/Bitbucket mutations that need no response body do not consume one; metadata reads are stream-bounded before JSON parsing. Hugging Face Hub control calls run with a scoped bounded lower-level HTTP client and restore the prior SDK client factory immediately afterward. Intentional large offline dataset/model downloads remain separately scoped (`SEC-P2-49`).
|
README.md
CHANGED
|
@@ -396,6 +396,7 @@ All values are in seconds. Non-integer values silently fall back to the default
|
|
| 396 |
| `SHARE_REQUIRE_DURABLE` | `false` | When true, Global Share writes fail closed unless the active store reports durable lifecycle storage. |
|
| 397 |
| `SHARE_REQUIRE_SHARED` | `false` | When true, Global Share writes fail closed unless the active store is shared and authoritative. Use for horizontally scaled replicas. |
|
| 398 |
| `MAX_BODY_BYTES` | `10485760` | Maximum accepted chat request body size. Enforced while streaming and hard-clamped to 16 MiB. |
|
|
|
|
| 399 |
| `CHAT_RATE_LIMIT_PER_HOUR` | `30` | Chat requests per resolved client identity. Enforced by the selected local or Redis backend. |
|
| 400 |
| `SHARE_RATE_LIMIT_PER_HOUR` | `10` | Global Share creates/updates per resolved client identity. Enforced by the selected backend. |
|
| 401 |
| `FEEDBACK_RATE_LIMIT_PER_HOUR` | `30` | Feedback writes, including retractions, per resolved client identity. |
|
|
@@ -870,3 +871,8 @@ the custom ZeroGPU Space that actually has the weights loaded. This is why
|
|
| 870 |
- [HuggingFace Inference API](https://huggingface.co/docs/api-inference/)
|
| 871 |
- [HuggingFace Datasets — `huggingface_hub`](https://huggingface.co/docs/huggingface_hub/guides/repository)
|
| 872 |
- [ZeroGPU documentation](https://huggingface.co/docs/hub/spaces-zerogpu)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
| `SHARE_REQUIRE_DURABLE` | `false` | When true, Global Share writes fail closed unless the active store reports durable lifecycle storage. |
|
| 397 |
| `SHARE_REQUIRE_SHARED` | `false` | When true, Global Share writes fail closed unless the active store is shared and authoritative. Use for horizontally scaled replicas. |
|
| 398 |
| `MAX_BODY_BYTES` | `10485760` | Maximum accepted chat request body size. Enforced while streaming and hard-clamped to 16 MiB. |
|
| 399 |
+
| `MAX_UPSTREAM_RESPONSE_BYTES` | `8388608` | Maximum decoded upstream response body accepted by the proxy. Enforced while streaming before whole-body buffering and hard-clamped to 32 MiB. Oversize or malformed declared lengths fail closed. |
|
| 400 |
| `CHAT_RATE_LIMIT_PER_HOUR` | `30` | Chat requests per resolved client identity. Enforced by the selected local or Redis backend. |
|
| 401 |
| `SHARE_RATE_LIMIT_PER_HOUR` | `10` | Global Share creates/updates per resolved client identity. Enforced by the selected backend. |
|
| 402 |
| `FEEDBACK_RATE_LIMIT_PER_HOUR` | `30` | Feedback writes, including retractions, per resolved client identity. |
|
|
|
|
| 871 |
- [HuggingFace Inference API](https://huggingface.co/docs/api-inference/)
|
| 872 |
- [HuggingFace Datasets — `huggingface_hub`](https://huggingface.co/docs/huggingface_hub/guides/repository)
|
| 873 |
- [ZeroGPU documentation](https://huggingface.co/docs/hub/spaces-zerogpu)
|
| 874 |
+
|
| 875 |
+
|
| 876 |
+
### Provider control-response boundary (Run 25 / B44)
|
| 877 |
+
|
| 878 |
+
Record-storage provider control responses are bounded independently from intentional model/dataset downloads. The default ceiling is **4 MiB** and `AI_RECORD_STORAGE_CONTROL_RESPONSE_MAX_BYTES` is clamped to **16 MiB**. Provider mutations that do not require response content are streamed and closed without buffering a body; metadata JSON is byte-counted before parsing. Custom `api_base` is supported only for GitLab and must be HTTPS with a valid host and no userinfo, query, fragment, control characters, or traversal.
|
_utils/_share_contract.py
CHANGED
|
@@ -363,7 +363,7 @@ def render_share_viewer_shell(read_path: str = "/v1/share/read") -> str:
|
|
| 363 |
conversation values with DOM ``textContent`` and never injects untrusted HTML.
|
| 364 |
"""
|
| 365 |
read_path_json = json.dumps(str(read_path), ensure_ascii=False)
|
| 366 |
-
template = r'''<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>Shared AI conversation</title><style>:root{font-family:system-ui,sans-serif;color-scheme:light dark}body{margin:0;background:Canvas;color:CanvasText}.wrap{max-width:850px;margin:auto;padding:24px}.head{border-bottom:1px solid currentColor;padding-bottom:16px}.source{overflow-wrap:anywhere}.source a{color:inherit}.msg{margin:18px 0;padding:14px;border:1px solid currentColor;border-radius:12px}.msg.user{margin-left:10%}.msg.error{border-style:dashed}.role{font-weight:700;margin-bottom:8px}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;margin:0}.error-note{border:1px dashed currentColor;padding:14px;border-radius:12px}</style></head><body><main id="app" class="wrap"><p>Loading shared conversation…</p></main><script>(()=>{'use strict';const app=document.getElementById('app');const fail=(m)=>{app.replaceChildren();const p=document.createElement('p');p.className='error-note';p.textContent=m;app.appendChild(p);};let raw=(location.hash||'').slice(1);if(raw.startsWith('share='))raw=raw.slice(6);try{raw=decodeURIComponent(raw)}catch(_e){}if(!/^(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.test(raw)){fail('This Share link is invalid or incomplete.');return;}fetch(__READ_PATH__,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({shareId:raw}),cache:'no-store',credentials:'omit',redirect:'error',referrerPolicy:'no-referrer'}).then(async r=>{
|
| 367 |
return template.replace("__READ_PATH__", read_path_json)
|
| 368 |
|
| 369 |
def generate_edit_token() -> str:
|
|
|
|
| 363 |
conversation values with DOM ``textContent`` and never injects untrusted HTML.
|
| 364 |
"""
|
| 365 |
read_path_json = json.dumps(str(read_path), ensure_ascii=False)
|
| 366 |
+
template = r'''<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>Shared AI conversation</title><style>:root{font-family:system-ui,sans-serif;color-scheme:light dark}body{margin:0;background:Canvas;color:CanvasText}.wrap{max-width:850px;margin:auto;padding:24px}.head{border-bottom:1px solid currentColor;padding-bottom:16px}.source{overflow-wrap:anywhere}.source a{color:inherit}.msg{margin:18px 0;padding:14px;border:1px solid currentColor;border-radius:12px}.msg.user{margin-left:10%}.msg.error{border-style:dashed}.role{font-weight:700;margin-bottom:8px}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;margin:0}.error-note{border:1px dashed currentColor;padding:14px;border-radius:12px}</style></head><body><main id="app" class="wrap"><p>Loading shared conversation…</p></main><script>(()=>{'use strict';const app=document.getElementById('app');const fail=(m)=>{app.replaceChildren();const p=document.createElement('p');p.className='error-note';p.textContent=m;app.appendChild(p);};let raw=(location.hash||'').slice(1);if(raw.startsWith('share='))raw=raw.slice(6);try{raw=decodeURIComponent(raw)}catch(_e){}if(!/^(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.test(raw)){fail('This Share link is invalid or incomplete.');return;}const readJson=async(r)=>{const max=4*1024*1024;const h=r.headers&&r.headers.get?r.headers.get('content-length'):null;if(h!=null&&String(h).trim()!==''){if(!/^\d+$/.test(String(h).trim())||Number(h)>max)throw new Error('Share response is too large.');}if(!r.body||typeof r.body.getReader!=='function'||typeof TextDecoder!=='function')throw new Error('Bounded Share reader unavailable.');const rd=r.body.getReader(),dec=new TextDecoder(),parts=[];let n=0;try{for(;;){const x=await rd.read();if(x.done)break;const v=x.value||new Uint8Array(0);n+=Number(v.byteLength||v.length||0);if(n>max)throw new Error('Share response is too large.');parts.push(dec.decode(v,{stream:true}));}parts.push(dec.decode());}catch(e){try{await rd.cancel()}catch(_e){}throw e;}finally{try{rd.releaseLock()}catch(_e){}}return JSON.parse(parts.join(''));};fetch(__READ_PATH__,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({shareId:raw}),cache:'no-store',credentials:'omit',redirect:'error',referrerPolicy:'no-referrer'}).then(async r=>{if(!r.ok){throw new Error(r.status===410?'This Share has expired.':r.status===404?'This Share is unavailable.':'Could not load this Share.');}return await readJson(r);}).then(data=>{app.replaceChildren();if(data.format==='html'&&data.snapshot&&data.snapshot.session&&Array.isArray(data.snapshot.records)){const snap=data.snapshot;const h=document.createElement('header');h.className='head';const h1=document.createElement('h1');h1.textContent=(snap.session.assistant_name||'AI Assistant')+' — Shared conversation';h.appendChild(h1);if(snap.session.page_title){const p=document.createElement('p');p.textContent=snap.session.page_title;h.appendChild(p);}if(snap.session.page_url){const p=document.createElement('p');p.className='source';p.append('Source: ');const a=document.createElement('a');a.href=snap.session.page_url;a.rel='noopener noreferrer';a.referrerPolicy='no-referrer';a.textContent=snap.session.page_url;p.appendChild(a);h.appendChild(p);}app.appendChild(h);for(const row of snap.records){const article=document.createElement('article');article.className='msg '+(row.role==='user'?'user':row.role==='error'?'error':'assistant');const role=document.createElement('div');role.className='role';role.textContent=row.role==='user'?'You':row.role==='error'?'Error':(snap.session.assistant_name||'AI Assistant');const pre=document.createElement('pre');pre.textContent=String(row.text||'');article.append(role,pre);app.appendChild(article);}return;}const pre=document.createElement('pre');pre.textContent=String(data.content||'');app.appendChild(pre);}).catch(e=>fail(e&&e.message?e.message:'Could not load this Share.'));})();</script></body></html>'''
|
| 367 |
return template.replace("__READ_PATH__", read_path_json)
|
| 368 |
|
| 369 |
def generate_edit_token() -> str:
|
_utils/_shared_logic.py
CHANGED
|
@@ -220,7 +220,7 @@ __all__ = [ # noqa: RUF022
|
|
| 220 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 221 |
|
| 222 |
#: Proxy release version — bump on every breaking change.
|
| 223 |
-
PROXY_VERSION: str = "7.
|
| 224 |
|
| 225 |
#: HuggingFace Inference Providers router base URL (no trailing slash).
|
| 226 |
#: Only used for Path 3 (standard provider models) when ``BACKEND_URL`` is
|
|
|
|
| 220 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 221 |
|
| 222 |
#: Proxy release version — bump on every breaking change.
|
| 223 |
+
PROXY_VERSION: str = "7.2.0"
|
| 224 |
|
| 225 |
#: HuggingFace Inference Providers router base URL (no trailing slash).
|
| 226 |
#: Only used for Path 3 (standard provider models) when ``BACKEND_URL`` is
|
_utils/_storage.py
CHANGED
|
@@ -15,10 +15,11 @@ import json
|
|
| 15 |
import os
|
| 16 |
import re
|
| 17 |
import time
|
|
|
|
| 18 |
from dataclasses import dataclass, field
|
| 19 |
from datetime import datetime, timezone
|
| 20 |
from typing import Any, Literal
|
| 21 |
-
from urllib.parse import quote, urlencode
|
| 22 |
|
| 23 |
import httpx
|
| 24 |
|
|
@@ -34,6 +35,113 @@ _ID_RE = re.compile(r"^[a-z][a-z0-9_-]{0,47}$")
|
|
| 34 |
_SEG_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
| 35 |
_ALLOWED_PROVIDERS = {"huggingface", "github", "gitlab", "bitbucket"}
|
| 36 |
_TRANSIENT_STATUS = {408, 409, 425, 429, 500, 502, 503, 504}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
class StorageConfigError(ValueError):
|
|
@@ -168,9 +276,13 @@ def _parse_target(raw: dict[str, Any], index: int) -> StorageTarget:
|
|
| 168 |
paths = raw.get("paths") if isinstance(raw.get("paths"), dict) else {}
|
| 169 |
token_env = _safe_token_env(raw.get("token_env"))
|
| 170 |
token_type = _normalize_token_type(raw.get("token_type") or os.environ.get(token_env + "_TYPE"))
|
| 171 |
-
|
| 172 |
-
if provider == "gitlab"
|
| 173 |
-
api_base =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
return StorageTarget(
|
| 175 |
id=target_id,
|
| 176 |
label=label,
|
|
@@ -353,10 +465,12 @@ class StorageCoordinator:
|
|
| 353 |
auth_check = getattr(api, "auth_check", None)
|
| 354 |
if auth_check and "write" in inspect.signature(auth_check).parameters:
|
| 355 |
await asyncio.to_thread(
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
|
|
|
|
|
|
| 360 |
)
|
| 361 |
return "verified"
|
| 362 |
except Exception as exc:
|
|
@@ -589,12 +703,14 @@ class StorageCoordinator:
|
|
| 589 |
|
| 590 |
api = HfApi(token=target.token)
|
| 591 |
await asyncio.to_thread(
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
|
|
|
|
|
|
| 598 |
)
|
| 599 |
return "removed-current-view"
|
| 600 |
except Exception as exc: # noqa: BLE001
|
|
@@ -611,23 +727,24 @@ class StorageCoordinator:
|
|
| 611 |
"Accept": "application/vnd.github+json",
|
| 612 |
"X-GitHub-Api-Version": "2022-11-28",
|
| 613 |
}
|
| 614 |
-
|
| 615 |
-
|
|
|
|
| 616 |
return "already-absent"
|
| 617 |
-
if
|
| 618 |
-
raise StorageWriteError("GITHUB_DELETE_LOOKUP", transient=
|
| 619 |
-
sha = str((
|
| 620 |
if not sha:
|
| 621 |
raise StorageWriteError("GITHUB_DELETE_SHA")
|
| 622 |
-
|
| 623 |
"DELETE", url, headers=headers,
|
| 624 |
json={"message": message, "sha": sha, "branch": target.branch}, timeout=20.0,
|
| 625 |
)
|
| 626 |
-
if
|
| 627 |
return "removed-current-view"
|
| 628 |
-
if
|
| 629 |
return "already-absent"
|
| 630 |
-
raise StorageWriteError("GITHUB_DELETE", transient=
|
| 631 |
|
| 632 |
async def _delete_gitlab(self, target: StorageTarget, path: str, message: str) -> str:
|
| 633 |
base = target.api_base or "https://gitlab.com/api/v4"
|
|
@@ -635,15 +752,15 @@ class StorageCoordinator:
|
|
| 635 |
file_path = quote(path, safe="")
|
| 636 |
url = f"{base}/projects/{project}/repository/files/{file_path}"
|
| 637 |
headers = {"PRIVATE-TOKEN": target.token}
|
| 638 |
-
|
| 639 |
"DELETE", url, headers=headers,
|
| 640 |
json={"branch": target.branch, "commit_message": message}, timeout=20.0,
|
| 641 |
)
|
| 642 |
-
if
|
| 643 |
return "removed-current-view"
|
| 644 |
-
if
|
| 645 |
return "already-absent"
|
| 646 |
-
raise StorageWriteError("GITLAB_DELETE", transient=
|
| 647 |
|
| 648 |
async def _delete_bitbucket(self, target: StorageTarget, path: str, message: str) -> str:
|
| 649 |
workspace, repo = _repo_parts(target.repo)
|
|
@@ -652,20 +769,20 @@ class StorageCoordinator:
|
|
| 652 |
f"/src/{quote(target.branch, safe='')}/{quote(path, safe='/')}"
|
| 653 |
)
|
| 654 |
headers = {"Authorization": f"Bearer {target.token}"}
|
| 655 |
-
|
| 656 |
-
if
|
| 657 |
return "already-absent"
|
| 658 |
-
if
|
| 659 |
-
raise StorageWriteError("BITBUCKET_DELETE_LOOKUP", transient=
|
| 660 |
url = f"https://api.bitbucket.org/2.0/repositories/{quote(workspace)}/{quote(repo)}/src"
|
| 661 |
form = urlencode([("branch", target.branch), ("message", message), ("files", "/" + path)])
|
| 662 |
delete_headers = {**headers, "Content-Type": "application/x-www-form-urlencoded"}
|
| 663 |
-
|
| 664 |
-
url, headers=delete_headers, content=form.encode("utf-8"), timeout=25.0
|
| 665 |
)
|
| 666 |
-
if
|
| 667 |
return "removed-current-view"
|
| 668 |
-
raise StorageWriteError("BITBUCKET_DELETE", transient=
|
| 669 |
|
| 670 |
async def _dispatch(self, target: StorageTarget, path: str, content: bytes, message: str) -> None:
|
| 671 |
try:
|
|
@@ -692,12 +809,14 @@ class StorageCoordinator:
|
|
| 692 |
|
| 693 |
api = HfApi(token=target.token)
|
| 694 |
await asyncio.to_thread(
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
|
|
|
|
|
|
| 701 |
)
|
| 702 |
except Exception as exc: # noqa: BLE001
|
| 703 |
# Keep the exception private. HTTP permission/transient distinction is
|
|
@@ -716,6 +835,40 @@ class StorageCoordinator:
|
|
| 716 |
raise StorageWriteError("NO_HTTP_CLIENT", transient=True)
|
| 717 |
return self.client
|
| 718 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 719 |
async def _write_github(self, target: StorageTarget, path: str, content: bytes, message: str) -> None:
|
| 720 |
owner, repo = _repo_parts(target.repo)
|
| 721 |
url = f"https://api.github.com/repos/{quote(owner)}/{quote(repo)}/contents/{quote(path, safe='/')}"
|
|
@@ -729,21 +882,22 @@ class StorageCoordinator:
|
|
| 729 |
"content": base64.b64encode(content).decode("ascii"),
|
| 730 |
"branch": target.branch,
|
| 731 |
}
|
| 732 |
-
|
| 733 |
-
if
|
| 734 |
return
|
| 735 |
# A retry may encounter an already-created idempotent path. Confirm
|
| 736 |
# content equality before treating the conflict as success.
|
| 737 |
-
if
|
| 738 |
-
|
| 739 |
-
|
|
|
|
| 740 |
try:
|
| 741 |
-
existing = base64.b64decode((
|
| 742 |
if existing == content:
|
| 743 |
return
|
| 744 |
except Exception:
|
| 745 |
pass
|
| 746 |
-
raise StorageWriteError("GITHUB_WRITE", transient=
|
| 747 |
|
| 748 |
async def _write_gitlab(self, target: StorageTarget, path: str, content: bytes, message: str) -> None:
|
| 749 |
base = target.api_base or "https://gitlab.com/api/v4"
|
|
@@ -752,18 +906,19 @@ class StorageCoordinator:
|
|
| 752 |
url = f"{base}/projects/{project}/repository/files/{file_path}"
|
| 753 |
headers = {"PRIVATE-TOKEN": target.token}
|
| 754 |
payload = {"branch": target.branch, "commit_message": message, "content": content.decode("utf-8")}
|
| 755 |
-
|
| 756 |
-
if
|
| 757 |
return
|
| 758 |
-
if
|
| 759 |
-
|
| 760 |
-
|
|
|
|
| 761 |
try:
|
| 762 |
-
if
|
| 763 |
return
|
| 764 |
except Exception:
|
| 765 |
pass
|
| 766 |
-
raise StorageWriteError("GITLAB_WRITE", transient=
|
| 767 |
|
| 768 |
async def _write_bitbucket(self, target: StorageTarget, path: str, content: bytes, message: str) -> None:
|
| 769 |
workspace, repo = _repo_parts(target.repo)
|
|
@@ -771,7 +926,7 @@ class StorageCoordinator:
|
|
| 771 |
headers = {"Authorization": f"Bearer {target.token}"}
|
| 772 |
files = {"/" + path: (path.rsplit("/", 1)[-1], content, "application/x-ndjson")}
|
| 773 |
data = {"branch": target.branch, "message": message}
|
| 774 |
-
|
| 775 |
-
if
|
| 776 |
return
|
| 777 |
-
raise StorageWriteError("BITBUCKET_WRITE", transient=
|
|
|
|
| 15 |
import os
|
| 16 |
import re
|
| 17 |
import time
|
| 18 |
+
import threading
|
| 19 |
from dataclasses import dataclass, field
|
| 20 |
from datetime import datetime, timezone
|
| 21 |
from typing import Any, Literal
|
| 22 |
+
from urllib.parse import quote, urlencode, urlsplit
|
| 23 |
|
| 24 |
import httpx
|
| 25 |
|
|
|
|
| 35 |
_SEG_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
| 36 |
_ALLOWED_PROVIDERS = {"huggingface", "github", "gitlab", "bitbucket"}
|
| 37 |
_TRANSIENT_STATUS = {408, 409, 425, 429, 500, 502, 503, 504}
|
| 38 |
+
_CONTROL_RESPONSE_DEFAULT = 4 * 1024 * 1024
|
| 39 |
+
_CONTROL_RESPONSE_HARD_MAX = 16 * 1024 * 1024
|
| 40 |
+
_HF_FACTORY_LOCK = threading.RLock()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class _ProviderResponseTooLarge(RuntimeError):
|
| 44 |
+
pass
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _control_response_limit() -> int:
|
| 48 |
+
raw = os.environ.get("AI_RECORD_STORAGE_CONTROL_RESPONSE_MAX_BYTES", "").strip()
|
| 49 |
+
try:
|
| 50 |
+
value = int(raw) if raw else _CONTROL_RESPONSE_DEFAULT
|
| 51 |
+
except ValueError:
|
| 52 |
+
value = _CONTROL_RESPONSE_DEFAULT
|
| 53 |
+
return max(1024, min(_CONTROL_RESPONSE_HARD_MAX, value))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _safe_gitlab_api_base(value: Any) -> str:
|
| 57 |
+
raw = str(value or "").strip().rstrip("/")
|
| 58 |
+
if not raw:
|
| 59 |
+
return "https://gitlab.com/api/v4"
|
| 60 |
+
if any(ord(ch) < 0x20 or ord(ch) == 0x7f for ch in raw):
|
| 61 |
+
raise StorageConfigError("TARGET_API_BASE")
|
| 62 |
+
try:
|
| 63 |
+
parsed = urlsplit(raw)
|
| 64 |
+
except Exception as exc:
|
| 65 |
+
raise StorageConfigError("TARGET_API_BASE") from exc
|
| 66 |
+
if parsed.scheme.lower() != "https" or not parsed.hostname:
|
| 67 |
+
raise StorageConfigError("TARGET_API_BASE")
|
| 68 |
+
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
|
| 69 |
+
raise StorageConfigError("TARGET_API_BASE")
|
| 70 |
+
if ".." in [seg for seg in parsed.path.split("/") if seg]:
|
| 71 |
+
raise StorageConfigError("TARGET_API_BASE")
|
| 72 |
+
if "\\" in parsed.path:
|
| 73 |
+
raise StorageConfigError("TARGET_API_BASE")
|
| 74 |
+
try:
|
| 75 |
+
_ = parsed.port
|
| 76 |
+
except ValueError as exc:
|
| 77 |
+
raise StorageConfigError("TARGET_API_BASE") from exc
|
| 78 |
+
return raw
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class _BoundedSyncStream(httpx.SyncByteStream):
|
| 82 |
+
def __init__(self, stream: httpx.SyncByteStream, limit: int) -> None:
|
| 83 |
+
self._stream = stream
|
| 84 |
+
self._limit = limit
|
| 85 |
+
self._seen = 0
|
| 86 |
+
|
| 87 |
+
def __iter__(self):
|
| 88 |
+
for chunk in self._stream:
|
| 89 |
+
self._seen += len(chunk)
|
| 90 |
+
if self._seen > self._limit:
|
| 91 |
+
raise _ProviderResponseTooLarge("provider control response exceeds configured limit")
|
| 92 |
+
yield chunk
|
| 93 |
+
|
| 94 |
+
def close(self) -> None:
|
| 95 |
+
self._stream.close()
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class _BoundedTransport(httpx.BaseTransport):
|
| 99 |
+
def __init__(self, transport: httpx.BaseTransport, limit: int) -> None:
|
| 100 |
+
self._transport = transport
|
| 101 |
+
self._limit = limit
|
| 102 |
+
|
| 103 |
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
| 104 |
+
response = self._transport.handle_request(request)
|
| 105 |
+
length = response.headers.get("content-length")
|
| 106 |
+
if length and length.isdigit() and int(length) > self._limit:
|
| 107 |
+
response.close()
|
| 108 |
+
raise _ProviderResponseTooLarge("provider control response exceeds configured limit")
|
| 109 |
+
response.stream = _BoundedSyncStream(response.stream, self._limit)
|
| 110 |
+
return response
|
| 111 |
+
|
| 112 |
+
def close(self) -> None:
|
| 113 |
+
self._transport.close()
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _with_bounded_hf_client(call):
|
| 117 |
+
"""Run one huggingface_hub control-plane call with bounded HTTP bodies.
|
| 118 |
+
|
| 119 |
+
Minimal test doubles and old SDK shims may not expose the factory module;
|
| 120 |
+
production is pinned to a version that does. In that compatibility-only
|
| 121 |
+
case there is no underlying SDK HTTP client to wrap, so execute directly.
|
| 122 |
+
"""
|
| 123 |
+
try:
|
| 124 |
+
from huggingface_hub.utils import _http as hf_http # noqa: PLC0415
|
| 125 |
+
except (ImportError, ModuleNotFoundError):
|
| 126 |
+
return call()
|
| 127 |
+
|
| 128 |
+
with _HF_FACTORY_LOCK:
|
| 129 |
+
previous = hf_http._GLOBAL_CLIENT_FACTORY
|
| 130 |
+
|
| 131 |
+
def factory():
|
| 132 |
+
client = previous()
|
| 133 |
+
transport = getattr(client, "_transport", None)
|
| 134 |
+
if transport is None:
|
| 135 |
+
client.close()
|
| 136 |
+
raise RuntimeError("HF_CLIENT_TRANSPORT")
|
| 137 |
+
client._transport = _BoundedTransport(transport, _control_response_limit())
|
| 138 |
+
return client
|
| 139 |
+
|
| 140 |
+
hf_http.set_client_factory(factory)
|
| 141 |
+
try:
|
| 142 |
+
return call()
|
| 143 |
+
finally:
|
| 144 |
+
hf_http.set_client_factory(previous)
|
| 145 |
|
| 146 |
|
| 147 |
class StorageConfigError(ValueError):
|
|
|
|
| 276 |
paths = raw.get("paths") if isinstance(raw.get("paths"), dict) else {}
|
| 277 |
token_env = _safe_token_env(raw.get("token_env"))
|
| 278 |
token_type = _normalize_token_type(raw.get("token_type") or os.environ.get(token_env + "_TYPE"))
|
| 279 |
+
raw_api_base = raw.get("api_base")
|
| 280 |
+
if provider == "gitlab":
|
| 281 |
+
api_base = _safe_gitlab_api_base(raw_api_base)
|
| 282 |
+
else:
|
| 283 |
+
if str(raw_api_base or "").strip():
|
| 284 |
+
raise StorageConfigError("TARGET_API_BASE_UNSUPPORTED")
|
| 285 |
+
api_base = ""
|
| 286 |
return StorageTarget(
|
| 287 |
id=target_id,
|
| 288 |
label=label,
|
|
|
|
| 465 |
auth_check = getattr(api, "auth_check", None)
|
| 466 |
if auth_check and "write" in inspect.signature(auth_check).parameters:
|
| 467 |
await asyncio.to_thread(
|
| 468 |
+
_with_bounded_hf_client,
|
| 469 |
+
lambda: auth_check(
|
| 470 |
+
repo_id=target.repo,
|
| 471 |
+
repo_type="dataset",
|
| 472 |
+
write=True,
|
| 473 |
+
),
|
| 474 |
)
|
| 475 |
return "verified"
|
| 476 |
except Exception as exc:
|
|
|
|
| 703 |
|
| 704 |
api = HfApi(token=target.token)
|
| 705 |
await asyncio.to_thread(
|
| 706 |
+
_with_bounded_hf_client,
|
| 707 |
+
lambda: api.create_commit(
|
| 708 |
+
repo_id=target.repo,
|
| 709 |
+
repo_type="dataset",
|
| 710 |
+
revision=target.branch,
|
| 711 |
+
operations=[CommitOperationDelete(path_in_repo=path)],
|
| 712 |
+
commit_message=message,
|
| 713 |
+
),
|
| 714 |
)
|
| 715 |
return "removed-current-view"
|
| 716 |
except Exception as exc: # noqa: BLE001
|
|
|
|
| 727 |
"Accept": "application/vnd.github+json",
|
| 728 |
"X-GitHub-Api-Version": "2022-11-28",
|
| 729 |
}
|
| 730 |
+
current_status, current_json = await self._request_bounded_json(
|
| 731 |
+
"GET", url, headers=headers, params={"ref": target.branch}, timeout=15.0)
|
| 732 |
+
if current_status == 404:
|
| 733 |
return "already-absent"
|
| 734 |
+
if current_status != 200:
|
| 735 |
+
raise StorageWriteError("GITHUB_DELETE_LOOKUP", transient=current_status in _TRANSIENT_STATUS)
|
| 736 |
+
sha = str((current_json or {}).get("sha") or "")
|
| 737 |
if not sha:
|
| 738 |
raise StorageWriteError("GITHUB_DELETE_SHA")
|
| 739 |
+
status = await self._request_no_body(
|
| 740 |
"DELETE", url, headers=headers,
|
| 741 |
json={"message": message, "sha": sha, "branch": target.branch}, timeout=20.0,
|
| 742 |
)
|
| 743 |
+
if status in {200, 204}:
|
| 744 |
return "removed-current-view"
|
| 745 |
+
if status == 404:
|
| 746 |
return "already-absent"
|
| 747 |
+
raise StorageWriteError("GITHUB_DELETE", transient=status in _TRANSIENT_STATUS)
|
| 748 |
|
| 749 |
async def _delete_gitlab(self, target: StorageTarget, path: str, message: str) -> str:
|
| 750 |
base = target.api_base or "https://gitlab.com/api/v4"
|
|
|
|
| 752 |
file_path = quote(path, safe="")
|
| 753 |
url = f"{base}/projects/{project}/repository/files/{file_path}"
|
| 754 |
headers = {"PRIVATE-TOKEN": target.token}
|
| 755 |
+
status = await self._request_no_body(
|
| 756 |
"DELETE", url, headers=headers,
|
| 757 |
json={"branch": target.branch, "commit_message": message}, timeout=20.0,
|
| 758 |
)
|
| 759 |
+
if status in {200, 204}:
|
| 760 |
return "removed-current-view"
|
| 761 |
+
if status == 404:
|
| 762 |
return "already-absent"
|
| 763 |
+
raise StorageWriteError("GITLAB_DELETE", transient=status in _TRANSIENT_STATUS)
|
| 764 |
|
| 765 |
async def _delete_bitbucket(self, target: StorageTarget, path: str, message: str) -> str:
|
| 766 |
workspace, repo = _repo_parts(target.repo)
|
|
|
|
| 769 |
f"/src/{quote(target.branch, safe='')}/{quote(path, safe='/')}"
|
| 770 |
)
|
| 771 |
headers = {"Authorization": f"Bearer {target.token}"}
|
| 772 |
+
current_status = await self._request_no_body("GET", read_url, headers=headers, timeout=15.0)
|
| 773 |
+
if current_status == 404:
|
| 774 |
return "already-absent"
|
| 775 |
+
if current_status != 200:
|
| 776 |
+
raise StorageWriteError("BITBUCKET_DELETE_LOOKUP", transient=current_status in _TRANSIENT_STATUS)
|
| 777 |
url = f"https://api.bitbucket.org/2.0/repositories/{quote(workspace)}/{quote(repo)}/src"
|
| 778 |
form = urlencode([("branch", target.branch), ("message", message), ("files", "/" + path)])
|
| 779 |
delete_headers = {**headers, "Content-Type": "application/x-www-form-urlencoded"}
|
| 780 |
+
status = await self._request_no_body(
|
| 781 |
+
"POST", url, headers=delete_headers, content=form.encode("utf-8"), timeout=25.0
|
| 782 |
)
|
| 783 |
+
if status in {200, 201}:
|
| 784 |
return "removed-current-view"
|
| 785 |
+
raise StorageWriteError("BITBUCKET_DELETE", transient=status in _TRANSIENT_STATUS)
|
| 786 |
|
| 787 |
async def _dispatch(self, target: StorageTarget, path: str, content: bytes, message: str) -> None:
|
| 788 |
try:
|
|
|
|
| 809 |
|
| 810 |
api = HfApi(token=target.token)
|
| 811 |
await asyncio.to_thread(
|
| 812 |
+
_with_bounded_hf_client,
|
| 813 |
+
lambda: api.create_commit(
|
| 814 |
+
repo_id=target.repo,
|
| 815 |
+
repo_type="dataset",
|
| 816 |
+
revision=target.branch,
|
| 817 |
+
operations=[CommitOperationAdd(path_in_repo=path, path_or_fileobj=content)],
|
| 818 |
+
commit_message=message,
|
| 819 |
+
),
|
| 820 |
)
|
| 821 |
except Exception as exc: # noqa: BLE001
|
| 822 |
# Keep the exception private. HTTP permission/transient distinction is
|
|
|
|
| 835 |
raise StorageWriteError("NO_HTTP_CLIENT", transient=True)
|
| 836 |
return self.client
|
| 837 |
|
| 838 |
+
async def _request_no_body(self, method: str, url: str, **kwargs: Any) -> int:
|
| 839 |
+
client = self._client()
|
| 840 |
+
request = client.build_request(method, url, **kwargs)
|
| 841 |
+
response = await client.send(request, stream=True)
|
| 842 |
+
try:
|
| 843 |
+
return response.status_code
|
| 844 |
+
finally:
|
| 845 |
+
await response.aclose()
|
| 846 |
+
|
| 847 |
+
async def _request_bounded_json(self, method: str, url: str, **kwargs: Any) -> tuple[int, Any]:
|
| 848 |
+
client = self._client()
|
| 849 |
+
request = client.build_request(method, url, **kwargs)
|
| 850 |
+
response = await client.send(request, stream=True)
|
| 851 |
+
try:
|
| 852 |
+
limit = _control_response_limit()
|
| 853 |
+
declared = response.headers.get("content-length")
|
| 854 |
+
if declared and declared.isdigit() and int(declared) > limit:
|
| 855 |
+
raise StorageWriteError("PROVIDER_RESPONSE_TOO_LARGE")
|
| 856 |
+
buf = bytearray()
|
| 857 |
+
async for chunk in response.aiter_bytes():
|
| 858 |
+
buf.extend(chunk)
|
| 859 |
+
if len(buf) > limit:
|
| 860 |
+
raise StorageWriteError("PROVIDER_RESPONSE_TOO_LARGE")
|
| 861 |
+
if not buf:
|
| 862 |
+
payload = {}
|
| 863 |
+
else:
|
| 864 |
+
try:
|
| 865 |
+
payload = json.loads(bytes(buf))
|
| 866 |
+
except Exception as exc:
|
| 867 |
+
raise StorageWriteError("PROVIDER_RESPONSE_JSON") from exc
|
| 868 |
+
return response.status_code, payload
|
| 869 |
+
finally:
|
| 870 |
+
await response.aclose()
|
| 871 |
+
|
| 872 |
async def _write_github(self, target: StorageTarget, path: str, content: bytes, message: str) -> None:
|
| 873 |
owner, repo = _repo_parts(target.repo)
|
| 874 |
url = f"https://api.github.com/repos/{quote(owner)}/{quote(repo)}/contents/{quote(path, safe='/')}"
|
|
|
|
| 882 |
"content": base64.b64encode(content).decode("ascii"),
|
| 883 |
"branch": target.branch,
|
| 884 |
}
|
| 885 |
+
status = await self._request_no_body("PUT", url, headers=headers, json=payload, timeout=20.0)
|
| 886 |
+
if status in {200, 201}:
|
| 887 |
return
|
| 888 |
# A retry may encounter an already-created idempotent path. Confirm
|
| 889 |
# content equality before treating the conflict as success.
|
| 890 |
+
if status in {409, 422}:
|
| 891 |
+
get_status, get_json = await self._request_bounded_json(
|
| 892 |
+
"GET", url, headers=headers, params={"ref": target.branch}, timeout=15.0)
|
| 893 |
+
if get_status == 200:
|
| 894 |
try:
|
| 895 |
+
existing = base64.b64decode((get_json.get("content") or "").replace("\n", ""))
|
| 896 |
if existing == content:
|
| 897 |
return
|
| 898 |
except Exception:
|
| 899 |
pass
|
| 900 |
+
raise StorageWriteError("GITHUB_WRITE", transient=status in _TRANSIENT_STATUS)
|
| 901 |
|
| 902 |
async def _write_gitlab(self, target: StorageTarget, path: str, content: bytes, message: str) -> None:
|
| 903 |
base = target.api_base or "https://gitlab.com/api/v4"
|
|
|
|
| 906 |
url = f"{base}/projects/{project}/repository/files/{file_path}"
|
| 907 |
headers = {"PRIVATE-TOKEN": target.token}
|
| 908 |
payload = {"branch": target.branch, "commit_message": message, "content": content.decode("utf-8")}
|
| 909 |
+
status = await self._request_no_body("POST", url, headers=headers, json=payload, timeout=20.0)
|
| 910 |
+
if status in {200, 201}:
|
| 911 |
return
|
| 912 |
+
if status == 400:
|
| 913 |
+
get_status, get_json = await self._request_bounded_json(
|
| 914 |
+
"GET", url, headers=headers, params={"ref": target.branch}, timeout=15.0)
|
| 915 |
+
if get_status == 200:
|
| 916 |
try:
|
| 917 |
+
if get_json.get("content_sha256") == hashlib.sha256(content).hexdigest():
|
| 918 |
return
|
| 919 |
except Exception:
|
| 920 |
pass
|
| 921 |
+
raise StorageWriteError("GITLAB_WRITE", transient=status in _TRANSIENT_STATUS)
|
| 922 |
|
| 923 |
async def _write_bitbucket(self, target: StorageTarget, path: str, content: bytes, message: str) -> None:
|
| 924 |
workspace, repo = _repo_parts(target.repo)
|
|
|
|
| 926 |
headers = {"Authorization": f"Bearer {target.token}"}
|
| 927 |
files = {"/" + path: (path.rsplit("/", 1)[-1], content, "application/x-ndjson")}
|
| 928 |
data = {"branch": target.branch, "message": message}
|
| 929 |
+
status = await self._request_no_body("POST", url, headers=headers, data=data, files=files, timeout=25.0)
|
| 930 |
+
if status in {200, 201}:
|
| 931 |
return
|
| 932 |
+
raise StorageWriteError("BITBUCKET_WRITE", transient=status in _TRANSIENT_STATUS)
|
app.py
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
# Authors: The scikit-plots developers
|
| 6 |
# SPDX-License-Identifier: BSD-3-Clause
|
| 7 |
#
|
| 8 |
-
# scikit-plots/ai · _hf_spaces_proxy/app.py v7.
|
| 9 |
#
|
| 10 |
# Server-authoritative chat proxy for sphinx-ai-assistant.
|
| 11 |
#
|
|
@@ -93,6 +93,7 @@
|
|
| 93 |
# prefix AI_RECORD_STORAGE_TOKEN_. When absent, legacy
|
| 94 |
# TRAINING_DATASET_REPO + HF_* token settings are used.
|
| 95 |
# MAX_BODY_BYTES Maximum accepted body size. Default: 10485760.
|
|
|
|
| 96 |
# SHARE_MAX_BODY_BYTES Global Share request/canonical snapshot limit. Default: 512000.
|
| 97 |
# SHARE_MAX_ENTRIES Maximum live in-memory Global Share entries. Default: 256.
|
| 98 |
# SHARE_MAX_TOTAL_BYTES Aggregate live Global Share byte budget. Default: 16777216.
|
|
@@ -459,6 +460,49 @@ def _origin_allowed(request: Request) -> bool:
|
|
| 459 |
return False
|
| 460 |
|
| 461 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
async def _read_limited_body(request: Request, max_bytes: int, label: str = "Request") -> bytes:
|
| 463 |
"""Read an ASGI request incrementally and stop once *max_bytes* is crossed."""
|
| 464 |
raw_cl = request.headers.get("content-length")
|
|
@@ -736,6 +780,14 @@ MAX_BODY_BYTES: int = max(16_384, min(
|
|
| 736 |
16 * 1024 * 1024,
|
| 737 |
))
|
| 738 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 739 |
#: Maximum JSON request size accepted by Global Share. Kept independent from
|
| 740 |
#: chat bodies so a public share service cannot consume 10 MiB per request.
|
| 741 |
SHARE_MAX_BODY_BYTES: int = max(16_384, min(
|
|
@@ -1845,9 +1897,21 @@ async def _forward(body: bytes, *, structured_body: bytes | None = None) -> Resp
|
|
| 1845 |
return json.dumps(clone, ensure_ascii=False, separators=(",", ":")).encode()
|
| 1846 |
|
| 1847 |
async def _post_buffered(request_body: bytes) -> Response:
|
|
|
|
| 1848 |
try:
|
| 1849 |
-
|
| 1850 |
-
url, content=request_body, headers=headers, timeout=req_timeout
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1851 |
)
|
| 1852 |
except httpx.ReadTimeout:
|
| 1853 |
logger.warning(
|
|
@@ -1897,11 +1961,14 @@ async def _forward(body: bytes, *, structured_body: bytes | None = None) -> Resp
|
|
| 1897 |
"upstream_request_error",
|
| 1898 |
"Failed to reach the upstream service.",
|
| 1899 |
)
|
|
|
|
|
|
|
|
|
|
| 1900 |
|
| 1901 |
return Response(
|
| 1902 |
-
content=
|
| 1903 |
-
status_code=
|
| 1904 |
-
media_type=
|
| 1905 |
)
|
| 1906 |
|
| 1907 |
if not stream_requested:
|
|
@@ -1983,12 +2050,7 @@ async def _forward(body: bytes, *, structured_body: bytes | None = None) -> Resp
|
|
| 1983 |
|
| 1984 |
if upstream.status_code != 200: # noqa: PLR2004
|
| 1985 |
status = upstream.status_code
|
| 1986 |
-
|
| 1987 |
-
await upstream.aread()
|
| 1988 |
-
except httpx.HTTPError:
|
| 1989 |
-
pass
|
| 1990 |
-
finally:
|
| 1991 |
-
await upstream.aclose()
|
| 1992 |
logger.warning(
|
| 1993 |
"AI proxy [UPSTREAM_STATUS]: upstream rejected streaming request status=%d",
|
| 1994 |
status,
|
|
@@ -1999,6 +2061,17 @@ async def _forward(body: bytes, *, structured_body: bytes | None = None) -> Resp
|
|
| 1999 |
f"The upstream service returned HTTP {status}.",
|
| 2000 |
)
|
| 2001 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2002 |
content_type = (upstream.headers.get("content-type") or "").lower()
|
| 2003 |
if "text/event-stream" not in content_type:
|
| 2004 |
# The custom ai-model Space currently returns an ordinary OpenAI JSON
|
|
@@ -2006,7 +2079,15 @@ async def _forward(body: bytes, *, structured_body: bytes | None = None) -> Resp
|
|
| 2006 |
# instead of falsely labelling raw JSON bytes as SSE (which the browser
|
| 2007 |
# would correctly ignore, producing an empty answer).
|
| 2008 |
try:
|
| 2009 |
-
content = await
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2010 |
except httpx.ReadTimeout:
|
| 2011 |
await upstream.aclose()
|
| 2012 |
logger.warning(
|
|
@@ -2078,10 +2159,20 @@ async def _forward(body: bytes, *, structured_body: bytes | None = None) -> Resp
|
|
| 2078 |
emitted = False
|
| 2079 |
saw_data_field = False
|
| 2080 |
probe_tail = b""
|
|
|
|
| 2081 |
try:
|
| 2082 |
async for chunk in upstream.aiter_bytes():
|
| 2083 |
if not chunk:
|
| 2084 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2085 |
emitted = True
|
| 2086 |
probe = probe_tail + chunk.lower()
|
| 2087 |
if b"data:" in probe:
|
|
@@ -2358,6 +2449,7 @@ async def root() -> JSONResponse:
|
|
| 2358 |
"required_shared": RATE_LIMIT_REQUIRE_SHARED,
|
| 2359 |
"scope": "single_redis_consistency_domain" if RATE_LIMIT_BACKEND == "redis" else "process_local",
|
| 2360 |
},
|
|
|
|
| 2361 |
"cors": _cors_public_status(),
|
| 2362 |
},
|
| 2363 |
headers={"Cache-Control": "no-store"},
|
|
@@ -2464,6 +2556,7 @@ async def health() -> JSONResponse:
|
|
| 2464 |
"ready": (RATE_LIMIT_BACKEND == "redis" and _SHARED_RATE_LIMITER_READY) or (RATE_LIMIT_BACKEND == "local" and not RATE_LIMIT_REQUIRE_SHARED),
|
| 2465 |
"required_shared": RATE_LIMIT_REQUIRE_SHARED,
|
| 2466 |
},
|
|
|
|
| 2467 |
"cors": _cors_public_status(),
|
| 2468 |
},
|
| 2469 |
headers={"Cache-Control": "no-store"},
|
|
|
|
| 5 |
# Authors: The scikit-plots developers
|
| 6 |
# SPDX-License-Identifier: BSD-3-Clause
|
| 7 |
#
|
| 8 |
+
# scikit-plots/ai · _hf_spaces_proxy/app.py v7.2.0
|
| 9 |
#
|
| 10 |
# Server-authoritative chat proxy for sphinx-ai-assistant.
|
| 11 |
#
|
|
|
|
| 93 |
# prefix AI_RECORD_STORAGE_TOKEN_. When absent, legacy
|
| 94 |
# TRAINING_DATASET_REPO + HF_* token settings are used.
|
| 95 |
# MAX_BODY_BYTES Maximum accepted body size. Default: 10485760.
|
| 96 |
+
# MAX_UPSTREAM_RESPONSE_BYTES Maximum decoded upstream response bytes. Default: 8388608.
|
| 97 |
# SHARE_MAX_BODY_BYTES Global Share request/canonical snapshot limit. Default: 512000.
|
| 98 |
# SHARE_MAX_ENTRIES Maximum live in-memory Global Share entries. Default: 256.
|
| 99 |
# SHARE_MAX_TOTAL_BYTES Aggregate live Global Share byte budget. Default: 16777216.
|
|
|
|
| 460 |
return False
|
| 461 |
|
| 462 |
|
| 463 |
+
class _UpstreamResponseTooLarge(RuntimeError):
|
| 464 |
+
"""Raised when decoded upstream response bytes exceed the hard application cap."""
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def _upstream_declared_length(response: httpx.Response) -> int | None:
|
| 468 |
+
raw = (response.headers.get("content-length") or "").strip()
|
| 469 |
+
if not raw:
|
| 470 |
+
return None
|
| 471 |
+
if not raw.isdigit():
|
| 472 |
+
raise _UpstreamResponseTooLarge("invalid upstream content-length")
|
| 473 |
+
value = int(raw)
|
| 474 |
+
if value < 0:
|
| 475 |
+
raise _UpstreamResponseTooLarge("invalid upstream content-length")
|
| 476 |
+
return value
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def _check_upstream_declared_length(
|
| 480 |
+
response: httpx.Response, max_bytes: int | None = None
|
| 481 |
+
) -> None:
|
| 482 |
+
limit = MAX_UPSTREAM_RESPONSE_BYTES if max_bytes is None else int(max_bytes)
|
| 483 |
+
declared = _upstream_declared_length(response)
|
| 484 |
+
if declared is not None and declared > limit:
|
| 485 |
+
raise _UpstreamResponseTooLarge("declared upstream response too large")
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
async def _read_upstream_limited(
|
| 489 |
+
response: httpx.Response, max_bytes: int | None = None
|
| 490 |
+
) -> bytes:
|
| 491 |
+
"""Read a streamed upstream response with a decoded-byte hard ceiling."""
|
| 492 |
+
limit = MAX_UPSTREAM_RESPONSE_BYTES if max_bytes is None else int(max_bytes)
|
| 493 |
+
_check_upstream_declared_length(response, limit)
|
| 494 |
+
out = bytearray()
|
| 495 |
+
total = 0
|
| 496 |
+
async for chunk in response.aiter_bytes():
|
| 497 |
+
if not chunk:
|
| 498 |
+
continue
|
| 499 |
+
total += len(chunk)
|
| 500 |
+
if total > limit:
|
| 501 |
+
raise _UpstreamResponseTooLarge("upstream response too large")
|
| 502 |
+
out.extend(chunk)
|
| 503 |
+
return bytes(out)
|
| 504 |
+
|
| 505 |
+
|
| 506 |
async def _read_limited_body(request: Request, max_bytes: int, label: str = "Request") -> bytes:
|
| 507 |
"""Read an ASGI request incrementally and stop once *max_bytes* is crossed."""
|
| 508 |
raw_cl = request.headers.get("content-length")
|
|
|
|
| 780 |
16 * 1024 * 1024,
|
| 781 |
))
|
| 782 |
|
| 783 |
+
#: Maximum decoded upstream response bytes accepted before the proxy aborts the
|
| 784 |
+
#: provider stream. This is independent from request-body limits because a
|
| 785 |
+
#: malicious/misconfigured upstream controls response size.
|
| 786 |
+
MAX_UPSTREAM_RESPONSE_BYTES: int = max(64 * 1024, min(
|
| 787 |
+
_safe_int(os.environ.get("MAX_UPSTREAM_RESPONSE_BYTES"), 8 * 1024 * 1024),
|
| 788 |
+
32 * 1024 * 1024,
|
| 789 |
+
))
|
| 790 |
+
|
| 791 |
#: Maximum JSON request size accepted by Global Share. Kept independent from
|
| 792 |
#: chat bodies so a public share service cannot consume 10 MiB per request.
|
| 793 |
SHARE_MAX_BODY_BYTES: int = max(16_384, min(
|
|
|
|
| 1897 |
return json.dumps(clone, ensure_ascii=False, separators=(",", ":")).encode()
|
| 1898 |
|
| 1899 |
async def _post_buffered(request_body: bytes) -> Response:
|
| 1900 |
+
upstream: httpx.Response | None = None
|
| 1901 |
try:
|
| 1902 |
+
request = _http_client.build_request(
|
| 1903 |
+
"POST", url, content=request_body, headers=headers, timeout=req_timeout
|
| 1904 |
+
)
|
| 1905 |
+
upstream = await _http_client.send(request, stream=True)
|
| 1906 |
+
status = upstream.status_code
|
| 1907 |
+
media_type = upstream.headers.get("content-type", "application/json")
|
| 1908 |
+
content = await _read_upstream_limited(upstream)
|
| 1909 |
+
except _UpstreamResponseTooLarge:
|
| 1910 |
+
logger.warning("AI proxy [UPSTREAM_RESPONSE_LIMIT]: upstream response exceeded the application byte ceiling")
|
| 1911 |
+
return _error_response(
|
| 1912 |
+
502,
|
| 1913 |
+
"upstream_response_too_large",
|
| 1914 |
+
"The upstream response exceeded the proxy safety limit.",
|
| 1915 |
)
|
| 1916 |
except httpx.ReadTimeout:
|
| 1917 |
logger.warning(
|
|
|
|
| 1961 |
"upstream_request_error",
|
| 1962 |
"Failed to reach the upstream service.",
|
| 1963 |
)
|
| 1964 |
+
finally:
|
| 1965 |
+
if upstream is not None and not upstream.is_closed:
|
| 1966 |
+
await upstream.aclose()
|
| 1967 |
|
| 1968 |
return Response(
|
| 1969 |
+
content=content,
|
| 1970 |
+
status_code=status,
|
| 1971 |
+
media_type=media_type,
|
| 1972 |
)
|
| 1973 |
|
| 1974 |
if not stream_requested:
|
|
|
|
| 2050 |
|
| 2051 |
if upstream.status_code != 200: # noqa: PLR2004
|
| 2052 |
status = upstream.status_code
|
| 2053 |
+
await upstream.aclose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2054 |
logger.warning(
|
| 2055 |
"AI proxy [UPSTREAM_STATUS]: upstream rejected streaming request status=%d",
|
| 2056 |
status,
|
|
|
|
| 2061 |
f"The upstream service returned HTTP {status}.",
|
| 2062 |
)
|
| 2063 |
|
| 2064 |
+
try:
|
| 2065 |
+
_check_upstream_declared_length(upstream)
|
| 2066 |
+
except _UpstreamResponseTooLarge:
|
| 2067 |
+
await upstream.aclose()
|
| 2068 |
+
logger.warning("AI proxy [UPSTREAM_RESPONSE_LIMIT]: declared upstream response exceeded the application byte ceiling")
|
| 2069 |
+
return _error_response(
|
| 2070 |
+
502,
|
| 2071 |
+
"upstream_response_too_large",
|
| 2072 |
+
"The upstream response exceeded the proxy safety limit.",
|
| 2073 |
+
)
|
| 2074 |
+
|
| 2075 |
content_type = (upstream.headers.get("content-type") or "").lower()
|
| 2076 |
if "text/event-stream" not in content_type:
|
| 2077 |
# The custom ai-model Space currently returns an ordinary OpenAI JSON
|
|
|
|
| 2079 |
# instead of falsely labelling raw JSON bytes as SSE (which the browser
|
| 2080 |
# would correctly ignore, producing an empty answer).
|
| 2081 |
try:
|
| 2082 |
+
content = await _read_upstream_limited(upstream)
|
| 2083 |
+
except _UpstreamResponseTooLarge:
|
| 2084 |
+
await upstream.aclose()
|
| 2085 |
+
logger.warning("AI proxy [UPSTREAM_RESPONSE_LIMIT]: buffered JSON fallback exceeded the application byte ceiling")
|
| 2086 |
+
return _error_response(
|
| 2087 |
+
502,
|
| 2088 |
+
"upstream_response_too_large",
|
| 2089 |
+
"The upstream response exceeded the proxy safety limit.",
|
| 2090 |
+
)
|
| 2091 |
except httpx.ReadTimeout:
|
| 2092 |
await upstream.aclose()
|
| 2093 |
logger.warning(
|
|
|
|
| 2159 |
emitted = False
|
| 2160 |
saw_data_field = False
|
| 2161 |
probe_tail = b""
|
| 2162 |
+
total_bytes = 0
|
| 2163 |
try:
|
| 2164 |
async for chunk in upstream.aiter_bytes():
|
| 2165 |
if not chunk:
|
| 2166 |
continue
|
| 2167 |
+
total_bytes += len(chunk)
|
| 2168 |
+
if total_bytes > MAX_UPSTREAM_RESPONSE_BYTES:
|
| 2169 |
+
logger.warning("AI proxy [UPSTREAM_RESPONSE_LIMIT]: SSE response exceeded the application byte ceiling")
|
| 2170 |
+
yield _sse_error_frame(
|
| 2171 |
+
502,
|
| 2172 |
+
"upstream_response_too_large",
|
| 2173 |
+
"The upstream response exceeded the proxy safety limit.",
|
| 2174 |
+
)
|
| 2175 |
+
return
|
| 2176 |
emitted = True
|
| 2177 |
probe = probe_tail + chunk.lower()
|
| 2178 |
if b"data:" in probe:
|
|
|
|
| 2449 |
"required_shared": RATE_LIMIT_REQUIRE_SHARED,
|
| 2450 |
"scope": "single_redis_consistency_domain" if RATE_LIMIT_BACKEND == "redis" else "process_local",
|
| 2451 |
},
|
| 2452 |
+
"limits": {"max_upstream_response_bytes": MAX_UPSTREAM_RESPONSE_BYTES},
|
| 2453 |
"cors": _cors_public_status(),
|
| 2454 |
},
|
| 2455 |
headers={"Cache-Control": "no-store"},
|
|
|
|
| 2556 |
"ready": (RATE_LIMIT_BACKEND == "redis" and _SHARED_RATE_LIMITER_READY) or (RATE_LIMIT_BACKEND == "local" and not RATE_LIMIT_REQUIRE_SHARED),
|
| 2557 |
"required_shared": RATE_LIMIT_REQUIRE_SHARED,
|
| 2558 |
},
|
| 2559 |
+
"limits": {"max_upstream_response_bytes": MAX_UPSTREAM_RESPONSE_BYTES},
|
| 2560 |
"cors": _cors_public_status(),
|
| 2561 |
},
|
| 2562 |
headers={"Cache-Control": "no-store"},
|