celik-muhammed commited on
Commit
b750b4f
·
verified ·
1 Parent(s): b998a26

Upload 34 files

Browse files
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=>{let data=null;try{data=await r.json()}catch(_e){}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 data||{};}).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:
 
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.0.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
 
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
- api_base = str(raw.get("api_base") or "").strip().rstrip("/")
172
- if provider == "gitlab" and not api_base:
173
- api_base = "https://gitlab.com/api/v4"
 
 
 
 
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
- auth_check,
357
- repo_id=target.repo,
358
- repo_type="dataset",
359
- write=True,
 
 
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
- api.create_commit,
593
- repo_id=target.repo,
594
- repo_type="dataset",
595
- revision=target.branch,
596
- operations=[CommitOperationDelete(path_in_repo=path)],
597
- commit_message=message,
 
 
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
- current = await self._client().get(url, headers=headers, params={"ref": target.branch}, timeout=15.0)
615
- if current.status_code == 404:
 
616
  return "already-absent"
617
- if current.status_code != 200:
618
- raise StorageWriteError("GITHUB_DELETE_LOOKUP", transient=current.status_code in _TRANSIENT_STATUS)
619
- sha = str((current.json() or {}).get("sha") or "")
620
  if not sha:
621
  raise StorageWriteError("GITHUB_DELETE_SHA")
622
- resp = await self._client().request(
623
  "DELETE", url, headers=headers,
624
  json={"message": message, "sha": sha, "branch": target.branch}, timeout=20.0,
625
  )
626
- if resp.status_code in {200, 204}:
627
  return "removed-current-view"
628
- if resp.status_code == 404:
629
  return "already-absent"
630
- raise StorageWriteError("GITHUB_DELETE", transient=resp.status_code in _TRANSIENT_STATUS)
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
- resp = await self._client().request(
639
  "DELETE", url, headers=headers,
640
  json={"branch": target.branch, "commit_message": message}, timeout=20.0,
641
  )
642
- if resp.status_code in {200, 204}:
643
  return "removed-current-view"
644
- if resp.status_code == 404:
645
  return "already-absent"
646
- raise StorageWriteError("GITLAB_DELETE", transient=resp.status_code in _TRANSIENT_STATUS)
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
- current = await self._client().get(read_url, headers=headers, timeout=15.0)
656
- if current.status_code == 404:
657
  return "already-absent"
658
- if current.status_code != 200:
659
- raise StorageWriteError("BITBUCKET_DELETE_LOOKUP", transient=current.status_code in _TRANSIENT_STATUS)
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
- resp = await self._client().post(
664
- url, headers=delete_headers, content=form.encode("utf-8"), timeout=25.0
665
  )
666
- if resp.status_code in {200, 201}:
667
  return "removed-current-view"
668
- raise StorageWriteError("BITBUCKET_DELETE", transient=resp.status_code in _TRANSIENT_STATUS)
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
- api.create_commit,
696
- repo_id=target.repo,
697
- repo_type="dataset",
698
- revision=target.branch,
699
- operations=[CommitOperationAdd(path_in_repo=path, path_or_fileobj=content)],
700
- commit_message=message,
 
 
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
- resp = await self._client().put(url, headers=headers, json=payload, timeout=20.0)
733
- if resp.status_code in {200, 201}:
734
  return
735
  # A retry may encounter an already-created idempotent path. Confirm
736
  # content equality before treating the conflict as success.
737
- if resp.status_code in {409, 422}:
738
- get = await self._client().get(url, headers=headers, params={"ref": target.branch}, timeout=15.0)
739
- if get.status_code == 200:
 
740
  try:
741
- existing = base64.b64decode((get.json().get("content") or "").replace("\n", ""))
742
  if existing == content:
743
  return
744
  except Exception:
745
  pass
746
- raise StorageWriteError("GITHUB_WRITE", transient=resp.status_code in _TRANSIENT_STATUS)
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
- resp = await self._client().post(url, headers=headers, json=payload, timeout=20.0)
756
- if resp.status_code in {200, 201}:
757
  return
758
- if resp.status_code == 400:
759
- get = await self._client().get(url, headers=headers, params={"ref": target.branch}, timeout=15.0)
760
- if get.status_code == 200:
 
761
  try:
762
- if get.json().get("content_sha256") == hashlib.sha256(content).hexdigest():
763
  return
764
  except Exception:
765
  pass
766
- raise StorageWriteError("GITLAB_WRITE", transient=resp.status_code in _TRANSIENT_STATUS)
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
- resp = await self._client().post(url, headers=headers, data=data, files=files, timeout=25.0)
775
- if resp.status_code in {200, 201}:
776
  return
777
- raise StorageWriteError("BITBUCKET_WRITE", transient=resp.status_code in _TRANSIENT_STATUS)
 
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.0.0
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
- upstream = await _http_client.post(
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=upstream.content,
1903
- status_code=upstream.status_code,
1904
- media_type=upstream.headers.get("content-type", "application/json"),
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
- try:
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 upstream.aread()
 
 
 
 
 
 
 
 
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"},