diff --git a/.github/PULL_REQUEST_TEMPLATE/general.md b/.github/PULL_REQUEST_TEMPLATE/general.md
index 4f1a18e925e..d766897ab3c 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
diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml
index 94152aeb7a0..f1456b1fde8 100644
--- a/.github/workflows/test-unit.yml
+++ b/.github/workflows/test-unit.yml
@@ -419,6 +419,7 @@ jobs:
- shard: router
test-path: >-
+ tests/unit/router_rust
tests/unit/test_router
python-version: "3.12"
workers: 4
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/enterprise/litellm_enterprise/types/enterprise_callbacks/send_emails.py b/enterprise/litellm_enterprise/types/enterprise_callbacks/send_emails.py
index a2322d7f650..bb3b712d50b 100644
--- a/enterprise/litellm_enterprise/types/enterprise_callbacks/send_emails.py
+++ b/enterprise/litellm_enterprise/types/enterprise_callbacks/send_emails.py
@@ -1,9 +1,9 @@
import enum
-from typing import Dict, List, Optional
from pydantic import Field
from litellm.proxy._types import WebhookEvent
+from litellm.types.integrations.model_deprecation_email import MODEL_DEPRECATION_EMAIL_EVENT
from litellm.types.llms.base import LiteLLMBaseModel
@@ -26,7 +26,7 @@ class SendKeyCreatedEmailEvent(WebhookEvent):
class SendKeyRotatedEmailEvent(WebhookEvent):
virtual_key: str
- key_alias: Optional[str] = None
+ key_alias: str | None = None
"""
The virtual key that was rotated
this will be sk-123xxx, since we will be emailing this to the user to start using the new key
@@ -34,23 +34,33 @@ class SendKeyRotatedEmailEvent(WebhookEvent):
class EmailEvent(str, enum.Enum):
+ model_deprecations = MODEL_DEPRECATION_EMAIL_EVENT
virtual_key_created = "Virtual Key Created"
new_user_invitation = "New User Invitation"
virtual_key_rotated = "Virtual Key Rotated"
soft_budget_crossed = "Soft Budget Crossed"
max_budget_alert = "Max Budget Alert"
+
class EmailEventSettings(LiteLLMBaseModel):
event: EmailEvent
enabled: bool
+
+
class EmailEventSettingsUpdateRequest(LiteLLMBaseModel):
- settings: List[EmailEventSettings]
+ settings: list[EmailEventSettings]
+
+
class EmailEventSettingsResponse(LiteLLMBaseModel):
- settings: List[EmailEventSettings]
+ settings: list[EmailEventSettings]
+
+
class DefaultEmailSettings(LiteLLMBaseModel):
"""Default settings for email events"""
- settings: Dict[EmailEvent, bool] = Field(
+
+ settings: dict[EmailEvent, bool] = Field(
default_factory=lambda: {
+ EmailEvent.model_deprecations: False, # Explicit opt-in for owner retirement reminders.
EmailEvent.virtual_key_created: True, # On by default
EmailEvent.new_user_invitation: True, # On by default
EmailEvent.virtual_key_rotated: True, # On by default
@@ -58,10 +68,12 @@ class DefaultEmailSettings(LiteLLMBaseModel):
EmailEvent.max_budget_alert: True, # On by default
}
)
- def to_dict(self) -> Dict[str, bool]:
+
+ def to_dict(self) -> dict[str, bool]:
"""Convert to dictionary with string keys for storage"""
return {event.value: enabled for event, enabled in self.settings.items()}
+
@classmethod
- def get_defaults(cls) -> Dict[str, bool]:
+ def get_defaults(cls) -> dict[str, bool]:
"""Get the default settings as a dictionary with string keys"""
- return cls().to_dict()
\ No newline at end of file
+ return cls().to_dict()
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261103010000_spendlogs_accounting_delivery/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261103010000_spendlogs_accounting_delivery/migration.sql
new file mode 100644
index 00000000000..b00cf892b68
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261103010000_spendlogs_accounting_delivery/migration.sql
@@ -0,0 +1,19 @@
+SET LOCAL lock_timeout = '10s';
+CREATE TABLE IF NOT EXISTS "LiteLLM_SpendLogDelivery" (
+ "delivery_id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
+ "request_id" TEXT NOT NULL,
+ "state" "AccountingDeliveryState" NOT NULL DEFAULT 'pending',
+ "attempts" INTEGER NOT NULL DEFAULT 0,
+ "next_attempt_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "applied_at" TIMESTAMP(3),
+ "last_error" TEXT,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "accounting_payload" JSONB NOT NULL,
+ "is_batch" BOOLEAN NOT NULL
+);
+CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogDelivery_state_next_attempt_at_idx"
+ ON "LiteLLM_SpendLogDelivery" ("state", "next_attempt_at");
+CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogDelivery_state_applied_at_idx"
+ ON "LiteLLM_SpendLogDelivery" ("state", "applied_at");
+CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogDelivery_request_id_idx"
+ ON "LiteLLM_SpendLogDelivery" ("request_id");
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261104010000_drop_accounting_event_tables/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261104010000_drop_accounting_event_tables/migration.sql
new file mode 100644
index 00000000000..cba6a18368f
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261104010000_drop_accounting_event_tables/migration.sql
@@ -0,0 +1,5 @@
+SET LOCAL lock_timeout = '10s';
+DROP TABLE IF EXISTS "LiteLLM_AccountingDelivery";
+DROP TABLE IF EXISTS "LiteLLM_AccountingEvent";
+DROP TYPE IF EXISTS "AccountingDeliveryTarget";
+DROP TYPE IF EXISTS "AccountingEventKind";
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 c007b047087..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
@@ -2071,74 +2130,27 @@ model LiteLLM_TelemetryReport {
@@index([created_at])
}
-enum AccountingEventKind {
- inbound_usage
- outbound_usage
- cost
-}
-
enum AccountingDeliveryState {
pending
applied
parked
}
-enum AccountingDeliveryTarget {
- LiteLLM_VerificationToken
- LiteLLM_UserTable
- LiteLLM_TeamTable
- LiteLLM_TeamMembership
- LiteLLM_OrganizationTable
- LiteLLM_OrganizationMembership
- LiteLLM_EndUserTable
- LiteLLM_AgentsTable
- LiteLLM_TagTable
- LiteLLM_ProjectTable
- LiteLLM_ModelAccessGroupBudgetTable
- LiteLLM_BudgetTable
- LiteLLM_BudgetWindowSpend
- LiteLLM_DailyUserSpend
- LiteLLM_DailyTeamSpend
- LiteLLM_DailyOrganizationSpend
- LiteLLM_DailyEndUserSpend
- LiteLLM_DailyAgentSpend
- LiteLLM_DailyTagSpend
- LiteLLM_DailyModelUsage
- LiteLLM_DailyToolSpend
- LiteLLM_AutoRouterDailySpend
-}
+model LiteLLM_SpendLogDelivery {
+ delivery_id String @id @default(dbgenerated("(gen_random_uuid())::text"))
+ request_id String
+ state AccountingDeliveryState @default(pending)
+ accounting_payload Json
+ is_batch Boolean
+ attempts Int @default(0)
+ next_attempt_at DateTime @default(now())
+ applied_at DateTime?
+ last_error String?
+ created_at DateTime @default(now())
-// Durable accounting ledger. One row per usage or cost event; payload holds the
-// private event envelope versioned by schema_version. Usage rows retain request-scoped
-// pricing context; cost rows include resolved rates and the finalized pricing snapshot.
-model LiteLLM_AccountingEvent {
- event_id String @id
- kind AccountingEventKind
- source_event_id String?
- schema_version Int
- payload_hash String
- occurred_at DateTime
- recorded_at DateTime @default(now())
- payload Json
-
- @@index([recorded_at])
- @@index([kind, source_event_id])
-}
-
-// One row per (event, target table, version): pending work for the projector and,
-// once applied in the same transaction as the target write, the receipt that stops a retry.
-model LiteLLM_AccountingDelivery {
- event_id String
- target AccountingDeliveryTarget
- version Int
- state AccountingDeliveryState @default(pending)
- attempts Int @default(0)
- next_attempt_at DateTime @default(now())
- applied_at DateTime?
- last_error String?
-
- @@id([event_id, target, version])
- @@index([target, state, next_attempt_at])
+ @@index([state, next_attempt_at])
+ @@index([state, applied_at])
+ @@index([request_id])
}
model LiteLLM_SubscriptionAccountTable {
diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock
index ba1131d7a92..23ebfc7d3cf 100644
--- a/litellm-rust/Cargo.lock
+++ b/litellm-rust/Cargo.lock
@@ -4288,6 +4288,7 @@ dependencies = [
name = "litellm-python-bridge"
version = "0.1.0"
dependencies = [
+ "async-trait",
"aws-sdk-secretsmanager",
"bytes",
"criterion",
@@ -4312,6 +4313,7 @@ dependencies = [
"litellm-llms",
"litellm-llms-types",
"litellm-model-catalog",
+ "litellm-router-core",
"litellm-router-types",
"litellm-secret-value",
"litellm-secrets",
@@ -4393,6 +4395,24 @@ dependencies = [
[[package]]
name = "litellm-router-core"
version = "0.1.0"
+dependencies = [
+ "arc-swap",
+ "async-trait",
+ "jiff",
+ "litellm-cache",
+ "litellm-router-types",
+ "parking_lot",
+ "rand 0.8.7",
+ "rstest",
+ "serde",
+ "serde_json",
+ "serde_with",
+ "sha2 0.10.9",
+ "strum",
+ "thiserror 2.0.19",
+ "tokio",
+ "tracing",
+]
[[package]]
name = "litellm-router-types"
diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml
index 943e8576ae6..71914a0bffe 100644
--- a/litellm-rust/Cargo.toml
+++ b/litellm-rust/Cargo.toml
@@ -13,6 +13,7 @@ litellm-config = { path = "crates/config" }
litellm-error = { path = "crates/error" }
litellm-router = { path = "crates/router" }
litellm-router-types = { path = "crates/router-types" }
+litellm-router-core = { path = "crates/router-core" }
litellm-tracing = { path = "crates/tracing" }
litellm-spend-clickhouse = { path = "crates/spend-clickhouse" }
litellm-storage-clickhouse = { path = "crates/storage-clickhouse" }
@@ -76,6 +77,8 @@ litellm-cost = { path = "crates/cost" }
litellm-model-catalog = { path = "crates/model-catalog" }
litellm-model-catalog-refresh = { path = "crates/model-catalog-refresh" }
+arc-swap = "1.9.2"
+async-trait = "0.1.91"
askama = { version = "0.16.1", default-features = false, features = ["derive", "std"] }
tracing = "0.1"
axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio", "multipart"] }
diff --git a/litellm-rust/crates/python-bridge/Cargo.toml b/litellm-rust/crates/python-bridge/Cargo.toml
index fd826f549e5..ba892199dcf 100644
--- a/litellm-rust/crates/python-bridge/Cargo.toml
+++ b/litellm-rust/crates/python-bridge/Cargo.toml
@@ -25,6 +25,7 @@ litellm-tracing.workspace = true
litellm-spend-clickhouse.workspace = true
litellm-storage-clickhouse.workspace = true
litellm-host.workspace = true
+async-trait.workspace = true
bytes.workspace = true
futures-util.workspace = true
litellm-cache.workspace = true
@@ -47,6 +48,7 @@ litellm-secrets = { workspace = true, features = ["aws", "azure", "google", "has
litellm-secrets-types.workspace = true
litellm-llms-types.workspace = true
litellm-router-types.workspace = true
+litellm-router-core.workspace = true
litellm-host-python.workspace = true
litellm-model-catalog.workspace = true
litellm-token-counter = { path = "../token-counter", default-features = false }
diff --git a/litellm-rust/crates/python-bridge/src/lib.rs b/litellm-rust/crates/python-bridge/src/lib.rs
index de35419308a..f4a4f7c2b26 100644
--- a/litellm-rust/crates/python-bridge/src/lib.rs
+++ b/litellm-rust/crates/python-bridge/src/lib.rs
@@ -12,6 +12,7 @@ mod marshal;
mod model_registry;
mod preflight;
mod python_settings;
+mod router;
mod routes;
mod secrets;
mod tokenizer;
@@ -36,6 +37,8 @@ mod _native {
#[pymodule_export]
use crate::model_registry::bundled_model_cost_map;
#[pymodule_export]
+ use crate::router::{RouterCall, RouterCoreHandle};
+ #[pymodule_export]
use crate::routes::audio_transcription::{atranscription, transcription};
#[pymodule_export]
use crate::routes::chat_completions::{acompletion, completion};
@@ -115,6 +118,8 @@ mod tests {
"bundled_model_cost_map",
"TokenCounter",
"Tokenizer",
+ "RouterCoreHandle",
+ "RouterCall",
"gil_stats",
"process_state_started",
"reserve_process_for_forking",
diff --git a/litellm-rust/crates/python-bridge/src/router/config.rs b/litellm-rust/crates/python-bridge/src/router/config.rs
new file mode 100644
index 00000000000..de383a0ffa1
--- /dev/null
+++ b/litellm-rust/crates/python-bridge/src/router/config.rs
@@ -0,0 +1,412 @@
+//! The documents `RouterCoreHandle` reads: the router's static and dynamic config, a call's
+//! request and an `apply` change set. A config key router-core does not read declines the
+//! handle, so Python keeps the router on its own core.
+
+use std::collections::BTreeMap;
+
+use litellm_router_core::select::PassThrough;
+use litellm_router_core::{
+ ChangeSet, ConfigError, DeploymentSpec, OutcomeCallbacks, RouterConfig, RouterDynamicConfig,
+ RouterRequest,
+ config::{Fallbacks, RetryPolicy},
+ host::UncountedRetries,
+ mock::MockFailure,
+ registry::IndexOverrides,
+};
+use serde::{Deserialize, de::DeserializeOwned};
+use serde_json::{Map, Value};
+
+/// The `RouterConfig` keys router-core reads.
+const STATIC_KEYS: &[&str] = &[
+ "model_list",
+ "routing_strategy",
+ "routing_groups",
+ "routing_group_members",
+ "plugins",
+ "cooldown_time",
+ "disable_cooldowns",
+ "allowed_fails_policy",
+ "retry_policy",
+ "model_group_retry_policy",
+ "retry_after",
+ "timeout",
+ "request_timeout",
+ "drop_params",
+ "enable_pre_call_checks",
+ "routing_plugins",
+ "callback_filters",
+ "enable_health_check_routing",
+ "health_check_staleness_threshold",
+ "background_health_check_model_groups",
+ "enable_weighted_failover",
+ "fallback_on_unsupported_params",
+ "provider_default_deployment_ids",
+];
+
+/// The `RouterDynamicConfig` keys router-core reads.
+const DYNAMIC_KEYS: &[&str] = &[
+ "version",
+ "model_alias_map",
+ "model_group_alias",
+ "fallbacks",
+ "default_fallbacks",
+ "context_window_fallbacks",
+ "content_policy_fallbacks",
+ "num_retries",
+ "max_fallbacks",
+ "allowed_fails",
+ "allowed_fails_is_default",
+ "fallback_keys",
+ "default_deployment",
+ "default_deployment_raises",
+];
+
+/// Router arguments that do not change routing, accepted in either config and dropped.
+const IGNORED_KEYS: &[&str] = &[
+ "set_verbose",
+ "debug_level",
+ "alerting_config",
+ "client_ttl",
+];
+
+/// Fallback lists Python treats as unset when empty (`context_window_fallbacks or None`).
+const EMPTY_MEANS_UNSET: &[&str] = &["context_window_fallbacks", "content_policy_fallbacks"];
+
+/// Which document a failure is about.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::Display)]
+pub(crate) enum Document {
+ #[strum(serialize = "router config")]
+ Static,
+ #[strum(serialize = "dynamic router config")]
+ Dynamic,
+ #[strum(serialize = "router request")]
+ Request,
+ #[strum(serialize = "change set")]
+ Changes,
+}
+
+#[derive(Debug, thiserror::Error)]
+pub(crate) enum Error {
+ #[error("the {document} must be a JSON object")]
+ NotObject { document: Document },
+ #[error("router-core does not read `{key}` in the {document}")]
+ UnknownKey { document: Document, key: String },
+ #[error("the {document} does not have the shape router-core reads")]
+ Shape {
+ document: Document,
+ #[source]
+ source: serde_json::Error,
+ },
+ #[error("the request names no model")]
+ MissingModel,
+ #[error("router-core rejected the router config")]
+ Core(#[from] ConfigError),
+}
+
+impl Error {
+ /// Whether router-core cannot serve this config, so Python keeps its own core.
+ pub(crate) fn declined(&self) -> bool {
+ match self {
+ Self::UnknownKey { .. } | Self::Core(_) => true,
+ Self::Shape { document, .. } => {
+ matches!(document, Document::Static | Document::Dynamic)
+ }
+ Self::NotObject { .. } | Self::MissingModel => false,
+ }
+ }
+}
+
+fn object(document: Document, value: Value) -> Result