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/osv-scan.yml b/.github/workflows/osv-scan.yml
index 1c7e8b2841a..5016957bd6f 100644
--- a/.github/workflows/osv-scan.yml
+++ b/.github/workflows/osv-scan.yml
@@ -5,6 +5,9 @@ on:
branches:
- main
- "litellm_**"
+ - "rc/**"
+ - "stable/**"
+ - "lts/**"
schedule:
- cron: "23 6 * * *"
workflow_dispatch:
diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml
index 0a490bb8f45..f1456b1fde8 100644
--- a/.github/workflows/test-unit.yml
+++ b/.github/workflows/test-unit.yml
@@ -5,6 +5,9 @@ on:
branches:
- main
- "litellm_**"
+ - "rc/**"
+ - "stable/**"
+ - "lts/**"
push:
branches:
- main
@@ -280,6 +283,7 @@ jobs:
tests/unit/llms/e2b
tests/unit/llms/edenai
tests/unit/llms/elevenlabs
+ tests/unit/llms/empiriolabs
tests/unit/llms/exa_ai
tests/unit/llms/fal_ai
tests/unit/llms/fastcrw
@@ -415,6 +419,7 @@ jobs:
- shard: router
test-path: >-
+ tests/unit/router_rust
tests/unit/test_router
python-version: "3.12"
workers: 4
@@ -973,10 +978,10 @@ jobs:
dist: loadscope
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- timeout-minutes: ${{ matrix.shard == 'db-and-spend' && 10 || 3 }}
+ timeout-minutes: 3
with:
persist-credentials: false
- fetch-depth: ${{ matrix.shard == 'db-and-spend' && '0' || '1' }}
+ fetch-depth: 1
- name: Detect relevant changes
id: changes
@@ -1009,13 +1014,11 @@ jobs:
with:
artifact: ${{ needs.rust-bridge.outputs.artifact }}
- - name: Install dependencies and check schema
+ - name: Install dependencies
if: steps.changes.outputs.decision != 'skip'
- timeout-minutes: 8
+ timeout-minutes: 5
env:
RUST_BRIDGE_ARTIFACT: ${{ needs.rust-bridge.outputs.artifact }}
- UNIT_SHARD: ${{ matrix.shard }}
- SCHEMA_BASE_REF: origin/${{ github.event.repository.default_branch }}
run: |
diff -u model_prices_and_context_window.json litellm/model_prices_and_context_window_backup.json
if [ -z "$RUST_BRIDGE_ARTIFACT" ]; then
@@ -1027,9 +1030,21 @@ jobs:
uv run --no-sync python -c "import importlib.metadata; import litellm.rust_bridge._native; print(importlib.metadata.version('litellm'))"
fi
uv run --no-sync python -c 'import os, sys; print(sys.version); assert f"{sys.version_info.major}.{sys.version_info.minor}" == os.environ["UV_PYTHON"]'
- if [ "$UNIT_SHARD" = "db-and-spend" ]; then
- uv run --no-sync python scripts/check_schema_compatibility.py --base-ref "$SCHEMA_BASE_REF"
- fi
+
+ - name: Check schema.prisma against the merge base
+ if: steps.changes.outputs.decision != 'skip' && matrix.shard == 'db-and-spend' && github.event_name == 'pull_request'
+ timeout-minutes: 3
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: |
+ base_ref=$(gh api "repos/${GITHUB_REPOSITORY}/branches/${EVENT_BASE_REF}" --jq .name) || base_ref=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq .base.ref)
+ merge_base=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${base_ref}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha')
+ test -n "$merge_base"
+ git fetch --no-tags --depth=1 origin "$merge_base"
+ uv run --no-sync python scripts/check_schema_compatibility.py --base-commit "$merge_base"
- name: Cache Prisma binaries
if: steps.changes.outputs.decision != 'skip'
diff --git a/README.md b/README.md
index c4aeee80fb4..31a93bf8c67 100644
--- a/README.md
+++ b/README.md
@@ -398,6 +398,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th
| [Deepseek (`deepseek`)](https://docs.litellm.ai/docs/providers/deepseek) | ✅ | ✅ | ✅ | | | | | | | |
| [Eden AI (`edenai`)](https://docs.litellm.ai/docs/providers/edenai) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | |
| [ElevenLabs (`elevenlabs`)](https://docs.litellm.ai/docs/providers/elevenlabs) | ✅ | ✅ | ✅ | | | ✅ | ✅ | | | |
+| [EmpirioLabs AI (`empiriolabs`)](https://docs.litellm.ai/docs/providers/empiriolabs) | ✅ | | ✅ | | | | | | | |
| [Empower (`empower`)](https://docs.litellm.ai/docs/providers/empower) | ✅ | ✅ | ✅ | | | | | | | |
| [eu/jev Decisions API (`eu_jev`)](https://docs.litellm.ai/docs/pass_through/eu_jev) | | | | | | | | | | |
| [Fal AI (`fal_ai`)](https://docs.litellm.ai/docs/providers/fal_ai) | ✅ | ✅ | ✅ | | ✅ | | | | | |
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/proxy/common_utils/check_responses_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py
index cfe4ca98145..69e36a0c076 100644
--- a/enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py
+++ b/enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py
@@ -200,7 +200,6 @@ async def log_billed_background_response(billed: BilledBackgroundResponse) -> Bi
unpriced: Final = _without_read_price(billed.response)
cost: Final = logging_obj.response_cost_calculator(result=unpriced)
end_time: Final = datetime.now()
- logging_obj.capture_usage_before_logging(unpriced, logging_obj.start_time, end_time)
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(
async_coroutine=logging_obj.async_success_handler(
result=unpriced, start_time=logging_obj.start_time, end_time=end_time
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/helm/litellm-helm/Chart.yaml b/helm/litellm-helm/Chart.yaml
index a3cb388ffc6..059889cb667 100644
--- a/helm/litellm-helm/Chart.yaml
+++ b/helm/litellm-helm/Chart.yaml
@@ -18,7 +18,7 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
-version: 1.1.3
+version: 1.1.4
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
diff --git a/helm/litellm-helm/README.md b/helm/litellm-helm/README.md
index 694cca05b52..bc6e29632f1 100644
--- a/helm/litellm-helm/README.md
+++ b/helm/litellm-helm/README.md
@@ -56,8 +56,8 @@ If `db.useStackgresOperator` is used (not yet implemented):
| `billingMetrics.enabled` | Enable enterprise billable-request metering. Requires an enterprise license. | `false` |
| `billingMetrics.endpoint` | Collector that the billable-request counter is pushed to. | `https://telemetry.litellm.ai` |
-| `billingMetrics.secretName` | Name of an existing Secret holding the mTLS client certificate, under the keys `tls.crt` and `tls.key`. | `litellm-billing-metrics-mtls` |
-| `billingMetrics.caSecretName` | Name of an existing Secret holding a CA bundle under the key `ca.crt`. Only needed for a private or test collector whose server certificate is not on the public web PKI. | `""` |
+| `billingMetrics.tokenUrl` | Cognito OAuth2 client-credentials token endpoint. | `https://litellm-prod.auth.us-west-1.amazoncognito.com/oauth2/token` |
+| `billingMetrics.secretName` | Name of an existing Secret with `client-id` and `client-secret` keys. | `litellm-billing-metrics` |
| `billingMetrics.exportIntervalMs` | How often the counter is pushed, in milliseconds. The proxy defaults to `60000` when unset. | `""` |
#### Example `proxy_config` ConfigMap from values (default):
@@ -109,10 +109,10 @@ type: Opaque
#### Enterprise billable-request metering
-Enterprise licenses meter billable requests by pushing a counter to LiteLLM's collector over mutual TLS. The chart does not create the client certificate; it mounts one you already hold, read-only, so the private key is never exposed through the environment. Create the Secret under the name the chart expects, then turn the block on:
+Enterprise licenses meter billable requests by pushing a counter to LiteLLM's collector with Cognito bearer authentication. Create a Secret with `client-id` and `client-secret` keys, then enable the chart values:
```
-kubectl create secret tls litellm-billing-metrics-mtls --cert=client.crt --key=client.key
+kubectl create secret generic litellm-billing-metrics --from-literal=client-id=... --from-literal=client-secret=...
```
```
@@ -120,7 +120,8 @@ billingMetrics:
enabled: true
```
-Set `billingMetrics.caSecretName` only when the collector is a private or test one whose server certificate is not on the public web PKI; the production collector needs no CA override. The chart fails the render rather than deploying a proxy that silently never exports, so a missing `secretName` or an emptied `endpoint` surfaces at `helm install` time.
+The endpoint and token URL must use HTTPS. The chart requires both URLs and
+the Secret name when billing metrics are enabled
### Database Settings
diff --git a/helm/litellm-helm/templates/_helpers.tpl b/helm/litellm-helm/templates/_helpers.tpl
index 929fc415163..230d2acaeef 100644
--- a/helm/litellm-helm/templates/_helpers.tpl
+++ b/helm/litellm-helm/templates/_helpers.tpl
@@ -50,53 +50,27 @@ app.kubernetes.io/name: {{ include "litellm.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
-{{/*
-Enterprise billable-request metering. The client certificate identifies the
-deployment to LiteLLM's collector, so it is mounted read-only from an existing
-Secret rather than passed through the environment.
-*/}}
-{{- define "litellm.billingMetrics.certDir" -}}/etc/litellm/billing-mtls{{- end -}}
-{{- define "litellm.billingMetrics.caDir" -}}/etc/litellm/billing-mtls-ca{{- end -}}
-
{{- define "litellm.billingMetricsEnv" -}}
- name: LITELLM_BILLING_METRICS_ENDPOINT
value: {{ required "billingMetrics.endpoint is required when billingMetrics.enabled is true" .Values.billingMetrics.endpoint | quote }}
-- name: LITELLM_BILLING_METRICS_CLIENT_CERT
- value: {{ printf "%s/tls.crt" (include "litellm.billingMetrics.certDir" .) | quote }}
-- name: LITELLM_BILLING_METRICS_CLIENT_KEY
- value: {{ printf "%s/tls.key" (include "litellm.billingMetrics.certDir" .) | quote }}
-{{- if .Values.billingMetrics.caSecretName }}
-- name: LITELLM_BILLING_METRICS_CA_CERT
- value: {{ printf "%s/ca.crt" (include "litellm.billingMetrics.caDir" .) | quote }}
-{{- end }}
+- name: LITELLM_BILLING_METRICS_TOKEN_URL
+ value: {{ required "billingMetrics.tokenUrl is required when billingMetrics.enabled is true" .Values.billingMetrics.tokenUrl | quote }}
+- name: LITELLM_BILLING_METRICS_CLIENT_ID
+ valueFrom:
+ secretKeyRef:
+ name: {{ required "billingMetrics.secretName is required when billingMetrics.enabled is true" .Values.billingMetrics.secretName }}
+ key: client-id
+- name: LITELLM_BILLING_METRICS_CLIENT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ required "billingMetrics.secretName is required when billingMetrics.enabled is true" .Values.billingMetrics.secretName }}
+ key: client-secret
{{- with .Values.billingMetrics.exportIntervalMs }}
- name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS
value: {{ . | quote }}
{{- end }}
{{- end -}}
-{{- define "litellm.billingMetricsVolumes" -}}
-- name: billing-metrics-mtls
- secret:
- secretName: {{ required "billingMetrics.secretName is required when billingMetrics.enabled is true (an existing Secret with tls.crt and tls.key)" .Values.billingMetrics.secretName }}
-{{- if .Values.billingMetrics.caSecretName }}
-- name: billing-metrics-mtls-ca
- secret:
- secretName: {{ .Values.billingMetrics.caSecretName }}
-{{- end }}
-{{- end -}}
-
-{{- define "litellm.billingMetricsVolumeMounts" -}}
-- name: billing-metrics-mtls
- mountPath: {{ include "litellm.billingMetrics.certDir" . }}
- readOnly: true
-{{- if .Values.billingMetrics.caSecretName }}
-- name: billing-metrics-mtls-ca
- mountPath: {{ include "litellm.billingMetrics.caDir" . }}
- readOnly: true
-{{- end }}
-{{- end -}}
-
{{/*
Create the name of the service account to use
*/}}
diff --git a/helm/litellm-helm/templates/deployment.yaml b/helm/litellm-helm/templates/deployment.yaml
index 37e7eaec9e9..a37193f0efa 100644
--- a/helm/litellm-helm/templates/deployment.yaml
+++ b/helm/litellm-helm/templates/deployment.yaml
@@ -148,9 +148,6 @@ spec:
- name: npm
mountPath: /.npm
{{- end }}
- {{- if .Values.billingMetrics.enabled }}
- {{- include "litellm.billingMetricsVolumeMounts" . | nindent 12 }}
- {{- end }}
{{- if include "litellm.collector.socketDir" . }}
- name: collector-socket
mountPath: {{ include "litellm.collector.socketDir" . }}
@@ -234,9 +231,6 @@ spec:
items:
- key: {{ .Values.proxyConfigMap.key | default "config.yaml" }}
path: "config.yaml"
- {{- if .Values.billingMetrics.enabled }}
- {{- include "litellm.billingMetricsVolumes" . | nindent 8 }}
- {{- end }}
{{- if include "litellm.collector.socketDir" . }}
- name: collector-socket
emptyDir:
diff --git a/helm/litellm-helm/tests/billing_metrics_tests.yaml b/helm/litellm-helm/tests/billing_metrics_tests.yaml
index 71803df378c..52185c8dd9d 100644
--- a/helm/litellm-helm/tests/billing_metrics_tests.yaml
+++ b/helm/litellm-helm/tests/billing_metrics_tests.yaml
@@ -1,31 +1,21 @@
-suite: test billingMetrics wiring on the proxy deployment
+suite: test bearer-only billingMetrics wiring
templates:
- deployment.yaml
- configmap-litellm.yaml
- - migrations-job.yaml
tests:
- - it: is off by default, adding no env, volume, or mount
+ - it: is disabled by default
template: deployment.yaml
asserts:
- - notContains:
- path: spec.template.spec.volumes
- content:
- name: billing-metrics-mtls
- secret:
- secretName: litellm-billing-metrics-mtls
- - notContains:
- path: spec.template.spec.containers[0].volumeMounts
- content:
- name: billing-metrics-mtls
- mountPath: /etc/litellm/billing-mtls
- readOnly: true
- notContains:
path: spec.template.spec.containers[0].env
content:
name: LITELLM_BILLING_METRICS_ENDPOINT
- value: https://telemetry.litellm.ai
+ - notContains:
+ path: spec.template.spec.volumes
+ content:
+ name: billing-metrics
- - it: renders the endpoint and the mounted cert paths when enabled
+ - it: renders required URLs and Cognito secret references when enabled
template: deployment.yaml
set:
billingMetrics:
@@ -39,119 +29,34 @@ tests:
- contains:
path: spec.template.spec.containers[0].env
content:
- name: LITELLM_BILLING_METRICS_CLIENT_CERT
- value: /etc/litellm/billing-mtls/tls.crt
+ name: LITELLM_BILLING_METRICS_TOKEN_URL
+ value: https://litellm-prod.auth.us-west-1.amazoncognito.com/oauth2/token
- contains:
path: spec.template.spec.containers[0].env
content:
- name: LITELLM_BILLING_METRICS_CLIENT_KEY
- value: /etc/litellm/billing-mtls/tls.key
-
- # The conventional Secret name is the default, so enabling the block is enough.
- - it: mounts the default cert secret read-only alongside the config volume
- template: deployment.yaml
- set:
- billingMetrics:
- enabled: true
- asserts:
- - contains:
- path: spec.template.spec.volumes
- content:
- name: billing-metrics-mtls
- secret:
- secretName: litellm-billing-metrics-mtls
- - contains:
- path: spec.template.spec.containers[0].volumeMounts
- content:
- name: billing-metrics-mtls
- mountPath: /etc/litellm/billing-mtls
- readOnly: true
-
- - it: honours a secretName override
- template: deployment.yaml
- set:
- billingMetrics:
- enabled: true
- secretName: my-billing-mtls
- asserts:
- - contains:
- path: spec.template.spec.volumes
- content:
- name: billing-metrics-mtls
- secret:
- secretName: my-billing-mtls
- - notContains:
- path: spec.template.spec.volumes
- content:
- name: billing-metrics-mtls
- secret:
- secretName: litellm-billing-metrics-mtls
-
- - it: honours an endpoint override
- template: deployment.yaml
- set:
- billingMetrics:
- enabled: true
- endpoint: https://collector.internal:4318
- asserts:
+ name: LITELLM_BILLING_METRICS_CLIENT_ID
+ valueFrom:
+ secretKeyRef:
+ name: litellm-billing-metrics
+ key: client-id
- contains:
path: spec.template.spec.containers[0].env
content:
- name: LITELLM_BILLING_METRICS_ENDPOINT
- value: https://collector.internal:4318
-
- # The production collector presents a public web-PKI certificate, so the CA
- # override must stay absent unless a private collector is configured.
- - it: omits the CA env, volume, and mount when no caSecretName is set
- template: deployment.yaml
- set:
- billingMetrics:
- enabled: true
- asserts:
+ name: LITELLM_BILLING_METRICS_CLIENT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: litellm-billing-metrics
+ key: client-secret
- notContains:
path: spec.template.spec.volumes
content:
- name: billing-metrics-mtls-ca
- secret:
- secretName: billing-ca
+ name: billing-metrics
- notContains:
path: spec.template.spec.containers[0].volumeMounts
content:
- name: billing-metrics-mtls-ca
- mountPath: /etc/litellm/billing-mtls-ca
- readOnly: true
- - notContains:
- path: spec.template.spec.containers[0].env
- content:
- name: LITELLM_BILLING_METRICS_CA_CERT
- value: /etc/litellm/billing-mtls-ca/ca.crt
+ name: billing-metrics
- - it: mounts the CA secret when caSecretName is set
- template: deployment.yaml
- set:
- billingMetrics:
- enabled: true
- caSecretName: billing-ca
- asserts:
- - contains:
- path: spec.template.spec.containers[0].env
- content:
- name: LITELLM_BILLING_METRICS_CA_CERT
- value: /etc/litellm/billing-mtls-ca/ca.crt
- - contains:
- path: spec.template.spec.volumes
- content:
- name: billing-metrics-mtls-ca
- secret:
- secretName: billing-ca
- - contains:
- path: spec.template.spec.containers[0].volumeMounts
- content:
- name: billing-metrics-mtls-ca
- mountPath: /etc/litellm/billing-mtls-ca
- readOnly: true
-
- - it: passes the export interval through only when set
+ - it: renders the export interval only when set
template: deployment.yaml
set:
billingMetrics:
@@ -164,129 +69,7 @@ tests:
name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS
value: "5000"
- - it: omits the export interval when unset
- template: deployment.yaml
- set:
- billingMetrics:
- enabled: true
- asserts:
- - notContains:
- path: spec.template.spec.containers[0].env
- content:
- name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS
- value: "60000"
-
- # Kubernetes resolves duplicate env names last-wins, so the chart-owned billing
- # entries must render after .Values.envVars or a user could silently redirect
- # the metering export. The three billing entries are the last ones emitted here
- # (migrationJob, which appends DISABLE_SCHEMA_UPDATE, is off for this case).
- - it: renders the billing endpoint after envVars so it cannot be shadowed
- template: deployment.yaml
- set:
- migrationJob:
- enabled: false
- billingMetrics:
- enabled: true
- envVars:
- LITELLM_BILLING_METRICS_ENDPOINT: https://shadowed.example
- asserts:
- - contains:
- path: spec.template.spec.containers[0].env
- content:
- name: LITELLM_BILLING_METRICS_ENDPOINT
- value: https://shadowed.example
- - equal:
- path: spec.template.spec.containers[0].env[-3]
- value:
- name: LITELLM_BILLING_METRICS_ENDPOINT
- value: https://telemetry.litellm.ai
- - equal:
- path: spec.template.spec.containers[0].env[-2].name
- value: LITELLM_BILLING_METRICS_CLIENT_CERT
- - equal:
- path: spec.template.spec.containers[0].env[-1].name
- value: LITELLM_BILLING_METRICS_CLIENT_KEY
-
- - it: keeps user-supplied volumes and mounts alongside the billing secret
- template: deployment.yaml
- set:
- billingMetrics:
- enabled: true
- volumes:
- - name: custom-callbacks
- configMap:
- name: my-callbacks
- volumeMounts:
- - name: custom-callbacks
- mountPath: /app/callbacks
- asserts:
- - contains:
- path: spec.template.spec.volumes
- content:
- name: custom-callbacks
- configMap:
- name: my-callbacks
- - contains:
- path: spec.template.spec.volumes
- content:
- name: billing-metrics-mtls
- secret:
- secretName: litellm-billing-metrics-mtls
- - contains:
- path: spec.template.spec.containers[0].volumeMounts
- content:
- name: custom-callbacks
- mountPath: /app/callbacks
- - contains:
- path: spec.template.spec.containers[0].volumeMounts
- content:
- name: billing-metrics-mtls
- mountPath: /etc/litellm/billing-mtls
- readOnly: true
-
- - it: still mounts the proxy config when enabled
- template: deployment.yaml
- set:
- billingMetrics:
- enabled: true
- asserts:
- - contains:
- path: spec.template.spec.containers[0].volumeMounts
- content:
- name: litellm-config
- mountPath: /etc/litellm/config.yaml
- subPath: config.yaml
-
- # Only the proxy serves billable traffic. The migrations Job must never mount
- # the client certificate, and it renders its own env and volumes, so nothing
- # stops a future edit from wiring the billing include into it by mistake.
- - it: does not touch the migrations job when enabled
- template: migrations-job.yaml
- set:
- billingMetrics:
- enabled: true
- asserts:
- - notContains:
- path: spec.template.spec.containers[0].env
- content:
- name: LITELLM_BILLING_METRICS_ENDPOINT
- value: https://telemetry.litellm.ai
- - notExists:
- path: spec.template.spec.containers[0].volumeMounts
- - notExists:
- path: spec.template.spec.volumes
-
- - it: fails loudly when enabled with an emptied secretName
- template: deployment.yaml
- set:
- billingMetrics:
- enabled: true
- secretName: ""
- asserts:
- - failedTemplate:
- errorMessage: billingMetrics.secretName is required when billingMetrics.enabled is true (an existing Secret with tls.crt and tls.key)
-
- - it: fails loudly when enabled without an endpoint
+ - it: requires the endpoint when enabled
template: deployment.yaml
set:
billingMetrics:
@@ -295,3 +78,23 @@ tests:
asserts:
- failedTemplate:
errorMessage: billingMetrics.endpoint is required when billingMetrics.enabled is true
+
+ - it: requires the token URL when enabled
+ template: deployment.yaml
+ set:
+ billingMetrics:
+ enabled: true
+ tokenUrl: ""
+ asserts:
+ - failedTemplate:
+ errorMessage: billingMetrics.tokenUrl is required when billingMetrics.enabled is true
+
+ - it: requires the secret name when enabled
+ template: deployment.yaml
+ set:
+ billingMetrics:
+ enabled: true
+ secretName: ""
+ asserts:
+ - failedTemplate:
+ errorMessage: billingMetrics.secretName is required when billingMetrics.enabled is true
diff --git a/helm/litellm-helm/tests/collector_tests.yaml b/helm/litellm-helm/tests/collector_tests.yaml
index 0340b1161b7..1365290e16c 100644
--- a/helm/litellm-helm/tests/collector_tests.yaml
+++ b/helm/litellm-helm/tests/collector_tests.yaml
@@ -191,7 +191,7 @@ tests:
metricsServer.port: 9090
billingMetrics.enabled: true
billingMetrics.endpoint: https://metering.example.com
- billingMetrics.secretName: billing-mtls
+ billingMetrics.secretName: billing-metrics
asserts:
- contains:
path: spec.template.spec.containers[0].env
@@ -214,9 +214,9 @@ tests:
name: LITELLM_BILLING_METRICS_ENDPOINT
any: true
- notContains:
- path: spec.template.spec.containers[1].volumeMounts
+ path: spec.template.spec.containers[1].env
content:
- name: billing-metrics-mtls
+ name: LITELLM_BILLING_METRICS_CLIENT_SECRET
any: true
- it: should give the sidecar the same scratch mounts as the proxy on a read-only root
diff --git a/helm/litellm-helm/values.yaml b/helm/litellm-helm/values.yaml
index f51f8bdd173..9afce050261 100644
--- a/helm/litellm-helm/values.yaml
+++ b/helm/litellm-helm/values.yaml
@@ -154,19 +154,13 @@ masterkeySecretName: ""
# if set, use this secret key for the master key; otherwise, use the default key
masterkeySecretKey: ""
-# Optional: enterprise billable-request metering. When enabled, the proxy counts
-# successful requests to inference, MCP, and A2A endpoints and pushes them to
-# LiteLLM's collector over mutual TLS. Requires an enterprise license.
-# The client certificate identifies the deployment, so it is mounted read-only
-# from an existing Secret and never passed through the environment.
+# Optional: enterprise billable-request metering. Requires an enterprise license.
billingMetrics:
enabled: false
- endpoint: https://telemetry.litellm.ai # collector to push the counter to
- secretName: litellm-billing-metrics-mtls # existing Secret holding tls.crt and tls.key
- # Only for private or test collectors whose server certificate is not on the
- # public web PKI. The production collector needs no CA override.
- caSecretName: "" # existing Secret holding ca.crt
- exportIntervalMs: "" # push cadence; the proxy defaults to 60000
+ endpoint: https://telemetry.litellm.ai
+ tokenUrl: https://litellm-prod.auth.us-west-1.amazoncognito.com/oauth2/token
+ secretName: litellm-billing-metrics
+ exportIntervalMs: ""
proxyConfigMap:
# when true, creates a new configmap
diff --git a/helm/litellm/Chart.yaml b/helm/litellm/Chart.yaml
index e67f5790c7e..341172da925 100644
--- a/helm/litellm/Chart.yaml
+++ b/helm/litellm/Chart.yaml
@@ -2,7 +2,7 @@ apiVersion: v2
name: litellm
description: LiteLLM componentized — gateway, UI backend, and UI as separate services
type: application
-version: 0.1.0
+version: 0.1.1
appVersion: "0.1.0"
annotations:
org.opencontainers.image.source: "https://github.com/BerriAI/litellm"
diff --git a/helm/litellm/README.md b/helm/litellm/README.md
index 97b94754402..d66bb3284bf 100644
--- a/helm/litellm/README.md
+++ b/helm/litellm/README.md
@@ -22,6 +22,23 @@ backend:
gatewayUrl: https://gateway.example.com
```
+## Enterprise billing metrics
+
+Billing metrics use Cognito bearer authentication. Create the Secret with
+`client-id` and `client-secret` keys, then enable billing metrics:
+
+```bash
+kubectl create secret generic litellm-billing-metrics --from-literal=client-id=... --from-literal=client-secret=...
+```
+
+```yaml
+billingMetrics:
+ enabled: true
+```
+
+The chart requires an HTTPS endpoint, an HTTPS token URL, and the Secret name
+when billing metrics are enabled
+
## Routes to another Service
For a shared hostname, `ingress.extraPaths` can route to a Service in the same namespace using `serviceName` and `servicePort`. The port can be a number or a named Service port. Omit `service` when using these fields
diff --git a/helm/litellm/templates/_helpers.tpl b/helm/litellm/templates/_helpers.tpl
index 18e938564e9..b704782f5d1 100644
--- a/helm/litellm/templates/_helpers.tpl
+++ b/helm/litellm/templates/_helpers.tpl
@@ -58,56 +58,27 @@ app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
{{- end -}}
-{{/*
-Enterprise billable-request metering. Wired into gateway and backend, not the
-migrations job. The gateway serves nearly all billable traffic, but the backend
-keeps the named-server MCP transport (/{mcp_server_name}/mcp), which writes a
-SpendLogs row, so metering only the gateway would silently drop that traffic.
-The client certificate identifies the deployment to LiteLLM's collector, so it is
-mounted read-only from an existing Secret rather than passed through the
-environment.
-*/}}
-{{- define "litellm.billingMetrics.certDir" -}}/etc/litellm/billing-mtls{{- end -}}
-{{- define "litellm.billingMetrics.caDir" -}}/etc/litellm/billing-mtls-ca{{- end -}}
-
{{- define "litellm.billingMetricsEnv" -}}
- name: LITELLM_BILLING_METRICS_ENDPOINT
value: {{ required "billingMetrics.endpoint is required when billingMetrics.enabled is true" .Values.billingMetrics.endpoint | quote }}
-- name: LITELLM_BILLING_METRICS_CLIENT_CERT
- value: {{ printf "%s/tls.crt" (include "litellm.billingMetrics.certDir" .) | quote }}
-- name: LITELLM_BILLING_METRICS_CLIENT_KEY
- value: {{ printf "%s/tls.key" (include "litellm.billingMetrics.certDir" .) | quote }}
-{{- if .Values.billingMetrics.caSecretName }}
-- name: LITELLM_BILLING_METRICS_CA_CERT
- value: {{ printf "%s/ca.crt" (include "litellm.billingMetrics.caDir" .) | quote }}
-{{- end }}
+- name: LITELLM_BILLING_METRICS_TOKEN_URL
+ value: {{ required "billingMetrics.tokenUrl is required when billingMetrics.enabled is true" .Values.billingMetrics.tokenUrl | quote }}
+- name: LITELLM_BILLING_METRICS_CLIENT_ID
+ valueFrom:
+ secretKeyRef:
+ name: {{ required "billingMetrics.secretName is required when billingMetrics.enabled is true" .Values.billingMetrics.secretName }}
+ key: client-id
+- name: LITELLM_BILLING_METRICS_CLIENT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ required "billingMetrics.secretName is required when billingMetrics.enabled is true" .Values.billingMetrics.secretName }}
+ key: client-secret
{{- with .Values.billingMetrics.exportIntervalMs }}
- name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS
value: {{ . | quote }}
{{- end }}
{{- end -}}
-{{- define "litellm.billingMetricsVolumes" -}}
-- name: billing-metrics-mtls
- secret:
- secretName: {{ required "billingMetrics.secretName is required when billingMetrics.enabled is true (an existing Secret with tls.crt and tls.key)" .Values.billingMetrics.secretName }}
-{{- if .Values.billingMetrics.caSecretName }}
-- name: billing-metrics-mtls-ca
- secret:
- secretName: {{ .Values.billingMetrics.caSecretName }}
-{{- end }}
-{{- end -}}
-
-{{- define "litellm.billingMetricsVolumeMounts" -}}
-- name: billing-metrics-mtls
- mountPath: {{ include "litellm.billingMetrics.certDir" . }}
- readOnly: true
-{{- if .Values.billingMetrics.caSecretName }}
-- name: billing-metrics-mtls-ca
- mountPath: {{ include "litellm.billingMetrics.caDir" . }}
- readOnly: true
-{{- end }}
-{{- end -}}
{{/*
Per-component selector labels — used in both Service selectors and Deployment matchLabels.
diff --git a/helm/litellm/templates/backend/deployment.yaml b/helm/litellm/templates/backend/deployment.yaml
index 7360261dab1..7db14ad87a5 100644
--- a/helm/litellm/templates/backend/deployment.yaml
+++ b/helm/litellm/templates/backend/deployment.yaml
@@ -79,16 +79,13 @@ spec:
{{- include "litellm.billingMetricsEnv" . | nindent 12 }}
{{- end }}
{{- include "litellm.envFrom" .Values.backend | nindent 10 }}
- {{- if or .Values.gateway.config.create .Values.backend.volumeMounts .Values.billingMetrics.enabled }}
+ {{- if or .Values.gateway.config.create .Values.backend.volumeMounts }}
volumeMounts:
{{- if .Values.gateway.config.create }}
- name: gateway-config
mountPath: /app/config/config.yaml
subPath: config.yaml
{{- end }}
- {{- if .Values.billingMetrics.enabled }}
- {{- include "litellm.billingMetricsVolumeMounts" . | nindent 12 }}
- {{- end }}
{{- with .Values.backend.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
@@ -114,16 +111,13 @@ spec:
{{- with .Values.backend.extraContainers }}
{{- tpl (toYaml .) $ | nindent 8 }}
{{- end }}
- {{- if or .Values.gateway.config.create .Values.backend.volumes .Values.billingMetrics.enabled }}
+ {{- if or .Values.gateway.config.create .Values.backend.volumes }}
volumes:
{{- if .Values.gateway.config.create }}
- name: gateway-config
configMap:
name: {{ include "litellm.gateway.fullname" . }}-config
{{- end }}
- {{- if .Values.billingMetrics.enabled }}
- {{- include "litellm.billingMetricsVolumes" . | nindent 8 }}
- {{- end }}
{{- with .Values.backend.volumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
diff --git a/helm/litellm/templates/gateway/deployment.yaml b/helm/litellm/templates/gateway/deployment.yaml
index b0e7f0dcebb..987109e361e 100644
--- a/helm/litellm/templates/gateway/deployment.yaml
+++ b/helm/litellm/templates/gateway/deployment.yaml
@@ -86,7 +86,7 @@ spec:
{{- include "litellm.gateway.collectorEnv" . | nindent 12 }}
{{- end }}
{{- include "litellm.envFrom" .Values.gateway | nindent 10 }}
- {{- if or .Values.gateway.config.create .Values.gateway.volumeMounts .Values.billingMetrics.enabled .Values.gateway.metricsServer.enabled (include "litellm.gateway.collectorSocketDir" .) }}
+ {{- if or .Values.gateway.config.create .Values.gateway.volumeMounts .Values.gateway.metricsServer.enabled (include "litellm.gateway.collectorSocketDir" .) }}
volumeMounts:
{{- if .Values.gateway.config.create }}
- name: gateway-config
@@ -101,9 +101,6 @@ spec:
- name: collector-socket
mountPath: {{ include "litellm.gateway.collectorSocketDir" . }}
{{- end }}
- {{- if .Values.billingMetrics.enabled }}
- {{- include "litellm.billingMetricsVolumeMounts" . | nindent 12 }}
- {{- end }}
{{- with .Values.gateway.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
@@ -206,7 +203,7 @@ spec:
{{- with .Values.gateway.extraContainers }}
{{- tpl (toYaml .) $ | nindent 8 }}
{{- end }}
- {{- if or .Values.gateway.config.create .Values.gateway.volumes .Values.billingMetrics.enabled .Values.gateway.metricsServer.enabled (include "litellm.gateway.collectorSocketDir" .) }}
+ {{- if or .Values.gateway.config.create .Values.gateway.volumes .Values.gateway.metricsServer.enabled (include "litellm.gateway.collectorSocketDir" .) }}
volumes:
{{- if .Values.gateway.config.create }}
- name: gateway-config
@@ -222,9 +219,6 @@ spec:
emptyDir:
sizeLimit: 1Mi
{{- end }}
- {{- if .Values.billingMetrics.enabled }}
- {{- include "litellm.billingMetricsVolumes" . | nindent 8 }}
- {{- end }}
{{- with .Values.gateway.volumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
diff --git a/helm/litellm/tests/billing_metrics_tests.yaml b/helm/litellm/tests/billing_metrics_tests.yaml
index ceba0bd1430..d17b8192fce 100644
--- a/helm/litellm/tests/billing_metrics_tests.yaml
+++ b/helm/litellm/tests/billing_metrics_tests.yaml
@@ -1,39 +1,30 @@
-suite: test billingMetrics wiring on gateway and backend
+suite: test bearer-only billingMetrics wiring
templates:
- gateway/deployment.yaml
- gateway/configmap.yaml
- backend/deployment.yaml
+ - backend/configmap.yaml
- migrations-job.yaml
values:
- ./values/required.yaml
tests:
- - it: is off by default, adding no env, volume, or mount
+ - it: is disabled by default
template: gateway/deployment.yaml
asserts:
- - notContains:
- path: spec.template.spec.volumes
- content:
- name: billing-metrics-mtls
- secret:
- secretName: billing-mtls
- - equal:
- path: spec.template.spec.containers[0].volumeMounts
- value:
- - name: gateway-config
- mountPath: /app/config/config.yaml
- subPath: config.yaml
- notContains:
path: spec.template.spec.containers[0].env
content:
name: LITELLM_BILLING_METRICS_ENDPOINT
- value: https://telemetry.litellm.ai
+ - notContains:
+ path: spec.template.spec.volumes
+ content:
+ name: billing-metrics
- - it: renders the endpoint and the mounted cert paths when enabled
+ - it: wires gateway URLs and bearer credentials from Secret keys
template: gateway/deployment.yaml
set:
billingMetrics:
enabled: true
- secretName: billing-mtls
asserts:
- contains:
path: spec.template.spec.containers[0].env
@@ -43,87 +34,68 @@ tests:
- contains:
path: spec.template.spec.containers[0].env
content:
- name: LITELLM_BILLING_METRICS_CLIENT_CERT
- value: /etc/litellm/billing-mtls/tls.crt
+ name: LITELLM_BILLING_METRICS_TOKEN_URL
+ value: https://litellm-prod.auth.us-west-1.amazoncognito.com/oauth2/token
- contains:
path: spec.template.spec.containers[0].env
content:
- name: LITELLM_BILLING_METRICS_CLIENT_KEY
- value: /etc/litellm/billing-mtls/tls.key
-
- - it: mounts the cert secret read-only alongside the config volume
- template: gateway/deployment.yaml
- set:
- billingMetrics:
- enabled: true
- secretName: billing-mtls
- asserts:
+ name: LITELLM_BILLING_METRICS_CLIENT_ID
+ valueFrom:
+ secretKeyRef:
+ name: litellm-billing-metrics
+ key: client-id
- contains:
- path: spec.template.spec.volumes
+ path: spec.template.spec.containers[0].env
content:
- name: billing-metrics-mtls
- secret:
- secretName: billing-mtls
- - contains:
- path: spec.template.spec.containers[0].volumeMounts
- content:
- name: billing-metrics-mtls
- mountPath: /etc/litellm/billing-mtls
- readOnly: true
-
- # The production collector presents a public web-PKI certificate, so the CA
- # override must stay absent unless a private collector is configured.
- - it: omits the CA env, volume, and mount when no caSecretName is set
- template: gateway/deployment.yaml
- set:
- billingMetrics:
- enabled: true
- secretName: billing-mtls
- asserts:
+ name: LITELLM_BILLING_METRICS_CLIENT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: litellm-billing-metrics
+ key: client-secret
- notContains:
path: spec.template.spec.volumes
content:
- name: billing-metrics-mtls-ca
- secret:
- secretName: billing-ca
+ name: billing-metrics
- notContains:
- path: spec.template.spec.containers[0].env
+ path: spec.template.spec.containers[0].volumeMounts
content:
- name: LITELLM_BILLING_METRICS_CA_CERT
- value: /etc/litellm/billing-mtls-ca/ca.crt
+ name: billing-metrics
- - it: mounts the CA secret when caSecretName is set
- template: gateway/deployment.yaml
+ - it: wires billing metrics on the backend without volumes
+ template: backend/deployment.yaml
set:
billingMetrics:
enabled: true
- secretName: billing-mtls
- caSecretName: billing-ca
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
- name: LITELLM_BILLING_METRICS_CA_CERT
- value: /etc/litellm/billing-mtls-ca/ca.crt
- - contains:
+ name: LITELLM_BILLING_METRICS_CLIENT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: litellm-billing-metrics
+ key: client-secret
+ - notContains:
path: spec.template.spec.volumes
content:
- name: billing-metrics-mtls-ca
- secret:
- secretName: billing-ca
- - contains:
- path: spec.template.spec.containers[0].volumeMounts
- content:
- name: billing-metrics-mtls-ca
- mountPath: /etc/litellm/billing-mtls-ca
- readOnly: true
+ name: billing-metrics
- - it: passes the export interval through only when set
+ - it: does not add billing metrics to migrations
+ template: migrations-job.yaml
+ set:
+ billingMetrics:
+ enabled: true
+ asserts:
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_BILLING_METRICS_ENDPOINT
+
+ - it: renders an optional export interval
template: gateway/deployment.yaml
set:
billingMetrics:
enabled: true
- secretName: billing-mtls
exportIntervalMs: 5000
asserts:
- contains:
@@ -132,102 +104,27 @@ tests:
name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS
value: "5000"
- - it: keeps user-supplied gateway volumes alongside the billing secret
+ - it: requires the endpoint when enabled
template: gateway/deployment.yaml
set:
billingMetrics:
enabled: true
- secretName: billing-mtls
- gateway.volumes:
- - name: custom-callbacks
- configMap:
- name: my-callbacks
- gateway.volumeMounts:
- - name: custom-callbacks
- mountPath: /app/callbacks
+ endpoint: ""
asserts:
- - contains:
- path: spec.template.spec.volumes
- content:
- name: custom-callbacks
- configMap:
- name: my-callbacks
- - contains:
- path: spec.template.spec.volumes
- content:
- name: billing-metrics-mtls
- secret:
- secretName: billing-mtls
+ - failedTemplate:
+ errorMessage: billingMetrics.endpoint is required when billingMetrics.enabled is true
- # The backend keeps the named-server MCP transport (/{mcp_server_name}/mcp),
- # which writes a SpendLogs row, so it must meter too or that traffic is lost.
- - it: meters the backend as well, since it serves the MCP transport
- template: backend/deployment.yaml
- set:
- billingMetrics:
- enabled: true
- secretName: billing-mtls
- asserts:
- - contains:
- path: spec.template.spec.containers[0].env
- content:
- name: LITELLM_BILLING_METRICS_ENDPOINT
- value: https://telemetry.litellm.ai
- - contains:
- path: spec.template.spec.containers[0].volumeMounts
- content:
- name: billing-metrics-mtls
- mountPath: /etc/litellm/billing-mtls
- readOnly: true
- - contains:
- path: spec.template.spec.volumes
- content:
- name: billing-metrics-mtls
- secret:
- secretName: billing-mtls
-
- - it: leaves the backend alone when metering is off
- template: backend/deployment.yaml
- asserts:
- - notContains:
- path: spec.template.spec.containers[0].env
- content:
- name: LITELLM_BILLING_METRICS_ENDPOINT
- value: https://telemetry.litellm.ai
-
- # The migrations job runs prisma and serves no traffic; it must never receive
- # the client key.
- - it: never mounts the billing cert on the migrations job
- template: migrations-job.yaml
- set:
- billingMetrics:
- enabled: true
- secretName: billing-mtls
- asserts:
- - notContains:
- path: spec.template.spec.containers[0].env
- content:
- name: LITELLM_BILLING_METRICS_ENDPOINT
- value: https://telemetry.litellm.ai
- - isNull:
- path: spec.template.spec.volumes
-
- # The conventional Secret name is the default, so enabling metering needs no
- # secretName at all; the guard below only fires on an explicitly blanked one.
- - it: uses the conventional secret name by default
+ - it: requires the token URL when enabled
template: gateway/deployment.yaml
set:
billingMetrics:
enabled: true
+ tokenUrl: ""
asserts:
- - contains:
- path: spec.template.spec.volumes
- content:
- name: billing-metrics-mtls
- secret:
- secretName: litellm-billing-metrics-mtls
+ - failedTemplate:
+ errorMessage: billingMetrics.tokenUrl is required when billingMetrics.enabled is true
- - it: fails loudly when the secretName is explicitly blanked
+ - it: requires the secret name when enabled
template: gateway/deployment.yaml
set:
billingMetrics:
@@ -235,15 +132,4 @@ tests:
secretName: ""
asserts:
- failedTemplate:
- errorMessage: billingMetrics.secretName is required when billingMetrics.enabled is true (an existing Secret with tls.crt and tls.key)
-
- - it: fails loudly when enabled without an endpoint
- template: gateway/deployment.yaml
- set:
- billingMetrics:
- enabled: true
- endpoint: ""
- secretName: billing-mtls
- asserts:
- - failedTemplate:
- errorMessage: billingMetrics.endpoint is required when billingMetrics.enabled is true
+ errorMessage: billingMetrics.secretName is required when billingMetrics.enabled is true
diff --git a/helm/litellm/tests/migration_job_tests.yaml b/helm/litellm/tests/migration_job_tests.yaml
index 2ebb1b44926..b9e52ba7123 100644
--- a/helm/litellm/tests/migration_job_tests.yaml
+++ b/helm/litellm/tests/migration_job_tests.yaml
@@ -91,7 +91,7 @@ tests:
app.kubernetes.io/name: litellm
app.kubernetes.io/instance: RELEASE-NAME
app.kubernetes.io/managed-by: Helm
- helm.sh/chart: litellm-0.1.0
+ helm.sh/chart: litellm-0.1.1
app.kubernetes.io/component: migrations
- it: renders pod-level and container-level securityContext in their own scopes
diff --git a/helm/litellm/values.yaml b/helm/litellm/values.yaml
index 92a48153c5a..cb37656cb77 100644
--- a/helm/litellm/values.yaml
+++ b/helm/litellm/values.yaml
@@ -180,23 +180,14 @@ masterKey:
secretKey: master-key
# Optional: enterprise billable-request metering. When enabled, the gateway and
-# backend count successful requests to inference, MCP, and A2A endpoints and push
-# them to LiteLLM's collector over mutual TLS. Both components serve billable
-# routes: the backend keeps the named-server MCP transport. Requires an
-# enterprise license. The client certificate identifies the deployment, so it is
-# mounted read-only from an existing Secret and never passed through the env.
+# backend count successful requests to inference, MCP, and A2A endpoints. Requires
+# an enterprise license.
billingMetrics:
enabled: false
- endpoint: https://telemetry.litellm.ai # collector to push the counter to
- # An existing Secret holding the client certificate under tls.crt and its key
- # under tls.key, usually created from the onboarding artifact. The default is
- # the conventional name, so the common path is to create that Secret and set
- # enabled: true. Override only if yours is named differently.
- secretName: litellm-billing-metrics-mtls
- # Only for private or test collectors whose server certificate is not on the
- # public web PKI. The production collector needs no CA override.
- caSecretName: "" # existing Secret holding ca.crt
- exportIntervalMs: "" # push cadence; the proxy defaults to 60000
+ endpoint: https://telemetry.litellm.ai
+ tokenUrl: https://litellm-prod.auth.us-west-1.amazoncognito.com/oauth2/token
+ secretName: litellm-billing-metrics
+ exportIntervalMs: ""
# External Postgres connection.
database:
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/20261104000000_skill_zip_upload/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261104000000_skill_zip_upload/migration.sql
new file mode 100644
index 00000000000..acbe87515d9
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261104000000_skill_zip_upload/migration.sql
@@ -0,0 +1,9 @@
+SET LOCAL lock_timeout = '10s';
+
+CREATE TABLE IF NOT EXISTS "LiteLLM_ClaudeCodePluginUpload" (
+ "plugin_name" TEXT NOT NULL,
+ "content" BYTEA NOT NULL,
+ CONSTRAINT "LiteLLM_ClaudeCodePluginUpload_pkey" PRIMARY KEY ("plugin_name"),
+ CONSTRAINT "LiteLLM_ClaudeCodePluginUpload_plugin_name_fkey" FOREIGN KEY ("plugin_name")
+ REFERENCES "LiteLLM_ClaudeCodePluginTable"("name") ON DELETE CASCADE ON UPDATE CASCADE
+);
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 179b741fe7c..818f2dc4858 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
+++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
@@ -1615,9 +1615,77 @@ model LiteLLM_ClaudeCodePluginTable {
updated_at DateTime? @default(now()) @updatedAt
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
+ plugin LiteLLM_ClaudeCodePluginTable @relation(fields: [plugin_name], references: [name], onDelete: Cascade, onUpdate: Cascade)
+}
+
+
// User/team-scoped memory store with a GLOBAL unique key.
// `value` is a string (typically markdown/text meant for LLM context);
// `metadata` is an optional JSON envelope for structured tags without schema changes.
@@ -2062,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 f91deaaa401..23ebfc7d3cf 100644
--- a/litellm-rust/Cargo.lock
+++ b/litellm-rust/Cargo.lock
@@ -3389,15 +3389,19 @@ version = "0.1.0"
dependencies = [
"aws-config",
"aws-credential-types",
+ "aws-runtime",
"aws-sdk-sts",
"aws-sigv4",
"aws-smithy-runtime-api",
+ "aws-smithy-types",
"aws-types",
"bytes",
"futures-util",
"litellm-auth-types",
"litellm-error",
"litellm-http",
+ "litellm-secret-value",
+ "litellm-secrets",
"moka",
"reqwest",
"rstest",
@@ -3406,6 +3410,7 @@ dependencies = [
"sha2 0.10.9",
"thiserror 2.0.19",
"tokio",
+ "url",
"veil",
]
@@ -3418,6 +3423,7 @@ dependencies = [
"litellm-auth-types",
"litellm-error",
"litellm-secret-value",
+ "litellm-secrets",
"moka",
"rstest",
"serde_json",
@@ -3431,6 +3437,7 @@ dependencies = [
name = "litellm-auth-gcp"
version = "0.1.0"
dependencies = [
+ "axum",
"base64 0.22.1",
"gcp_auth",
"google-cloud-auth",
@@ -3440,10 +3447,12 @@ dependencies = [
"litellm-secret-value",
"moka",
"rstest",
+ "rustls 0.23.42",
"serde",
"serde_json",
"sha2 0.10.9",
"tempfile",
+ "time",
"tokio",
"veil",
]
@@ -3569,37 +3578,6 @@ dependencies = [
"uuid",
]
-[[package]]
-name = "litellm-cache-redis"
-version = "0.1.0"
-dependencies = [
- "litellm-cache",
- "litellm-cache-testing",
- "litellm-error",
- "r2d2",
- "redis",
- "redis-test",
- "rstest",
- "serde_json",
- "thiserror 2.0.19",
- "tokio",
-]
-
-[[package]]
-name = "litellm-cache-redis-semantic"
-version = "0.1.0"
-dependencies = [
- "litellm-cache",
- "litellm-cache-redis",
- "litellm-cache-testing",
- "redis",
- "redis-test",
- "rstest",
- "serde_json",
- "sha2 0.10.9",
- "tokio",
-]
-
[[package]]
name = "litellm-cache-response"
version = "0.1.0"
@@ -3607,11 +3585,8 @@ dependencies = [
"litellm-cache",
"litellm-cache-gcs",
"litellm-cache-memory",
- "litellm-cache-redis",
"litellm-http",
"py_literal",
- "redis",
- "redis-test",
"rstest",
"serde",
"serde_json",
@@ -3650,24 +3625,6 @@ dependencies = [
"litellm-cache",
]
-[[package]]
-name = "litellm-cache-valkey-semantic"
-version = "0.1.0"
-dependencies = [
- "litellm-cache",
- "litellm-cache-redis",
- "litellm-cache-testing",
- "litellm-error",
- "redis",
- "redis-test",
- "rstest",
- "serde_json",
- "sha2 0.10.9",
- "thiserror 2.0.19",
- "tokio",
- "uuid",
-]
-
[[package]]
name = "litellm-callbacks-legacy-python"
version = "0.1.0"
@@ -4331,6 +4288,7 @@ dependencies = [
name = "litellm-python-bridge"
version = "0.1.0"
dependencies = [
+ "async-trait",
"aws-sdk-secretsmanager",
"bytes",
"criterion",
@@ -4339,8 +4297,6 @@ dependencies = [
"litellm-auth",
"litellm-auth-aws",
"litellm-cache",
- "litellm-cache-memory",
- "litellm-cache-redis",
"litellm-cache-response",
"litellm-callbacks-legacy-python",
"litellm-core-utils",
@@ -4357,6 +4313,7 @@ dependencies = [
"litellm-llms",
"litellm-llms-types",
"litellm-model-catalog",
+ "litellm-router-core",
"litellm-router-types",
"litellm-secret-value",
"litellm-secrets",
@@ -4369,7 +4326,6 @@ dependencies = [
"prost",
"pyo3",
"pyo3-async-runtimes",
- "redis",
"reqwest",
"rstest",
"serde",
@@ -4439,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"
@@ -5782,17 +5756,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
-[[package]]
-name = "r2d2"
-version = "0.8.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93"
-dependencies = [
- "log",
- "parking_lot",
- "scheduled-thread-pool",
-]
-
[[package]]
name = "rand"
version = "0.8.7"
@@ -5966,18 +5929,6 @@ dependencies = [
"xxhash-rust",
]
-[[package]]
-name = "redis-test"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "804d36862e4323b69f96440cbb13c9894fc90176abdeaf91264e21d5d77f6aca"
-dependencies = [
- "rand 0.9.5",
- "redis",
- "socket2 0.6.5",
- "tempfile",
-]
-
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -6454,15 +6405,6 @@ dependencies = [
"windows-sys 0.61.2",
]
-[[package]]
-name = "scheduled-thread-pool"
-version = "0.2.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19"
-dependencies = [
- "parking_lot",
-]
-
[[package]]
name = "schemars"
version = "0.9.0"
diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml
index 75cac2a5d63..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" }
@@ -57,11 +58,9 @@ litellm-db-testing = { path = "crates/db-testing" }
litellm-cache = { path = "crates/cache" }
litellm-cache-azure-blob = { path = "crates/cache-azure-blob" }
litellm-cache-memory = { path = "crates/cache-memory" }
-litellm-cache-redis = { path = "crates/cache-redis" }
litellm-cache-s3 = { path = "crates/cache-s3" }
litellm-cache-gcs = { path = "crates/cache-gcs" }
litellm-cache-disk = { path = "crates/cache-disk" }
-litellm-cache-redis-semantic = { path = "crates/cache-redis-semantic" }
litellm-cache-response = { path = "crates/cache-response" }
litellm-cache-qdrant-semantic = { path = "crates/cache-qdrant-semantic" }
litellm-cache-testing = { path = "crates/cache-testing" }
@@ -78,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"] }
@@ -97,7 +98,6 @@ macro_rules_attribute = "0.2.3"
schemars = "1"
reqwest = { version = "0.13", default-features = false, features = ["json", "multipart", "rustls", "http2", "stream", "query"] }
redis = "1.8.0"
-redis-test = "1.0.4"
testcontainers-modules = "0.15.0"
qdrant-client = { version = "1.19.0", default-features = false }
uuid = { version = "1", features = ["v4"] }
diff --git a/litellm-rust/crates/auth-aws/Cargo.toml b/litellm-rust/crates/auth-aws/Cargo.toml
index e4578777a08..c8ed7bb0f14 100644
--- a/litellm-rust/crates/auth-aws/Cargo.toml
+++ b/litellm-rust/crates/auth-aws/Cargo.toml
@@ -11,14 +11,16 @@ litellm-error.workspace = true
litellm-http.workspace = true
reqwest.workspace = true
-moka = { workspace = true, features = ["sync"] }
+moka.workspace = true
+litellm-secret-value.workspace = true
serde.workspace = true
serde_json = { workspace = true, features = ["preserve_order"] }
sha2.workspace = true
thiserror.workspace = true
+url.workspace = true
veil.workspace = true
-aws-config = { version = "1.9.0", default-features = false, features = ["rustls", "rt-tokio"] }
+aws-config = { version = "1.9.0", default-features = false, features = ["rustls", "rt-tokio", "credentials-process"] }
aws-credential-types = { version = "1.3.0", features = ["hardcoded-credentials"] }
aws-sdk-sts = { version = "1.108.0", default-features = false, features = ["rustls", "rt-tokio"] }
aws-sigv4 = "1.5.1"
@@ -26,6 +28,9 @@ aws-types = "1.4.0"
aws-smithy-runtime-api = "1.13.0"
[dev-dependencies]
+litellm-secrets.workspace = true
+aws-smithy-types = "1.6.4"
+aws-runtime = { version = "1.9.4", default-features = false }
rstest.workspace = true
litellm-http = { workspace = true, features = ["test-support"] }
bytes.workspace = true
diff --git a/litellm-rust/crates/auth-aws/src/aws.rs b/litellm-rust/crates/auth-aws/src/aws.rs
index 81aa4b068bc..e6e32754b4f 100644
--- a/litellm-rust/crates/auth-aws/src/aws.rs
+++ b/litellm-rust/crates/auth-aws/src/aws.rs
@@ -1,13 +1,16 @@
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
-use moka::sync::Cache;
-use sha2::{Digest, Sha256};
+use aws_credential_types::provider::{SharedCredentialsProvider, error::CredentialsError};
+use litellm_secret_value::SecretValue;
+use moka::{Expiry, future::Cache};
+use std::{future::Future, pin::Pin, sync::Arc};
use aws_credential_types::Credentials;
use aws_credential_types::provider::ProvideCredentials;
use aws_sigv4::http_request::{
- SignableBody, SignableRequest, SigningParams, SigningSettings, sign,
+ SignableBody, SignableRequest, SignatureLocation, SigningInstructions, SigningParams,
+ SigningSettings, sign,
};
use aws_sigv4::sign::v4;
use aws_smithy_runtime_api::client::identity::Identity;
@@ -23,72 +26,106 @@ use super::constants::{
};
use crate::error::{CredentialFlow, Error, SigningStage};
-const STATIC_CREDENTIALS_TTL: Duration = Duration::from_secs(3600 - 60);
-const AMBIENT_CREDENTIALS_TTL: Duration = Duration::from_secs(600);
+const CREDENTIAL_CACHE_TTL: Duration = Duration::from_secs(600);
+const EXPIRY_BUFFER: Duration = Duration::from_secs(60);
#[derive(Clone)]
pub struct AwsAuthService {
- static_credentials: Cache,
- ambient_credentials: Cache,
+ providers: Cache,
+ credentials: Cache,
+ loader: Arc,
}
impl Default for AwsAuthService {
fn default() -> Self {
+ Self::new(Arc::new(NativeProviderLoader))
+ }
+}
+
+impl AwsAuthService {
+ fn new(loader: Arc) -> Self {
Self {
- static_credentials: Cache::builder()
+ providers: Cache::builder().max_capacity(200).build(),
+ credentials: Cache::builder()
.max_capacity(200)
- .time_to_live(STATIC_CREDENTIALS_TTL)
- .build(),
- ambient_credentials: Cache::builder()
- .max_capacity(200)
- .time_to_live(AMBIENT_CREDENTIALS_TTL)
+ .expire_after(CredentialExpiry)
.build(),
+ loader,
}
}
}
-fn credential_cache_ttl(flow: &AwsAuthFlow) -> Option {
- match flow {
- AwsAuthFlow::StaticKeys { .. } => Some(STATIC_CREDENTIALS_TTL),
- AwsAuthFlow::DefaultChain => Some(AMBIENT_CREDENTIALS_TTL),
- AwsAuthFlow::WebIdentity { .. }
- | AwsAuthFlow::AssumeRole { .. }
- | AwsAuthFlow::Profile { .. }
- | AwsAuthFlow::SessionToken { .. } => None,
+struct CredentialExpiry;
+
+impl Expiry for CredentialExpiry {
+ fn expire_after_create(
+ &self,
+ _: &AwsAuthConfig,
+ credentials: &Credentials,
+ _: std::time::Instant,
+ ) -> Option {
+ Some(credentials.expiry().map_or(CREDENTIAL_CACHE_TTL, |expiry| {
+ expiry
+ .duration_since(SystemTime::now())
+ .unwrap_or_default()
+ .saturating_sub(EXPIRY_BUFFER)
+ .min(CREDENTIAL_CACHE_TTL)
+ }))
}
}
-#[derive(Clone, Debug, Default, PartialEq, Eq)]
+type ProviderFuture<'a> =
+ Pin> + Send + 'a>>;
+
+trait ProviderLoader: Send + Sync {
+ fn load(&self, config: AwsAuthConfig, flow: AwsAuthFlow) -> ProviderFuture<'_>;
+}
+
+struct NativeProviderLoader;
+
+impl ProviderLoader for NativeProviderLoader {
+ fn load(&self, config: AwsAuthConfig, flow: AwsAuthFlow) -> ProviderFuture<'_> {
+ Box::pin(build_provider(config, flow))
+ }
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct AwsAuthConfig {
- pub access_key_id: Option,
- pub secret_access_key: Option,
- pub session_token: Option,
+ pub access_key_id: Option,
+ pub secret_access_key: Option,
+ pub session_token: Option,
pub region_name: Option,
pub session_name: Option,
pub profile_name: Option,
pub role_name: Option,
- pub web_identity_token: Option,
+ pub web_identity_token: Option,
pub sts_endpoint: Option,
- pub external_id: Option,
+ pub external_id: Option,
}
impl AwsAuthConfig {
fn with_environment(self, env_lookup: &(dyn Fn(&str) -> Option + Sync)) -> Self {
Self {
- access_key_id: self.access_key_id.or_else(|| env_lookup(AWS_ACCESS_KEY_ID)),
+ access_key_id: self
+ .access_key_id
+ .or_else(|| env_lookup(AWS_ACCESS_KEY_ID).map(SecretValue::new)),
secret_access_key: self
.secret_access_key
- .or_else(|| env_lookup(AWS_SECRET_ACCESS_KEY)),
- session_token: self.session_token.or_else(|| env_lookup(AWS_SESSION_TOKEN)),
+ .or_else(|| env_lookup(AWS_SECRET_ACCESS_KEY).map(SecretValue::new)),
+ session_token: self
+ .session_token
+ .or_else(|| env_lookup(AWS_SESSION_TOKEN).map(SecretValue::new)),
region_name: self.region_name.or_else(|| env_lookup(AWS_REGION_NAME)),
session_name: self.session_name.or_else(|| env_lookup(AWS_SESSION_NAME)),
profile_name: self.profile_name.or_else(|| env_lookup(AWS_PROFILE_NAME)),
role_name: self.role_name.or_else(|| env_lookup(AWS_ROLE_NAME)),
web_identity_token: self
.web_identity_token
- .or_else(|| env_lookup(AWS_WEB_IDENTITY_TOKEN)),
+ .or_else(|| env_lookup(AWS_WEB_IDENTITY_TOKEN).map(SecretValue::new)),
sts_endpoint: self.sts_endpoint.or_else(|| env_lookup(AWS_STS_ENDPOINT)),
- external_id: self.external_id.or_else(|| env_lookup(AWS_EXTERNAL_ID)),
+ external_id: self
+ .external_id
+ .or_else(|| env_lookup(AWS_EXTERNAL_ID).map(SecretValue::new)),
}
}
}
@@ -96,52 +133,31 @@ impl AwsAuthConfig {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AwsAuthFlow {
WebIdentity {
- token: String,
+ token: SecretValue,
role: String,
session_name: String,
},
AssumeRole {
role: String,
session_name: Option,
+ web_identity_role: Option,
},
Profile {
name: String,
},
SessionToken {
- access_key_id: String,
- secret_access_key: String,
- session_token: String,
+ access_key_id: SecretValue,
+ secret_access_key: SecretValue,
+ session_token: SecretValue,
},
StaticKeys {
- access_key_id: String,
- secret_access_key: String,
+ access_key_id: SecretValue,
+ secret_access_key: SecretValue,
region_name: String,
},
DefaultChain,
}
-fn cache_key(config: &AwsAuthConfig, flow: &AwsAuthFlow) -> String {
- let mut hasher = Sha256::new();
- hasher.update(format!("{config:?}:{flow:?}"));
- format!("{:x}", hasher.finalize())
-}
-
-impl AwsAuthService {
- fn get_cached_credentials(&self, key: &str) -> Option {
- self.static_credentials
- .get(key)
- .or_else(|| self.ambient_credentials.get(key))
- }
-
- fn set_cached_credentials(&self, key: String, credentials: Credentials, ttl: Duration) {
- if ttl == STATIC_CREDENTIALS_TTL {
- self.static_credentials.insert(key, credentials);
- } else {
- self.ambient_credentials.insert(key, credentials);
- }
- }
-}
-
fn role_identity(arn: &str) -> Option<(&str, &str, &str)> {
let mut parts = arn.splitn(6, ':');
let ("arn", partition, _, _, account, resource) = (
@@ -170,6 +186,9 @@ pub fn classify_auth(
config: AwsAuthConfig,
env_lookup: &(dyn Fn(&str) -> Option + Sync),
) -> AwsAuthFlow {
+ let web_identity_role = env_lookup(AWS_WEB_IDENTITY_TOKEN_FILE)
+ .filter(|token_file| !token_file.is_empty())
+ .and_then(|_| env_lookup(AWS_ROLE_ARN));
let config = config.with_environment(env_lookup);
if let (Some(token), Some(role), Some(session_name)) = (
config.web_identity_token.clone(),
@@ -186,6 +205,7 @@ pub fn classify_auth(
return AwsAuthFlow::AssumeRole {
role,
session_name: config.session_name.clone(),
+ web_identity_role,
};
}
if let Some(name) = config.profile_name {
@@ -216,6 +236,30 @@ pub fn classify_auth(
AwsAuthFlow::DefaultChain
}
+async fn profile_provider(
+ name: &str,
+ region: Option,
+ config: aws_config::provider_config::ProviderConfig,
+ builder: aws_config::profile::credentials::Builder,
+) -> Result {
+ let region = match region {
+ Some(region) => Some(aws_types::region::Region::new(region)),
+ None => {
+ aws_config::default_provider::region::DefaultRegionChain::builder()
+ .profile_name(name)
+ .build()
+ .region()
+ .await
+ }
+ };
+ Ok(SharedCredentialsProvider::new(
+ builder
+ .configure(&config.with_region(region))
+ .profile_name(name)
+ .build(),
+ ))
+}
+
impl AwsAuthService {
pub async fn resolve_credentials(
&self,
@@ -224,183 +268,240 @@ impl AwsAuthService {
) -> Result {
let resolved = config.clone().with_environment(env_lookup);
let flow = classify_auth(config, env_lookup);
- match flow {
- AwsAuthFlow::SessionToken {
- access_key_id,
- secret_access_key,
- session_token,
- } => Ok(Credentials::new(
- access_key_id,
- secret_access_key,
- Some(session_token),
- None,
- "litellm-static-session",
- )),
- AwsAuthFlow::StaticKeys {
- access_key_id,
- secret_access_key,
- region_name,
- } => {
- let flow = AwsAuthFlow::StaticKeys {
- access_key_id: access_key_id.clone(),
- secret_access_key: secret_access_key.clone(),
- region_name,
- };
- let key = cache_key(&resolved, &flow);
- if let Some(credentials) = self.get_cached_credentials(&key) {
- return Ok(credentials);
- }
- let credentials = Credentials::new(
- access_key_id,
- secret_access_key,
- None,
- None,
- "litellm-static",
- );
- self.set_cached_credentials(
- key,
- credentials.clone(),
- credential_cache_ttl(&flow).unwrap_or(STATIC_CREDENTIALS_TTL),
- );
- Ok(credentials)
- }
- AwsAuthFlow::Profile { name } => {
- let provider = aws_config::profile::ProfileFileCredentialsProvider::builder()
- .profile_name(name)
- .build();
+ self.fresh_credentials(&resolved, &flow).await
+ }
+
+ /// The credentials botocore's default chain finds in `region`, without LiteLLM's Bedrock settings
+ /// (`AWS_ROLE_NAME`, `AWS_PROFILE_NAME`, `AWS_SESSION_NAME`) that `resolve_credentials` also reads.
+ pub async fn default_chain_credentials(&self, region: &str) -> Result {
+ let config = AwsAuthConfig {
+ region_name: Some(region.to_owned()),
+ ..Default::default()
+ };
+ self.fresh_credentials(&config, &AwsAuthFlow::DefaultChain)
+ .await
+ }
+
+ async fn fresh_credentials(
+ &self,
+ resolved: &AwsAuthConfig,
+ flow: &AwsAuthFlow,
+ ) -> Result {
+ let cached = self.cached_credentials(resolved, flow).await?;
+ if fresh(&cached) {
+ return Ok(cached);
+ }
+ self.credentials.invalidate(resolved).await;
+ let refetched = self.cached_credentials(resolved, flow).await?;
+ if fresh(&refetched) {
+ return Ok(refetched);
+ }
+ self.credentials.invalidate(resolved).await;
+ Err(Error::ExpiredCredentials)
+ }
+
+ async fn cached_credentials(
+ &self,
+ resolved: &AwsAuthConfig,
+ flow: &AwsAuthFlow,
+ ) -> Result {
+ self.credentials
+ .try_get_with(resolved.clone(), async {
+ let provider = self
+ .providers
+ .try_get_with(
+ resolved.clone(),
+ self.loader.load(resolved.clone(), flow.clone()),
+ )
+ .await
+ .map_err(|error| (*error).clone())?;
provider
.provide_credentials()
.await
- .map_err(Error::credentials(CredentialFlow::Profile))
- }
- AwsAuthFlow::AssumeRole { role, session_name } => {
- if is_already_running_as_role(&role, &resolved).await? {
- let ambient_flow = AwsAuthFlow::DefaultChain;
- let key = cache_key(&resolved, &ambient_flow);
- if let Some(credentials) = self.get_cached_credentials(&key) {
- return Ok(credentials);
- }
- let provider =
- aws_config::default_provider::credentials::DefaultCredentialsChain::builder()
- .build()
- .await;
- let credentials = provider
- .provide_credentials()
- .await
- .map_err(Error::credentials(CredentialFlow::DefaultChain))?;
- self.set_cached_credentials(
- key,
- credentials.clone(),
- credential_cache_ttl(&ambient_flow).unwrap_or(AMBIENT_CREDENTIALS_TTL),
- );
- return Ok(credentials);
- }
- let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
- if let Some(region) = resolved.region_name.clone() {
- loader = loader.region(aws_types::region::Region::new(region));
- }
- if let Some(endpoint) = resolved.sts_endpoint.clone() {
- loader = loader.endpoint_url(endpoint);
- }
- if let (Some(access_key_id), Some(secret_access_key)) =
- (resolved.access_key_id, resolved.secret_access_key)
- {
- loader = loader.credentials_provider(Credentials::new(
- access_key_id,
- secret_access_key,
- resolved.session_token,
- None,
- "litellm-role-source",
- ));
- }
- let sdk_config = loader.load().await;
- let builder = aws_config::sts::AssumeRoleProvider::builder(role);
- let builder = match session_name {
- Some(name) => builder.session_name(name),
- None => builder.session_name(default_session_name()),
- };
- let builder = match resolved.external_id {
- Some(id) => builder.external_id(id),
- None => builder,
- };
- let provider = builder.configure(&sdk_config).build().await;
- provider
- .provide_credentials()
- .await
- .map_err(Error::credentials(CredentialFlow::AssumeRole))
- }
- AwsAuthFlow::WebIdentity {
- token,
- role,
- session_name,
- } => {
- let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
- if let Some(region) = resolved.region_name {
- loader = loader.region(aws_types::region::Region::new(region));
- }
- if let Some(endpoint) = resolved.sts_endpoint {
- loader = loader.endpoint_url(endpoint);
- }
- let sdk_config = loader.load().await;
- let client = aws_sdk_sts::Client::new(&sdk_config);
- let response = client
- .assume_role_with_web_identity()
- .role_arn(role)
- .role_session_name(session_name)
- .web_identity_token(token)
- .send()
- .await
- .map_err(Error::credentials(CredentialFlow::WebIdentity))?;
- let credentials = response
- .credentials()
- .ok_or(Error::MissingWebIdentityCredentials)?;
- let expiration =
- SystemTime::try_from(*credentials.expiration()).map_err(|source| {
- Error::WebIdentityExpiration(litellm_error::Source::new(source))
- })?;
- Ok(Credentials::new(
- credentials.access_key_id(),
- credentials.secret_access_key(),
- Some(credentials.session_token().to_string()),
- Some(expiration),
- "litellm-web-identity",
- ))
- }
- AwsAuthFlow::DefaultChain => {
- let key = cache_key(&resolved, &AwsAuthFlow::DefaultChain);
- if let Some(credentials) = self.get_cached_credentials(&key) {
- return Ok(credentials);
- }
- let provider =
- aws_config::default_provider::credentials::DefaultCredentialsChain::builder()
- .build()
- .await;
- let credentials = provider
- .provide_credentials()
- .await
- .map_err(Error::credentials(CredentialFlow::DefaultChain))?;
- self.set_cached_credentials(
- key,
- credentials.clone(),
- credential_cache_ttl(&AwsAuthFlow::DefaultChain)
- .unwrap_or(AMBIENT_CREDENTIALS_TTL),
- );
- Ok(credentials)
+ .map_err(Error::credentials(flow.credential_flow()))
+ })
+ .await
+ .map_err(|error| (*error).clone())
+ }
+}
+
+fn fresh(credentials: &Credentials) -> bool {
+ credentials
+ .expiry()
+ .is_none_or(|expiry| expiry > SystemTime::now() + EXPIRY_BUFFER)
+}
+
+impl AwsAuthFlow {
+ fn credential_flow(&self) -> CredentialFlow {
+ match self {
+ Self::Profile { .. } => CredentialFlow::Profile,
+ Self::AssumeRole { .. } => CredentialFlow::AssumeRole,
+ Self::WebIdentity { .. } => CredentialFlow::WebIdentity,
+ Self::DefaultChain | Self::StaticKeys { .. } | Self::SessionToken { .. } => {
+ CredentialFlow::DefaultChain
}
}
}
}
-async fn is_already_running_as_role(role: &str, config: &AwsAuthConfig) -> Result {
+async fn build_provider(
+ resolved: AwsAuthConfig,
+ flow: AwsAuthFlow,
+) -> Result {
+ match flow {
+ AwsAuthFlow::SessionToken {
+ access_key_id,
+ secret_access_key,
+ session_token,
+ } => Ok(SharedCredentialsProvider::new(Credentials::new(
+ access_key_id.expose(),
+ secret_access_key.expose(),
+ Some(session_token.expose().to_owned()),
+ None,
+ "litellm-static-session",
+ ))),
+ AwsAuthFlow::StaticKeys {
+ access_key_id,
+ secret_access_key,
+ ..
+ } => Ok(SharedCredentialsProvider::new(Credentials::new(
+ access_key_id.expose(),
+ secret_access_key.expose(),
+ None,
+ None,
+ "litellm-static",
+ ))),
+ AwsAuthFlow::Profile { name } => {
+ profile_provider(
+ &name,
+ resolved.region_name,
+ aws_config::provider_config::ProviderConfig::without_region(),
+ aws_config::profile::ProfileFileCredentialsProvider::builder(),
+ )
+ .await
+ }
+ AwsAuthFlow::DefaultChain => {
+ let builder =
+ aws_config::default_provider::credentials::DefaultCredentialsChain::builder();
+ let builder = match resolved.region_name {
+ Some(region) => builder.region(aws_types::region::Region::new(region)),
+ None => builder,
+ };
+ Ok(SharedCredentialsProvider::new(builder.build().await))
+ }
+ AwsAuthFlow::AssumeRole {
+ role,
+ session_name,
+ web_identity_role,
+ } => {
+ if is_already_running_as_role(&role, web_identity_role.as_deref(), &resolved).await? {
+ return Ok(SharedCredentialsProvider::new(
+ aws_config::default_provider::credentials::DefaultCredentialsChain::builder()
+ .build()
+ .await,
+ ));
+ }
+ let sdk_config = sdk_config(resolved.clone()).await;
+ let builder = aws_config::sts::AssumeRoleProvider::builder(role)
+ .session_name(session_name.unwrap_or_else(default_session_name));
+ let builder = match resolved.external_id {
+ Some(id) => builder.external_id(id.expose()),
+ None => builder,
+ };
+ Ok(SharedCredentialsProvider::new(
+ builder.configure(&sdk_config).build().await,
+ ))
+ }
+ AwsAuthFlow::WebIdentity {
+ token,
+ role,
+ session_name,
+ } => Ok(SharedCredentialsProvider::new(WebIdentityProvider {
+ client: aws_sdk_sts::Client::new(&sdk_config(resolved).await),
+ token,
+ role,
+ session_name,
+ })),
+ }
+}
+
+async fn sdk_config(config: AwsAuthConfig) -> aws_types::SdkConfig {
+ let loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
+ let loader = match config.region_name {
+ Some(region) => loader.region(aws_types::region::Region::new(region)),
+ None => loader,
+ };
+ let loader = match config.sts_endpoint {
+ Some(endpoint) => loader.endpoint_url(endpoint),
+ None => loader,
+ };
+ let loader = match (config.access_key_id, config.secret_access_key) {
+ (Some(key), Some(secret)) => loader.credentials_provider(Credentials::new(
+ key.expose(),
+ secret.expose(),
+ config.session_token.map(|token| token.expose().to_owned()),
+ None,
+ "litellm-role-source",
+ )),
+ _ => loader,
+ };
+ loader.load().await
+}
+
+#[derive(Debug)]
+struct WebIdentityProvider {
+ client: aws_sdk_sts::Client,
+ token: SecretValue,
+ role: String,
+ session_name: String,
+}
+
+impl ProvideCredentials for WebIdentityProvider {
+ fn provide_credentials<'a>(
+ &'a self,
+ ) -> aws_credential_types::provider::future::ProvideCredentials<'a>
+ where
+ Self: 'a,
+ {
+ aws_credential_types::provider::future::ProvideCredentials::new(async {
+ let response = self
+ .client
+ .assume_role_with_web_identity()
+ .role_arn(&self.role)
+ .role_session_name(&self.session_name)
+ .web_identity_token(self.token.expose())
+ .send()
+ .await
+ .map_err(CredentialsError::provider_error)?;
+ let credentials = response.credentials().ok_or_else(|| {
+ CredentialsError::provider_error(Error::MissingWebIdentityCredentials)
+ })?;
+ let expiration = SystemTime::try_from(*credentials.expiration())
+ .map_err(CredentialsError::provider_error)?;
+ Ok(Credentials::new(
+ credentials.access_key_id(),
+ credentials.secret_access_key(),
+ Some(credentials.session_token().to_owned()),
+ Some(expiration),
+ "litellm-web-identity",
+ ))
+ })
+ }
+}
+
+async fn is_already_running_as_role(
+ role: &str,
+ web_identity_role: Option<&str>,
+ config: &AwsAuthConfig,
+) -> Result {
+ if config.access_key_id.is_some() || config.secret_access_key.is_some() {
+ return Ok(false);
+ }
if role_identity(role).is_none() {
return Ok(false);
}
- if let (Ok(current_role), Ok(token_file)) = (
- std::env::var(AWS_ROLE_ARN),
- std::env::var(AWS_WEB_IDENTITY_TOKEN_FILE),
- ) && !token_file.is_empty()
- {
- return Ok(same_role_arns(role, ¤t_role));
+ if let Some(current_role) = web_identity_role {
+ return Ok(same_role_arns(role, current_role));
}
let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
@@ -473,16 +574,6 @@ pub fn sign_headers(
headers: &HeaderMap,
scope: SigningScope<'_>,
) -> Result {
- let identity: Identity = scope.credentials.clone().into();
- let params = v4::SigningParams::builder()
- .identity(&identity)
- .region(scope.region)
- .name(scope.service)
- .time(scope.time)
- .settings(SigningSettings::default())
- .build()
- .map(SigningParams::from)
- .map_err(Error::signing(SigningStage::Parameters))?;
let header_refs = headers
.iter()
.map(|(name, value)| {
@@ -494,16 +585,14 @@ pub fn sign_headers(
})
})
.collect::, _>>()?;
- let request = SignableRequest::new(
+ let instructions = sign_request(
method,
url,
header_refs.into_iter(),
- SignableBody::Bytes(body),
- )
- .map_err(Error::signing(SigningStage::Request))?;
- let (instructions, _) = sign(request, ¶ms)
- .map_err(Error::signing(SigningStage::Signature))?
- .into_parts();
+ body,
+ SigningSettings::default(),
+ scope,
+ )?;
instructions
.headers()
.map(|(name, value)| {
@@ -516,6 +605,60 @@ pub fn sign_headers(
.collect()
}
+pub fn presign_url(
+ url: &str,
+ expires_in: Duration,
+ scope: SigningScope<'_>,
+) -> Result {
+ let mut signed = url::Url::parse(url).map_err(Error::signing(SigningStage::Request))?;
+ let mut settings = SigningSettings::default();
+ settings.signature_location = SignatureLocation::QueryParams;
+ settings.expires_in = Some(expires_in);
+ let instructions = sign_request(
+ "GET",
+ signed.as_str(),
+ std::iter::empty(),
+ &[],
+ settings,
+ scope,
+ )?;
+ let (signature, auth): (Vec<_>, Vec<_>) = instructions
+ .params()
+ .iter()
+ .map(|(name, value)| (*name, value.as_ref()))
+ .partition(|(name, _)| *name == "X-Amz-Signature");
+ signed
+ .query_pairs_mut()
+ .extend_pairs(auth.into_iter().chain(signature));
+ Ok(signed.into())
+}
+
+fn sign_request<'a>(
+ method: &'a str,
+ url: &'a str,
+ headers: impl Iterator- ,
+ body: &'a [u8],
+ settings: SigningSettings,
+ scope: SigningScope<'_>,
+) -> Result {
+ let identity: Identity = scope.credentials.clone().into();
+ let params = v4::SigningParams::builder()
+ .identity(&identity)
+ .region(scope.region)
+ .name(scope.service)
+ .time(scope.time)
+ .settings(settings)
+ .build()
+ .map(SigningParams::from)
+ .map_err(Error::signing(SigningStage::Parameters))?;
+ let request = SignableRequest::new(method, url, headers, SignableBody::Bytes(body))
+ .map_err(Error::signing(SigningStage::Request))?;
+ let (instructions, _) = sign(request, ¶ms)
+ .map_err(Error::signing(SigningStage::Signature))?
+ .into_parts();
+ Ok(instructions)
+}
+
/// Model-id and region parsing shared by every Bedrock route.
pub fn bedrock_model_id_and_region(model: &str) -> (String, Option) {
let mut stripped = model;
@@ -586,16 +729,26 @@ pub fn aws_auth_config(
env_lookup: &dyn Fn(&str) -> Option,
) -> AwsAuthConfig {
AwsAuthConfig {
- access_key_id: params.resolve(&AwsParams::ACCESS_KEY_ID, env_lookup),
- secret_access_key: params.resolve(&AwsParams::SECRET_ACCESS_KEY, env_lookup),
- session_token: params.resolve(&AwsParams::SESSION_TOKEN, env_lookup),
+ access_key_id: params
+ .resolve(&AwsParams::ACCESS_KEY_ID, env_lookup)
+ .map(SecretValue::new),
+ secret_access_key: params
+ .resolve(&AwsParams::SECRET_ACCESS_KEY, env_lookup)
+ .map(SecretValue::new),
+ session_token: params
+ .resolve(&AwsParams::SESSION_TOKEN, env_lookup)
+ .map(SecretValue::new),
region_name: params.resolve(&AwsParams::REGION, env_lookup),
session_name: params.resolve(&AwsParams::SESSION_NAME, env_lookup),
profile_name: params.resolve(&AwsParams::PROFILE_NAME, env_lookup),
role_name: params.resolve(&AwsParams::ROLE_NAME, env_lookup),
- web_identity_token: params.resolve(&AwsParams::WEB_IDENTITY_TOKEN, env_lookup),
+ web_identity_token: params
+ .resolve(&AwsParams::WEB_IDENTITY_TOKEN, env_lookup)
+ .map(SecretValue::new),
sts_endpoint: params.resolve(&AwsParams::STS_ENDPOINT, env_lookup),
- external_id: params.resolve(&AwsParams::EXTERNAL_ID, env_lookup),
+ external_id: params
+ .resolve(&AwsParams::EXTERNAL_ID, env_lookup)
+ .map(SecretValue::new),
}
}
@@ -660,10 +813,133 @@ mod tests {
use crate::constants::{AWS_REGION, BEDROCK_SERVICE};
use reqwest::header::CONTENT_TYPE;
+ #[cfg(unix)]
+ #[rstest::fixture]
+ fn process_profile() -> aws_runtime::env_config::file::EnvConfigFiles {
+ let credentials = serde_json::json!({
+ "Version": 1,
+ "AccessKeyId": "process-access",
+ "SecretAccessKey": "process-secret",
+ "SessionToken": "process-session",
+ });
+ aws_runtime::env_config::file::EnvConfigFiles::builder()
+ .include_default_config_file(false)
+ .include_default_credentials_file(false)
+ .with_contents(
+ aws_runtime::env_config::file::EnvConfigFileKind::Config,
+ format!(
+ "[profile smoke]\ncredential_process = /usr/bin/printf '%s' '{credentials}'\n"
+ ),
+ )
+ .build()
+ }
+
+ #[cfg(unix)]
+ #[rstest::rstest]
+ #[tokio::test]
+ async fn profile_process_credentials_can_be_loaded(
+ process_profile: aws_runtime::env_config::file::EnvConfigFiles,
+ ) {
+ let provider = profile_provider(
+ "smoke",
+ Some("us-west-2".into()),
+ aws_config::provider_config::ProviderConfig::without_region(),
+ aws_config::profile::ProfileFileCredentialsProvider::builder()
+ .profile_files(process_profile),
+ )
+ .await
+ .unwrap();
+ let credentials = provider.provide_credentials().await.unwrap();
+
+ assert_eq!(credentials.access_key_id(), "process-access");
+ assert_eq!(credentials.secret_access_key(), "process-secret");
+ assert_eq!(credentials.session_token(), Some("process-session"));
+ }
+
+ #[derive(Debug)]
+ struct StsConnector;
+
+ impl aws_smithy_runtime_api::client::http::HttpConnector for StsConnector {
+ fn call(
+ &self,
+ request: aws_smithy_runtime_api::client::orchestrator::HttpRequest,
+ ) -> aws_smithy_runtime_api::client::http::HttpConnectorFuture {
+ assert!(request.uri().contains("sts.us-west-2."));
+ aws_smithy_runtime_api::client::http::HttpConnectorFuture::new(async {
+ Ok(
+ aws_smithy_runtime_api::client::orchestrator::HttpResponse::new(
+ 200_u16.try_into().unwrap(),
+ aws_smithy_types::body::SdkBody::from(
+ r#"
+
+ role-accessrole-secret
+ role-session2100-01-01T00:00:00Z
+ arn:aws:sts::123456789012:assumed-role/smoke/session
+ smoke:session
+ smoke"#,
+ ),
+ ),
+ )
+ })
+ }
+ }
+
+ #[rstest::rstest]
+ #[tokio::test]
+ async fn role_profile_uses_region_when_acquiring_sts_credentials() {
+ use aws_smithy_runtime_api::client::http::{SharedHttpConnector, http_client_fn};
+ let files = aws_runtime::env_config::file::EnvConfigFiles::builder()
+ .include_default_config_file(false)
+ .include_default_credentials_file(false)
+ .with_contents(
+ aws_runtime::env_config::file::EnvConfigFileKind::Config,
+ "[profile smoke]\nrole_arn = arn:aws:iam::123456789012:role/smoke\nsource_profile = source\n\n[profile source]\naws_access_key_id = source-access\naws_secret_access_key = source-secret\n",
+ ).build();
+ let config = aws_config::provider_config::ProviderConfig::without_region()
+ .with_http_client(http_client_fn(|_, _| {
+ SharedHttpConnector::new(StsConnector)
+ }));
+ let credentials = profile_provider(
+ "smoke",
+ Some("us-west-2".into()),
+ config,
+ aws_config::profile::ProfileFileCredentialsProvider::builder().profile_files(files),
+ )
+ .await
+ .unwrap();
+ let credentials = credentials.provide_credentials().await.unwrap();
+ assert_eq!(credentials.access_key_id(), "role-access");
+ assert_eq!(credentials.secret_access_key(), "role-secret");
+ assert_eq!(credentials.session_token(), Some("role-session"));
+ assert!(credentials.expiry().is_some());
+ }
+
fn no_env(_: &str) -> Option {
None
}
+ #[rstest::rstest]
+ #[case::access_key(Some("source-access"), None)]
+ #[case::secret_key(None, Some("source-secret"))]
+ #[case::source_pair(Some("source-access"), Some("source-secret"))]
+ #[tokio::test]
+ async fn explicit_source_keys_cannot_reuse_an_ambient_role(
+ #[case] access_key: Option<&str>,
+ #[case] secret_key: Option<&str>,
+ ) {
+ let role = "arn:aws:iam::123456789012:role/target";
+ let config = AwsAuthConfig {
+ access_key_id: access_key.map(SecretValue::new),
+ secret_access_key: secret_key.map(SecretValue::new),
+ ..Default::default()
+ };
+ assert!(
+ !is_already_running_as_role(role, Some(role), &config)
+ .await
+ .unwrap()
+ );
+ }
+
#[test]
fn secret_names_cover_environment_reads() {
let seen = std::sync::Arc::new(std::sync::Mutex::new(
@@ -706,7 +982,10 @@ mod tests {
};
assert_eq!(
- aws_auth_config(¶ms, &env).secret_access_key.as_deref(),
+ aws_auth_config(¶ms, &env)
+ .secret_access_key
+ .as_ref()
+ .map(SecretValue::expose),
expected
);
assert_eq!(
@@ -831,6 +1110,53 @@ mod tests {
);
}
+ #[rstest::rstest]
+ #[case::running_with_web_identity("/var/run/token", Some("arn:aws:iam::1:role/current"))]
+ #[case::empty_token_file("", None)]
+ #[tokio::test]
+ async fn assume_role_reads_the_ambient_role_from_the_secret_snapshot(
+ #[case] token_file: &str,
+ #[case] expected: Option<&str>,
+ ) {
+ use litellm_secrets::source::SecretSource;
+
+ struct WebIdentitySecrets<'a>(&'a str);
+
+ impl SecretSource for WebIdentitySecrets<'_> {
+ fn get_secret_str<'a>(
+ &'a self,
+ name: &'a str,
+ ) -> futures_util::future::BoxFuture<
+ 'a,
+ Result