diff --git a/.github/PULL_REQUEST_TEMPLATE/general.md b/.github/PULL_REQUEST_TEMPLATE/general.md
index 4f1a18e925e..f815601a29c 100644
--- a/.github/PULL_REQUEST_TEMPLATE/general.md
+++ b/.github/PULL_REQUEST_TEMPLATE/general.md
@@ -6,19 +6,12 @@
## Handwritten 1-3 sentence description
-
-``
-
-``
+[insert handwritten description here]
Details
@@ -100,6 +93,7 @@ If you're seeing a delay in your PR being merged, ping the LiteLLM Team on [Slac
Otherwise proof must be fully e2e, no mocks, with real provider calls where applicable. Pytest alone is insufficient. The following format applies to live proof
Show only the latest proof. Before uses the merge base and After the tested tip. Rerun affected QA after behavior changes. Verified neutral diffs may carry proof with the tested SHA, current tip, equivalence reason and focused checks, following AGENTS.md and /qa
Use matching Before/After case headings with numbered commands and observed output. Put shared setup above them. For one case, omit case headings
+ A reviewer must be able to rerun every case from the body alone, so the shared setup gives the proxy start command, the config.yaml, env var names (never values) and the client code or curl used, all exactly as run. Wrap any block longer than about 20 lines in a collapsed with a one-line summary
### Before ()
diff --git a/.github/workflows/interactions-spec-canary.yml b/.github/workflows/interactions-spec-canary.yml
index a2e4646dce5..89ebdd87a7c 100644
--- a/.github/workflows/interactions-spec-canary.yml
+++ b/.github/workflows/interactions-spec-canary.yml
@@ -49,9 +49,9 @@ jobs:
id: compliance
run: |
uv run --no-sync pytest tests/unit/interactions/test_openapi_compliance.py \
- --interactions-spec-url=https://ai.google.dev/static/api/interactions.openapi.json \
+ --interactions-spec-url=https://aistudio.google.com/api-reference/interactions.openapi.json \
--tb=short -vv -p no:rerunfailures -p no:pytest-retry \
- --allow-hosts=127.0.0.1,::1,localhost,ai.google.dev
+ --allow-hosts=127.0.0.1,::1,localhost,ai.google.dev,aistudio.google.com
tracking-issue:
name: tracking-issue
diff --git a/AGENTS.md b/AGENTS.md
index 9aa9ab32528..01127303da3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,7 +45,7 @@ Run QA proxies with `python litellm/proxy/proxy_cli.py --config litellm/proxy/de
Simple PRs may skip before/after e2e when the full diff is mechanical and focused checks completely prove it, such as #49030 restoring an existing helper name. Trace affected callers and run relevant tests/static checks at the tip. For a bug fix, establish that the regression catches the original defect. Size alone never qualifies. New behavior, API/provider semantics, auth, billing, persistence, concurrency or uncertain integration risk need live e2e. In Screenshots / Proof of Fix, record `E2E exception at `, scope, rationale, commands and results. Tests count as proof only for this exception. Reassess changed diffs, retain CI/bot gates, and honor explicit e2e requests (2026-10-28, unnecessary live QA for #49030)
-For other PRs, proof must show the end user's real flow before and after, with commands and output against a live proxy and real provider calls where applicable. Pytest alone is insufficient. For UI or interactive coding-tool flows, drive that surface and embed before/after screenshots, with URLs, clicks and input fields a reviewer can reproduce
+For other PRs, proof must show the end user's real flow before and after, with commands and output against a live proxy and real provider calls where applicable. The body alone must let a reviewer rerun it, with the proxy command, config, env var names and client code exactly as run, and long blocks collapsed in . Pytest alone is insufficient. For UI or interactive coding-tool flows, drive that surface and embed before/after screenshots, with URLs, clicks and input fields a reviewer can reproduce
If you ever write any human-facing text (pull requests, issues, commit messages, discussion posts, github comments, release notes, docs, etc.), always follow these guidelines to sound less AI-y:
- don't use emojis
diff --git a/cookbook/skill_scanner/README.md b/cookbook/skill_scanner/README.md
new file mode 100644
index 00000000000..2a68e116002
--- /dev/null
+++ b/cookbook/skill_scanner/README.md
@@ -0,0 +1,60 @@
+# Manual skill security scans
+
+This standalone adapter runs NVIDIA SkillSpector on a separate machine. It is not installed in the LiteLLM proxy and does not require a shared Helm or Compose deployment
+
+Start it with Python 3.12 or newer and uv. The script pins SkillSpector and its HTTP dependencies
+
+```sh
+export SKILL_SCANNER_API_KEY="$(openssl rand -hex 32)"
+uv run server.py
+```
+
+For another host, place the loopback service behind an HTTPS reverse proxy that forwards the Authorization header and enforces a 65 MiB request limit. Run it under an unprivileged account with a writable temporary directory and no production files or credentials. The scanner host needs Git and network access to requested sources and optional model providers. Apply network egress restrictions suitable for those sources
+
+Configure the same scanner secret in the gateway's environment, then add this to LiteLLM's configuration
+
+```yaml
+general_settings:
+ skill_scanner:
+ endpoint: https://scanner.example.com/scan
+ api_key_env: SKILL_SCANNER_API_KEY
+ use_llm: false
+ timeout_seconds: 180
+```
+
+The endpoint must be reachable from the gateway. A customer's laptop works while it is reachable through their network, VPN or authenticated HTTPS tunnel. `localhost` refers to the gateway machine, not the admin's browser. HTTP is accepted only for loopback development
+
+Open Skills, Submissions, and Review as a proxy admin. Click **Scan for threats**, inspect the report, then independently approve or reject the proposal. Scanning is optional and never changes approval permissions or blocks publication. The report shows findings, locations, remediation, risk score, scanner version and whether semantic analysis actually ran. Incomplete reports do not establish safety
+
+Uploaded ZIPs are sent privately to the scanner with their exact stored bytes. Metadata-only edits reuse the matching stored archive. If that archive is missing or changed, submit a new ZIP before scanning. The displayed SHA-256 identifies that archive, and the scan examines its files rather than proposed catalog metadata. Remote URL reports describe the source at scan time and do not freeze its contents. GitHub branch/tag refs and explicit-ref subdirectories are supported. Other ref/subdirectory combinations require a ZIP so the adapter does not silently scan the wrong revision
+
+The latest scan result is retained with the submission. Running scans have a deadline. If a gateway restarts or the scanner becomes unavailable, the attempt expires and an admin can retry. Retries replace the latest report. Approving or rejecting during a scan does not cancel it or change the decision
+
+Set `use_llm: true` to request semantic analysis and configure the provider on the scanner machine using [SkillSpector's provider instructions](https://github.com/NVIDIA/skillspector#llm-analysis). Provider credentials stay on that machine. Static analysis can also call OSV for dependency checks
+
+## HTTP contract
+
+The gateway sends an authenticated `POST` to the configured endpoint. The body contains form fields `source` (JSON source descriptor), `use_llm` (boolean), and `expires_at` (timezone-aware ISO timestamp), plus `file` (ZIP) when bytes are available. The service returns HTTP 200 with this JSON shape
+
+```json
+{
+ "scanner_version": "2.12.0",
+ "complete": true,
+ "scan_mode": "static-only",
+ "risk_score": 70,
+ "findings": [{
+ "rule_id": "example-rule",
+ "severity": "HIGH",
+ "message": "Potential data exfiltration",
+ "file": "SKILL.md",
+ "start_line": 8,
+ "explanation": "Instructions request sending local files to an external host",
+ "remediation": "Remove the external upload instruction"
+ }],
+ "limitations": []
+}
+```
+
+Reports are limited to 1 MiB and 500 findings. Scanner uploads allow 64 MiB because the gateway can expand compressed skill uploads into stored compatibility ZIPs. The gateway rejects malformed reports, refuses redirects and verifies TLS. Only the configured scanner credential is sent. A timeout, HTTP error or incomplete report remains visible to the reviewer
+
+The bundled adapter runs at most two child scans concurrently. Each child and its descendants are terminated at the gateway deadline, capped at 570 seconds. Keep the gateway and scanner clocks synchronized. Direct adapter clients may omit `expires_at` to use the cap. Child processes never execute submitted skill scripts. Raise the gateway deadline up to 600 seconds when needed for semantic analysis and configure the reverse proxy timeout to match
diff --git a/cookbook/skill_scanner/server.py b/cookbook/skill_scanner/server.py
new file mode 100644
index 00000000000..44a1cecb00f
--- /dev/null
+++ b/cookbook/skill_scanner/server.py
@@ -0,0 +1,172 @@
+# /// script
+# requires-python = ">=3.12"
+# dependencies = [
+# "fastapi==0.162.0", "uvicorn==0.54.0", "python-multipart==0.0.33",
+# "skillspector @ git+https://github.com/NVIDIA/skillspector.git@62c2fe8622f580ac292d4078c3b71f790194dbbd",
+# ]
+# ///
+import asyncio
+import hmac
+import json
+import os
+import signal
+import sys
+import tempfile
+from contextlib import suppress
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Annotated, Final
+from urllib.parse import quote, urlsplit
+
+from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from pydantic import AwareDatetime, TypeAdapter, ValidationError
+
+MAX_UPLOAD: Final = 64 * 1024 * 1024
+MAX_REPORT: Final = 1024 * 1024
+SCAN_TIMEOUT: Final = 570
+SOURCE: Final = TypeAdapter(dict[str, str])
+SLOTS: Final = asyncio.Semaphore(2)
+app: Final = FastAPI(title="SkillSpector adapter", docs_url=None, redoc_url=None, openapi_url=None)
+
+
+def authenticate(credentials: Annotated[HTTPAuthorizationCredentials, Depends(HTTPBearer())]) -> None:
+ token: Final = os.environ.get("SKILL_SCANNER_API_KEY", "")
+ if not token or not hmac.compare_digest(credentials.credentials, token):
+ raise HTTPException(401, "Invalid scanner credentials")
+
+
+def source_target(source: dict[str, str]) -> str:
+ target: Final = (
+ "https://github.com/" + source.get("repo", "") if source.get("source") == "github" else source.get("url", "")
+ )
+ parsed: Final = urlsplit(target)
+ if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
+ raise HTTPException(400, "Remote sources require an HTTPS URL without credentials or a fragment")
+ ref: Final = source.get("ref")
+ subdir: Final = source.get("path")
+ if ref or subdir:
+ if parsed.hostname != "github.com" or len(parsed.path.strip("/").split("/")) != 2 or not ref:
+ raise HTTPException(400, "Upload a ZIP to scan this source's exact ref and subdirectory")
+ suffix: Final = "/" + quote(subdir, safe="/") if subdir else ""
+ return target.removesuffix(".git").rstrip("/") + "/tree/" + quote(ref, safe="/") + suffix
+ return target
+
+
+async def worker(target: str, output: Path, use_llm: bool) -> None:
+ from skillspector.mcp_server import run_scan
+
+ result: Final = await run_scan(target, use_llm=use_llm, allow_local_targets=not target.startswith("https://"))
+ coverage: Final = result.get("analysis_completeness", {})
+ findings: Final = result["findings"]
+ complete: Final = (
+ result.get("execution_successful") is True
+ and coverage.get("is_complete") is True
+ and (not use_llm or (result.get("llm_available") is True and result.get("llm_used") is True))
+ and len(findings) <= 500
+ )
+ limitations: Final = tuple(str(value)[:2000] for value in coverage.get("limitations", ()))
+ report: Final = {
+ "scanner_version": result["version"],
+ "complete": complete,
+ "scan_mode": result["scan_mode"],
+ "risk_score": result["risk_score"],
+ "findings": [
+ {
+ "rule_id": finding["id"],
+ "severity": finding["severity"],
+ "message": finding.get("pattern") or finding.get("finding") or "Security finding",
+ "file": finding["location"]["file"],
+ "start_line": finding["location"].get("start_line"),
+ "explanation": finding.get("explanation"),
+ "remediation": finding.get("remediation"),
+ }
+ for finding in findings[:500]
+ ],
+ "limitations": (
+ *limitations,
+ *(("Only the first 500 findings are displayed.",) if len(findings) > 500 else ()),
+ ),
+ }
+ encoded: Final = json.dumps(report).encode()
+ if len(encoded) > MAX_REPORT:
+ raise ValueError("Scanner report exceeded the response limit")
+ output.write_bytes(encoded)
+
+
+async def run_worker(target: str, output: Path, use_llm: bool, expires_at: datetime | None = None) -> dict[str, object]:
+ timeout: Final = (
+ SCAN_TIMEOUT
+ if expires_at is None
+ else min(SCAN_TIMEOUT, (expires_at - datetime.now(timezone.utc)).total_seconds())
+ )
+ if timeout <= 0:
+ raise HTTPException(504, "SkillSpector scan deadline expired")
+ process: Final = await asyncio.create_subprocess_exec(
+ sys.executable,
+ str(Path(__file__).resolve()),
+ "--scan",
+ target,
+ str(output),
+ str(use_llm).lower(),
+ stdout=asyncio.subprocess.DEVNULL,
+ stderr=asyncio.subprocess.DEVNULL,
+ start_new_session=True,
+ env={
+ **os.environ,
+ "HOME": str(output.parent),
+ "TMPDIR": str(output.parent),
+ "GIT_CONFIG_GLOBAL": os.devnull,
+ "GIT_CONFIG_NOSYSTEM": "1",
+ "GIT_TERMINAL_PROMPT": "0",
+ "GIT_ALLOW_PROTOCOL": "https",
+ },
+ )
+ try:
+ code: Final = await asyncio.wait_for(process.wait(), timeout)
+ if code != 0 or not output.is_file() or output.stat().st_size > MAX_REPORT:
+ raise HTTPException(502, "SkillSpector could not produce a report for this source")
+ return TypeAdapter(dict[str, object]).validate_json(output.read_bytes())
+ except TimeoutError:
+ raise HTTPException(504, "SkillSpector scan timed out") from None
+ finally:
+ with suppress(ProcessLookupError):
+ os.killpg(process.pid, signal.SIGKILL)
+ await process.wait()
+
+
+@app.post("/scan", dependencies=[Depends(authenticate)])
+async def scan(
+ source: Annotated[str, Form(max_length=16384)],
+ use_llm: Annotated[bool, Form()] = False,
+ expires_at: Annotated[AwareDatetime | None, Form()] = None,
+ file: Annotated[UploadFile | None, File()] = None,
+) -> dict[str, object]:
+ if SLOTS.locked():
+ raise HTTPException(429, "Scanner is busy; try again shortly")
+ try:
+ parsed: Final = SOURCE.validate_json(source)
+ except ValidationError:
+ raise HTTPException(400, "Invalid source metadata") from None
+ async with SLOTS:
+ with tempfile.TemporaryDirectory(prefix="skill-scan-") as directory:
+ root: Final = Path(directory)
+ if file is not None:
+ content: Final = await file.read(MAX_UPLOAD + 1)
+ if len(content) > MAX_UPLOAD:
+ raise HTTPException(413, "Upload exceeds 64 MiB")
+ archive: Final = root / "skill.zip"
+ archive.write_bytes(content)
+ return await run_worker(str(archive), root / "report.json", use_llm, expires_at)
+ return await run_worker(source_target(parsed), root / "report.json", use_llm, expires_at)
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1 and sys.argv[1] == "--scan":
+ asyncio.run(worker(sys.argv[2], Path(sys.argv[3]), sys.argv[4] == "true"))
+ else:
+ import uvicorn
+
+ if not os.environ.get("SKILL_SCANNER_API_KEY"):
+ raise SystemExit("Set SKILL_SCANNER_API_KEY before starting the scanner")
+ uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PORT", "8099")))
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105000000_skill_version_history/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105000000_skill_version_history/migration.sql
new file mode 100644
index 00000000000..ecfa7321d87
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105000000_skill_version_history/migration.sql
@@ -0,0 +1,16 @@
+SET LOCAL lock_timeout = '10s';
+CREATE TABLE IF NOT EXISTS "LiteLLM_ClaudeCodePluginVersion" (
+ "id" TEXT NOT NULL,
+ "plugin_id" TEXT NOT NULL,
+ "version" TEXT,
+ "description" TEXT,
+ "manifest_json" TEXT,
+ "published_at" TIMESTAMP(3),
+ "published_by" TEXT,
+ "archive_available" BOOLEAN NOT NULL DEFAULT false,
+ "content" BYTEA,
+ CONSTRAINT "LiteLLM_ClaudeCodePluginVersion_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "LiteLLM_ClaudeCodePluginVersion_plugin_id_fkey" FOREIGN KEY ("plugin_id") REFERENCES "LiteLLM_ClaudeCodePluginTable"("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+CREATE UNIQUE INDEX IF NOT EXISTS "LiteLLM_ClaudeCodePluginVersion_plugin_id_version_key" ON "LiteLLM_ClaudeCodePluginVersion"("plugin_id", "version");
+CREATE INDEX IF NOT EXISTS "LiteLLM_ClaudeCodePluginVersion_plugin_id_published_at_idx" ON "LiteLLM_ClaudeCodePluginVersion"("plugin_id", "published_at");
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105010000_skill_submissions/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105010000_skill_submissions/migration.sql
new file mode 100644
index 00000000000..e7975258ca9
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105010000_skill_submissions/migration.sql
@@ -0,0 +1,27 @@
+SET LOCAL lock_timeout = '10s';
+
+CREATE TABLE IF NOT EXISTS "LiteLLM_SkillSubmission" (
+ "id" TEXT NOT NULL,
+ "plugin_name" TEXT NOT NULL,
+ "operation" TEXT NOT NULL,
+ "status" TEXT NOT NULL DEFAULT 'pending_review',
+ "proposal_json" TEXT NOT NULL,
+ "archive_content" BYTEA,
+ "submitted_by" TEXT NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "base_plugin_id" TEXT,
+ "base_updated_at" TIMESTAMP(3),
+ "reviewed_by" TEXT,
+ "reviewed_at" TIMESTAMP(3),
+ "rejection_reason" TEXT,
+ CONSTRAINT "LiteLLM_SkillSubmission_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX IF NOT EXISTS "LiteLLM_SkillSubmission_submitted_by_created_at_id_idx"
+ ON "LiteLLM_SkillSubmission"("submitted_by", "created_at" DESC, "id" DESC);
+CREATE INDEX IF NOT EXISTS "LiteLLM_SkillSubmission_submitted_by_status_created_at_id_idx"
+ ON "LiteLLM_SkillSubmission"("submitted_by", "status", "created_at" DESC, "id" DESC);
+CREATE INDEX IF NOT EXISTS "LiteLLM_SkillSubmission_status_created_at_id_idx"
+ ON "LiteLLM_SkillSubmission"("status", "created_at" DESC, "id" DESC);
+CREATE INDEX IF NOT EXISTS "LiteLLM_SkillSubmission_created_at_id_idx"
+ ON "LiteLLM_SkillSubmission"("created_at" DESC, "id" DESC);
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105020000_skill_scanning/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105020000_skill_scanning/migration.sql
new file mode 100644
index 00000000000..6b70cca4a9a
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105020000_skill_scanning/migration.sql
@@ -0,0 +1,3 @@
+SET LOCAL lock_timeout = '10s';
+
+ALTER TABLE "LiteLLM_SkillSubmission" ADD COLUMN IF NOT EXISTS "scan_json" TEXT;
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105094500_add_skill_download_history/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105094500_add_skill_download_history/migration.sql
new file mode 100644
index 00000000000..43e26b324a0
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261105094500_add_skill_download_history/migration.sql
@@ -0,0 +1,37 @@
+SET LOCAL lock_timeout = '10s';
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_attribute
+ WHERE attrelid = to_regclass('"LiteLLM_ClaudeCodePluginTable"')
+ AND attname = 'anonymous_download_count' AND NOT attisdropped
+ ) THEN
+ ALTER TABLE "LiteLLM_ClaudeCodePluginTable"
+ ADD COLUMN IF NOT EXISTS "anonymous_download_count" BIGINT NOT NULL DEFAULT 0;
+ END IF;
+
+ IF to_regclass('"LiteLLM_ClaudeCodePluginDownload"') IS NULL THEN
+ CREATE TABLE IF NOT EXISTS "LiteLLM_ClaudeCodePluginDownload" (
+ "id" BIGSERIAL NOT NULL,
+ "plugin_id" TEXT NOT NULL,
+ "downloaded_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "version" TEXT,
+ "digest" TEXT NOT NULL,
+ "authenticated" BOOLEAN NOT NULL,
+ "user_id" TEXT,
+ "user_email" TEXT,
+ "key_alias" TEXT,
+ "team_id" TEXT,
+ "team_alias" TEXT,
+ CONSTRAINT "LiteLLM_ClaudeCodePluginDownload_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "LiteLLM_ClaudeCodePluginDownload_plugin_id_fkey" FOREIGN KEY ("plugin_id")
+ REFERENCES "LiteLLM_ClaudeCodePluginTable"("id") ON DELETE CASCADE ON UPDATE CASCADE
+ );
+ END IF;
+
+ IF to_regclass('"LiteLLM_ClaudeCodePluginDownload_plugin_id_id_idx"') IS NULL THEN
+ CREATE INDEX IF NOT EXISTS "LiteLLM_ClaudeCodePluginDownload_plugin_id_id_idx"
+ ON "LiteLLM_ClaudeCodePluginDownload"("plugin_id", "id");
+ END IF;
+END $$;
diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
index 78f16d9f1fd..818f2dc4858 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
+++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
@@ -1616,10 +1616,69 @@ model LiteLLM_ClaudeCodePluginTable {
created_by String?
uploaded_archive LiteLLM_ClaudeCodePluginUpload?
+ versions LiteLLM_ClaudeCodePluginVersion[]
+ downloads LiteLLM_ClaudeCodePluginDownload[]
+ anonymous_download_count BigInt @default(0)
@@map("LiteLLM_ClaudeCodePluginTable")
}
+model LiteLLM_ClaudeCodePluginVersion {
+ id String @id @default(uuid())
+ plugin_id String
+ version String?
+ description String?
+ manifest_json String?
+ published_at DateTime?
+ published_by String?
+ archive_available Boolean @default(false)
+ content Bytes?
+ plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
+
+ @@unique([plugin_id, version])
+ @@index([plugin_id, published_at])
+}
+
+
+model LiteLLM_SkillSubmission {
+ id String @id @default(uuid())
+ plugin_name String
+ operation String
+ status String @default("pending_review")
+ proposal_json String
+ archive_content Bytes?
+ submitted_by String
+ created_at DateTime @default(now())
+ base_plugin_id String?
+ base_updated_at DateTime?
+ reviewed_by String?
+ reviewed_at DateTime?
+ rejection_reason String?
+ scan_json String?
+
+ @@index([submitted_by, created_at(sort: Desc), id(sort: Desc)])
+ @@index([submitted_by, status, created_at(sort: Desc), id(sort: Desc)])
+ @@index([status, created_at(sort: Desc), id(sort: Desc)])
+ @@index([created_at(sort: Desc), id(sort: Desc)])
+}
+
+model LiteLLM_ClaudeCodePluginDownload {
+ id BigInt @id @default(autoincrement())
+ plugin_id String
+ downloaded_at DateTime @default(now())
+ version String?
+ digest String
+ authenticated Boolean
+ user_id String?
+ user_email String?
+ key_alias String?
+ team_id String?
+ team_alias String?
+ plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
+
+ @@index([plugin_id, id])
+}
+
model LiteLLM_ClaudeCodePluginUpload {
plugin_name String @id
content Bytes
diff --git a/litellm-rust/.agents/skills/rust-gateway/SKILL.md b/litellm-rust/.agents/skills/rust-gateway/SKILL.md
index 4fee0b54a68..923c9bf5ab6 100644
--- a/litellm-rust/.agents/skills/rust-gateway/SKILL.md
+++ b/litellm-rust/.agents/skills/rust-gateway/SKILL.md
@@ -7,7 +7,7 @@ metadata:
# Rust gateway
-`crates/gateway` mounts endpoint routers and serves the listener. `crates/gateway-inference`, `gateway-mcp` and `gateway-ui` own their endpoints, `gateway-auth` is the inbound authentication middleware and `gateway-management` holds management logic that has no router yet. A crate's `AGENTS.md`, where it has one, says what belongs in it. Read the reference for the part you are touching
+`crates/gateway` mounts endpoint routers and serves the listener. `crates/gateway-inference`, `gateway-mcp` and `gateway-ui` own their endpoints, `gateway-auth` is the inbound authentication middleware and `gateway-management` holds management logic that has no router yet. `gateway-forward` is the reverse proxy that relays every request to the Python proxy and has no routes of its own. A crate's `AGENTS.md`, where it has one, says what belongs in it. Read the reference for the part you are touching
- [surfaces](references/surfaces.md)
- Each API surface mirrors the envelope the Python proxy returns there, so the shapes differ on purpose
diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock
index 23ebfc7d3cf..107b55e89cb 100644
--- a/litellm-rust/Cargo.lock
+++ b/litellm-rust/Cargo.lock
@@ -547,7 +547,7 @@ dependencies = [
"http 1.4.2",
"http-body 0.4.6",
"hyper 0.14.32",
- "hyper 1.10.1",
+ "hyper 1.12.0",
"hyper-rustls 0.24.2",
"hyper-rustls 0.27.9",
"hyper-util",
@@ -736,7 +736,7 @@ dependencies = [
"http 1.4.2",
"http-body 1.1.0",
"http-body-util",
- "hyper 1.10.1",
+ "hyper 1.12.0",
"hyper-util",
"itoa",
"matchit",
@@ -1000,7 +1000,7 @@ dependencies = [
"home",
"http 1.4.2",
"http-body-util",
- "hyper 1.10.1",
+ "hyper 1.12.0",
"hyper-named-pipe",
"hyper-rustls 0.27.9",
"hyper-util",
@@ -2255,7 +2255,7 @@ dependencies = [
"chrono",
"http 1.4.2",
"http-body-util",
- "hyper 1.10.1",
+ "hyper 1.12.0",
"hyper-rustls 0.27.9",
"hyper-util",
"ring",
@@ -2391,7 +2391,7 @@ dependencies = [
"http 1.4.2",
"http-body 1.1.0",
"http-body-util",
- "hyper 1.10.1",
+ "hyper 1.12.0",
"lazy_static",
"opentelemetry",
"opentelemetry-semantic-conventions",
@@ -2833,9 +2833,9 @@ dependencies = [
[[package]]
name = "hyper"
-version = "1.10.1"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
dependencies = [
"atomic-waker",
"bytes",
@@ -2860,7 +2860,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf"
dependencies = [
"hex",
- "hyper 1.10.1",
+ "hyper 1.12.0",
"hyper-util",
"pin-project-lite",
"tokio",
@@ -2889,7 +2889,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http 1.4.2",
- "hyper 1.10.1",
+ "hyper 1.12.0",
"hyper-util",
"rustls 0.23.42",
"rustls-native-certs",
@@ -2904,7 +2904,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
dependencies = [
- "hyper 1.10.1",
+ "hyper 1.12.0",
"hyper-util",
"pin-project-lite",
"tokio",
@@ -2923,7 +2923,7 @@ dependencies = [
"futures-util",
"http 1.4.2",
"http-body 1.1.0",
- "hyper 1.10.1",
+ "hyper 1.12.0",
"ipnet",
"libc",
"percent-encoding",
@@ -2942,7 +2942,7 @@ checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7"
dependencies = [
"hex",
"http-body-util",
- "hyper 1.10.1",
+ "hyper 1.12.0",
"hyper-util",
"pin-project-lite",
"tokio",
@@ -3769,6 +3769,7 @@ dependencies = [
"litellm-config",
"litellm-error",
"litellm-gateway-auth",
+ "litellm-gateway-forward",
"litellm-gateway-inference",
"litellm-gateway-mcp",
"litellm-gateway-ui",
@@ -3815,6 +3816,29 @@ dependencies = [
"veil",
]
+[[package]]
+name = "litellm-gateway-forward"
+version = "0.1.0"
+dependencies = [
+ "axum",
+ "bytes",
+ "envy",
+ "futures-util",
+ "http-body-util",
+ "httparse",
+ "hyper 1.12.0",
+ "hyper-util",
+ "proptest",
+ "rstest",
+ "serde",
+ "thiserror 2.0.19",
+ "tokio",
+ "tokio-tungstenite",
+ "tokio-util",
+ "tower-service",
+ "tracing",
+]
+
[[package]]
name = "litellm-gateway-inference"
version = "0.1.0"
@@ -6031,7 +6055,7 @@ dependencies = [
"http 1.4.2",
"http-body 1.1.0",
"http-body-util",
- "hyper 1.10.1",
+ "hyper 1.12.0",
"hyper-rustls 0.27.9",
"hyper-util",
"js-sys",
@@ -7512,6 +7536,7 @@ dependencies = [
"bytes",
"futures-core",
"futures-sink",
+ "futures-util",
"pin-project-lite",
"tokio",
]
@@ -7591,7 +7616,7 @@ dependencies = [
"http 1.4.2",
"http-body 1.1.0",
"http-body-util",
- "hyper 1.10.1",
+ "hyper 1.12.0",
"hyper-timeout",
"hyper-util",
"percent-encoding",
@@ -8167,9 +8192,9 @@ dependencies = [
[[package]]
name = "want"
-version = "0.3.1"
+version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
dependencies = [
"try-lock",
]
@@ -8551,7 +8576,7 @@ dependencies = [
"futures",
"http 1.4.2",
"http-body-util",
- "hyper 1.10.1",
+ "hyper 1.12.0",
"hyper-util",
"log",
"once_cell",
diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml
index 71914a0bffe..7121df06bc0 100644
--- a/litellm-rust/Cargo.toml
+++ b/litellm-rust/Cargo.toml
@@ -26,6 +26,7 @@ litellm-inference-ocr = { path = "crates/inference-ocr" }
litellm-inference-testing = { path = "crates/inference-testing" }
litellm-gateway-mcp = { path = "crates/gateway-mcp" }
litellm-gateway = { path = "crates/gateway" }
+litellm-gateway-forward = { path = "crates/gateway-forward" }
litellm-gateway-inference = { path = "crates/gateway-inference" }
litellm-gateway-auth = { path = "crates/gateway-auth" }
litellm-gateway-management = { path = "crates/gateway-management" }
diff --git a/litellm-rust/crates/gateway-forward/AGENTS.md b/litellm-rust/crates/gateway-forward/AGENTS.md
new file mode 100644
index 00000000000..2135980266f
--- /dev/null
+++ b/litellm-rust/crates/gateway-forward/AGENTS.md
@@ -0,0 +1,37 @@
+# Forwarding gateway
+
+`Forward` is a reverse proxy in front of the Python proxy on loopback. Python must see each request as if the client had connected to it directly, because every Python route (inference, MCP, pass-through, UI, WebSocket) is served behind it without Rust knowing which one it is
+
+- Serve HTTP/1.1 only, as uvicorn does. The supervisor never puts Rust in front of a Python server that speaks HTTP/2
+- Never branch on a route or path. A behavior one endpoint needs is a protocol behavior every endpoint gets
+- Forward the method, request target, headers, and body byte for byte within the [URL contract](FORWARDING.md#url-fragments-and-literal-hashes). An unescaped `#` starts a fragment and is omitted with everything after it, while `%23` is preserved. Only drop hop-by-hop headers (RFC 9110 section 7.6.1) and set `x-litellm-rust-peer`, which replaces any client copy
+- Stream both bodies. Never buffer, cap, or time out a body, since uploads and provider streams have no upper bound
+- A client disconnect closes the upstream connection at every stage, because that is how Python learns to stop a provider stream
+- An upstream that dies mid-response fails the client body. Never end it cleanly, or SSE clients treat a truncated stream as complete
+- Never invent a Host. Python builds absolute URLs from it, so a missing one must never become the private upstream address
+- A request Python could not have received unchanged (HTTP/1.1 without Host, a transfer coding other than chunked, a target without a path) gets the status uvicorn would have given, from Rust
+- Pooled upstream connections are retired well before Python's keep-alive timeout, so a request never lands on a connection Python is closing
+- Idle client connections close after Python's keep-alive timeout, as uvicorn closed them, so a client that vanishes cannot hold a socket forever. Only the wait for the next request is timed, never an exchange in progress
+- WebSocket is the one protocol switch Python serves. Upgrades are relayed as raw bytes after the 101 and drained on shutdown
+
+## Layout
+
+Every forwarding decision has one home. `rewrite.rs` owns what Python and the client receive, as pure functions over requests and responses. `Rejection` in `error.rs` is the only place that picks a status for a request Rust answers itself. `settings.rs` parses the environment into typed values, so an `Upstream` is a loopback origin by construction. `server.rs` accepts client connections and owns their timeouts, since `axum::serve` exposes none, and `connections.rs` tracks every connection and relay for shutdown. `forward.rs` and `websocket.rs` only move requests, bodies and upgraded connections and never touch a header themselves. `connector.rs` keeps hyper reading Python's answer after Python stops reading the request
+
+## Environment
+
+`Settings` reads only these variables, and only when `LITELLM_FORWARD_UPSTREAM` is set. The Python supervisor in `litellm/proxy/rust_forward/` sets every one of them from the values Python actually runs with, so all of them are required and a missing or invalid one fails startup naming it. The crate never reads Python's own settings (`HOST`, `PORT`, `KEEPALIVE_TIMEOUT`, `GRACEFUL_SHUTDOWN_TIMEOUT`) and keeps no copy of Python's defaults, since Python can get those values from CLI flags the environment never shows
+
+| Variable | Meaning |
+|---|---|
+| `LITELLM_FORWARD_UPSTREAM` | Python's loopback origin, e.g. `http://127.0.0.1:53211` |
+| `LITELLM_FORWARD_HOST` | public bind host |
+| `LITELLM_FORWARD_PORT` | public bind port |
+| `LITELLM_FORWARD_UPSTREAM_KEEPALIVE` | seconds uvicorn keeps an idle connection open. Pooled connections are retired at half of it, and idle client connections are closed after it |
+| `LITELLM_FORWARD_SHUTDOWN_DRAIN` | seconds to drain on shutdown, already including Python's graceful timeout and teardown headroom |
+
+## Tests
+
+Test through real sockets with `tests/support`, never `oneshot`. `Gateway` runs the forwarder on a listener like production, `recording` is a well-behaved upstream that captures what Python would receive, and `raw_upstream` and `Raw` drive bytes by hand for framing, timing and disconnects a real server would not produce. Waits measured in minutes use paused tokio time instead of real sleeps
+
+The forwarder sees HTTP/1.1 messages, not routes, so the tests are split into one file per traffic shape uvicorn can emit: `exchange` (plain request and response), `framing` (requests whose framing a parser could read two ways), `slow_head` (minutes before response headers), `stream` (incremental response bodies), `idle_stream` (long-lived streams that go quiet), `upload`, `download`, `websocket`, `client_identity`, `lifecycle` (disconnects, pooling, idle timeouts, shutdown) and `roundtrip` (the forwarding invariant for generated messages). A new route never gets its own test. Add its behavior to the shape it already has, and when it needs a wire behavior none of the files covers, add a new shape file for it
diff --git a/litellm-rust/crates/gateway-forward/Cargo.toml b/litellm-rust/crates/gateway-forward/Cargo.toml
new file mode 100644
index 00000000000..2fef19dca34
--- /dev/null
+++ b/litellm-rust/crates/gateway-forward/Cargo.toml
@@ -0,0 +1,30 @@
+[package]
+name = "litellm-gateway-forward"
+version = "0.1.0"
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+axum.workspace = true
+envy = "0.4.2"
+hyper = { version = "1.12.0", features = ["http1", "server"] }
+hyper-util = { workspace = true, features = ["client-legacy", "http1", "tokio"] }
+serde.workspace = true
+thiserror.workspace = true
+tokio = { workspace = true, features = ["io-util"] }
+tokio-util = { version = "0.7", features = ["rt"] }
+tower-service = "0.3.3"
+tracing.workspace = true
+
+[dev-dependencies]
+bytes.workspace = true
+futures-util.workspace = true
+http-body-util = "0.1"
+httparse = "1"
+hyper = { version = "1.12.0", features = ["client"] }
+proptest.workspace = true
+rstest.workspace = true
+tokio = { workspace = true, features = ["io-util", "sync", "test-util"] }
+tokio-tungstenite.workspace = true
diff --git a/litellm-rust/crates/gateway-forward/FORWARDING.md b/litellm-rust/crates/gateway-forward/FORWARDING.md
new file mode 100644
index 00000000000..532f341aba5
--- /dev/null
+++ b/litellm-rust/crates/gateway-forward/FORWARDING.md
@@ -0,0 +1,31 @@
+# Python forwarding compatibility
+
+The opt-in forwarding gateway serves HTTP/1.1 and WebSockets in front of Python. Python still owns every route and policy. Its protocol contract and environment settings are in [AGENTS.md](AGENTS.md)
+
+## URL fragments and literal hashes
+
+The gateway follows Hyper's `http::Uri` parsing. If a raw client sends an unescaped `#` in the request target, only the path and query before the first `#` reach Python. For example, `/v1/files?purpose=batch#retry&limit=1` becomes `/v1/files?purpose=batch`, and `/gemini/v1beta/files/a#b?key=k` becomes `/gemini/v1beta/files/a`
+
+This is accepted behavior because `#` starts a URL fragment, which is separated before dereferencing ([RFC 3986 section 3.5](https://www.rfc-editor.org/rfc/rfc3986.html#section-3.5)). HTTP origin-form contains only the path and optional query, not a fragment ([RFC 9112 section 3.2.1](https://www.rfc-editor.org/rfc/rfc9112.html#section-3.2.1)). Browsers and URL-based clients omit the fragment before sending the request, so UI hash routing is unaffected
+
+A literal hash in path or query data must be encoded as `%23`, which the gateway forwards unchanged. Direct Python with h11 also accepts unescaped hashes as data, but that permissive raw-target behavior is outside this URL contract. Active socket tests cover both [fragment omission](tests/exchange.rs) and [encoded-hash preservation](tests/exchange.rs)
+
+## Known differences from uvicorn with h11
+
+These cases remain executable as ignored socket tests. They are not passing parity checks
+
+| Input | Direct Python | Through Rust | Regression test |
+|---|---|---|---|
+| Header after a removed hop-by-hop field | Keeps the remaining header order | `HeaderMap::remove` can move a later header | [dropping_a_hop_by_hop_header_keeps_the_others_in_order](tests/exchange.rs) |
+| Repeated header names interleaved with other names | Keeps the interleaving | Groups values by name, keeping their order within that name | [interleaved_repeated_headers_reach_python_interleaved](tests/exchange.rs) |
+| Obsolete folded header, such as `x-a: b\r\n c` | Unfolds to `b c` | Rejects with 400 | [a_folded_header_reaches_python_unfolded](tests/exchange.rs) |
+| Identical Content-Length list, such as `7, 7` | Accepts length 7 | Rejects with 400 | [a_content_length_list_of_one_value_reaches_python](tests/framing.rs) |
+| HTTP/1.0 request with chunked transfer coding | Decodes the body | Rejects with 400 | [an_http10_chunked_upload_reaches_python](tests/framing.rs) |
+
+Clients can avoid the parser rejections by sending unfolded headers, one Content-Length value, and HTTP/1.1 for chunked uploads. Integrations must not depend on header ordering across different field names
+
+Run `cargo test -p litellm-gateway-forward --test exchange --test framing -- --ignored` from this directory to reproduce the differences
+
+## Connection header handling
+
+The gateway strips standard hop-by-hop fields and fields named by Connection, except routing, representation, credential, forwarding, and WebSocket handshake headers. A client cannot nominate those end-to-end fields for removal. The private `x-litellm-rust-peer` always replaces any client-supplied copy
diff --git a/litellm-rust/crates/gateway-forward/src/connections.rs b/litellm-rust/crates/gateway-forward/src/connections.rs
new file mode 100644
index 00000000000..10a4867fa51
--- /dev/null
+++ b/litellm-rust/crates/gateway-forward/src/connections.rs
@@ -0,0 +1,58 @@
+use tokio_util::{
+ sync::{CancellationToken, DropGuard},
+ task::TaskTracker,
+};
+
+/// Every client connection and WebSocket relay the gateway runs, so shutdown can wait for them
+/// and then close whatever outlives the drain.
+#[derive(Clone, Default)]
+pub(crate) struct Connections {
+ tasks: TaskTracker,
+ draining: CancellationToken,
+ closed: CancellationToken,
+}
+
+impl Connections {
+ /// Runs `work` until it finishes or the gateway closes every connection.
+ pub fn spawn(&self, work: impl Future