Merge pull request #25616 from BerriAI/main

merge main
This commit is contained in:
Sameer Kankute 2026-04-13 08:43:43 +05:30 • committed by GitHub
commit fa605d85c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
583 changed files with 19121 additions and 4730 deletions

View file

@ -32,6 +32,13 @@ If you're seeing a delay in your PR being merged, ping the LiteLLM Team on [Slac
- [ ] **Merge / cherry-pick CI run**
Links:
## Screenshots / Proof of Fix
<!-- Include screenshots, screen recordings, or log output demonstrating that your changes work as expected.
For bug fixes: show reproduction before the fix and passing behavior after.
For new features: show the feature working end-to-end.
For UI changes: include before/after screenshots. -->
## Type
<!-- Select the type of Pull Request -->

View file

@ -12,7 +12,7 @@
</a>
</p>
</p>
<h4 align="center"><a href="https://docs.litellm.ai/docs/simple_proxy" target="_blank">LiteLLM Proxy Server (AI Gateway)</a> | <a href="https://docs.litellm.ai/docs/enterprise#hosted-litellm-proxy" target="_blank"> Hosted Proxy</a> | <a href="https://litellm.ai/enterprise"target="_blank">Enterprise Tier</a> | <a href="https://litellm.ai/" target="_blank">Website</a></h4>
<h4 align="center"><a href="https://docs.litellm.ai/docs/simple_proxy" target="_blank">LiteLLM Proxy Server (AI Gateway)</a> | <a href="https://docs.litellm.ai/docs/enterprise#hosted-litellm-proxy" target="_blank"> Hosted Proxy</a> | <a href="https://litellm.ai/enterprise"target="_blank">Enterprise Tier</a> | <a href="https://www.litellm.ai/ai-gateway" target="_blank">Website</a></h4>
<h4 align="center">
<a href="https://pypi.org/project/litellm/" target="_blank">
<img src="https://img.shields.io/pypi/v/litellm.svg" alt="PyPI Version">

View file

@ -71,8 +71,16 @@ WORKDIR /app
RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh
RUN sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh
# Run as non-root user
RUN groupadd --gid 1000 appuser && useradd --uid 1000 --gid 1000 --no-create-home appuser \
&& chown -R appuser:appuser /app
USER appuser
# Expose the necessary port
EXPOSE 4000/tcp
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/health')"]
# Override the CMD instruction with your desired command and arguments
CMD ["--port", "4000", "--config", "config.yaml", "--detailed_debug"]

View file

@ -13,12 +13,12 @@ RUN pip install --no-cache-dir -r requirements.txt
RUN chmod +x /app/health_check_client.py
# Run as non-root user
RUN adduser --disabled-password --gecos "" --uid 1001 healthcheck
USER healthcheck
RUN groupadd --gid 1000 appuser && useradd --uid 1000 --gid 1000 --no-create-home appuser
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python /app/health_check_client.py --help || exit 1
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["python", "/app/health_check_client.py", "--help"]
# Set entrypoint
ENTRYPOINT ["python", "/app/health_check_client.py"]

View file

@ -4,6 +4,7 @@ title: "April Townhall: Security + Product Roadmap"
date: 2026-04-02T07:30:00
authors:
- krrish
- ishaan-alt
description: "Join the LiteLLM April townhall on Friday, 10 April at 7:30 AM to learn about LiteLLM's security and product roadmap."
tags: [announcement, townhall]
hide_table_of_contents: true

View file

@ -0,0 +1,162 @@
---
slug: april-townhall-updates
title: "April Townhall Updates: CI/CD v2, Stability, and Product Roadmap"
date: 2026-04-10T12:00:00
authors:
- krrish
- ishaan-alt
description: "A recap of the April LiteLLM town hall covering CI/CD v2, product stability work, and the near-term roadmap."
tags: [townhall, security, reliability, product]
hide_table_of_contents: false
---
import Image from '@theme/IdealImage';
Thank you to everyone who joined our April town hall.
We used the session to share our CI/CD v2 improvements, product stability work, and what we are prioritizing next across reliability and product roadmap.
{/* truncate */}
## CI/CD v2 improvements
Our CI/CD v2 work is centered around four goals:
1. **Limit** what each package can access
2. **Reduce** the number of sensitive environment variables
3. **Avoid** compromised packages
4. **Reduce the risk of** release tampering
#### New architecture: isolated environments
We have begun moving to isolated environments for distinct CI/CD stages to reduce the chance that a single compromised step can inherit broad access across the entire pipeline.
<Image
img={require('../../img/april_townhall_isolated_environments.png')}
style={{width: '900px', height: 'auto', display: 'block'}}
/>
#### Current rollout status
These changes are deployed in our current release workflow. [See here](https://github.com/BerriAI/litellm/tags)
#### Independently verify releases
A key part of CI/CD v2 is supporting independent verification of release artifacts using our published verification process, while reducing reliance on any single credential or release path.
[**Learn more about how to verify releases**](https://docs.litellm.ai/docs/proxy/docker_image_security)
<Image
img={require('../../img/verify_releases.png')}
style={{width: '900px', height: 'auto', display: 'block'}}
/>
## Stability improvements
### SDLC improvements
This month, we're focusing on process stability improvements around:
- Improving main-branch stability
- Mapping UI QA to built Docker images for 1:1 environment parity
- Consistent release tags across PyPI and Docker
- Fixing release notes publication
#### Improving main-branch stability
We're introducing a staging-gated flow:
<Image
img={require('../../img/stable_main.png')}
style={{width: '900px', height: 'auto', display: 'block'}}
/>
- Only an internal staging branch can push to `main`.
- PRs to that staging branch must pass CircleCI LLM API testing.
- Collision handling happens on staging, which is designed to reduce unstable changes reaching `main`.
#### UI QA in Docker environment
Moving forward, all UI QA will be performed in the built Docker image that users run.
Previously, some UI QA paths were run in local environments that did not fully replicate Docker runtime conditions.
That contributed to release-specific issues, including MCP registration problems in `v1.82.3`.
#### Consistent release tags
Today we publish releases for multiple scenarios:
- Dev (Built of a PR for a customer-specific scenario)
- Nightly (Passes all CI/CD checks)
- Release Candidate (Passes all CI/CD checks + manual UI QA)
- Stable (intended to pass all CI/CD checks + manual UI QA + 7 days of production testing)
We are targeting a consistent naming convention across PyPI and Docker by the end of April.
#### Release notes
CI/CD v2 changes moved release notes to a manual path. This is a temporary solution while we investigate a better automated workflow. We are targeting a more consistent process by the end of April.
### Product stability improvements
#### Stable Prisma migrations
Today, we have observed several migration failure classes:
- Migration not applied
- Migration marked applied but incomplete
- Migration not applied due to non-root image issues
We're prioritizing this work this month and have assigned an engineering owner to the effort. Our target is to resolve these error classes by the end of April.
#### UI type safety
Another area of focus is improving the stability of the UI. Today, one cause of errors is that the UI maintains its own assumptions about backend API types. This can lead to issues when backend responses differ from UI assumptions.
We aim to move to having the UI and Backend be in sync with each other, and are exploring OpenAPI-driven mapping to achieve this.
## Product roadmap
### Our Assumptions
Over the next few years, we expect:
- Companies will give employees more AI tools.
- More AI agents will move into production workflows across HR, finance, support, and operations.
### Our Inferences
#### Near-term
- AI spend will increase.
- Uptime and latency will become even more important.
- More AI resources (skills, CLIs, and related assets) will require governance.
- Agent and MCP usage patterns will require deeper controls.
- Broader developer adoption will increase the need for simpler, more discoverable tooling.
#### Long-term
- We expect many organizations to treat agent auditability (how decisions were made across LLM + MCP + sub-agent inputs/outputs) as a compliance expectation.
- Permission management will get more complex as user-agent interaction chains deepen.
Roadmap timelines in this post are targets and may evolve based on validation and user feedback.
## April investments
### Reliability
- Increase uptime for 10k+ RPS scenarios.
- Investigate latency overhead for long-running Claude Code requests.
### Feature reliability
- Polish MCP authentication.
- Better understand how teams are using agents through LiteLLM.
### Governance
- Launch Skills as a first-class citizen in LiteLLM.
## Q&A
Thank you again for all the questions and direct feedback. We will keep sharing concrete progress updates as these efforts ship.
## Hiring
We are actively hiring across several roles, please apply [here](https://jobs.ashbyhq.com/litellm) if you're interested!

View file

@ -24,7 +24,7 @@ ishaan:
# Alias for typo in name
ishaan-alt:
name: Ishaan Jaff
name: Ishaan Jaffer
title: CTO, LiteLLM
url: https://www.linkedin.com/in/reffajnaahsi/
image_url: https://pbs.twimg.com/profile_images/1613813310264340481/lz54oEiB_400x400.jpg

View file

@ -0,0 +1,159 @@
import React from 'react';
const s = {
fig: {margin: '2.5rem 0', fontFamily: 'inherit'},
box: {borderRadius: 12, border: '1px solid #e5e7eb', background: '#fff', padding: '2rem 2.5rem'},
label: {fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.12em', color: '#9ca3af', textAlign: 'center', marginBottom: '1.5rem'},
caption: {textAlign: 'center', fontSize: 12, color: '#9ca3af', marginTop: 12},
node: (border='#d1d5db', bg='#f9fafb') => ({
border: `1px solid ${border}`, borderRadius: 6, padding: '8px 20px',
fontSize: 13, background: bg, display: 'inline-block',
}),
arrow: {display: 'flex', flexDirection: 'column', alignItems: 'center'},
};
const SmallArrow = ({color='#9ca3af'}) => (
<svg width="2" height="28" style={{display:'block'}}>
<line x1="1" y1="0" x2="1" y2="22" stroke={color} strokeWidth="1.5"/>
<polygon points="1,28 -2,21 4,21" fill={color}/>
</svg>
);
export function CascadeFailure() {
return (
<figure style={s.fig}>
<div style={s.box}>
<p style={s.label}>Without circuit breaker — cascade failure</p>
<div style={{display:'flex', flexDirection:'column', alignItems:'center', gap:0}}>
<div style={s.node()}>LiteLLM Pod (×100)</div>
<SmallArrow />
<div style={s.node()}>Rate limit / cache check</div>
<div style={{position:'relative', display:'flex', flexDirection:'column', alignItems:'center'}}>
<SmallArrow color="#f87171"/>
<span style={{position:'absolute', left:8, top:4, fontSize:11, color:'#f87171', whiteSpace:'nowrap'}}>hangs 30s per request</span>
</div>
<div style={s.node('#fca5a5','#fef2f2')}><span style={{color:'#b91c1c', fontWeight:600}}>Redis — degraded, timing out</span></div>
<SmallArrow color="#fb923c"/>
<div style={s.node('#fdba74','#fff7ed')}><span style={{color:'#c2410c', fontWeight:600}}>Postgres — 100× normal read load</span></div>
<SmallArrow />
<div style={{...s.node('#111827','#111827'), color:'#fff', fontWeight:600}}>Total outage — gateway down</div>
</div>
</div>
<figcaption style={s.caption}>Slow Redis → every auth check times out → database overwhelmed → full cascade</figcaption>
</figure>
);
}
export function CircuitBreakerStates() {
const circle = (border, color, label, sub) => (
<div style={{display:'flex', flexDirection:'column', alignItems:'center', width: 140}}>
<div style={{width:88, height:88, borderRadius:'50%', border:`2px solid ${border}`, background:'#fff', display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center'}}>
<span style={{fontSize:11, fontWeight:700, color, letterSpacing:'0.06em'}}>{label}</span>
<span style={{fontSize:10, color:'#9ca3af', marginTop:2}}>{sub}</span>
</div>
<p style={{fontSize:11, color:'#6b7280', textAlign:'center', marginTop:10, lineHeight:1.5}}>{'\u00a0'}</p>
</div>
);
const arrow = (label) => (
<div style={{display:'flex', flexDirection:'column', alignItems:'center', marginTop:36, marginLeft:4, marginRight:4}}>
<span style={{fontSize:10, color:'#6b7280', marginBottom:4}}>{label}</span>
<div style={{display:'flex', alignItems:'center'}}>
<div style={{height:1, width:48, background:'#9ca3af'}}/>
<svg width="8" height="8" style={{marginLeft:-1}}><polygon points="0,0 8,4 0,8" fill="#6b7280"/></svg>
</div>
</div>
);
return (
<figure style={s.fig}>
<div style={s.box}>
<p style={s.label}>Circuit breaker state machine</p>
<div style={{display:'flex', justifyContent:'center', alignItems:'flex-start'}}>
{circle('#1f2937','#111827','CLOSED','normal')}
{arrow('5 failures')}
{circle('#f87171','#dc2626','OPEN','fast-fail')}
{arrow('60s timeout')}
{circle('#fbbf24','#b45309','HALF-OPEN','probing')}
</div>
<div style={{display:'flex', justifyContent:'center', gap:32, marginTop:24}}>
<div style={{display:'flex', flexDirection:'column', alignItems:'center', gap:4}}>
<div style={{display:'flex', alignItems:'center', gap:4}}>
<svg width="8" height="8"><polygon points="8,0 0,4 8,8" fill="#16a34a"/></svg>
<div style={{height:1, width:100, background:'#16a34a'}}/>
</div>
<span style={{fontSize:10, color:'#16a34a'}}>probe success → CLOSED</span>
</div>
<div style={{display:'flex', flexDirection:'column', alignItems:'center', gap:4}}>
<div style={{display:'flex', alignItems:'center', gap:4}}>
<svg width="8" height="8"><polygon points="8,0 0,4 8,8" fill="#ef4444"/></svg>
<div style={{height:1, width:100, borderTop:'2px dashed #f87171'}}/>
</div>
<span style={{fontSize:10, color:'#ef4444'}}>probe failure → OPEN again</span>
</div>
</div>
</div>
</figure>
);
}
export function CircuitBreakerFlow() {
return (
<figure style={s.fig}>
<div style={s.box}>
<p style={s.label}>With circuit breaker — graceful degradation</p>
<div style={{display:'flex', flexDirection:'column', alignItems:'center'}}>
<div style={s.node()}>Incoming request</div>
<SmallArrow />
<div style={{...s.node('#111827'), border:'2px solid #111827', fontWeight:600}}>Circuit Breaker</div>
<div style={{display:'flex', gap:80, marginTop:20, alignItems:'flex-start'}}>
<div style={{display:'flex', flexDirection:'column', alignItems:'center', gap:8}}>
<SmallArrow />
<span style={{fontSize:10, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.06em', color:'#6b7280', border:'1px solid #e5e7eb', borderRadius:4, padding:'2px 8px'}}>Closed</span>
<div style={{...s.node(), textAlign:'center', fontSize:13}}>Redis call<br/><span style={{fontSize:11, color:'#9ca3af'}}>normal latency</span></div>
</div>
<div style={{display:'flex', flexDirection:'column', alignItems:'center', gap:8}}>
<SmallArrow />
<span style={{fontSize:10, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.06em', color:'#ef4444', border:'1px solid #fca5a5', borderRadius:4, padding:'2px 8px'}}>Open</span>
<div style={{...s.node('#fca5a5'), textAlign:'center', fontSize:13}}>Fast-fail — 0ms<br/><span style={{fontSize:11, color:'#9ca3af'}}>no network call</span></div>
<SmallArrow />
<div style={{...s.node(), textAlign:'center', fontSize:13}}>DB fallback<br/><span style={{fontSize:11, color:'#9ca3af'}}>bounded load</span></div>
</div>
</div>
<div style={{...s.node('#111827','#111827'), color:'#fff', fontWeight:600, marginTop:24}}>Request completes — gateway stays up</div>
</div>
</div>
<figcaption style={s.caption}>Redis down → circuit opens → 0ms rejection → DB absorbs bounded fallback traffic</figcaption>
</figure>
);
}
export function IncidentTimeline() {
const row = (color, text) => (
<div style={{display:'flex', alignItems:'flex-start', gap:10, marginBottom:12}}>
<div style={{marginTop:5, width:6, height:6, borderRadius:'50%', background:color, flexShrink:0}}/>
<p style={{fontSize:13, color:'#4b5563', margin:0, lineHeight:1.5}}>{text}</p>
</div>
);
return (
<figure style={s.fig}>
<div style={s.box}>
<p style={s.label}>Redis degrades — before vs. after</p>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:20}}>
<div style={{border:'1px solid #e5e7eb', borderRadius:8, padding:20}}>
<p style={{fontSize:10, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.1em', color:'#9ca3af', marginBottom:16}}>Without circuit breaker</p>
{row('#f87171','All 100 pods hang for 30s on each auth check')}
{row('#f87171','Threadpools fill up, requests queue')}
{row('#f87171','100× simultaneous DB fallbacks overwhelm Postgres')}
{row('#f87171','Requires manual intervention to recover')}
</div>
<div style={{border:'1px solid #111827', borderRadius:8, padding:20}}>
<p style={{fontSize:10, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.1em', color:'#9ca3af', marginBottom:16}}>With circuit breaker</p>
{row('#111827','Circuit opens after 5 failures — 0ms fast-fail')}
{row('#111827','Auth falls back to DB — bounded, not 100× load')}
{row('#111827','Cache miss rate temporarily elevated — gateway stays up')}
{row('#111827','Auto-recovers when Redis comes back — no intervention needed')}
</div>
</div>
</div>
</figure>
);
}

View file

@ -0,0 +1,141 @@
---
slug: redis-circuit-breaker
title: "Making the AI Gateway Resilient to Redis Failures"
date: 2026-04-11T09:00:00
authors:
- ishaan
description: "How LiteLLM's production AI Gateway handles Redis degradation at scale without cascading failures — circuit breaker pattern, 0ms fast-fail, automatic recovery."
tags: [reliability, redis, infrastructure, engineering, ai-gateway]
hide_table_of_contents: true
---
import { CascadeFailure, CircuitBreakerStates, CircuitBreakerFlow, IncidentTimeline } from './diagrams';
*Last Updated: April 2026*
Enterprise AI Gateway deployments put Redis in the hot path for nearly every request: rate limiting, cache lookups, spend tracking. When Redis is healthy, the latency contribution is single-digit milliseconds — invisible to end users. When it degrades, a production AI Gateway needs to stay up regardless.
Running LiteLLM at scale across 100+ pods means designing for failure modes before they appear. The easy case is Redis going fully down: fail fast, fall through to the database, continue serving requests. The hard case — the one that takes down gateways — is a *slow* Redis: still accepting connections, still responding, but timing out after 20-30 seconds per operation.
{/* truncate */}
## Why slow Redis is harder than a full outage
<CascadeFailure />
With 100 pods each hanging 30 seconds on every auth check, threadpools fill up and requests queue. By the time Redis times out and falls through to Postgres, the database receives 100× its normal load from simultaneous fallbacks. A slow Redis becomes a database outage becomes a full gateway outage. A production-grade AI Gateway cannot allow one degraded dependency to cascade into total failure.
## The fix: circuit breaker pattern
The circuit breaker pattern tracks consecutive failures and cuts off the unhealthy dependency before it cascades. Instead of hanging 30 seconds on each Redis call, the circuit opens after 5 consecutive failures and fast-fails at 0ms — no network call, no wait.
<CircuitBreakerStates />
Three states:
- **CLOSED** — normal. All Redis calls pass through.
- **OPEN** — Redis is unhealthy. Every call fast-fails instantly. Requests continue with degraded-but-functional behavior: auth and rate limiting fall back to the database.
- **HALF-OPEN** — after 60 seconds, one probe request tests recovery. Success closes the circuit; failure resets the timer.
This is how a reliable AI Gateway handles infrastructure degradation: stay up, degrade gracefully, recover automatically.
## How requests flow through the AI Gateway
<CircuitBreakerFlow />
When the circuit is open, the gateway does not stall. Auth checks fall back to Postgres — slower, but bounded. The database absorbs the load because it receives *some* requests via DB fallback, not *all* 100 pods simultaneously dumping their queued requests after a 30-second timeout.
The difference between a resilient AI Gateway and a fragile one: controlled degradation vs. uncontrolled cascade.
## The implementation
```python
class RedisCircuitBreaker:
def __init__(self, failure_threshold: int, recovery_timeout: int):
self.failure_threshold = failure_threshold # default: 5
self.recovery_timeout = recovery_timeout # default: 60s
self._failure_count = 0
self._state = self.CLOSED
def is_open(self) -> bool:
if self._state == self.OPEN:
if time.time() - self._opened_at > self.recovery_timeout:
self._state = self.HALF_OPEN
return False # this caller is the recovery probe
return True # fast-fail
return False
def record_failure(self):
self._failure_count += 1
self._opened_at = time.time()
if self._failure_count >= self.failure_threshold:
self._state = self.OPEN # open the circuit
def record_success(self):
self._failure_count = 0
self._state = self.CLOSED # Redis recovered
```
Every async Redis operation goes through a decorator that checks the breaker before touching the network. When open, it raises immediately:
```python
@_redis_circuit_breaker_guard
async def async_get_cache(self, key: str):
...
```
The decorator handles all bookkeeping — success resets nothing, failures increment the counter, exceptions trigger `record_failure()`. The caller sees a clean exception and falls through to its normal non-Redis path. No changes required in calling code.
## AI Gateway resilience in production
<IncidentTimeline />
Redis degradation events no longer cascade in production. The observable symptom during a Redis slowdown is a temporary bump in cache miss rate — the right failure mode for a resilient AI Gateway. Auth still works. Rate limiting still works. Spend tracking still works, at slightly higher DB cost. Recovery is fully automatic when Redis comes back.
```bash
# configure via environment variables
REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD=5 # failures before opening
REDIS_CIRCUIT_BREAKER_RECOVERY_TIMEOUT=60 # seconds before probe
```
The circuit breaker ships on by default in all LiteLLM versions since `v1.82.0`. No configuration needed for most deployments.
## Key Takeaways
- A slow Redis is more dangerous than a downed one: 30-second timeouts across 100+ pods overwhelm Postgres at 100× normal load
- LiteLLM's AI Gateway uses a circuit breaker that fast-fails Redis calls at 0ms after 5 consecutive failures
- Three states: CLOSED (normal), OPEN (fast-fail + DB fallback), HALF-OPEN (probe recovery)
- Auth, rate limiting, and spend tracking continue working during Redis outages
- Resilient, production-grade behavior — enabled by default since `v1.82.0`, no configuration required
---
### Frequently Asked Questions
### Does the circuit breaker affect normal Redis performance?
No. When Redis is healthy (circuit CLOSED), every call passes through with zero overhead. The breaker only activates after 5 consecutive failures — transparent under normal conditions.
### What happens to rate limiting when the circuit is open?
Rate limiting falls back to Postgres with bounded load. Limits remain enforced at slightly higher DB cost until Redis recovers and the circuit closes automatically.
### How is this different from basic Redis retry logic?
Retry logic still waits for each timeout (30s × retries). The circuit breaker cuts the connection immediately at 0ms after the failure threshold, preventing threadpool exhaustion across all pods simultaneously. Retries make slow-Redis worse; the circuit breaker contains it.
### Is this available in LiteLLM OSS?
Yes. The circuit breaker ships in LiteLLM OSS (Apache 2.0) by default since `v1.82.0`. [LiteLLM Enterprise](https://litellm.ai/enterprise) adds SSO/SCIM, air-gapped deployment, 24/7 SLA support, and advanced guardrails on top of the OSS foundation.
---
## Conclusion
Redis resilience is one layer of what makes LiteLLM a production-grade, reliable AI Gateway at scale. The circuit breaker pattern ensures infrastructure degradation stays contained — the right failure mode is a temporary cache miss rate bump, not a full outage. This is how AI Gateway infrastructure should behave under pressure: degrade gracefully, recover automatically, keep serving traffic. For teams with strict uptime and compliance requirements, [LiteLLM Enterprise](https://litellm.ai/enterprise) provides the additional controls needed for regulated production environments.
## Recommended Reading
- [LiteLLM AI Gateway — full feature overview](https://docs.litellm.ai/docs/simple_proxy)
- [Load balancing and routing across 100+ LLM providers](https://docs.litellm.ai/docs/routing)
- [Spend tracking and budget controls](https://docs.litellm.ai/docs/proxy/cost_tracking)

View file

@ -5,6 +5,55 @@ import Image from '@theme/IdealImage';
Benchmarks for LiteLLM Gateway (Proxy Server) tested against a fake OpenAI endpoint.
LiteLLM Gateway has **8ms P95 latency** at 1k RPS (See benchmarks [here](#4-instances))
## Machine Spec used for testing
Each machine deploying LiteLLM had the following specs:
- 4 CPU
- 8GB RAM
## Configuration
- Database: PostgreSQL
- Redis: Not used
### 2 Instance LiteLLM Proxy
In these tests the baseline latency characteristics are measured against a fake-openai-endpoint.
#### Performance Metrics
| **Type** | **Name** | **Median (ms)** | **95%ile (ms)** | **99%ile (ms)** | **Average (ms)** | **Current RPS** |
| --- | --- | --- | --- | --- | --- | --- |
| POST | /chat/completions | 200 | 630 | 1200 | 262.46 | 1035.7 |
| Custom | LiteLLM Overhead Duration (ms) | 12 | 29 | 43 | 14.74 | 1035.7 |
| | Aggregated | 100 | 430 | 930 | 138.6 | 2071.4 |
<!-- <Image img={require('../img/1_instance_proxy.png')} /> -->
<!-- ## **Horizontal Scaling - 10K RPS**
<Image img={require('../img/instances_vs_rps.png')} /> -->
### 4 Instances
| **Type** | **Name** | **Median (ms)** | **95%ile (ms)** | **99%ile (ms)** | **Average (ms)** | **Current RPS** |
| --- | --- | --- | --- | --- | --- | --- |
| POST | /chat/completions | 100 | 150 | 240 | 111.73 | 1170 |
| Custom | LiteLLM Overhead Duration (ms) | 2 | 8 | 13 | 3.32 | 1170 |
| | Aggregated | 77 | 130 | 180 | 57.53 | 2340 |
#### Key Findings
- Doubling from 2 to 4 LiteLLM instances halves median latency: 200 ms → 100 ms.
- High-percentile latencies drop significantly: P95 630 ms → 150 ms, P99 1,200 ms → 240 ms.
- Setting workers equal to CPU count gives optimal performance.
## Setting Up Benchmarking with Network Mock
The fastest way to benchmark proxy overhead is using `network_mock` mode. This intercepts outbound requests at the httpx transport layer and returns canned responses, no need for setting up a mock provider.
@ -41,6 +90,8 @@ litellm --config benchmark_config.yaml --port 4000 --num_workers 8
python scripts/benchmark_mock.py --requests 2000 --max-concurrent 200 --runs 3
```
Get the benchmarking script [here](https://github.com/BerriAI/litellm/blob/main/scripts/benchmark_mock.py)
This measures pure proxy overhead on the hot path without any network latency to a real or fake provider.
## Setting Up a Fake OpenAI Endpoint
@ -61,38 +112,6 @@ model_list:
api_key: "test"
```
### 2 Instance LiteLLM Proxy
In these tests the baseline latency characteristics are measured against a fake-openai-endpoint.
#### Performance Metrics
| **Type** | **Name** | **Median (ms)** | **95%ile (ms)** | **99%ile (ms)** | **Average (ms)** | **Current RPS** |
| --- | --- | --- | --- | --- | --- | --- |
| POST | /chat/completions | 200 | 630 | 1200 | 262.46 | 1035.7 |
| Custom | LiteLLM Overhead Duration (ms) | 12 | 29 | 43 | 14.74 | 1035.7 |
| | Aggregated | 100 | 430 | 930 | 138.6 | 2071.4 |
<!-- <Image img={require('../img/1_instance_proxy.png')} /> -->
<!-- ## **Horizontal Scaling - 10K RPS**
<Image img={require('../img/instances_vs_rps.png')} /> -->
### 4 Instances
| **Type** | **Name** | **Median (ms)** | **95%ile (ms)** | **99%ile (ms)** | **Average (ms)** | **Current RPS** |
| --- | --- | --- | --- | --- | --- | --- |
| POST | /chat/completions | 100 | 150 | 240 | 111.73 | 1170 |
| Custom | LiteLLM Overhead Duration (ms) | 2 | 8 | 13 | 3.32 | 1170 |
| | Aggregated | 77 | 130 | 180 | 57.53 | 2340 |
#### Key Findings
- Doubling from 2 to 4 LiteLLM instances halves median latency: 200 ms → 100 ms.
- High-percentile latencies drop significantly: P95 630 ms → 150 ms, P99 1,200 ms → 240 ms.
- Setting workers equal to CPU count gives optimal performance.
## `/realtime` API Benchmarks
End-to-end latency benchmarks for the `/realtime` endpoint tested against a fake realtime endpoint.
@ -115,17 +134,6 @@ End-to-end latency benchmarks for the `/realtime` endpoint tested against a fake
| **System** | 4 vCPUs, 8 GB RAM, 4 workers, 4 instances |
| **Database** | PostgreSQL (Redis unused) |
## Machine Spec used for testing
Each machine deploying LiteLLM had the following specs:
- 4 CPU
- 8GB RAM
## Configuration
- Database: PostgreSQL
- Redis: Not used
## Infrastructure Recommendations

View file

@ -0,0 +1,422 @@
# Advisor Tool
Pair a faster executor model with a higher-intelligence advisor model that provides strategic guidance mid-generation.
The advisor tool lets a fast, lower-cost executor model (Sonnet or Haiku) consult a high-intelligence advisor model (Opus 4.6) mid-generation. The advisor reads the full conversation and produces a plan or course correction — typically 400–700 text tokens — and the executor continues with the task.
This pattern is well-suited for long-horizon agentic workloads (coding agents, computer use, multi-step research) where most turns are mechanical but having an excellent plan is crucial. You get close to advisor-solo quality while the bulk of token generation happens at executor-model rates.
:::info Beta
The advisor tool is in beta. Include `anthropic-beta: advisor-tool-2026-03-01` in your requests — LiteLLM adds this automatically when it detects the advisor tool in your `tools` array.
:::
## Supported Providers
| Provider | Chat Completions API | Messages API |
|----------|---------------------|--------------|
| **Anthropic API** | ✅ | ✅ |
| **Azure Anthropic** | ❌ (coming soon) | ❌ (coming soon) |
| **Google Cloud Vertex AI** | ❌ (coming soon) | ❌ (coming soon) |
| **Amazon Bedrock** | ❌ (coming soon) | ❌ (coming soon) |
## Model Compatibility
The executor and advisor models must form a valid pair. Currently the only supported advisor model is `claude-opus-4-6`.
| Executor | Advisor |
|----------|---------|
| `claude-haiku-4-5-20251001` | `claude-opus-4-6` |
| `claude-sonnet-4-6` | `claude-opus-4-6` |
| `claude-opus-4-6` | `claude-opus-4-6` |
---
## Chat Completions API
### SDK Usage
#### Basic Example
```python showLineNumbers title="Advisor Tool — litellm.completion()"
import litellm
response = litellm.completion(
model="anthropic/claude-sonnet-4-6",
messages=[
{"role": "user", "content": "Build a concurrent worker pool in Go with graceful shutdown."}
],
tools=[
{
"type": "advisor_20260301",
"name": "advisor",
"model": "claude-opus-4-6",
}
],
max_tokens=4096,
)
print(response.choices[0].message.content)
```
#### With Optional Parameters
```python showLineNumbers title="Advisor Tool with max_uses and caching"
import litellm
response = litellm.completion(
model="anthropic/claude-sonnet-4-6",
messages=[
{"role": "user", "content": "Build a REST API with authentication in Python."}
],
tools=[
{
"type": "advisor_20260301",
"name": "advisor",
"model": "claude-opus-4-6",
"max_uses": 3, # cap advisor calls per request
"caching": {"type": "ephemeral", "ttl": "5m"}, # enable for 3+ calls per conversation
}
],
max_tokens=4096,
)
```
#### Streaming
```python showLineNumbers title="Streaming with Advisor Tool"
import litellm
response = litellm.completion(
model="anthropic/claude-sonnet-4-6",
messages=[
{"role": "user", "content": "Implement a distributed rate limiter."}
],
tools=[
{
"type": "advisor_20260301",
"name": "advisor",
"model": "claude-opus-4-6",
}
],
max_tokens=4096,
stream=True,
)
for chunk in response:
if chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="")
```
:::note Streaming behavior
The advisor sub-inference does not stream. The executor's stream pauses while the advisor runs, then the full advisor result arrives in a single event. Executor output resumes streaming afterward.
:::
#### Multi-Turn Conversation
```python showLineNumbers title="Multi-Turn with Advisor Tool"
import litellm
tools = [
{
"type": "advisor_20260301",
"name": "advisor",
"model": "claude-opus-4-6",
}
]
messages = [
{"role": "user", "content": "Build a concurrent worker pool in Go with graceful shutdown."}
]
response = litellm.completion(
model="anthropic/claude-sonnet-4-6",
messages=messages,
tools=tools,
max_tokens=4096,
)
# Append the full response (includes server_tool_use + advisor_tool_result blocks)
messages.append({"role": "assistant", "content": response.choices[0].message.content})
# Continue the conversation — keep the same tools array
messages.append({"role": "user", "content": "Now add a max-in-flight limit of 10."})
response2 = litellm.completion(
model="anthropic/claude-sonnet-4-6",
messages=messages,
tools=tools,
max_tokens=4096,
)
```
:::tip Auto-strip on follow-up turns
LiteLLM automatically strips `advisor_tool_result` blocks from message history when the advisor tool is not present in the current request. This prevents the Anthropic 400 error that would otherwise occur.
:::
### AI Gateway Usage
#### Proxy Configuration
```yaml showLineNumbers title="config.yaml"
model_list:
- model_name: claude-sonnet
litellm_params:
model: anthropic/claude-sonnet-4-6
api_key: os.environ/ANTHROPIC_API_KEY
```
#### Client Request via Proxy
```python showLineNumbers title="Advisor Tool via AI Gateway"
from openai import OpenAI
client = OpenAI(
api_key="your-litellm-proxy-key",
base_url="http://0.0.0.0:4000/v1"
)
response = client.chat.completions.create(
model="claude-sonnet",
messages=[
{"role": "user", "content": "Implement a distributed rate limiter in Python."}
],
tools=[
{
"type": "advisor_20260301",
"name": "advisor",
"model": "claude-opus-4-6",
}
],
max_tokens=4096,
)
```
---
## Messages API
### SDK Usage
#### Basic Example
```python showLineNumbers title="Advisor Tool — litellm.anthropic.messages"
import asyncio
import litellm
async def main():
response = await litellm.anthropic.messages.acreate(
model="anthropic/claude-sonnet-4-6",
messages=[
{"role": "user", "content": "Build a concurrent worker pool in Go with graceful shutdown."}
],
tools=[
{
"type": "advisor_20260301",
"name": "advisor",
"model": "claude-opus-4-6",
}
],
max_tokens=4096,
)
print(response)
asyncio.run(main())
```
#### Streaming
```python showLineNumbers title="Messages API Streaming with Advisor Tool"
import asyncio
import json
import litellm
async def main():
response = await litellm.anthropic.messages.acreate(
model="anthropic/claude-sonnet-4-6",
messages=[
{"role": "user", "content": "Implement a distributed rate limiter."}
],
tools=[
{
"type": "advisor_20260301",
"name": "advisor",
"model": "claude-opus-4-6",
}
],
max_tokens=4096,
stream=True,
)
async for chunk in response:
if isinstance(chunk, bytes):
for line in chunk.decode("utf-8").split("\n"):
if line.startswith("data: "):
try:
print(json.loads(line[6:]))
except json.JSONDecodeError:
pass
asyncio.run(main())
```
### AI Gateway Usage
#### Proxy Configuration
```yaml showLineNumbers title="config.yaml"
model_list:
- model_name: claude-sonnet
litellm_params:
model: anthropic/claude-sonnet-4-6
api_key: os.environ/ANTHROPIC_API_KEY
```
#### Client Request via Proxy (Anthropic SDK)
```python showLineNumbers title="Advisor Tool via AI Gateway (Anthropic SDK)"
import anthropic
client = anthropic.Anthropic(
api_key="your-litellm-proxy-key",
base_url="http://0.0.0.0:4000"
)
response = client.beta.messages.create(
model="claude-sonnet",
max_tokens=4096,
betas=["advisor-tool-2026-03-01"],
messages=[
{"role": "user", "content": "Build a concurrent worker pool in Go with graceful shutdown."}
],
tools=[
{
"type": "advisor_20260301",
"name": "advisor",
"model": "claude-opus-4-6",
}
],
)
print(response)
```
---
## Response Structure
A successful advisor call returns `server_tool_use` and `advisor_tool_result` blocks in the assistant content:
```json title="Response with advisor blocks"
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "Let me consult the advisor on this."
},
{
"type": "server_tool_use",
"id": "srvtoolu_abc123",
"name": "advisor",
"input": {}
},
{
"type": "advisor_tool_result",
"tool_use_id": "srvtoolu_abc123",
"content": {
"type": "advisor_result",
"text": "Use a channel-based coordination pattern. The tricky part is draining in-flight work during shutdown: close the input channel first, then wait on a WaitGroup..."
}
},
{
"type": "text",
"text": "Here's the implementation using a channel-based coordination pattern..."
}
]
}
```
Pass the full assistant content, including advisor blocks, back on subsequent turns. LiteLLM handles this automatically through `provider_specific_fields`.
---
## Cost Control
Advisor calls run as a separate sub-inference billed at the advisor model's rates. Usage is reported in `usage.iterations[]`:
```json title="Usage with advisor sub-inference"
{
"usage": {
"input_tokens": 412,
"output_tokens": 531,
"iterations": [
{
"type": "message",
"input_tokens": 412,
"output_tokens": 89
},
{
"type": "advisor_message",
"model": "claude-opus-4-6",
"input_tokens": 823,
"output_tokens": 1612
},
{
"type": "message",
"input_tokens": 1348,
"output_tokens": 442
}
]
}
}
```
Top-level `usage` reflects executor tokens only. Advisor tokens appear in `iterations` entries with `type: "advisor_message"` and are billed at Opus rates.
**Tips:**
- Enable `caching` on the tool definition only when you expect 3+ advisor calls per conversation; it costs more than it saves below that threshold.
- Use `max_uses` to cap advisor calls per request. Once reached, the executor continues without further advice.
- For conversation-level caps, count advisor calls client-side. When you reach your limit, remove the advisor tool from `tools`.
---
## Recommended System Prompt
For coding and agent tasks, Anthropic recommends prepending these blocks to your system prompt for consistent advisor timing and optimal cost/quality:
```text title="Timing guidance (prepend to system prompt)"
You have access to an `advisor` tool backed by a stronger reviewer model. It takes NO parameters — when you call advisor(), your entire conversation history is automatically forwarded. They see the task, every tool call you've made, every result you've seen.
Call advisor BEFORE substantive work — before writing, before committing to an interpretation, before building on an assumption. If the task requires orientation first (finding files, fetching a source, seeing what's there), do that, then call advisor. Orientation is not substantive work. Writing, editing, and declaring an answer are.
Also call advisor:
- When you believe the task is complete. BEFORE this call, make your deliverable durable: write the file, save the result, commit the change.
- When stuck — errors recurring, approach not converging, results that don't fit.
- When considering a change of approach.
On tasks longer than a few steps, call advisor at least once before committing to an approach and once before declaring done. On short reactive tasks where the next action is dictated by tool output you just read, you don't need to keep calling.
```
```text title="Advice weight guidance (add after timing block)"
Give the advice serious weight. If you follow a step and it fails empirically, or you have primary-source evidence that contradicts a specific claim, adapt. A passing self-test is not evidence the advice is wrong.
If you've already retrieved data pointing one way and the advisor points another: don't silently switch. Surface the conflict in one more advisor call — "I found X, you suggest Y, which constraint breaks the tie?"
```
To reduce advisor output length by 35–45% without losing quality, add:
```text title="Cost reduction (optional, add before timing block)"
The advisor should respond in under 100 words and use enumerated steps, not explanations.
```
---
## Additional Resources
- [Anthropic Advisor Tool Documentation](https://platform.claude.com/docs/en/agents-and-tools/tool-use/advisor-tool)
- [LiteLLM Tool Calling Guide](https://docs.litellm.ai/docs/completion/function_call)

View file

@ -65,14 +65,13 @@ response = completion(
- modalities
- reasoning_content
- audio (for TTS models only)
- service_tier
**Anthropic Params**
- thinking (used to set max budget tokens across anthropic/gemini models)
[**See Updated List**](https://github.com/BerriAI/litellm/blob/main/litellm/llms/gemini/chat/transformation.py#L70)
## Usage - Thinking / `reasoning_content`
LiteLLM translates OpenAI's `reasoning_effort` to Gemini's `thinking` parameter. [Code](https://github.com/BerriAI/litellm/blob/620664921902d7a9bfb29897a7b27c1a7ef4ddfb/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py#L362)
@ -298,6 +297,19 @@ curl http://0.0.0.0:4000/v1/chat/completions \
## Usage - `service_tier`
LiteLLM propagates OpenAI's `service_tier` parameter to Gemini, and also extracts it from the response headers (`x-gemini-service-tier`) into `model_response.service_tier`.
| OpenAI `service_tier` | Gemini `service_tier` | Notes |
| --------------------- | --------------------- | ----- |
| `"auto"` | `"priority"` | LiteLLM maps OpenAI's `"auto"` to Gemini's `"priority"` tier, as `priority` will fall back on Gemini. |
| `"flex"` | `"flex"` | Direct mapping. |
| `"priority"` | `"priority"` | Direct mapping. |
| `"default"` | `"standard"` | LiteLLM maps `"default"` to `"standard"`. |
| Any other value | Passed as-is (lowercased) | Values are case-insensitive and normalized to lowercase. |
On the response, LiteLLM maps `"standard"` back to `"default"` for the Gemini API.
## Text-to-Speech (TTS) Audio Output

View file

@ -55,24 +55,33 @@ pip install litellm
```
### Step 2: Set Your Credentials
Choose **one** of these authentication methods:
> **Breaking change**: credential resolution is "first-source-wins"
>
> Credential resolution no longer merges individual fields across sources.
>
> Resolution order is:
`kwargs` → `service key` → `env (AICORE_*)` → `config` → `VCAP service`
>
> **Important behavior:** once LiteLLM finds *any* credential value in a source, it takes **all** credentials from that source exclusively (except `resource_group`, which may still be resolved separately).
Choose **one** of these authentication methods:
<Tabs>
<TabItem value="service-key" label="Service Key JSON (Recommended)">
<Tabs>
<TabItem value="service-key" label="Service Key JSON (Recommended)">
The simplest approach - paste your entire service key as a single environment variable.
The simplest approach - paste your entire service key as a single environment variable. The service key must be wrapped in a `credentials` object:
> **Note:** the service key no more needs to be wrapped in a "credentials" key.
```bash
export AICORE_SERVICE_KEY='{
"credentials": {
"clientid": "your-client-id",
"clientsecret": "your-client-secret",
"url": "https://<your-instance>.authentication.sap.hana.ondemand.com",
"serviceurls": {
"AI_API_URL": "https://api.ai.<your-region>.aws.ml.hana.ondemand.com"
}
}
}'
export AICORE_RESOURCE_GROUP="default"
```
@ -220,6 +229,17 @@ model="sap/gemini-2.5-pro"
# Incorrect - missing prefix
model="gpt-4o" # ❌ Won't work
```
3. **Environment variables** - Set the following list of credentials in .env file
<pre>
AICORE_AUTH_URL = "https://* * * .authentication.sap.hana.ondemand.com/oauth/token",
AICORE_CLIENT_ID = " *** ",
AICORE_CLIENT_SECRET = " *** ",
AICORE_RESOURCE_GROUP = " *** ",
AICORE_BASE_URL = "https://api.ai.***.cfapps.sap.hana.ondemand.com/v2"
</pre>
Other credential configuration options are also available. For more information, see the [SAP AI Core Documentation](https://help.sap.com/doc/generative-ai-hub-sdk/CLOUD/en-US/_reference/README_sphynx.html#configuration).
## Usage - LiteLLM Python SDK
### Proxy Usage
@ -506,6 +526,241 @@ response = embedding(
print(response.data[0]["embedding"]) # Vector representation
```
### Additional Modules
The SAP Gen AI Hub includes additional modules for advanced use cases:
- [Grounding](https://help.sap.com/docs/sap-ai-core/generative-ai/grounding-035c455a5a424697b60f4a24b6d791fe?locale=en-US)
- [Translation](https://help.sap.com/docs/sap-ai-core/generative-ai/translation?locale=en-US)
- [Data Masking](https://help.sap.com/docs/sap-ai-core/generative-ai/data-masking-d9a54d9ca54b40beacbd24e1663ec3b4?locale=en-US)
- [Content Filtering](https://help.sap.com/docs/sap-ai-core/generative-ai/content-filtering?locale=en-US)
#### Grounding
Grounding is a service designed to handle data-related tasks, such as grounding and retrieval, using vector databases. It provides specialized data retrieval through these databases, grounding the retrieval process with your own external and context-relevant data. Grounding combines generative AI capabilities with the ability to use real-time, precise data to improve decision-making and business operations for specific AI-driven business solutions.
##### Prerequisites
To use the Grounding module in the orchestration pipeline, you need to prepare the knowledge base in advance.
Generative AI hub offers multiple options for users to provide data (prepare a knowledge base):
- For Option 1: Upload the documents to a supported data repository and run the data pipeline to vectorize the documents.
- For Option 2: Provide the chunks of document via Vector API directly.
To use grounding, choose from one of the following options.
Usage example:
```python showLineNumbers title="Grounding Example"
from litellm import completion
grounding_config = {
'type': 'document_grounding_service',
'config': {
'filters': [
{'id': 's3-docs',
'data_repository_type': 'vector',
'search_config': {'max_chunk_count': 2},
'data_repositories': ['012345-6789-0123-4567-890123456789']
}
],
'placeholders': {'input': ['user_query'], 'output': 'grounding_response'},
'metadata_params': ['source', 'webUrl', 'title', 'mimeType', 'fileSuffix']
}
}
response = completion(model="sap/gpt-4o",
messages=[
{"content":"""Facility Solutions Company provides services to luxury residential complexes,
apartments, individual homes, and commercial properties such as office buildings, retail
spaces, industrial facilities, and educational institutions. Customers are encouraged to
reach out with maintenance requests, service deficiencies, follow-ups, or any issues they
need by email.""", "role": "system"},
{"content":"""You are a helpful assistant for any queries for answering questions.
Answer the request by providing relevant answers that fit to the request.
Request: {{ ?user_query }}
Context:{{ ?grounding_response }}""", "role": "user"}
],
placeholder_values={"user_query": "Is there a complaint?"},
grounding=grounding_config
)
print(response.choices[0].message.content)
```
For more information about all available grounding configurations, see the [documentation](https://help.sap.com/docs/sap-ai-core/generative-ai/using-grounding-module-e1c4dd100dfb42ab890e1d95f3516187?locale=en-US).
#### Translation
The translation module allows you to translate LLM text prompts into a chosen target language.
```python showLineNumbers title="Translation Example"
from litellm import completion
translation_config = {
'input':
{'type': 'sap_document_translation',
'config':
{'source_language': 'en-US',
'target_language': 'de-DE'}
},
'output':
{'type': 'sap_document_translation',
'config':
{'source_language': 'de-DE',
'target_language': 'fr-FR'}
}
}
response = completion(model="sap/gpt-4o",
messages=[{"role": "user", "content": "Hello world!"}],
translation=translation_config)
print(response.choices[0].message.content)
```
For more information about all available translation configurations, see the [documentation](https://help.sap.com/docs/sap-ai-core/generative-ai/translation?locale=en-US)
#### Data Masking
The data masking module serves to anonymize or pseudonymize personally identifiable information from the input for selected entities.
```python showLineNumbers title="Data Masking Example"
from litellm import completion, embedding
masking_config = {
'providers':
[
{
'type': 'sap_data_privacy_integration',
'method': 'anonymization',
'entities': [
{'type': 'profile-address'},
{'type': 'profile-email'},
{'type': 'profile-phone'},
{'type': 'profile-person'},
{'type': 'profile-location'}
]
}
]
}
mock_cv = "some text with personal information"
response = completion(model="sap/gpt-4o",
messages=[{"role": "user", "content": "Give a one sentence summary of the CV. CV: {{?cv}}?"}],
placeholder_values={"cv": mock_cv},
masking=masking_config)
print(response.choices[0].message.content)
# Data masking module also available for embedding
response = embedding(model="sap/text-embedding-3-small",
input=mock_cv,
masking=masking_config)
print(response.data[0])
```
For more information about all available data masking configurations, see the [documentation](https://help.sap.com/docs/sap-ai-core/generative-ai/enhancing-model-consumption-with-data-masking-66ad6f469afc4c2cbaa91a27a33f7b21?locale=en-US)
#### Content Filtering
The content filtering module allows you to filter input and output based on content safety criteria.
The module supports two services:
* Azure Content Safety
* Llama Guard 3
```python showLineNumbers title="Content Filtering Example"
from litellm import completion
filtering_config_azure = {
'input':
{
'filters':
[
{'type': 'azure_content_safety',
'config':
{'hate': 0,
'sexual': 0,
'violence': 0,
'self_harm': 0
}
}
]
},
'output':
{
'filters':
[
{'type': 'azure_content_safety',
'config': {'hate': 0,
'sexual': 0,
'violence': 0,
'self_harm': 0
}
}
]
}
}
response = completion(model="sap/gpt-4o",
messages=[{"role": "user", "content": "Hello world!"}],
filtering=filtering_config_azure)
print(response.choices[0].message.content)
# The model responds normally because the content does not violate any safety rules.
try:
response = completion(model="sap/gpt-4o",
messages=[{"role": "user", "content": "I hate you"}],
filtering=filtering_config_azure)
except Exception as e:
print(e)
# The service raises an error:
# "Input Filter: Content filtered due to safety violations. Please modify the prompt and try again."
```
For more information about all available content filtering configurations, see the [documentation](https://help.sap.com/docs/sap-ai-core/generative-ai/content-filtering?locale=en-US)
#### List of modules configuration for fallback
SAP GEN AI Hub supports a fallback mechanism for handling errors. This mechanism allows you to specify a list of fallback modules to use in case of errors. The fallback modules should contain all parameters that are required for configuring the request.
Required parameters:
- `model`
- `messages`
Optional parameters:
- `filtering`
- `grounding`
- `translation`
- `masking`
- `tools`
- and any of model's specific parameters.
```python showLineNumbers title="Fallback Example"
from litellm import completion
translation_config = {
'input':
{'type': 'sap_document_translation',
'config':
{'source_language': 'en-US',
'target_language': 'de-DE'}
},
'output':
{'type': 'sap_document_translation',
'config':
{'source_language': 'de-DE',
'target_language': 'fr-FR'}
}
}
response = completion(model="sap/gpt-4o",
messages=[{"role": "user", "content": "Hello world!"}],
translation=translation_config,
fallback_sap_modules=[{
"model":"sap/gemini-2.5-flash",
"messages":[{"role": "user", "content": "Hello world!"}],
"translation":translation_config
}])
# In case of error with the first configuration (model gpt-4o), the fallback module is used.
print(response.choices[0].message.content)
```
## Reference
### Supported Parameters

View file

@ -197,6 +197,7 @@ router_settings:
| key_generation_settings | object | Restricts who can generate keys. [Further docs](./virtual_keys.md#restricting-key-generation) |
| disable_add_transform_inline_image_block | boolean | For Fireworks AI models - if true, turns off the auto-add of `#transform=inline` to the url of the image_url, if the model is not a vision model. |
| use_chat_completions_url_for_anthropic_messages | boolean | If true, routes OpenAI `/v1/messages` requests through chat/completions instead of the Responses API. Can also be set via env var `LITELLM_USE_CHAT_COMPLETIONS_URL_FOR_ANTHROPIC_MESSAGES=true`. |
| skip_system_message_in_guardrail | boolean | If true, unified guardrails omit `role: system` from scanned input on **chat completions** and **Anthropic `/v1/messages`** only; the LLM still receives full messages. Per-guardrail override: `litellm_params.skip_system_message_in_guardrail` on each guardrail. [Guardrails quick start](./guardrails/quick_start#skip-system-messages-in-guardrail-evaluation) |
| disable_hf_tokenizer_download | boolean | If true, it defaults to using the openai tokenizer for all models (including huggingface models). |
| enable_json_schema_validation | boolean | If true, enables json schema validation for all requests. |
| enable_key_alias_format_validation | boolean | If true, validates `key_alias` format on `/key/generate` and `/key/update`. Must be 2-255 chars, start/end with alphanumeric, only allow `a-zA-Z0-9_-/.@`. Default `false`. |
@ -602,6 +603,8 @@ router_settings:
| MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE | Maximum number of entries in MCP OAuth2 token cache. Default is 200
| MCP_OAUTH2_TOKEN_CACHE_MIN_TTL | Minimum TTL in seconds for MCP OAuth2 token cache. Default is 10
| MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS | Seconds to subtract from token expiry when computing cache TTL. Default is 60
| MCP_PER_USER_TOKEN_DEFAULT_TTL | Default TTL in seconds for per-user MCP OAuth tokens stored in Redis. Default is 43200 (12 hours)
| MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS | Seconds to subtract from per-user MCP OAuth token expiry when computing Redis TTL. Default is 60
| DEFAULT_MOCK_RESPONSE_COMPLETION_TOKEN_COUNT | Default token count for mock response completions. Default is 20
| DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT | Default token count for mock response prompts. Default is 10
| DEFAULT_MODEL_CREATED_AT_TIME | Default creation timestamp for models. Default is 1677610602

View file

@ -0,0 +1,274 @@
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Per-Team/Project Credential Routing
Route the same model to different LLM provider endpoints (e.g. different Azure instances) based on which team or project makes the request.
## Overview
In multi-tenant deployments, different teams often need the same model name (e.g., `gpt-4`) to hit different provider endpoints — for example, separate Azure OpenAI instances per business unit for cost isolation, data residency, or rate limit separation.
**Credential routing** lets you configure this in team/project metadata using the existing [credentials table](./ui_credentials.md), without duplicating model definitions or creating separate model groups per team.
```
Hotel Team → gpt-4 → https://hotel-eastus.openai.azure.com/
Flight Team → gpt-4 → https://flight-centralus.openai.azure.com/
```
### Precedence Chain
When a request comes in, the system walks this precedence chain (first match wins):
1. **Clientside credentials** — `api_base`/`api_key` passed in the request body ([docs](./clientside_auth.md))
2. **Project model-specific** — override for this exact model in the project's `model_config`
3. **Project default** — `defaultconfig` in the project's `model_config`
4. **Team model-specific** — override for this exact model in the team's `model_config`
5. **Team default** — `defaultconfig` in the team's `model_config`
6. **Deployment default** — the model's `litellm_params` as configured in `config.yaml`
## Quick Start
### Step 1: Create Credentials
Store your Azure endpoint credentials in the credentials table. You can do this via the [UI](./ui_credentials.md) or API:
```bash showLineNumbers
# Create credential for Hotel team's Azure endpoint
curl -X POST 'http://0.0.0.0:4000/credentials' \
-H 'Authorization: Bearer sk-1234' \
-H 'Content-Type: application/json' \
-d '{
"credential_name": "hotel-azure-eastus",
"credential_values": {
"api_base": "https://hotel-eastus.openai.azure.com/",
"api_key": "sk-azure-hotel-key-xxx"
}
}'
```
```bash showLineNumbers
# Create credential for Flight team's Azure endpoint
curl -X POST 'http://0.0.0.0:4000/credentials' \
-H 'Authorization: Bearer sk-1234' \
-H 'Content-Type: application/json' \
-d '{
"credential_name": "flight-azure-centralus",
"credential_values": {
"api_base": "https://flight-centralus.openai.azure.com/",
"api_key": "sk-azure-flight-key-xxx"
}
}'
```
### Step 2: Set `model_config` on Teams
Add a `model_config` key to the team's metadata referencing the credential by name:
```bash showLineNumbers
# Hotel team — default Azure endpoint for all models
curl -X PATCH 'http://0.0.0.0:4000/team/update' \
-H 'Authorization: Bearer sk-1234' \
-H 'Content-Type: application/json' \
-d '{
"team_id": "hotel-team-id",
"metadata": {
"model_config": {
"defaultconfig": {
"azure": {
"litellm_credentials": "hotel-azure-eastus"
}
}
}
}
}'
```
```bash showLineNumbers
# Flight team — default Azure endpoint for all models
curl -X PATCH 'http://0.0.0.0:4000/team/update' \
-H 'Authorization: Bearer sk-1234' \
-H 'Content-Type: application/json' \
-d '{
"team_id": "flight-team-id",
"metadata": {
"model_config": {
"defaultconfig": {
"azure": {
"litellm_credentials": "flight-azure-centralus"
}
}
}
}
}'
```
### Step 3: Make Requests
Requests are automatically routed to the correct Azure endpoint based on the API key's team:
```bash showLineNumbers
# Request using Hotel team's API key → routes to hotel-eastus.openai.azure.com
curl http://localhost:4000/v1/chat/completions \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer sk-hotel-team-key' \
-d '{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}'
# Request using Flight team's API key → routes to flight-centralus.openai.azure.com
curl http://localhost:4000/v1/chat/completions \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer sk-flight-team-key' \
-d '{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}'
```
## Per-Model Overrides
You can set different credentials for specific models while keeping a default for everything else:
```bash showLineNumbers
curl -X PATCH 'http://0.0.0.0:4000/team/update' \
-H 'Authorization: Bearer sk-1234' \
-H 'Content-Type: application/json' \
-d '{
"team_id": "hotel-team-id",
"metadata": {
"model_config": {
"defaultconfig": {
"azure": {
"litellm_credentials": "hotel-azure-eastus"
}
},
"gpt-4": {
"azure": {
"litellm_credentials": "hotel-azure-westus"
}
}
}
}
}'
```
With this config:
- `gpt-4` requests → `hotel-azure-westus` credential (model-specific)
- All other models → `hotel-azure-eastus` credential (default)
## Project-Level Overrides
Projects inherit their team's `model_config` but can override at the project level. Project overrides take precedence over team overrides.
```bash showLineNumbers
# Project overrides the team default for all models
curl -X PATCH 'http://0.0.0.0:4000/project/update' \
-H 'Authorization: Bearer sk-1234' \
-H 'Content-Type: application/json' \
-d '{
"project_id": "hotel-rec-app-id",
"metadata": {
"model_config": {
"defaultconfig": {
"azure": {
"litellm_credentials": "hotel-rec-azure"
}
},
"gpt-4-vision": {
"azure": {
"litellm_credentials": "hotel-rec-vision"
}
}
}
}
}'
```
### Full Example: Hotel Team with Two Projects
**Setup:**
- **Hotel Team**: default `hotel-azure-eastus`, GPT-4 override to `hotel-azure-westus`
- **Hotel Rec App** (project): default `hotel-rec-azure`, GPT-4-Vision override to `hotel-rec-vision`
- **Hotel Review App** (project): no overrides — inherits team config
**Resolution:**
| Request | Resolved Credential | Why |
|---|---|---|
| Hotel Rec App → `gpt-4` | `hotel-rec-azure` | Project default (no project model-specific match for gpt-4) |
| Hotel Rec App → `gpt-4-vision` | `hotel-rec-vision` | Project model-specific |
| Hotel Review App → `gpt-3.5` | `hotel-azure-eastus` | Team default (no project config) |
| Hotel Review App → `gpt-4` | `hotel-azure-westus` | Team model-specific |
## `model_config` Schema
The `model_config` key is a JSON object in team/project `metadata`:
```json
{
"model_config": {
"defaultconfig": {
"<provider>": {
"litellm_credentials": "<credential-name>"
}
},
"<model-name>": {
"<provider>": {
"litellm_credentials": "<credential-name>"
}
}
}
}
```
| Field | Description |
|---|---|
| `defaultconfig` | Fallback credential for any model not explicitly listed |
| `<model-name>` | Model-specific override — must match the LiteLLM model group name |
| `<provider>` | Provider key (e.g. `azure`, `openai`, `bedrock`). When the model name includes a provider prefix (e.g. `azure/gpt-4`), the system prefers the matching provider key |
| `litellm_credentials` | Name of a credential in the [credentials table](./ui_credentials.md) |
### Credential Values
The referenced credential can contain any combination of:
| Key | Description |
|---|---|
| `api_base` | Provider endpoint URL |
| `api_key` | API key for the provider |
| `api_version` | API version (e.g. for Azure) |
Only keys present in the credential are applied. Keys already in the request (e.g. clientside `api_version`) are never overwritten.
## Enabling the Feature
This feature is **disabled by default** and must be explicitly enabled. To enable it:
<Tabs>
<TabItem value="config" label="config.yaml">
```yaml
litellm_settings:
enable_model_config_credential_overrides: true
```
</TabItem>
<TabItem value="env" label="Environment Variable">
```bash
export LITELLM_ENABLE_MODEL_CONFIG_CREDENTIAL_OVERRIDES=true
```
</TabItem>
</Tabs>
:::info
The feature flag must be enabled before `model_config` entries in team/project metadata take effect. Without it, credential routing is completely inert — no metadata is read, no credentials are resolved.
:::
## Related Documentation
- [Adding LLM Credentials](./ui_credentials.md) — Create and manage reusable credentials
- [Project Management](./project_management.md) — Project hierarchy and API
- [Team Budgets](./team_budgets.md) — Team-level budget management
- [Clientside LLM Credentials](./clientside_auth.md) — Passing credentials in the request body
- [Credential Usage Tracking](./credential_usage_tracking.md) — Track spend by credential

View file

@ -99,7 +99,7 @@ The following checks were performed on each of these signatures:
- The signatures were verified against the specified public key
```
Learn more about LiteLLM's release signing in the [CI/CD v2 announcement](https://docs.litellm.ai/blog/ci-cd-v2-improvements#verify-docker-image-signatures).
Learn more about LiteLLM's release signing in the [CI/CD v2 announcement](https://docs.litellm.ai/blog/ci-cd-v2-improvements#verify-docker-image-signatures). For a complete guide covering all image variants, CI/CD enforcement, and deployment best practices, see the [Docker Image Security Guide](./docker_image_security.md).
### Docker Run

View file

@ -0,0 +1,189 @@
# Docker Image Security Guide
LiteLLM signs every Docker image published to GHCR with [cosign](https://docs.sigstore.dev/cosign/overview/) starting from **v1.83.0**. This page covers how to verify signatures, enforce verification in CI/CD, and follow recommended deployment patterns.
## Signed images
All image variants published to `ghcr.io/berriai/` are signed with the same cosign key:
| Image | Description |
|---|---|
| `ghcr.io/berriai/litellm` | Core proxy |
| `ghcr.io/berriai/litellm-database` | Proxy with Postgres dependencies |
| `ghcr.io/berriai/litellm-non_root` | Non-root variant |
| `ghcr.io/berriai/litellm-spend_logs` | Spend-logs sidecar |
The signing key was introduced in [commit `0112e53`](https://github.com/BerriAI/litellm/commit/0112e53046018d726492c814b3644b7d376029d0) and the public key is checked into the repository at [`cosign.pub`](https://github.com/BerriAI/litellm/blob/main/cosign.pub).
:::info Enterprise images
Enterprise images (`litellm-ee`) follow the same signing process. Contact [support@berri.ai](mailto:support@berri.ai) to confirm coverage for your specific enterprise image tag.
:::
## Verify image signatures
Install cosign following the [official instructions](https://docs.sigstore.dev/cosign/system_config/installation/).
### Verify with the pinned commit hash (recommended)
A commit hash is cryptographically immutable, making this the strongest verification method:
```bash
cosign verify \
--key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \
ghcr.io/berriai/litellm:v1.83.0-stable
```
Replace the image reference with any signed variant:
```bash
# litellm-database
cosign verify \
--key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \
ghcr.io/berriai/litellm-database:v1.83.0-stable
# litellm-non_root
cosign verify \
--key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \
ghcr.io/berriai/litellm-non_root:v1.83.0-stable
```
### Verify with a release tag (convenience)
Tags are protected in this repository and resolve to the same key:
```bash
cosign verify \
--key https://raw.githubusercontent.com/BerriAI/litellm/v1.83.0-stable/cosign.pub \
ghcr.io/berriai/litellm-database:v1.83.0-stable
```
### Expected output
```
The following checks were performed on each of these signatures:
- The cosign claims were validated
- The signatures were verified against the specified public key
```
## Enforce verification in CI/CD
### Kubernetes — Sigstore Policy Controller
The [Sigstore Policy Controller](https://docs.sigstore.dev/policy-controller/overview/) rejects pods whose images fail cosign verification.
1. Install the controller:
```bash
helm repo add sigstore https://sigstore.github.io/helm-charts
helm install policy-controller sigstore/policy-controller \
-n cosign-system --create-namespace
```
2. Create a `ClusterImagePolicy` with the LiteLLM public key:
```yaml
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: litellm-signed-images
spec:
images:
- glob: "ghcr.io/berriai/litellm*"
authorities:
- key:
data: |
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKi4ivqGpE231OGH50PKbqy1Y1Kkb
POJC8+i2Wko82gBOUCe3M0Vw86H/4rhUhfoYEti4gdJ9wZbYmK0I2EE96g==
-----END PUBLIC KEY-----
```
3. Label the namespace to enable enforcement:
```bash
kubectl label namespace litellm policy.sigstore.dev/include=true
```
Any pod in that namespace using an unsigned `ghcr.io/berriai/litellm*` image will be rejected at admission.
### GCP — Binary Authorization
[Binary Authorization](https://cloud.google.com/binary-authorization/docs) can enforce cosign signatures on Cloud Run and GKE.
1. Create a cosign-based attestor using the LiteLLM public key:
```bash
# Import the public key into a Cloud KMS keyring or use a PGP/PKIX attestor.
# See: https://cloud.google.com/binary-authorization/docs/creating-attestors-console
```
2. Configure a Binary Authorization policy that requires the attestor for `ghcr.io/berriai/litellm*` images.
3. Enable the policy on your Cloud Run service or GKE cluster.
Refer to the [GCP Binary Authorization docs](https://cloud.google.com/binary-authorization/docs/setting-up) for full setup steps.
### AWS — ECS / ECR
AWS does not natively verify cosign signatures at deploy time. Common approaches:
- **CI/CD gate**: Run `cosign verify` in your deployment pipeline before pushing to ECR or updating the ECS task definition. Fail the pipeline if verification fails.
- **OPA/Gatekeeper on EKS**: If running on EKS, use the Sigstore Policy Controller (same as the Kubernetes approach above).
### GitHub Actions gate
Add a verification step before any deployment job:
```yaml
- name: Verify LiteLLM image signature
run: |
cosign verify \
--key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \
ghcr.io/berriai/litellm-database:${{ env.LITELLM_VERSION }}
```
## Recommended deployment patterns
### Pin by digest
Digest pinning guarantees the exact image content regardless of tag mutations:
```yaml
image: ghcr.io/berriai/litellm-database@sha256:<digest>
```
Get the digest after pulling:
```bash
docker inspect --format='{{index .RepoDigests 0}}' \
ghcr.io/berriai/litellm-database:v1.83.0-stable
```
Cosign verification works with digests too:
```bash
cosign verify \
--key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \
ghcr.io/berriai/litellm-database@sha256:<digest>
```
### Use stable release tags
If digest pinning is too rigid for your workflow, use `-stable` release tags (e.g. `v1.83.0-stable`). These are immutable release tags that will not be overwritten.
Avoid `main-latest` or `main-stable` in production — these rolling tags point to the most recent build and can change between deployments.
### Safe upgrade checklist
1. **Verify the new image** — run `cosign verify` against the new release tag or digest.
2. **Test in staging** — deploy the verified image to a non-production environment.
3. **Update your pinned reference** — change the digest or tag in your deployment manifest.
4. **Deploy to production** — roll out using your standard deployment process.
5. **Monitor `/health`** — confirm the proxy is healthy after the upgrade.
## Further reading
- [CI/CD v2 announcement](https://docs.litellm.ai/blog/ci-cd-v2-improvements) — background on LiteLLM's signing infrastructure
- [Docker deployment guide](./deploy.md) — full Docker, Helm, and Terraform setup
- [cosign documentation](https://docs.sigstore.dev/cosign/overview/) — cosign usage and key management
- [Sigstore Policy Controller](https://docs.sigstore.dev/policy-controller/overview/) — Kubernetes admission control

View file

@ -9,6 +9,7 @@ Setup Prompt Injection Detection, PII Masking on LiteLLM Proxy (AI Gateway)
## 1. Define guardrails on your LiteLLM config.yaml
Set your guardrails under the `guardrails` section
```yaml
model_list:
- model_name: gpt-3.5-turbo
@ -82,27 +83,58 @@ For generic guardrail APIs you can also set **static headers** (`headers`: key/v
- `during_call` Run **during** LLM call, on **input** Same as `pre_call` but runs in parallel as LLM call. Response not returned until guardrail check completes
- A list of the above values to run multiple modes, e.g. `mode: [pre_call, post_call]`
### Skip system messages in guardrail evaluation
You can stop **unified** guardrails from scanning `role: system` content while still sending the full `messages` list to the model.
**Global** — in `litellm_settings`:
```yaml
litellm_settings:
skip_system_message_in_guardrail: true
```
**Per guardrail** — under that guardrail’s `litellm_params`: set `skip_system_message_in_guardrail: true` or `false`. If omitted, the global `litellm_settings` value is used; per-guardrail `false` forces system messages to be included even when the global flag is `true`.
**Via LiteLLM UI** — when **creating** or **editing** a guardrail in the LiteLLM Admin Dashboard, set **Skip system messages in guardrail** (under Basic Info on create, or in the edit / guardrail settings flows):
| UI option | Effect |
| ------------------------------------- | -------------------------------------------------------------------------------------- |
| **Use global default** | Uses `litellm_settings.skip_system_message_in_guardrail` from your proxy config |
| **Yes — exclude from guardrail scan** | Sets per-guardrail `skip_system_message_in_guardrail: true` |
| **No — always include in scan** | Sets per-guardrail `skip_system_message_in_guardrail: false` (overrides a global skip) |
<Image
img={require('../../../img/skip_system_message_guardrail_ui.png')}
alt="Create guardrail: Skip system messages in guardrail dropdown with Use global default, Yes exclude from guardrail scan, and No always include in scan"
style={{ width: '100%', maxWidth: '900px', height: 'auto' }}
/>
**Where this applies:** Only the **unified** guardrail path (providers that implement `apply_guardrail` and run through LiteLLM’s message translation layer) on **OpenAI Chat Completions** (`/v1/chat/completions`) and **Anthropic Messages** (`/v1/messages`). Examples include Presidio, Bedrock guardrails, `litellm_content_filter`, OpenAI Moderation, Generic Guardrail API, and custom code guardrails that define `apply_guardrail`.
**Where this does *not* apply:** Guardrails that run only via direct hooks on the raw request (e.g. Lakera v2, Aporia, DynamoAI, Javelin, Lasso, Pangea, Model Armor, Azure Content Safety hooks, Guardrails AI, AIM, tool permission, MCP security). It also does not apply to other routes until those endpoints use the same translation layer (e.g. Responses API, embeddings, speech).
### Load Balancing Guardrails
Need to distribute guardrail requests across multiple accounts or regions? See [Guardrail Load Balancing](./guardrail_load_balancing.md) for details on:
- Load balancing across multiple AWS Bedrock accounts (useful for rate limit management)
- Weighted distribution across guardrail instances
- Multi-region guardrail deployments
## 2. Start LiteLLM Gateway
## 2. Start LiteLLM Gateway
```shell
litellm --config config.yaml --detailed_debug
```
## 3. Test request
## 3. Test request
**[Langchain, OpenAI SDK Usage Examples](../proxy/user_keys#request-format)**
<Tabs>
<TabItem label="Unsuccessful call" value = "not-allowed">
Expect this to fail since since `ishaan@berri.ai` in the request is PII
@ -141,9 +173,9 @@ Expected response on failure
```
</TabItem>
<TabItem label="Successful Call " value = "allowed">
```shell
curl -i http://localhost:4000/v1/chat/completions \
@ -158,10 +190,8 @@ curl -i http://localhost:4000/v1/chat/completions \
}'
```
</TabItem>
</Tabs>
## **Default On Guardrails**
@ -183,7 +213,6 @@ guardrails:
In this request, the guardrail `aporia-pre-guard` will run on every request because `default_on: true` is set.
```shell
curl -i http://localhost:4000/v1/chat/completions \
-H "Content-Type: application/json" \
@ -207,6 +236,7 @@ x-litellm-applied-guardrails: aporia-pre-guard
### Guardrail Policies
Need more control? Use [Guardrail Policies](./guardrail_policies.md) to:
- Group guardrails into reusable policies
- Enable/disable guardrails for specific teams, keys, or models
- Inherit from existing policies and override specific guardrails
@ -217,7 +247,6 @@ Need more control? Use [Guardrail Policies](./guardrail_policies.md) to:
Pass `guardrails` to your request body to test it
```shell
curl -i http://localhost:4000/v1/chat/completions \
-H "Content-Type: application/json" \
@ -239,7 +268,6 @@ Follow this simple workflow to implement and tune guardrails:
First, check what guardrails are available and their parameters:
Call `/guardrails/list` to view available guardrails and the guardrail info (supported parameters, description, etc)
```shell
@ -271,9 +299,12 @@ Expected response
}
```
>
This config will return the `/guardrails/list` response above. The `guardrail_info` field is optional and you can add any fields under info for consumers of your guardrail
>
```yaml
- guardrail_name: "aporia-post-guard"
litellm_params:
@ -291,9 +322,10 @@ This config will return the `/guardrails/list` response above. The `guardrail_in
type: "boolean"
```
### 2. Apply Guardrails
Add selected guardrails to your chat completion request:
```shell
curl -i http://localhost:4000/v1/chat/completions \
-H "Content-Type: application/json" \
@ -322,7 +354,6 @@ curl -i http://localhost:4000/v1/chat/completions \
}'
```
### 4. ✨ Pass Dynamic Parameters to Guardrail
:::info
@ -334,9 +365,8 @@ curl -i http://localhost:4000/v1/chat/completions \
Use this to pass additional parameters to the guardrail API call. e.g. things like success threshold. **[See `guardrails` spec for more details](#spec-guardrails-parameter)**
<Tabs>
<TabItem value="openai" label="OpenAI Python v1.0.0+">
Set `guardrails={"aporia-pre-guard": {"extra_body": {"success_threshold": 0.9}}}` to pass additional parameters to the guardrail
@ -371,10 +401,10 @@ response = client.chat.completions.create(
print(response)
```
</TabItem>
<TabItem value="Curl" label="Curl Request">
```shell
curl --location 'http://0.0.0.0:4000/chat/completions' \
@ -396,11 +426,8 @@ curl --location 'http://0.0.0.0:4000/chat/completions' \
}
}'
```
</TabItem>
</Tabs>
@ -426,9 +453,6 @@ Monitor which guardrails were executed and whether they passed or failed. e.g. g
<Image img={require('../../../img/gd_fail.png')} />
### ✨ Control Guardrails per API Key
:::info
@ -438,12 +462,12 @@ Monitor which guardrails were executed and whether they passed or failed. e.g. g
:::
Use this to control what guardrails run per API Key. In this tutorial we only want the following guardrails to run for 1 API Key
- `guardrails`: ["aporia-pre-guard", "aporia-post-guard"]
**Step 1** Create Key with guardrail settings
<Tabs>
<TabItem value="/key/generate" label="/key/generate">
```shell
curl -X POST 'http://0.0.0.0:4000/key/generate' \
@ -454,8 +478,7 @@ curl -X POST 'http://0.0.0.0:4000/key/generate' \
}'
```
</TabItem>
<TabItem value="/key/update" label="/key/update">
```shell
curl --location 'http://0.0.0.0:4000/key/update' \
@ -467,8 +490,7 @@ curl --location 'http://0.0.0.0:4000/key/update' \
}'
```
</TabItem>
</Tabs>
**Step 2** Test it with new key
@ -499,8 +521,7 @@ Run guardrails based on the user-agent header. This is useful for running pre-ca
Both `default` and tag values can be a single mode string or a list of modes.
<Tabs>
<TabItem value="single" label="Single Default Mode">
```yaml
model_list:
@ -522,11 +543,10 @@ guardrails:
default_on: true # run on every request
```
</TabItem>
<TabItem value="multi" label="Multiple Default Modes">
```yaml
model_list:
Per guardrailmodel_list:
- model_name: gpt-3.5-turbo
litellm_params:
model: gpt-3.5-turbo
@ -545,8 +565,7 @@ guardrails:
default_on: true
```
</TabItem>
<TabItem value="tag-list" label="Multiple Tag Modes">
```yaml
model_list:
@ -568,8 +587,6 @@ guardrails:
default_on: true
```
</TabItem>
</Tabs>
### ✨ Model-level Guardrails
@ -580,10 +597,8 @@ guardrails:
:::
This is great for cases when you have an on-prem and hosted model, and just want to run prevent sending PII to the hosted model.
```yaml
model_list:
- model_name: claude-sonnet-4
@ -620,8 +635,7 @@ guardrails:
:::
#### 1. Disable team from modifying guardrails
#### 1. Disable team from modifying guardrails
```bash
curl -X POST 'http://0.0.0.0:4000/team/update' \
@ -633,7 +647,7 @@ curl -X POST 'http://0.0.0.0:4000/team/update' \
}'
```
#### 2. Try to disable guardrails for a call
#### 2. Try to disable guardrails for a call
```bash
curl --location 'http://0.0.0.0:4000/chat/completions' \
@ -672,8 +686,7 @@ Expect to NOT see `+1 412-612-9992` in your server logs on your callback.
The `pii_masking` guardrail ran on this request because api key=sk-jNm1Zar7XfNdZXp49Z1kSQ has `"permissions": {"pii_masking": true}`
:::
## Specification
## Specification
### `guardrails` Configuration on YAML
@ -723,6 +736,7 @@ The `guardrails` parameter can be passed to any LiteLLM Proxy endpoint (`/chat/c
#### Format Options
1. Simple List Format:
```python
"guardrails": [
"aporia-pre-guard",
@ -730,9 +744,10 @@ The `guardrails` parameter can be passed to any LiteLLM Proxy endpoint (`/chat/c
]
```
2. Advanced Dictionary Format:
1. Advanced Dictionary Format:
In this format the dictionary key is `guardrail_name` you want to run
```python
"guardrails": {
"aporia-pre-guard": {
@ -745,6 +760,7 @@ In this format the dictionary key is `guardrail_name` you want to run
```
#### Type Definition
```python
guardrails: Union[
List[str], # Simple list of guardrail names
@ -754,3 +770,4 @@ guardrails: Union[
class DynamicGuardrailParams:
extra_body: Dict[str, Any] # Additional parameters for the guardrail
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -350,6 +350,7 @@ const sidebars = {
"proxy/debugging",
"proxy/error_diagnosis",
"proxy/deploy",
"proxy/docker_image_security",
"proxy/health",
"proxy/master_key_rotations",
"proxy/model_management",
@ -564,7 +565,8 @@ const sidebars = {
"proxy/model_access",
"proxy/model_access_groups",
"proxy/access_groups",
"proxy/team_model_add"
"proxy/team_model_add",
"proxy/credential_routing"
]
},
{
@ -860,6 +862,7 @@ const sidebars = {
]
},
"providers/anthropic",
"providers/anthropic_tool_search",
"providers/aws_sagemaker",
{
type: "category",
@ -1057,16 +1060,7 @@ const sidebars = {
"proxy/health_check_routing"
],
},
{
type: "category",
label: "Load Testing",
items: [
"benchmarks",
"load_test_advanced",
"load_test_sdk",
"load_test_rpm",
]
},
"benchmarks",
{
type: "category",
label: "Contributing",
@ -1095,6 +1089,9 @@ const sidebars = {
"data_retention",
"proxy/security_encryption_faq",
"migration_policy",
"load_test_advanced",
"load_test_sdk",
"load_test_rpm",
{
type: "category",
label: "❤️ 🚅 Projects built on LiteLLM",
@ -1231,6 +1228,7 @@ const learnSidebar = {
"completion/web_fetch",
"completion/computer_use",
"guides/code_interpreter",
"completion/anthropic_advisor_tool",
"completion/message_sanitization",
],
},

View file

@ -794,3 +794,123 @@ video {
max-width: calc(9 / 12 * 100%) !important;
}
}
/* =========================================
BLOG — Ramp-style aesthetic
========================================= */
/* Hide blog sidebar on post pages */
.blog-post-page aside.col {
display: none !important;
}
/* Make blog post content full-width + constrained */
.blog-post-page main.col--7 {
--ifm-col-width: 100% !important;
max-width: 820px !important;
margin: 0 auto !important;
flex: 0 0 100% !important;
}
/* Clean post header */
.blog-wrapper article header h1 {
font-size: 2rem;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.25;
color: #111827;
margin-bottom: 0.75rem;
}
/* Author / date line */
.blog-wrapper article header .avatar,
.blog-wrapper article header [class*='blogPostData'] {
margin-top: 0.75rem;
}
/* Clean prose body */
.blog-wrapper article .markdown {
font-size: 0.95rem;
line-height: 1.7;
color: #374151;
}
.blog-wrapper article .markdown h2 {
font-size: 1.35rem;
font-weight: 600;
letter-spacing: -0.01em;
margin-top: 2.5rem;
margin-bottom: 0.75rem;
color: #111827;
}
.blog-wrapper article .markdown h3 {
font-size: 1.1rem;
font-weight: 600;
margin-top: 2rem;
margin-bottom: 0.5rem;
color: #111827;
}
.blog-wrapper article .markdown p {
margin-bottom: 1.25rem;
}
.blog-wrapper article .markdown a {
color: #0ea5e9;
text-decoration: none;
}
.blog-wrapper article .markdown a:hover {
text-decoration: underline;
}
.blog-wrapper article .markdown code {
font-size: 0.85em;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 0.15em 0.4em;
color: #111827;
}
.blog-wrapper article .markdown pre {
background: #ffffff !important;
border: 1px solid #e5e7eb !important;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.blog-wrapper article .markdown pre code {
background: transparent;
border: none;
padding: 0;
color: inherit;
}
/* Hide tags section at bottom of blog posts */
.blog-wrapper footer [class*='blogPostTags'],
.blog-wrapper footer [class*='tags'] {
display: none;
}
/* Nav buttons (prev/next) at bottom - keep clean */
.blog-wrapper .pagination-nav__label {
font-size: 0.85rem;
}
[data-theme='dark'] .blog-wrapper article header h1,
[data-theme='dark'] .blog-wrapper article .markdown h2,
[data-theme='dark'] .blog-wrapper article .markdown h3 {
color: #f9fafb;
}
[data-theme='dark'] .blog-wrapper article .markdown {
color: #d1d5db;
}
[data-theme='dark'] .blog-wrapper article .markdown code {
background: #1f2937;
border-color: #374151;
color: #f9fafb;
}

View file

@ -3,78 +3,86 @@ import Layout from '@theme/Layout';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
const TAG_COLORS = {
gemini: {bg: '#d2e3fc', text: '#174ea6', darkBg: '#1a3a5c', darkText: '#8ab4f8'},
anthropic: {bg: '#fde0c4', text: '#b33d00', darkBg: '#4a2800', darkText: '#ffb74d'},
claude: {bg: '#fde0c4', text: '#b33d00', darkBg: '#4a2800', darkText: '#ffb74d'},
llms: {bg: '#c8e6c9', text: '#1b5e20', darkBg: '#1b3d1f', darkText: '#81c784'},
};
// ── Provider marquee ──────────────────────────────────────────────────────
const PROVIDERS = [
{ name: 'OpenAI', img: 'https://www.google.com/s2/favicons?domain=openai.com&sz=64' },
{ name: 'Anthropic', img: 'https://www.google.com/s2/favicons?domain=claude.ai&sz=64' },
{ name: 'Google Gemini', img: 'https://www.google.com/s2/favicons?domain=ai.google.dev&sz=64' },
{ name: 'AWS Bedrock', img: 'https://www.google.com/s2/favicons?domain=aws.amazon.com&sz=64' },
{ name: 'Azure OpenAI', img: 'https://www.google.com/s2/favicons?domain=azure.microsoft.com&sz=64' },
{ name: 'Mistral AI', img: 'https://www.google.com/s2/favicons?domain=mistral.ai&sz=64' },
{ name: 'Meta Llama', img: 'https://www.google.com/s2/favicons?domain=meta.com&sz=64' },
{ name: 'Groq', img: 'https://www.google.com/s2/favicons?domain=groq.com&sz=64' },
{ name: 'Hugging Face', img: 'https://www.google.com/s2/favicons?domain=huggingface.co&sz=64' },
{ name: 'Perplexity', img: 'https://www.google.com/s2/favicons?domain=perplexity.ai&sz=64' },
{ name: 'DeepSeek', img: 'https://www.google.com/s2/favicons?domain=deepseek.com&sz=64' },
{ name: 'Cohere', img: 'https://www.google.com/s2/favicons?domain=cohere.com&sz=64' },
{ name: 'Together AI', img: 'https://www.google.com/s2/favicons?domain=together.ai&sz=64' },
{ name: 'Vertex AI', img: 'https://www.google.com/s2/favicons?domain=cloud.google.com&sz=64' },
];
function hashHue(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return Math.abs(hash) % 360;
}
function getTagColor(label) {
const key = label.toLowerCase();
for (const [k, v] of Object.entries(TAG_COLORS)) {
if (key === k) return v;
}
const hue = hashHue(key);
return {
bg: `hsl(${hue}, 40%, 90%)`,
text: `hsl(${hue}, 60%, 25%)`,
darkBg: `hsl(${hue}, 40%, 20%)`,
darkText: `hsl(${hue}, 50%, 75%)`,
};
}
function formatDate(dateStr) {
const d = new Date(dateStr);
const now = new Date();
const diffDays = Math.floor((now - d) / (1000 * 60 * 60 * 24));
if (diffDays <= 0) return 'Today';
if (diffDays === 1) return '1d ago';
if (diffDays < 30) return `${diffDays}d ago`;
return d.toLocaleDateString('en-US', {month: 'short', day: 'numeric', year: 'numeric'});
}
function BlogCard({post, featured}) {
const {title, permalink, date, description, tags} = post;
const visibleTags = (tags || []).slice(0, 3);
const DOUBLED = [...PROVIDERS, ...PROVIDERS];
function ProviderMarquee() {
return (
<Link to={permalink} className={styles.cardLink} aria-label={title}>
<article className={featured ? styles.cardFeatured : styles.card}>
<div className={styles.meta}>
<time className={styles.time} dateTime={date}>{formatDate(date)}</time>
{featured && <span className={styles.badge}>Latest</span>}
<div className={styles.marqueeWrap}>
<p className={styles.marqueeLabel}>Routing to 100+ providers</p>
<div className={styles.marqueeOuter}>
<div className={styles.fadeLeft} />
<div className={styles.fadeRight} />
<div className={styles.marqueeTrack}>
{DOUBLED.map((p, i) => (
<span key={i} className={styles.marqueeItem}>
<img src={p.img} alt={p.name} width={18} height={18} className={styles.marqueeIcon} />
<span>{p.name}</span>
<span className={styles.marqueeSep}>|</span>
</span>
))}
</div>
</div>
</div>
);
}
// ── Post row ──────────────────────────────────────────────────────────────
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'long', day: 'numeric', year: 'numeric',
});
}
function AuthorList({authors}) {
if (!authors || authors.length === 0) return null;
return (
<>
{authors.map((a, i) => (
<React.Fragment key={a.name}>
{i > 0 && <span className={styles.authorSep}> </span>}
{a.url ? (
<a href={a.url} target="_blank" rel="noopener" className={styles.authorLink}>{a.name}</a>
) : (
<span className={styles.authorName}>{a.name}</span>
)}
</React.Fragment>
))}
</>
);
}
function PostRow({post}) {
const {title, permalink, date, description, authors} = post;
return (
<article className={styles.post}>
<Link to={permalink} className={styles.titleLink}>
<h2 className={styles.title}>{title}</h2>
{description && <p className={styles.desc}>{description}</p>}
{visibleTags.length > 0 && (
<div className={styles.tags}>
{visibleTags.map(tag => {
const c = getTagColor(tag.label);
return (
<span key={tag.label} className={styles.tag} style={{
'--tag-bg': c.bg, '--tag-text': c.text,
'--tag-bg-dark': c.darkBg, '--tag-text-dark': c.darkText,
}}>{tag.label}</span>
);
})}
</div>
)}
<div className={styles.arrow} aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M6 3l5 5-5 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
</article>
</Link>
</Link>
{description && <p className={styles.desc}>{description}</p>}
<div className={styles.meta}>
<AuthorList authors={authors} />
{authors && authors.length > 0 && <span className={styles.metaDash}> — </span>}
<time className={styles.date} dateTime={date}>{formatDate(date)}</time>
</div>
</article>
);
}
@ -83,41 +91,47 @@ function Pagination({metadata}) {
if (!previousPage && !nextPage) return null;
return (
<nav className={styles.pagination} aria-label="Blog list pagination">
{previousPage ? (
<Link to={previousPage} className={styles.paginationLink}>&larr; Newer posts</Link>
) : <span />}
{nextPage ? (
<Link to={nextPage} className={styles.paginationLink}>Older posts &rarr;</Link>
) : <span />}
{previousPage ? <Link to={previousPage} className={styles.pageLink}>&larr; Newer posts</Link> : <span />}
{nextPage ? <Link to={nextPage} className={styles.pageLink}>Older posts &rarr;</Link> : <span />}
</nav>
);
}
// ── Page ──────────────────────────────────────────────────────────────────
export default function BlogListPage(props) {
const items = props.items || [];
const metadata = props.metadata || {};
const [first, ...rest] = items;
return (
<Layout
title={metadata.blogTitle || 'Blog'}
description={metadata.blogDescription || 'Guides, announcements, and best practices from the LiteLLM team.'}
title="Engineering Blog"
description="How we build the world's most widely used open-source AI Gateway. Routing, reliability, observability, and what we learn along the way."
>
<header className={styles.hero}>
<h1 className={styles.heroTitle}>The LiteLLM Blog</h1>
<p className={styles.heroSubtitle}>Guides, announcements, and best practices from the LiteLLM team.</p>
</header>
<div className={styles.page}>
{/* Hero */}
<header className={styles.hero}>
<p className={styles.eyebrow}>AI Gateway</p>
<h1 className={styles.heroTitle}>Engineering</h1>
<p className={styles.heroSub}>
How we build the world's most widely used open-source AI Gateway.
Routing, reliability, observability, and what we learn along the way.
</p>
<a href="https://jobs.ashbyhq.com/litellm" target="_blank" rel="noopener noreferrer" className={styles.hiringBtn}>
We're hiring!
</a>
</header>
<main className={styles.grid}>
{first && (
<BlogCard post={first.content.metadata} featured />
)}
{rest.map(({content}) => (
<BlogCard key={content.metadata.permalink} post={content.metadata} />
))}
</main>
<ProviderMarquee />
<Pagination metadata={metadata} />
{/* Post list */}
<main className={styles.list}>
{items.map(({content}) => (
<PostRow key={content.metadata.permalink} post={content.metadata} />
))}
</main>
<Pagination metadata={metadata} />
</div>
</Layout>
);
}

View file

@ -1,163 +1,254 @@
.hero {
max-width: 960px;
/* ── Page shell ───────────────────────────────────────────────────────── */
.page {
max-width: 860px;
margin: 0 auto;
padding: 3rem 1.5rem 1rem;
text-align: center;
padding: 0 2rem;
}
/* ── Hero ─────────────────────────────────────────────────────────────── */
.hero {
padding: 3.5rem 0 0;
}
.eyebrow {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #0ea5e9;
margin: 0 0 0.5rem;
}
.heroTitle {
font-size: 2.25rem;
font-weight: 700;
margin-bottom: 0.25rem;
letter-spacing: -0.02em;
}
.heroSubtitle {
color: var(--ifm-color-emphasis-600);
font-size: 1.1rem;
margin-bottom: 0;
}
.grid {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem;
display: grid;
gap: 1rem;
}
.cardLink {
display: block;
text-decoration: none;
color: inherit;
}
.card {
position: relative;
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 12px;
padding: 1.5rem;
padding-right: 2.5rem;
height: 100%;
transition: border-color 0.15s, transform 0.15s, background 0.15s;
background: var(--ifm-background-surface-color, var(--ifm-background-color));
}
.card:hover {
border-color: var(--ifm-color-primary);
transform: translateY(-2px);
background: var(--ifm-color-emphasis-100);
}
.cardFeatured {
composes: card;
border-color: var(--ifm-color-primary-lighter);
background: var(--ifm-color-emphasis-100);
}
.meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.time {
font-size: 0.8rem;
font-weight: 500;
color: var(--ifm-color-emphasis-600);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge {
font-size: 0.65rem;
font-size: 2.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 8px;
border-radius: 99px;
background: var(--ifm-color-primary);
color: #fff;
}
.title {
font-size: 1.15rem;
font-weight: 600;
margin: 0 0 0.4rem;
line-height: 1.35;
}
.desc {
font-size: 0.88rem;
color: var(--ifm-color-emphasis-700);
line-height: 1.5;
letter-spacing: -0.03em;
line-height: 1.1;
color: #111827;
margin: 0 0 0.75rem;
}
.tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
.heroSub {
font-size: 0.95rem;
color: #6b7280;
max-width: 540px;
line-height: 1.65;
margin: 0 0 1.25rem;
}
.tag {
font-size: 0.7rem;
.hiringBtn {
display: inline-block;
background: #111827;
color: #fff !important;
font-size: 0.82rem;
font-weight: 500;
padding: 2px 10px;
border-radius: 99px;
background: var(--tag-bg);
color: var(--tag-text);
padding: 0.45rem 1rem;
border-radius: 6px;
text-decoration: none !important;
transition: background 0.15s;
}
:global([data-theme='dark']) .tag {
background: var(--tag-bg-dark);
color: var(--tag-text-dark);
.hiringBtn:hover {
background: #000;
}
.arrow {
/* ── Marquee ──────────────────────────────────────────────────────────── */
.marqueeWrap {
margin: 2.5rem 0 0;
padding: 1.25rem 0;
border-top: 1px solid #f3f4f6;
border-bottom: 1px solid #f3f4f6;
overflow: hidden;
}
.marqueeLabel {
text-align: center;
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.14em;
color: #9ca3af;
margin: 0 0 1rem;
}
.marqueeOuter {
position: relative;
overflow: hidden;
}
.fadeLeft {
pointer-events: none;
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
color: var(--ifm-color-emphasis-400);
transition: color 0.15s, transform 0.15s;
left: 0; top: 0; bottom: 0;
width: 5rem;
background: linear-gradient(to right, var(--ifm-background-color, #fff), transparent);
z-index: 10;
}
.card:hover .arrow {
color: var(--ifm-color-primary);
transform: translateY(-50%) translateX(3px);
.fadeRight {
pointer-events: none;
position: absolute;
right: 0; top: 0; bottom: 0;
width: 5rem;
background: linear-gradient(to left, var(--ifm-background-color, #fff), transparent);
z-index: 10;
}
.marqueeTrack {
display: flex;
align-items: center;
white-space: nowrap;
animation: marquee 28s linear infinite;
}
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.marqueeItem {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0 1.4rem;
font-size: 0.82rem;
color: #4b5563;
font-weight: 500;
}
.marqueeIcon {
flex-shrink: 0;
border-radius: 2px;
}
.marqueeSep {
margin-left: 1.2rem;
color: #e5e7eb;
font-weight: 300;
}
/* ── Post list ────────────────────────────────────────────────────────── */
.list {
margin-top: 0.5rem;
}
.post {
padding: 2.25rem 0;
border-bottom: 1px solid #f3f4f6;
}
.titleLink {
text-decoration: none !important;
color: inherit;
}
.title {
font-size: 1.4rem;
font-weight: 600;
line-height: 1.3;
letter-spacing: -0.01em;
color: #111827;
margin: 0 0 0.5rem;
transition: color 0.12s;
}
.titleLink:hover .title {
color: #0ea5e9;
}
.desc {
font-size: 0.875rem;
color: #6b7280;
line-height: 1.55;
margin: 0 0 0.6rem;
}
.meta {
font-size: 0.82rem;
color: #6b7280;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
}
.authorLink {
color: #374151;
font-weight: 500;
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: #d1d5db;
}
.authorLink:hover {
color: #0ea5e9;
text-decoration-color: #0ea5e9;
}
.authorName {
color: #374151;
font-weight: 500;
}
.authorSep {
margin: 0 0.3rem;
color: #d1d5db;
}
.metaDash {
margin: 0 0.35rem;
color: #d1d5db;
}
.date {
color: #9ca3af;
}
/* ── Pagination ───────────────────────────────────────────────────────── */
.pagination {
max-width: 960px;
margin: 0 auto;
padding: 1rem 1.5rem 3rem;
padding: 1.5rem 0 4rem;
display: flex;
justify-content: space-between;
}
.paginationLink {
font-size: 0.9rem;
.pageLink {
font-size: 0.85rem;
font-weight: 500;
color: var(--ifm-color-primary);
color: #374151;
text-decoration: none;
}
.paginationLink:hover {
text-decoration: underline;
.pageLink:hover {
color: #0ea5e9;
}
@media (min-width: 640px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
.grid .cardLink:first-child {
grid-column: 1 / -1;
}
.grid .cardLink:last-child:nth-child(even) {
grid-column: 1 / -1;
}
/* ── Dark mode ────────────────────────────────────────────────────────── */
[data-theme='dark'] .heroTitle,
[data-theme='dark'] .title {
color: #f9fafb;
}
[data-theme='dark'] .heroSub,
[data-theme='dark'] .desc,
[data-theme='dark'] .date {
color: #9ca3af;
}
[data-theme='dark'] .post,
[data-theme='dark'] .marqueeWrap {
border-color: #1f2937;
}
[data-theme='dark'] .authorLink,
[data-theme='dark'] .authorName {
color: #e5e7eb;
}
[data-theme='dark'] .hiringBtn {
background: #f9fafb;
color: #111827 !important;
}
[data-theme='dark'] .hiringBtn:hover {
background: #fff;
}

View file

@ -0,0 +1,54 @@
import React, {useEffect} from 'react';
import OriginalBlogPostPage from '@theme-original/BlogPostPage';
import styles from './styles.module.css';
function BackLink() {
return (
<div className={styles.backOuter}>
<a href="/blog" className={styles.backLink}>
<svg className={styles.backArrow} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16l-4-4m0 0l4-4m-4 4h18" />
</svg>
Blog
</a>
</div>
);
}
function HiringCTA() {
return (
<div className={styles.ctaOuter}>
<div className={styles.cta}>
<p className={styles.ctaEyebrow}>We're hiring</p>
<a
href="https://jobs.ashbyhq.com/litellm"
target="_blank"
rel="noopener noreferrer"
className={styles.ctaLink}
>
Like what you see? Join us
<svg className={styles.ctaArrow} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
<p className={styles.ctaSub}>Come build the future of AI infrastructure.</p>
</div>
</div>
);
}
export default function BlogPostPage(props) {
// Add body class so CSS can hide the sidebar
useEffect(() => {
document.body.classList.add('blog-post-body');
return () => document.body.classList.remove('blog-post-body');
}, []);
return (
<>
<BackLink />
<OriginalBlogPostPage {...props} />
<HiringCTA />
</>
);
}

View file

@ -0,0 +1,109 @@
.backOuter {
position: fixed;
top: calc(var(--ifm-navbar-height, 60px) + 1rem);
left: 2rem;
z-index: 100;
}
.backLink {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
text-decoration: none !important;
transition: color 0.15s;
}
.backLink:hover {
color: #111827;
}
.backArrow {
width: 1rem;
height: 1rem;
transition: transform 0.15s;
flex-shrink: 0;
}
.backLink:hover .backArrow {
transform: translateX(-3px);
}
[data-theme='dark'] .backLink {
color: #9ca3af;
}
[data-theme='dark'] .backLink:hover {
color: #f9fafb;
}
.ctaOuter {
max-width: 820px;
margin: 0 auto;
padding: 0 2rem 4rem;
}
.cta {
border-radius: 16px;
background: #f9fafb;
border: 1px solid #e5e7eb;
padding: 2.5rem 2rem;
text-align: center;
}
.ctaEyebrow {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #9ca3af;
margin: 0 0 0.75rem;
}
.ctaLink {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.01em;
color: #111827;
text-decoration: none !important;
transition: color 0.15s;
}
.ctaLink:hover {
color: #0ea5e9;
}
.ctaArrow {
width: 1.25rem;
height: 1.25rem;
transition: transform 0.15s;
flex-shrink: 0;
}
.ctaLink:hover .ctaArrow {
transform: translateX(3px);
}
.ctaSub {
margin: 0.75rem 0 0;
font-size: 0.875rem;
color: #6b7280;
}
[data-theme='dark'] .cta {
background: #1f2937;
border-color: #374151;
}
[data-theme='dark'] .ctaLink {
color: #f9fafb;
}
[data-theme='dark'] .ctaSub {
color: #9ca3af;
}

View file

@ -114,6 +114,7 @@ class PagerDutyAlerting(SlackAlerting):
user_api_key_max_budget=_meta.get("user_api_key_max_budget"),
user_api_key_budget_reset_at=_meta.get("user_api_key_budget_reset_at"),
user_api_key_org_id=_meta.get("user_api_key_org_id"),
user_api_key_org_alias=_meta.get("user_api_key_org_alias"),
user_api_key_team_id=_meta.get("user_api_key_team_id"),
user_api_key_project_id=_meta.get("user_api_key_project_id"),
user_api_key_project_alias=_meta.get("user_api_key_project_alias"),
@ -196,6 +197,7 @@ class PagerDutyAlerting(SlackAlerting):
else None
),
user_api_key_org_id=user_api_key_dict.org_id,
user_api_key_org_alias=user_api_key_dict.organization_alias,
user_api_key_team_id=user_api_key_dict.team_id,
user_api_key_project_id=user_api_key_dict.project_id,
user_api_key_project_alias=user_api_key_dict.project_alias,

View file

@ -164,6 +164,7 @@ initialized_langfuse_clients: int = 0
langfuse_default_tags: Optional[List[str]] = None
langsmith_batch_size: Optional[int] = None
prometheus_initialize_budget_metrics: Optional[bool] = False
prometheus_latency_buckets: Optional[List[float]] = None
require_auth_for_metrics_endpoint: Optional[bool] = False
argilla_batch_size: Optional[int] = None
datadog_use_v1: Optional[bool] = False # if you want to use v1 datadog logged payload.
@ -203,6 +204,7 @@ add_user_information_to_llm_headers: Optional[
bool
] = None # adds user_id, team_id, token hash (params from StandardLoggingMetadata) to request headers
store_audit_logs = False # Enterprise feature, allow users to see audit logs
skip_system_message_in_guardrail: bool = False
### end of callbacks #############
email: Optional[
@ -318,6 +320,7 @@ return_response_headers: bool = (
False # get response headers from LLM Api providers - example x-remaining-requests,
)
enable_json_schema_validation: bool = False
enable_model_config_credential_overrides: bool = False
enable_key_alias_format_validation: bool = (
False # opt-in validation of key_alias format on /key/generate and /key/update
)

View file

@ -243,6 +243,12 @@ class JsonFormatter(Formatter):
if key not in _STANDARD_RECORD_ATTRS and key not in json_record:
json_record[key] = value
# Set component/logger only if not already supplied via extra={...}
if "component" not in json_record:
json_record["component"] = record.name
if "logger" not in json_record:
json_record["logger"] = f"{record.filename}:{record.lineno}"
if record.exc_info:
json_record["stacktrace"] = record.exc_text or self.formatException(
record.exc_info

View file

@ -615,7 +615,7 @@ async def asend_message_streaming( # noqa: PLR0915
async def create_a2a_client(
base_url: str,
timeout: float = 60.0,
timeout: float = DEFAULT_A2A_AGENT_TIMEOUT,
extra_headers: Optional[Dict[str, str]] = None,
) -> "A2AClientType":
"""
@ -626,7 +626,7 @@ async def create_a2a_client(
Args:
base_url: The base URL of the A2A agent (e.g., "http://localhost:10001")
timeout: Request timeout in seconds (default: 60.0)
timeout: Request timeout in seconds (default: ``DEFAULT_A2A_AGENT_TIMEOUT`` / env ``DEFAULT_A2A_AGENT_TIMEOUT``)
extra_headers: Optional additional headers to include in requests
Returns:
@ -711,7 +711,7 @@ async def aget_agent_card(
Args:
base_url: The base URL of the A2A agent (e.g., "http://localhost:10001")
timeout: Request timeout in seconds (default: 60.0)
timeout: Request timeout in seconds (default: ``DEFAULT_A2A_AGENT_TIMEOUT`` / env ``DEFAULT_A2A_AGENT_TIMEOUT``)
extra_headers: Optional additional headers to include in requests
Returns:

View file

@ -21,11 +21,11 @@ class PydanticAIProviderConfig(BaseA2AProviderConfig):
request_id: str,
params: Dict[str, Any],
api_base: Optional[str] = None,
**kwargs,
**kwargs: Any,
) -> Dict[str, Any]:
"""Handle non-streaming request to Pydantic AI agent."""
if not api_base:
raise ValueError("api_base is required for Pydantic AI agents")
if api_base is None:
raise ValueError("api_base is required for PydanticAIProviderConfig")
return await PydanticAIHandler.handle_non_streaming(
request_id=request_id,
params=params,

View file

@ -1,6 +1,7 @@
{
"description": "Mapping of Anthropic beta headers for each provider. Keys are input header names, values are provider-specific header names (or null if unsupported). Only headers present in mapping keys with non-null values can be forwarded.",
"anthropic": {
"advisor-tool-2026-03-01": "advisor-tool-2026-03-01",
"advanced-tool-use-2025-11-20": "advanced-tool-use-2025-11-20",
"bash_20241022": null,
"bash_20250124": null,
@ -31,6 +32,7 @@
"web-search-2025-03-05": "web-search-2025-03-05"
},
"azure_ai": {
"advisor-tool-2026-03-01": null,
"advanced-tool-use-2025-11-20": "advanced-tool-use-2025-11-20",
"bash_20241022": null,
"bash_20250124": null,
@ -60,6 +62,7 @@
"web-search-2025-03-05": "web-search-2025-03-05"
},
"bedrock_converse": {
"advisor-tool-2026-03-01": null,
"advanced-tool-use-2025-11-20": null,
"bash_20241022": null,
"bash_20250124": null,
@ -90,6 +93,7 @@
"web-search-2025-03-05": null
},
"bedrock": {
"advisor-tool-2026-03-01": null,
"advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19",
"bash_20241022": null,
"bash_20250124": null,
@ -120,6 +124,7 @@
"web-search-2025-03-05": null
},
"vertex_ai": {
"advisor-tool-2026-03-01": null,
"advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19",
"bash_20241022": null,
"bash_20250124": null,
@ -150,6 +155,7 @@
"web-search-2025-03-05": "web-search-2025-03-05"
},
"databricks": {
"advisor-tool-2026-03-01": null,
"advanced-tool-use-2025-11-20": "advanced-tool-use-2025-11-20",
"bash_20241022": null,
"bash_20250124": null,

View file

@ -135,6 +135,15 @@ MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL = int(
MCP_NPM_CACHE_DIR = os.getenv("MCP_NPM_CACHE_DIR", "/tmp/.npm_mcp_cache")
MCP_OAUTH2_TOKEN_CACHE_MIN_TTL = int(os.getenv("MCP_OAUTH2_TOKEN_CACHE_MIN_TTL", "10"))
# Per-user OAuth token Redis cache (for server-side token storage)
MCP_PER_USER_TOKEN_REDIS_KEY_PREFIX = "mcp:per_user_token"
MCP_PER_USER_TOKEN_DEFAULT_TTL = int(
os.getenv("MCP_PER_USER_TOKEN_DEFAULT_TTL", "43200") # 12 hours
)
MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS = int(
os.getenv("MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS", "60")
)
# MCP timeout defaults (seconds). Override via env vars for slow/custom MCP servers.
MCP_CLIENT_TIMEOUT = float(os.getenv("LITELLM_MCP_CLIENT_TIMEOUT", "60.0"))
MCP_TOOL_LISTING_TIMEOUT = float(os.getenv("LITELLM_MCP_TOOL_LISTING_TIMEOUT", "30.0"))

View file

@ -14,6 +14,7 @@ from typing import Any, Callable, Dict, List, Literal, Optional, Type
import litellm
from litellm.constants import request_timeout as DEFAULT_REQUEST_TIMEOUT
from litellm.containers.utils import decode_managed_container_id_for_request
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.containers.transformation import BaseContainerConfig
from litellm.llms.custom_httpx.container_handler import generic_container_handler
@ -53,7 +54,7 @@ def create_sync_endpoint_function(endpoint_config: Dict) -> Callable:
@client
def endpoint_func(
timeout: int = 600,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
extra_headers: Optional[Dict[str, Any]] = None,
extra_query: Optional[Dict[str, Any]] = None,
extra_body: Optional[Dict[str, Any]] = None,
@ -61,6 +62,7 @@ def create_sync_endpoint_function(endpoint_config: Dict) -> Callable:
):
local_vars = locals()
try:
resolved_custom_llm_provider: str = custom_llm_provider
litellm_logging_obj: LiteLLMLoggingObj = kwargs.pop("litellm_logging_obj")
litellm_call_id: Optional[str] = kwargs.get("litellm_call_id")
_is_async = kwargs.pop("async_call", False) is True
@ -76,15 +78,27 @@ def create_sync_endpoint_function(endpoint_config: Dict) -> Callable:
# Get provider config
litellm_params = GenericLiteLLMParams(**kwargs)
# Strip LiteLLM-managed container IDs before calling the provider API
# (OpenAI enforces max length 64 on container_id).
if "container_id" in kwargs and isinstance(kwargs["container_id"], str):
(
kwargs["container_id"],
resolved_custom_llm_provider,
litellm_params,
) = decode_managed_container_id_for_request(
container_id=kwargs["container_id"],
custom_llm_provider=resolved_custom_llm_provider,
litellm_params=litellm_params,
)
container_provider_config: Optional[
BaseContainerConfig
] = ProviderConfigManager.get_provider_container_config(
provider=litellm.LlmProviders(custom_llm_provider),
provider=litellm.LlmProviders(resolved_custom_llm_provider),
)
if container_provider_config is None:
raise ValueError(
f"Container provider config not found for: {custom_llm_provider}"
f"Container provider config not found for: {resolved_custom_llm_provider}"
)
# Build optional params for logging
@ -96,7 +110,7 @@ def create_sync_endpoint_function(endpoint_config: Dict) -> Callable:
model="",
optional_params=optional_params,
litellm_params={"litellm_call_id": litellm_call_id},
custom_llm_provider=custom_llm_provider,
custom_llm_provider=resolved_custom_llm_provider,
)
# Use generic handler
@ -115,7 +129,7 @@ def create_sync_endpoint_function(endpoint_config: Dict) -> Callable:
except Exception as e:
raise litellm.exception_type(
model="",
custom_llm_provider=custom_llm_provider,
custom_llm_provider=resolved_custom_llm_provider,
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,
@ -133,7 +147,7 @@ def create_async_endpoint_function(
@client
async def async_endpoint_func(
timeout: int = 600,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
extra_headers: Optional[Dict[str, Any]] = None,
extra_query: Optional[Dict[str, Any]] = None,
extra_body: Optional[Dict[str, Any]] = None,

View file

@ -6,7 +6,10 @@ from typing import Any, Coroutine, Dict, List, Literal, Optional, Union, overloa
import litellm
from litellm.constants import request_timeout as DEFAULT_REQUEST_TIMEOUT
from litellm.containers.utils import ContainerRequestUtils
from litellm.containers.utils import (
ContainerRequestUtils,
decode_managed_container_id_for_request,
)
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.containers.transformation import BaseContainerConfig
from litellm.main import base_llm_http_handler
@ -48,7 +51,7 @@ async def acreate_container(
file_ids: Optional[List[str]] = None,
timeout=600, # default to 10 minutes
# LiteLLM specific params,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Optional[Dict[str, Any]] = None,
@ -122,7 +125,7 @@ def create_container(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
*,
acreate_container: Literal[True],
**kwargs,
@ -139,7 +142,7 @@ def create_container(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
*,
acreate_container: Literal[False] = False,
**kwargs,
@ -158,7 +161,7 @@ def create_container(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Optional[Dict[str, Any]] = None,
@ -247,7 +250,7 @@ def create_container(
# Set the correct call type for container creation
litellm_logging_obj.call_type = CallTypes.create_container.value
return base_llm_http_handler.container_create_handler(
container_obj = base_llm_http_handler.container_create_handler(
name=name,
container_create_request_params=container_create_request_params,
container_provider_config=container_provider_config,
@ -257,6 +260,17 @@ def create_container(
timeout=timeout or DEFAULT_REQUEST_TIMEOUT,
_is_async=_is_async,
)
# Encode container_id with provider/model metadata for routing
if isinstance(container_obj, ContainerObject):
container_obj = ContainerRequestUtils.encode_container_id_in_response(
response_obj=container_obj,
custom_llm_provider=custom_llm_provider,
litellm_metadata=kwargs.get("litellm_metadata"),
extra_body=extra_body,
)
return container_obj
except Exception as e:
raise litellm.exception_type(
@ -275,7 +289,7 @@ async def alist_containers(
limit: Optional[int] = None,
order: Optional[str] = None,
timeout=600, # default to 10 minutes
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Optional[Dict[str, Any]] = None,
@ -348,7 +362,7 @@ def list_containers(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
*,
alist_containers: Literal[True],
**kwargs,
@ -365,7 +379,7 @@ def list_containers(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
*,
alist_containers: Literal[False] = False,
**kwargs,
@ -384,7 +398,7 @@ def list_containers(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Optional[Dict[str, Any]] = None,
@ -481,7 +495,7 @@ def list_containers(
async def aretrieve_container(
container_id: str,
timeout=600, # default to 10 minutes
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Optional[Dict[str, Any]] = None,
@ -548,7 +562,7 @@ def retrieve_container(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
*,
aretrieve_container: Literal[True],
**kwargs,
@ -563,7 +577,7 @@ def retrieve_container(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
*,
aretrieve_container: Literal[False] = False,
**kwargs,
@ -580,7 +594,7 @@ def retrieve_container(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Optional[Dict[str, Any]] = None,
@ -594,6 +608,7 @@ def retrieve_container(
"""
local_vars = locals()
try:
resolved_custom_llm_provider: str = custom_llm_provider
litellm_logging_obj: LiteLLMLoggingObj = kwargs.pop("litellm_logging_obj") # type: ignore
litellm_call_id: Optional[str] = kwargs.get("litellm_call_id")
_is_async = kwargs.pop("async_call", False) is True
@ -615,16 +630,28 @@ def retrieve_container(
api_version=api_version,
**kwargs,
)
# Decode container ID and extract provider info
original_container_id, resolved_custom_llm_provider, litellm_params = (
decode_managed_container_id_for_request(
container_id=container_id,
custom_llm_provider=custom_llm_provider,
litellm_params=litellm_params,
)
)
# True when input was a LiteLLM-managed ID (any length); needed to re-encode output for routing affinity
was_encoded = original_container_id != container_id
# get provider config
container_provider_config: Optional[
BaseContainerConfig
] = ProviderConfigManager.get_provider_container_config(
provider=litellm.LlmProviders(custom_llm_provider),
provider=litellm.LlmProviders(resolved_custom_llm_provider),
)
if container_provider_config is None:
raise ValueError(
f"Container provider config not found for provider: {custom_llm_provider}"
f"Container provider config not found for provider: {resolved_custom_llm_provider}"
)
# Pre Call logging
@ -635,14 +662,14 @@ def retrieve_container(
litellm_params={
"litellm_call_id": litellm_call_id,
},
custom_llm_provider=custom_llm_provider,
custom_llm_provider=resolved_custom_llm_provider,
)
# Set the correct call type
litellm_logging_obj.call_type = CallTypes.retrieve_container.value
return base_llm_http_handler.container_retrieve_handler(
container_id=container_id,
container_obj = base_llm_http_handler.container_retrieve_handler(
container_id=original_container_id, # Use decoded original ID
container_provider_config=container_provider_config,
litellm_params=litellm_params,
logging_obj=litellm_logging_obj,
@ -651,11 +678,33 @@ def retrieve_container(
timeout=timeout or DEFAULT_REQUEST_TIMEOUT,
_is_async=_is_async,
)
# Encode container_id with provider/model metadata for routing
# If input was encoded, preserve encoding in output using the decoded model_id
if isinstance(container_obj, ContainerObject):
# If input was encoded, use model_id from decoded params
litellm_metadata = kwargs.get("litellm_metadata", {})
if was_encoded and litellm_params.get("model_id"):
# Inject model_id from decoded container_id into litellm_metadata
if not litellm_metadata:
litellm_metadata = {}
if "model_info" not in litellm_metadata:
litellm_metadata["model_info"] = {}
litellm_metadata["model_info"]["id"] = litellm_params["model_id"]
container_obj = ContainerRequestUtils.encode_container_id_in_response(
response_obj=container_obj,
custom_llm_provider=resolved_custom_llm_provider,
litellm_metadata=litellm_metadata,
extra_body=None,
)
return container_obj
except Exception as e:
raise litellm.exception_type(
model="",
custom_llm_provider=custom_llm_provider,
custom_llm_provider=resolved_custom_llm_provider,
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,
@ -667,7 +716,7 @@ def retrieve_container(
async def adelete_container(
container_id: str,
timeout=600, # default to 10 minutes
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Optional[Dict[str, Any]] = None,
@ -734,7 +783,7 @@ def delete_container(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
*,
adelete_container: Literal[True],
**kwargs,
@ -749,7 +798,7 @@ def delete_container(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
*,
adelete_container: Literal[False] = False,
**kwargs,
@ -766,7 +815,7 @@ def delete_container(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Optional[Dict[str, Any]] = None,
@ -780,6 +829,7 @@ def delete_container(
"""
local_vars = locals()
try:
resolved_custom_llm_provider: str = custom_llm_provider
litellm_logging_obj: LiteLLMLoggingObj = kwargs.pop("litellm_logging_obj") # type: ignore
litellm_call_id: Optional[str] = kwargs.get("litellm_call_id")
_is_async = kwargs.pop("async_call", False) is True
@ -801,16 +851,28 @@ def delete_container(
api_version=api_version,
**kwargs,
)
# Decode container ID and extract provider info
original_container_id, resolved_custom_llm_provider, litellm_params = (
decode_managed_container_id_for_request(
container_id=container_id,
custom_llm_provider=custom_llm_provider,
litellm_params=litellm_params,
)
)
# True when input was a LiteLLM-managed ID (any length); needed to re-encode output for routing affinity
was_encoded = original_container_id != container_id
# get provider config
container_provider_config: Optional[
BaseContainerConfig
] = ProviderConfigManager.get_provider_container_config(
provider=litellm.LlmProviders(custom_llm_provider),
provider=litellm.LlmProviders(resolved_custom_llm_provider),
)
if container_provider_config is None:
raise ValueError(
f"Container provider config not found for provider: {custom_llm_provider}"
f"Container provider config not found for provider: {resolved_custom_llm_provider}"
)
# Pre Call logging
@ -821,14 +883,14 @@ def delete_container(
litellm_params={
"litellm_call_id": litellm_call_id,
},
custom_llm_provider=custom_llm_provider,
custom_llm_provider=resolved_custom_llm_provider,
)
# Set the correct call type
litellm_logging_obj.call_type = CallTypes.delete_container.value
return base_llm_http_handler.container_delete_handler(
container_id=container_id,
delete_result = base_llm_http_handler.container_delete_handler(
container_id=original_container_id, # Use decoded original ID
container_provider_config=container_provider_config,
litellm_params=litellm_params,
logging_obj=litellm_logging_obj,
@ -837,11 +899,33 @@ def delete_container(
timeout=timeout or DEFAULT_REQUEST_TIMEOUT,
_is_async=_is_async,
)
# Encode container_id in response with provider/model metadata for routing
# If input was encoded, preserve encoding in output using the decoded model_id
if isinstance(delete_result, DeleteContainerResult):
# If input was encoded, use model_id from decoded params
litellm_metadata = kwargs.get("litellm_metadata", {})
if was_encoded and litellm_params.get("model_id"):
# Inject model_id from decoded container_id into litellm_metadata
if not litellm_metadata:
litellm_metadata = {}
if "model_info" not in litellm_metadata:
litellm_metadata["model_info"] = {}
litellm_metadata["model_info"]["id"] = litellm_params["model_id"]
delete_result = ContainerRequestUtils.encode_container_id_in_response(
response_obj=delete_result,
custom_llm_provider=resolved_custom_llm_provider,
litellm_metadata=litellm_metadata,
extra_body=None,
)
return delete_result
except Exception as e:
raise litellm.exception_type(
model="",
custom_llm_provider=custom_llm_provider,
custom_llm_provider=resolved_custom_llm_provider,
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,
@ -856,7 +940,7 @@ async def alist_container_files(
limit: Optional[int] = None,
order: Optional[str] = None,
timeout=600, # default to 10 minutes
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
extra_headers: Optional[Dict[str, Any]] = None,
extra_query: Optional[Dict[str, Any]] = None,
extra_body: Optional[Dict[str, Any]] = None,
@ -930,7 +1014,7 @@ def list_container_files(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
*,
alist_container_files: Literal[True],
**kwargs,
@ -948,7 +1032,7 @@ def list_container_files(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
*,
alist_container_files: Literal[False] = False,
**kwargs,
@ -968,7 +1052,7 @@ def list_container_files(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
extra_headers: Optional[Dict[str, Any]] = None,
extra_query: Optional[Dict[str, Any]] = None,
extra_body: Optional[Dict[str, Any]] = None,
@ -980,6 +1064,7 @@ def list_container_files(
"""
local_vars = locals()
try:
resolved_custom_llm_provider: str = custom_llm_provider
litellm_logging_obj: LiteLLMLoggingObj = kwargs.pop("litellm_logging_obj") # type: ignore
litellm_call_id: Optional[str] = kwargs.get("litellm_call_id")
_is_async = kwargs.pop("async_call", False) is True
@ -1001,16 +1086,26 @@ def list_container_files(
api_version=api_version,
**kwargs,
)
# Decode container ID and extract provider info
original_container_id, resolved_custom_llm_provider, litellm_params = (
decode_managed_container_id_for_request(
container_id=container_id,
custom_llm_provider=custom_llm_provider,
litellm_params=litellm_params,
)
)
# get provider config
container_provider_config: Optional[
BaseContainerConfig
] = ProviderConfigManager.get_provider_container_config(
provider=litellm.LlmProviders(custom_llm_provider),
provider=litellm.LlmProviders(resolved_custom_llm_provider),
)
if container_provider_config is None:
raise ValueError(
f"Container provider config not found for provider: {custom_llm_provider}"
f"Container provider config not found for provider: {resolved_custom_llm_provider}"
)
# Pre Call logging
@ -1026,14 +1121,14 @@ def list_container_files(
litellm_params={
"litellm_call_id": litellm_call_id,
},
custom_llm_provider=custom_llm_provider,
custom_llm_provider=resolved_custom_llm_provider,
)
# Set the correct call type
litellm_logging_obj.call_type = CallTypes.list_container_files.value
return base_llm_http_handler.container_file_list_handler(
container_id=container_id,
container_id=original_container_id, # Use decoded original ID
container_provider_config=container_provider_config,
litellm_params=litellm_params,
logging_obj=litellm_logging_obj,
@ -1049,7 +1144,7 @@ def list_container_files(
except Exception as e:
raise litellm.exception_type(
model="",
custom_llm_provider=custom_llm_provider,
custom_llm_provider=resolved_custom_llm_provider,
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,
@ -1062,7 +1157,7 @@ async def aupload_container_file(
container_id: str,
file: FileTypes,
timeout=600, # default to 10 minutes
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
extra_headers: Optional[Dict[str, Any]] = None,
extra_query: Optional[Dict[str, Any]] = None,
extra_body: Optional[Dict[str, Any]] = None,
@ -1151,7 +1246,7 @@ def upload_container_file(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
*,
aupload_container_file: Literal[True],
**kwargs,
@ -1167,7 +1262,7 @@ def upload_container_file(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
*,
aupload_container_file: Literal[False] = False,
**kwargs,
@ -1185,7 +1280,7 @@ def upload_container_file(
api_key: Optional[str] = None,
api_base: Optional[str] = None,
api_version: Optional[str] = None,
custom_llm_provider: Literal["openai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai",
extra_headers: Optional[Dict[str, Any]] = None,
extra_query: Optional[Dict[str, Any]] = None,
extra_body: Optional[Dict[str, Any]] = None,
@ -1226,6 +1321,7 @@ def upload_container_file(
local_vars = locals()
try:
resolved_custom_llm_provider: str = custom_llm_provider
litellm_logging_obj: LiteLLMLoggingObj = kwargs.pop("litellm_logging_obj") # type: ignore
litellm_call_id: Optional[str] = kwargs.get("litellm_call_id")
_is_async = kwargs.pop("async_call", False) is True
@ -1247,16 +1343,26 @@ def upload_container_file(
api_version=api_version,
**kwargs,
)
# Decode container ID and extract provider info
original_container_id, resolved_custom_llm_provider, litellm_params = (
decode_managed_container_id_for_request(
container_id=container_id,
custom_llm_provider=custom_llm_provider,
litellm_params=litellm_params,
)
)
# get provider config
container_provider_config: Optional[
BaseContainerConfig
] = ProviderConfigManager.get_provider_container_config(
provider=litellm.LlmProviders(custom_llm_provider),
provider=litellm.LlmProviders(resolved_custom_llm_provider),
)
if container_provider_config is None:
raise ValueError(
f"Container provider config not found for provider: {custom_llm_provider}"
f"Container provider config not found for provider: {resolved_custom_llm_provider}"
)
# Pre Call logging
@ -1267,7 +1373,7 @@ def upload_container_file(
litellm_params={
"litellm_call_id": litellm_call_id,
},
custom_llm_provider=custom_llm_provider,
custom_llm_provider=resolved_custom_llm_provider,
)
# Set the correct call type
@ -1282,14 +1388,14 @@ def upload_container_file(
extra_query=extra_query,
timeout=timeout or DEFAULT_REQUEST_TIMEOUT,
_is_async=_is_async,
container_id=container_id,
container_id=original_container_id, # Use decoded original ID
file=file,
)
except Exception as e:
raise litellm.exception_type(
model="",
custom_llm_provider=custom_llm_provider,
custom_llm_provider=resolved_custom_llm_provider,
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,

View file

@ -1,10 +1,38 @@
from typing import Dict
from typing import Any, Dict, Optional, TypeVar
from litellm.llms.base_llm.containers.transformation import BaseContainerConfig
from litellm.responses.utils import ResponsesAPIRequestUtils
from litellm.types.containers.main import (
ContainerCreateOptionalRequestParams,
ContainerListOptionalRequestParams,
)
from litellm.types.router import GenericLiteLLMParams
def decode_managed_container_id_for_request(
container_id: str,
custom_llm_provider: str,
litellm_params: GenericLiteLLMParams,
) -> tuple[str, str, GenericLiteLLMParams]:
"""Decode a LiteLLM-managed container ID for upstream API calls.
Returns:
(original_container_id, resolved_provider, updated_litellm_params)
"""
decoded = ResponsesAPIRequestUtils._decode_container_id(container_id)
original_container_id = decoded.get("response_id", container_id)
decoded_provider = decoded.get("custom_llm_provider")
if decoded_provider and custom_llm_provider == "openai":
custom_llm_provider = decoded_provider
decoded_model_id = decoded.get("model_id")
if decoded_model_id and not litellm_params.get("model_id"):
litellm_params["model_id"] = decoded_model_id
return original_container_id, custom_llm_provider, litellm_params
T = TypeVar("T")
class ContainerRequestUtils:
@ -68,3 +96,66 @@ class ContainerRequestUtils:
container_list_optional_params[param] = passed_params[param] # type: ignore
return container_list_optional_params
@staticmethod
def encode_container_id_in_response(
response_obj: T,
custom_llm_provider: Optional[str],
litellm_metadata: Optional[Dict[str, Any]] = None,
extra_body: Optional[Dict[str, Any]] = None,
) -> T:
"""
Encode container_id in response object with provider/model metadata for routing.
This mirrors the responses API pattern where response IDs are encoded with
routing metadata so follow-up calls can route to the correct provider.
Encodes when:
1. litellm_metadata contains model_info.id (indicating router/proxy usage), OR
2. extra_body contains target_model_names (indicating model-specific routing)
Direct SDK calls with explicit custom_llm_provider and no routing hints return raw IDs.
Args:
response_obj: Response object with an `id` attribute (ContainerObject, DeleteContainerResult, etc.)
custom_llm_provider: Provider name (e.g., "azure", "openai")
litellm_metadata: Optional litellm_metadata dict that may contain model_info.id
extra_body: Optional extra_body dict that may contain target_model_names
Returns:
The same response object with encoded container_id (if routing metadata present)
"""
# Extract model_id from litellm_metadata
litellm_metadata = litellm_metadata or {}
model_info: Dict[str, Any] = litellm_metadata.get("model_info", {}) or {}
model_id = model_info.get("id")
# Check if we should encode based on routing metadata
should_encode = False
# Case 1: Router/proxy usage (model_id from router)
if model_id is not None:
should_encode = True
# Case 2: target_model_names in extra_body (model-specific routing)
if extra_body and "target_model_names" in extra_body:
should_encode = True
# Extract model_id from target_model_names if not already set
if model_id is None:
target_models = extra_body["target_model_names"]
# Use first model as model_id for encoding
if isinstance(target_models, str):
model_id = target_models.split(",")[0].strip()
elif isinstance(target_models, list) and len(target_models) > 0:
model_id = str(target_models[0]).strip()
# Only encode if we have routing metadata
if should_encode and response_obj and hasattr(response_obj, "id"):
encoded_id = ResponsesAPIRequestUtils._build_container_id(
custom_llm_provider=custom_llm_provider,
model_id=model_id,
container_id=response_obj.id,
)
response_obj.id = encoded_id
return response_obj

View file

@ -10,7 +10,7 @@ import contextvars
import time
import uuid as uuid_module
from functools import partial
from typing import Any, Coroutine, Dict, Literal, Optional, Union, cast
from typing import Any,Coroutine, Dict, Literal, Optional, Union, cast
import httpx
@ -30,12 +30,10 @@ FileRetrieveProvider = Literal[
]
FileDeleteProvider = Literal["openai", "azure", "gemini", "manus", "anthropic"]
FileListProvider = Literal["openai", "azure", "manus", "anthropic"]
FileContentProvider = Literal[
"openai", "azure", "vertex_ai", "bedrock", "hosted_vllm", "anthropic", "manus"
]
import litellm
from litellm import get_secret_str
from litellm.files.streaming import FileContentStreamingResponse
from litellm.files.types import FileContentProvider, FileContentStreamingResult
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.azure.common_utils import get_azure_credentials
@ -55,10 +53,7 @@ from litellm.types.llms.openai import (
OpenAIFileObject,
)
from litellm.types.router import *
from litellm.types.utils import (
OPENAI_COMPATIBLE_BATCH_AND_FILES_PROVIDERS,
LlmProviders,
)
from litellm.types.utils import OPENAI_COMPATIBLE_BATCH_AND_FILES_PROVIDERS, LlmProviders
from litellm.utils import (
ProviderConfigManager,
client,
@ -69,6 +64,15 @@ from litellm.utils import (
base_llm_http_handler = BaseLLMHTTPHandler()
####### ENVIRONMENT VARIABLES ###################
def _should_sdk_support_streaming(
custom_llm_provider: Optional[Union[FileContentProvider, str]],
) -> bool:
"""
Return whether file content streaming is supported for the provider.
"""
return custom_llm_provider in OPENAI_COMPATIBLE_BATCH_AND_FILES_PROVIDERS
openai_files_instance = OpenAIFilesAPI()
azure_files_instance = AzureOpenAIFilesAPI()
vertex_ai_files_instance = VertexAIFilesHandler()
@ -772,8 +776,10 @@ async def afile_content(
custom_llm_provider: FileContentProvider = "openai",
extra_headers: Optional[Dict[str, str]] = None,
extra_body: Optional[Dict[str, str]] = None,
chunk_size: int = 1024 * 1024,
stream: bool = False,
**kwargs,
) -> HttpxBinaryResponseContent:
) -> Union[HttpxBinaryResponseContent, FileContentStreamingResult]:
"""
Async: Get file contents
@ -787,11 +793,13 @@ async def afile_content(
# Use a partial function to pass your keyword arguments
func = partial(
file_content,
file_id,
model,
custom_llm_provider,
extra_headers,
extra_body,
file_id=file_id,
model=model,
custom_llm_provider=custom_llm_provider,
extra_headers=extra_headers,
extra_body=extra_body,
chunk_size=chunk_size,
stream=stream,
**kwargs,
)
@ -816,8 +824,15 @@ def file_content(
custom_llm_provider: Optional[Union[FileContentProvider, str]] = None,
extra_headers: Optional[Dict[str, str]] = None,
extra_body: Optional[Dict[str, str]] = None,
chunk_size: int = 1024 * 1024,
stream: bool = False,
**kwargs,
) -> Union[HttpxBinaryResponseContent, Coroutine[Any, Any, HttpxBinaryResponseContent]]:
) -> Union[
HttpxBinaryResponseContent,
FileContentStreamingResult,
Coroutine[Any, Any, HttpxBinaryResponseContent],
Coroutine[Any, Any, FileContentStreamingResult],
]:
"""
Returns the contents of the specified file.
@ -859,6 +874,23 @@ def file_content(
_is_async = kwargs.pop("afile_content", False) is True
if stream and _should_sdk_support_streaming(custom_llm_provider):
return file_content_streaming(
file_id=file_id,
model=model,
custom_llm_provider=custom_llm_provider,
extra_headers=extra_headers,
extra_body=extra_body,
chunk_size=chunk_size,
optional_params=optional_params,
timeout=timeout,
logging_obj=cast(
Optional[LiteLLMLoggingObj], kwargs.get("litellm_logging_obj")
),
_is_async=_is_async,
client=client,
)
# Check if provider has a custom files config (e.g., Anthropic, Manus)
provider_config = ProviderConfigManager.get_provider_files_config(
model="",
@ -982,3 +1014,89 @@ def file_content(
return response
except Exception as e:
raise e
def file_content_streaming(
*,
file_id: str,
model: Optional[str],
custom_llm_provider: Optional[Union[FileContentProvider, str]],
extra_headers: Optional[Dict[str, str]],
extra_body: Optional[Dict[str, str]],
chunk_size: int,
optional_params: GenericLiteLLMParams,
timeout: Union[float, httpx.Timeout],
logging_obj: Optional[LiteLLMLoggingObj],
_is_async: bool,
client: Optional[Any],
) -> Union[FileContentStreamingResult, Coroutine[Any, Any, FileContentStreamingResult]]:
if logging_obj is not None:
logging_obj.model = model or ""
logging_obj.model_call_details["model"] = model or ""
logging_obj.model_call_details["custom_llm_provider"] = custom_llm_provider
litellm_params = logging_obj.model_call_details.get("litellm_params", {}) or {}
if optional_params.api_base is not None:
litellm_params["api_base"] = optional_params.api_base
logging_obj.model_call_details["litellm_params"] = litellm_params
def _wrap_streaming_result(
response: FileContentStreamingResult,
) -> FileContentStreamingResult:
return FileContentStreamingResult(
stream_iterator=FileContentStreamingResponse(
stream_iterator=response.stream_iterator,
file_id=file_id,
model=model,
custom_llm_provider=custom_llm_provider,
logging_obj=logging_obj,
),
headers=response.headers,
)
response: Union[
FileContentStreamingResult, Coroutine[Any, Any, FileContentStreamingResult]
] = FileContentStreamingResult(stream_iterator=iter(()), headers={})
if custom_llm_provider in OPENAI_COMPATIBLE_BATCH_AND_FILES_PROVIDERS:
openai_creds = get_openai_credentials(
api_base=optional_params.api_base,
api_key=optional_params.api_key,
organization=optional_params.organization,
)
response = openai_files_instance.file_content_streaming(
_is_async=_is_async,
file_content_request=FileContentRequest(
file_id=file_id,
extra_headers=extra_headers,
extra_body=extra_body,
),
api_base=openai_creds.api_base,
api_key=openai_creds.api_key,
timeout=timeout,
max_retries=optional_params.max_retries,
organization=openai_creds.organization,
chunk_size=chunk_size,
client=client,
)
else:
raise litellm.exceptions.BadRequestError(
message="LiteLLM doesn't support {} for streaming 'file_content'. Supported providers are {}.".format(
custom_llm_provider,
sorted(OPENAI_COMPATIBLE_BATCH_AND_FILES_PROVIDERS),
),
model="n/a",
llm_provider=custom_llm_provider,
response=httpx.Response(
status_code=400,
content="Unsupported provider",
request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"), # type: ignore
),
)
if asyncio.iscoroutine(response):
async def _await_and_wrap() -> FileContentStreamingResult:
return _wrap_streaming_result(await response)
return _await_and_wrap()
return _wrap_streaming_result(response)

236
litellm/files/streaming.py Normal file
View file

@ -0,0 +1,236 @@
import datetime
import traceback
from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, Iterator, Optional, Union, cast
import anyio
from litellm.files.types import FileContentProvider
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import (
Logging as LiteLLMLoggingObj,
)
from litellm.types.utils import StandardLoggingHiddenParams, StandardLoggingPayload
class FileContentStreamingResponse:
"""
Iterator wrapper for file content streaming that carries LiteLLM metadata
and emits success/failure callbacks once the stream finishes.
"""
def __init__(
self,
stream_iterator: Union[Iterator[bytes], AsyncIterator[bytes]],
file_id: str,
model: Optional[str],
custom_llm_provider: Optional[Union[FileContentProvider, str]],
logging_obj: Optional["LiteLLMLoggingObj"],
) -> None:
self.stream_iterator = stream_iterator
self.file_id = file_id
self.model = model
self.custom_llm_provider = custom_llm_provider
self.logging_obj = logging_obj
self.standard_logging_object: Optional["StandardLoggingPayload"] = None
self._hidden_params: Dict[str, Any] = {}
self._logging_completed = False
self._close_completed = False
self._start_time = (
logging_obj.start_time
if logging_obj is not None and getattr(logging_obj, "start_time", None)
else datetime.datetime.now()
)
self._sync_hidden_params()
def __iter__(self) -> "FileContentStreamingResponse":
if not hasattr(self.stream_iterator, "__next__"):
raise TypeError("File content stream does not support sync iteration")
return self
def __next__(self) -> bytes:
if not hasattr(self.stream_iterator, "__next__"):
raise TypeError("File content stream does not support sync iteration")
try:
return next(cast(Iterator[bytes], self.stream_iterator))
except StopIteration:
self._log_success_sync()
raise
except Exception as e:
self._log_failure_sync(e)
raise
def __aiter__(self) -> "FileContentStreamingResponse":
if not hasattr(self.stream_iterator, "__anext__"):
raise TypeError("File content stream does not support async iteration")
return self
async def __anext__(self) -> bytes:
if not hasattr(self.stream_iterator, "__anext__"):
raise TypeError("File content stream does not support async iteration")
try:
return await cast(AsyncIterator[bytes], self.stream_iterator).__anext__()
except StopAsyncIteration:
await self._log_success_async()
raise
except Exception as e:
await self._log_failure_async(e)
raise
async def aclose(self) -> None:
if self._close_completed:
return
self._close_completed = True
self._logging_completed = True
stream_to_close = self.stream_iterator
self.stream_iterator = cast(Union[Iterator[bytes], AsyncIterator[bytes]], iter(()))
# Shield cleanup from request cancellation so upstream HTTP connections
# are released promptly on client disconnects.
with anyio.CancelScope(shield=True):
if hasattr(stream_to_close, "aclose"):
await cast(AsyncIterator[bytes], stream_to_close).aclose() # type: ignore[attr-defined]
elif hasattr(stream_to_close, "close"):
result = cast(Iterator[bytes], stream_to_close).close() # type: ignore[attr-defined]
if result is not None:
await result
def close(self) -> None:
if self._close_completed:
return
self._close_completed = True
self._logging_completed = True
stream_to_close = self.stream_iterator
self.stream_iterator = cast(Union[Iterator[bytes], AsyncIterator[bytes]], iter(()))
if hasattr(stream_to_close, "close"):
cast(Iterator[bytes], stream_to_close).close() # type: ignore[attr-defined]
def _build_logging_response(self) -> Dict[str, str]:
response = {
"id": self.file_id,
"object": "file.content",
}
if self.model:
response["model"] = self.model
return response
def _sync_hidden_params(self) -> None:
litellm_params: dict[str, Any] = {}
if self.logging_obj is not None:
litellm_params = (
self.logging_obj.model_call_details.get("litellm_params", {}) or {}
)
if "api_base" not in self._hidden_params and litellm_params.get("api_base"):
self._hidden_params["api_base"] = litellm_params["api_base"]
# The generic client decorator infers `model` from the first positional arg,
# which is `file_id` for this API. Correct it before logging callbacks run.
self._hidden_params["litellm_model_name"] = self.model
if "response_cost" not in self._hidden_params:
self._hidden_params["response_cost"] = None
def _build_standard_logging_object(
self,
end_time: datetime.datetime,
) -> Optional["StandardLoggingPayload"]:
if self.standard_logging_object is not None:
return self.standard_logging_object
if self.logging_obj is None:
return None
from litellm.litellm_core_utils.litellm_logging import (
get_standard_logging_object_payload,
)
self._sync_hidden_params()
payload = get_standard_logging_object_payload(
kwargs=self.logging_obj.model_call_details,
init_response_obj=self._build_logging_response(),
start_time=self._start_time,
end_time=end_time,
logging_obj=self.logging_obj,
status="success",
)
if payload is None:
return None
merged_hidden_params = cast(
"StandardLoggingHiddenParams",
{
**cast(Dict[str, Any], payload.get("hidden_params") or {}),
**self._hidden_params,
},
)
payload["hidden_params"] = merged_hidden_params
payload["response"] = self._build_logging_response()
if self.custom_llm_provider is not None:
payload["custom_llm_provider"] = self.custom_llm_provider
if self.model is not None:
payload["model"] = self.model
if self._hidden_params.get("api_base"):
payload["api_base"] = cast(str, self._hidden_params["api_base"])
self.standard_logging_object = payload
return payload
async def _log_success_async(self) -> None:
if self._logging_completed or self.logging_obj is None:
return
self._logging_completed = True
end_time = datetime.datetime.now()
standard_logging_object = self._build_standard_logging_object(end_time=end_time)
await self.logging_obj.async_success_handler(
result=self._build_logging_response(),
start_time=self._start_time,
end_time=end_time,
standard_logging_object=standard_logging_object,
)
self.logging_obj.handle_sync_success_callbacks_for_async_calls(
result=self._build_logging_response(),
start_time=self._start_time,
end_time=end_time,
)
def _log_success_sync(self) -> None:
if self._logging_completed or self.logging_obj is None:
return
self._logging_completed = True
end_time = datetime.datetime.now()
standard_logging_object = self._build_standard_logging_object(end_time=end_time)
self.logging_obj.success_handler(
result=self._build_logging_response(),
start_time=self._start_time,
end_time=end_time,
standard_logging_object=standard_logging_object,
)
async def _log_failure_async(self, error: Exception) -> None:
if self._logging_completed or self.logging_obj is None:
return
self._logging_completed = True
end_time = datetime.datetime.now()
traceback_str = traceback.format_exc()
self.logging_obj.failure_handler(
error, traceback_str, self._start_time, end_time
)
await self.logging_obj.async_failure_handler(
error, traceback_str, self._start_time, end_time
)
def _log_failure_sync(self, error: Exception) -> None:
if self._logging_completed or self.logging_obj is None:
return
self._logging_completed = True
end_time = datetime.datetime.now()
self.logging_obj.failure_handler(
error, traceback.format_exc(), self._start_time, end_time
)

11
litellm/files/types.py Normal file
View file

@ -0,0 +1,11 @@
from typing import AsyncIterator, Dict, Iterator, Literal, NamedTuple, Union
FileContentProvider = Literal[
"openai", "azure", "vertex_ai", "bedrock", "hosted_vllm", "anthropic", "manus"
]
class FileContentStreamingResult(NamedTuple):
stream_iterator: Union[Iterator[bytes], AsyncIterator[bytes]]
headers: Dict[str, str]

View file

@ -7,7 +7,8 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
import yaml
from jinja2 import DictLoader, Environment, select_autoescape
from jinja2 import DictLoader, select_autoescape
from jinja2.sandbox import ImmutableSandboxedEnvironment
class PromptTemplate:
@ -59,7 +60,10 @@ class PromptManager:
self.prompt_directory = Path(prompt_directory) if prompt_directory else None
self.prompts: Dict[str, PromptTemplate] = {}
self.prompt_file = prompt_file
self.jinja_env = Environment(
# Sandboxed env: templates can come from user input via /prompts/test,
# so we must block access to unsafe Python attributes and mutation of
# caller-supplied mutables.
self.jinja_env = ImmutableSandboxedEnvironment(
loader=DictLoader({}),
autoescape=select_autoescape(["html", "xml"]),
# Use Handlebars-style delimiters to match Dotprompt spec

View file

@ -86,6 +86,11 @@ class PrometheusLogger(CustomLogger):
# Always initialize label_filters, even for non-premium users
self.label_filters = self._parse_prometheus_config()
_custom_buckets = litellm.prometheus_latency_buckets
self.latency_buckets = (
tuple(_custom_buckets) if _custom_buckets is not None else LATENCY_BUCKETS
)
# Create metric factory functions
self._counter_factory = self._create_metric_factory(Counter)
self._gauge_factory = self._create_metric_factory(Gauge)
@ -114,14 +119,14 @@ class PrometheusLogger(CustomLogger):
labelnames=self.get_labels_for_metric(
"litellm_request_total_latency_metric"
),
buckets=LATENCY_BUCKETS,
buckets=self.latency_buckets,
)
self.litellm_llm_api_latency_metric = self._histogram_factory(
"litellm_llm_api_latency_metric",
"Total latency (seconds) for a models LLM API call",
labelnames=self.get_labels_for_metric("litellm_llm_api_latency_metric"),
buckets=LATENCY_BUCKETS,
buckets=self.latency_buckets,
)
self.litellm_llm_api_time_to_first_token_metric = self._histogram_factory(
@ -137,7 +142,7 @@ class PrometheusLogger(CustomLogger):
labelnames=self.get_labels_for_metric(
"litellm_llm_api_time_to_first_token_metric"
),
buckets=LATENCY_BUCKETS,
buckets=self.latency_buckets,
)
# Counter for spend
@ -314,7 +319,7 @@ class PrometheusLogger(CustomLogger):
labelnames=self.get_labels_for_metric(
"litellm_overhead_latency_metric"
),
buckets=LATENCY_BUCKETS,
buckets=self.latency_buckets,
)
# Request queue time metric
@ -324,7 +329,7 @@ class PrometheusLogger(CustomLogger):
labelnames=self.get_labels_for_metric(
"litellm_request_queue_time_seconds"
),
buckets=LATENCY_BUCKETS,
buckets=self.latency_buckets,
)
# Guardrail metrics
@ -332,7 +337,7 @@ class PrometheusLogger(CustomLogger):
"litellm_guardrail_latency_seconds",
"Latency (seconds) for guardrail execution",
labelnames=["guardrail_name", "status", "error_type", "hook_type"],
buckets=LATENCY_BUCKETS,
buckets=self.latency_buckets,
)
self.litellm_guardrail_errors_total = self._counter_factory(
@ -1031,6 +1036,9 @@ class PrometheusLogger(CustomLogger):
user_api_key_org_id = standard_logging_payload["metadata"].get(
"user_api_key_org_id"
)
user_api_key_org_alias = standard_logging_payload["metadata"].get(
"user_api_key_org_alias"
)
output_tokens = standard_logging_payload["completion_tokens"]
tokens_used = standard_logging_payload["total_tokens"]
response_cost = standard_logging_payload["response_cost"]
@ -1068,6 +1076,8 @@ class PrometheusLogger(CustomLogger):
model_group=standard_logging_payload["model_group"],
team=user_api_team,
team_alias=user_api_team_alias,
org_id=user_api_key_org_id,
org_alias=user_api_key_org_alias,
user=user_id,
user_email=standard_logging_payload["metadata"]["user_api_key_user_email"],
status_code="200",
@ -1746,6 +1756,8 @@ class PrometheusLogger(CustomLogger):
api_key_alias=user_api_key_dict.key_alias,
team=user_api_key_dict.team_id,
team_alias=user_api_key_dict.team_alias,
org_id=user_api_key_dict.org_id,
org_alias=user_api_key_dict.organization_alias,
requested_model=request_data.get("model", ""),
status_code=str(status_code),
exception_status=str(status_code),

View file

@ -5,6 +5,7 @@
from typing import Dict, List, Optional, Union
import litellm
from litellm._logging import print_verbose, verbose_logger
from litellm.types.integrations.prometheus import LATENCY_BUCKETS
from litellm.types.services import (
@ -35,6 +36,11 @@ class PrometheusServicesLogger:
"Missing prometheus_client. Run `pip install prometheus-client`"
)
_custom_buckets = litellm.prometheus_latency_buckets
self.latency_buckets = (
tuple(_custom_buckets) if _custom_buckets is not None else LATENCY_BUCKETS
)
self.Histogram = Histogram
self.Counter = Counter
self.Gauge = Gauge
@ -130,7 +136,7 @@ class PrometheusServicesLogger:
metric_name,
"Latency for {} service".format(service),
labelnames=[service],
buckets=LATENCY_BUCKETS,
buckets=self.latency_buckets,
)
def create_gauge(self, service: str, type_of_request: str):

View file

@ -7,6 +7,7 @@ NOTE 1: S3 does not provide a BATCH PUT API endpoint, so we create tasks to uplo
"""
import asyncio
import time
from datetime import datetime
from typing import List, Optional, cast
@ -403,11 +404,23 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
# Prepare the signed headers
signed_headers = dict(aws_request.headers.items())
# Make the request
response = await self.async_httpx_client.put(
url, data=json_string, headers=signed_headers
)
response.raise_for_status()
# Make the request with retry for transient S3 errors (500/503)
max_retries = 3
for attempt in range(max_retries):
response = await self.async_httpx_client.put(
url, data=json_string, headers=signed_headers
)
if response.status_code in (500, 503) and attempt < max_retries - 1:
wait_time = 2**attempt # 1s, 2s
verbose_logger.warning(
f"S3 upload returned {response.status_code}, retrying in {wait_time}s "
f"(attempt {attempt + 1}/{max_retries}) "
f"key={batch_logging_element.s3_object_key}"
)
await asyncio.sleep(wait_time)
continue
response.raise_for_status()
break
except Exception as e:
verbose_logger.exception(f"Error uploading to s3: {str(e)}")
self.handle_callback_failure(callback_name="S3Logger")
@ -582,9 +595,23 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM):
if self.s3_verify is not None
else None
)
# Make the request
response = httpx_client.put(url, data=json_string, headers=signed_headers)
response.raise_for_status()
# Make the request with retry for transient S3 errors (500/503)
max_retries = 3
for attempt in range(max_retries):
response = httpx_client.put(
url, data=json_string, headers=signed_headers
)
if response.status_code in (500, 503) and attempt < max_retries - 1:
wait_time = 2**attempt # 1s, 2s
verbose_logger.warning(
f"S3 upload returned {response.status_code}, retrying in {wait_time}s "
f"(attempt {attempt + 1}/{max_retries}) "
f"key={batch_logging_element.s3_object_key}"
)
time.sleep(wait_time)
continue
response.raise_for_status()
break
except Exception as e:
verbose_logger.exception(f"Error uploading to s3: {str(e)}")
self.handle_callback_failure(callback_name="S3Logger")

View file

@ -230,8 +230,15 @@ class WebSearchInterceptionLogger(CustomLogger):
# Keep other tools as-is
converted_tools.append(tool)
# Update tools in-place and return full kwargs
kwargs["tools"] = converted_tools
if kwargs.get("stream"):
verbose_logger.debug(
"WebSearchInterception: deployment hook converting stream=True to stream=False"
)
kwargs["stream"] = False
kwargs["_websearch_interception_converted_stream"] = True
return kwargs
@classmethod
@ -344,13 +351,12 @@ class WebSearchInterceptionLogger(CustomLogger):
else:
converted_tools.append(tool)
# Update kwargs with converted tools
kwargs["tools"] = converted_tools
verbose_logger.debug(
f"WebSearchInterception: Tools after conversion: {[t.get('name') for t in converted_tools]}"
)
# Convert stream=True to stream=False for WebSearch interception
# Also convert here for direct callers that bypass the deployment hook.
if kwargs.get("stream"):
verbose_logger.debug(
"WebSearchInterception: Converting stream=True to stream=False"

View file

@ -613,7 +613,17 @@ class Logging(LiteLLMLoggingBaseClass):
base_litellm_params["metadata"] = kwargs["litellm_metadata"].copy()
if litellm_params:
# Merge metadata carefully — don't overwrite the merged metadata
# from kwargs/litellm_metadata with the caller's litellm_params metadata.
# e.g. anthropic_messages passes Anthropic's native metadata ({user_id: ...})
# in litellm_params, which would overwrite proxy key-auth fields.
lp_metadata = litellm_params.pop("metadata", None)
base_litellm_params.update(litellm_params)
if lp_metadata and isinstance(lp_metadata, dict):
base_litellm_params.setdefault("metadata", {})
for k, v in lp_metadata.items():
if k not in base_litellm_params["metadata"]:
base_litellm_params["metadata"][k] = v
self.update_environment_variables(
litellm_params=base_litellm_params,
@ -4754,6 +4764,7 @@ class StandardLoggingPayloadSetup:
user_api_key_budget_reset_at=None,
user_api_key_team_id=None,
user_api_key_org_id=None,
user_api_key_org_alias=None,
user_api_key_project_id=None,
user_api_key_project_alias=None,
user_api_key_user_id=None,
@ -5586,6 +5597,7 @@ def get_standard_logging_metadata(
user_api_key_budget_reset_at=None,
user_api_key_team_id=None,
user_api_key_org_id=None,
user_api_key_org_alias=None,
user_api_key_project_id=None,
user_api_key_project_alias=None,
user_api_key_user_id=None,

View file

@ -322,9 +322,8 @@ class StandardBuiltInToolCostTracking:
)
if has_url_citations:
return True
# Fallback: Check usage object for providers that use usage instead of annotations
# (e.g., Vertex AI Gemini uses usage.prompt_tokens_details.web_search_requests)
if usage is not None:
# Vertex AI Gemini uses usage.prompt_tokens_details.web_search_requests
if (
hasattr(usage, "prompt_tokens_details")
and usage.prompt_tokens_details is not None
@ -335,6 +334,15 @@ class StandardBuiltInToolCostTracking:
and usage.prompt_tokens_details.web_search_requests is not None
):
return True
# Anthropic Claude (direct API and Vertex AI) uses server_tool_use.web_search_requests.
# Without this check, Claude ModelResponse always falls through to return False
# and _handle_web_search_cost() is never called.
if (
hasattr(usage, "server_tool_use")
and usage.server_tool_use is not None
and usage.server_tool_use.web_search_requests is not None
):
return True
return False
elif isinstance(response_object, ResponsesAPIResponse):
# response api explicitly includes web_search_call in the output

View file

@ -4371,17 +4371,19 @@ class BedrockConverseMessagesProcessor:
# if initial message is assistant message
if messages[0].get("role") is not None and messages[0]["role"] == "assistant":
if user_continue_message is not None:
messages.insert(0, user_continue_message)
elif litellm.modify_params:
messages.insert(0, DEFAULT_USER_CONTINUE_MESSAGE)
if not messages[0].get("prefix"):
if user_continue_message is not None:
messages.insert(0, user_continue_message)
elif litellm.modify_params:
messages.insert(0, DEFAULT_USER_CONTINUE_MESSAGE)
# if final message is assistant message
if messages[-1].get("role") is not None and messages[-1]["role"] == "assistant":
if user_continue_message is not None:
messages.append(user_continue_message)
elif litellm.modify_params:
messages.append(DEFAULT_USER_CONTINUE_MESSAGE)
if not messages[-1].get("prefix"):
if user_continue_message is not None:
messages.append(user_continue_message)
elif litellm.modify_params:
messages.append(DEFAULT_USER_CONTINUE_MESSAGE)
return messages
@staticmethod

View file

@ -123,10 +123,13 @@ class ChunkProcessor:
finish_reason = "stop"
for chunk in chunks:
if "choices" in chunk and len(chunk["choices"]) > 0:
chunk_finish_reason = None
if hasattr(chunk["choices"][0], "finish_reason"):
finish_reason = chunk["choices"][0].finish_reason
chunk_finish_reason = chunk["choices"][0].finish_reason
elif "finish_reason" in chunk["choices"][0]:
finish_reason = chunk["choices"][0]["finish_reason"]
chunk_finish_reason = chunk["choices"][0]["finish_reason"]
if chunk_finish_reason is not None:
finish_reason = chunk_finish_reason
# Initialize the response dictionary
response = ModelResponse(

View file

@ -1134,7 +1134,11 @@ class CustomStreamWrapper:
):
if self.received_finish_reason is not None:
_chunk_has_content = isinstance(chunk, dict) and (
bool(chunk.get("text", "")) or chunk.get("tool_use") is not None
bool(chunk.get("text", ""))
or chunk.get("tool_use") is not None
# Usage-only final chunks are valid and needed to surface
# finish_reason/usage to downstream translators.
or chunk.get("usage") is not None
)
if not _chunk_has_content and (
not isinstance(chunk, dict)

View file

@ -34,6 +34,18 @@ def get_cost_for_web_search_request(
return get_cost_for_anthropic_web_search(model_info=model_info, usage=usage)
elif custom_llm_provider.startswith("vertex_ai"):
# Anthropic Claude models on Vertex AI populate server_tool_use.web_search_requests
# (same as the direct Anthropic API), not prompt_tokens_details.web_search_requests
# (which is the Gemini field). Route claude-* models to the Anthropic calculator.
model_key: str = model_info.get("key", "") if model_info else ""
if "claude" in model_key.lower():
from .anthropic.cost_calculation import get_cost_for_anthropic_web_search
verbose_logger.debug(
"vertex_ai/claude model detected — routing web search cost to Anthropic calculator"
)
return get_cost_for_anthropic_web_search(model_info=model_info, usage=usage)
from .vertex_ai.gemini.cost_calculator import (
cost_per_web_search_request as cost_per_web_search_request_vertex_ai,
)

View file

@ -21,6 +21,10 @@ from litellm.llms.anthropic.experimental_pass_through.adapters.transformation im
LiteLLMAnthropicMessagesAdapter,
)
from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation
from litellm.llms.base_llm.guardrail_translation.utils import (
effective_skip_system_message_for_guardrail,
openai_messages_without_system,
)
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import (
AnthropicPassthroughLoggingHandler,
)
@ -29,6 +33,7 @@ from litellm.types.llms.anthropic import (
AnthropicMessagesRequest,
)
from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionToolCallChunk,
ChatCompletionToolParam,
)
@ -75,6 +80,8 @@ class AnthropicMessagesHandler(BaseTranslation):
if messages is None:
return data
skip_system = effective_skip_system_message_for_guardrail(guardrail_to_apply)
(
chat_completion_compatible_request,
_tool_name_mapping,
@ -83,7 +90,12 @@ class AnthropicMessagesHandler(BaseTranslation):
anthropic_message_request=cast(AnthropicMessagesRequest, data.copy())
)
structured_messages = chat_completion_compatible_request.get("messages", [])
structured_messages = cast(
List[AllMessageValues],
chat_completion_compatible_request.get("messages", []),
)
if skip_system:
structured_messages = openai_messages_without_system(structured_messages)
texts_to_check: List[str] = []
images_to_check: List[str] = []
@ -102,6 +114,7 @@ class AnthropicMessagesHandler(BaseTranslation):
texts_to_check=texts_to_check,
images_to_check=images_to_check,
task_mappings=task_mappings,
skip_system_message=skip_system,
)
# Step 2: Apply guardrail to all texts in batch
@ -165,12 +178,16 @@ class AnthropicMessagesHandler(BaseTranslation):
texts_to_check: List[str],
images_to_check: List[str],
task_mappings: List[Tuple[int, Optional[int]]],
skip_system_message: bool = False,
) -> None:
"""
Extract text content and images from a message.
Override this method to customize text/image extraction logic.
"""
if skip_system_message and str(message.get("role") or "").lower() == "system":
return
content = message.get("content", None)
tools = message.get("tools", None)
if content is None and tools is None:

View file

@ -89,7 +89,12 @@ async def make_call(
try:
response = await client.post(
api_base, headers=headers, data=data, stream=True, timeout=timeout
api_base,
headers=headers,
data=data,
stream=True,
timeout=timeout,
logging_obj=logging_obj,
)
except httpx.HTTPStatusError as e:
error_headers = getattr(e, "headers", None)
@ -142,7 +147,12 @@ def make_sync_call(
try:
response = client.post(
api_base, headers=headers, data=data, stream=True, timeout=timeout
api_base,
headers=headers,
data=data,
stream=True,
timeout=timeout,
logging_obj=logging_obj,
)
except httpx.HTTPStatusError as e:
error_headers = getattr(e, "headers", None)
@ -266,7 +276,11 @@ class AnthropicChatCompletion(BaseLLM):
try:
response = await async_handler.post(
api_base, headers=headers, json=data, timeout=timeout
api_base,
headers=headers,
json=data,
timeout=timeout,
logging_obj=logging_obj,
)
except Exception as e:
## LOGGING
@ -469,6 +483,7 @@ class AnthropicChatCompletion(BaseLLM):
headers=headers,
data=json.dumps(data),
timeout=timeout,
logging_obj=logging_obj,
)
except Exception as e:
status_code = getattr(e, "status_code", 500)

View file

@ -19,6 +19,7 @@ from litellm.litellm_core_utils.core_helpers import map_finish_reason
from litellm.llms.base_llm.base_utils import type_to_response_format_param
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
from litellm.types.llms.anthropic import (
ANTHROPIC_ADVISOR_TOOL_TYPE,
ANTHROPIC_BETA_HEADER_VALUES,
ANTHROPIC_HOSTED_TOOLS,
AllAnthropicMessageValues,
@ -75,7 +76,12 @@ from litellm.utils import (
token_counter,
)
from ..common_utils import AnthropicError, AnthropicModelInfo, process_anthropic_headers
from ..common_utils import (
AnthropicError,
AnthropicModelInfo,
process_anthropic_headers,
strip_advisor_blocks_from_messages,
)
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
@ -508,6 +514,23 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
type="tool_search_tool_bm25_20251119",
name=tool_name,
)
elif tool["type"] == ANTHROPIC_ADVISOR_TOOL_TYPE:
from litellm.types.llms.anthropic import AnthropicAdvisorTool
_tool_dict = cast(dict, tool)
advisor_model = _tool_dict.get("model")
if not isinstance(advisor_model, str):
raise ValueError("Advisor tool must have a valid model")
_advisor_tool = AnthropicAdvisorTool(
type=ANTHROPIC_ADVISOR_TOOL_TYPE,
name="advisor",
model=advisor_model,
)
if _tool_dict.get("max_uses") is not None:
_advisor_tool["max_uses"] = _tool_dict["max_uses"]
if _tool_dict.get("caching") is not None:
_advisor_tool["caching"] = _tool_dict["caching"]
returned_tool = _advisor_tool # type: ignore[assignment]
if returned_tool is None and mcp_server is None:
raise ValueError(f"Unsupported tool type: {tool['type']}")
@ -1311,6 +1334,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
self._ensure_beta_header(
headers, ANTHROPIC_BETA_HEADER_VALUES.FAST_MODE_2026_02_01.value
)
for tool in _tools:
if tool.get("type") == ANTHROPIC_ADVISOR_TOOL_TYPE:
self._ensure_beta_header(
headers, ANTHROPIC_BETA_HEADER_VALUES.ADVISOR_TOOL_2026_03_01.value
)
break
return headers
def transform_request(
@ -1390,6 +1419,16 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
message="{}\nReceived Messages={}".format(str(e), messages),
) # don't use verbose_logger.exception, if exception is raised
## Auto-strip advisor blocks from history if advisor tool is absent.
## Prevents Anthropic 400: advisor_tool_result in history requires advisor tool.
_all_tools = optional_params.get("tools") or []
_has_advisor = any(
isinstance(t, dict) and t.get("type") == ANTHROPIC_ADVISOR_TOOL_TYPE
for t in _all_tools
)
if not _has_advisor:
anthropic_messages = strip_advisor_blocks_from_messages(anthropic_messages)
## Add code_execution tool if container_upload is in messages
_tools = (
cast(
@ -1440,23 +1479,32 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
**optional_params,
}
## Handle output_config (Anthropic-specific parameter)
if "output_config" in optional_params:
output_config = optional_params.get("output_config")
if output_config and isinstance(output_config, dict):
effort = output_config.get("effort")
if effort and effort not in ["high", "medium", "low", "max"]:
raise ValueError(
f"Invalid effort value: {effort}. Must be one of: 'high', 'medium', 'low', 'max'"
)
if effort == "max" and not self._is_opus_4_6_model(model):
raise ValueError(
f"effort='max' is only supported by Claude Opus 4.6. Got model: {model}"
)
data["output_config"] = output_config
self._apply_output_config(
data=data, model=model, optional_params=optional_params
)
return data
def _apply_output_config(
self, data: dict, model: str, optional_params: dict
) -> None:
"""Validate and apply output_config to the request data."""
if "output_config" not in optional_params:
return
output_config = optional_params.get("output_config")
if not output_config or not isinstance(output_config, dict):
return
effort = output_config.get("effort")
if effort and effort not in ["high", "medium", "low", "max"]:
raise ValueError(
f"Invalid effort value: {effort}. Must be one of: 'high', 'medium', 'low', 'max'"
)
if effort == "max" and not self._is_opus_4_6_model(model):
raise ValueError(
f"effort='max' is only supported by Claude Opus 4.6. Got model: {model}"
)
data["output_config"] = output_config
def _transform_response_for_json_mode(
self,
json_mode: Optional[bool],

View file

@ -2,7 +2,7 @@
This file contains common utils for anthropic calls.
"""
from typing import Dict, List, Optional, Union
from typing import Any, Dict, List, Optional, Union
import httpx
@ -464,9 +464,9 @@ class AnthropicModelInfo(BaseLLMModelInfo):
if web_search_tool_used:
from litellm.types.llms.anthropic import ANTHROPIC_BETA_HEADER_VALUES
headers[
"anthropic-beta"
] = ANTHROPIC_BETA_HEADER_VALUES.WEB_SEARCH_2025_03_05.value
headers["anthropic-beta"] = (
ANTHROPIC_BETA_HEADER_VALUES.WEB_SEARCH_2025_03_05.value
)
elif len(betas) > 0:
headers["anthropic-beta"] = ",".join(betas)
@ -639,6 +639,54 @@ class AnthropicModelInfo(BaseLLMModelInfo):
return AnthropicTokenCounter()
def strip_advisor_blocks_from_messages(messages: List[Any]) -> List[Any]:
"""
Remove server_tool_use (name='advisor') and advisor_tool_result blocks from
assistant message content when the advisor tool is absent from the request.
Prevents Anthropic 400 invalid_request_error: if advisor_tool_result blocks
exist in history but the advisor tool is not in the tools array, the API rejects
the request. This happens when the user has removed the advisor tool for cost
control or on a follow-up turn.
"""
for message in messages:
if not isinstance(message, dict) or message.get("role") != "assistant":
continue
content = message.get("content")
if not isinstance(content, list):
continue
advisor_ids: set = set()
for block in content:
if (
isinstance(block, dict)
and block.get("type") == "server_tool_use"
and block.get("name") == "advisor"
):
bid = block.get("id")
if bid:
advisor_ids.add(bid)
if not advisor_ids:
continue
message["content"] = [
block
for block in content
if not (
isinstance(block, dict)
and (
(
block.get("type") == "server_tool_use"
and block.get("name") == "advisor"
)
or (
block.get("type") == "advisor_tool_result"
and block.get("tool_use_id") in advisor_ids
)
)
)
]
return messages
def process_anthropic_headers(headers: Union[httpx.Headers, dict]) -> dict:
openai_headers = {}
if "anthropic-ratelimit-requests-limit" in headers:

View file

@ -187,11 +187,9 @@ async def anthropic_messages(
"""
Async: Make llm api request in Anthropic /messages API spec
"""
# Save original stream flag before pre-request hooks can convert it.
# The websearch interception hook converts stream=True → stream=False
# for the agentic loop, but the short-circuit path needs to know
# whether the caller originally requested streaming.
original_stream = stream
original_stream = stream or kwargs.get(
"_websearch_interception_converted_stream", False
)
# Execute pre-request hooks to allow CustomLoggers to modify request
request_kwargs = await _execute_pre_request_hooks(

View file

@ -8,6 +8,7 @@ from litellm.llms.base_llm.anthropic_messages.transformation import (
BaseAnthropicMessagesConfig,
)
from litellm.types.llms.anthropic import (
ANTHROPIC_ADVISOR_TOOL_TYPE,
ANTHROPIC_BETA_HEADER_VALUES,
AnthropicMessagesRequest,
)
@ -21,6 +22,7 @@ from ...common_utils import (
AnthropicError,
AnthropicModelInfo,
optionally_handle_anthropic_oauth,
strip_advisor_blocks_from_messages,
)
DEFAULT_ANTHROPIC_API_VERSION = "2023-06-01"
@ -208,12 +210,23 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
)
)
if transformed_context_management is not None:
anthropic_messages_optional_request_params[
"context_management"
] = transformed_context_management
anthropic_messages_optional_request_params["context_management"] = (
transformed_context_management
)
####### get required params for all anthropic messages requests ######
verbose_logger.debug(f"TRANSFORMATION DEBUG - Messages: {messages}")
# Auto-strip advisor blocks from history if advisor tool is absent.
# Prevents Anthropic 400: advisor_tool_result in history requires advisor tool.
_tools = anthropic_messages_optional_request_params.get("tools") or []
_has_advisor = any(
isinstance(t, dict) and t.get("type") == ANTHROPIC_ADVISOR_TOOL_TYPE
for t in _tools
)
if not _has_advisor:
messages = strip_advisor_blocks_from_messages(messages) # type: ignore[assignment]
anthropic_messages_request: AnthropicMessagesRequest = AnthropicMessagesRequest(
messages=messages,
max_tokens=max_tokens,
@ -324,6 +337,19 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
if optional_params.get("speed") == "fast":
beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.FAST_MODE_2026_02_01.value)
# Check for advisor tool
tools = optional_params.get("tools")
if tools:
for tool in tools:
if (
isinstance(tool, dict)
and tool.get("type") == ANTHROPIC_ADVISOR_TOOL_TYPE
):
beta_values.add(
ANTHROPIC_BETA_HEADER_VALUES.ADVISOR_TOOL_2026_03_01.value
)
break
# Check for tool search tools
tools = optional_params.get("tools")
if tools:

View file

@ -0,0 +1,48 @@
from typing import Optional
from litellm.llms.azure.common_utils import BaseAzureLLM
from litellm.llms.openai.containers.transformation import OpenAIContainerConfig
from litellm.types.router import GenericLiteLLMParams
class AzureContainerConfig(OpenAIContainerConfig):
"""
Configuration class for Azure OpenAI container API.
Inherits request/response transformations from OpenAIContainerConfig since
Azure's container API is wire-compatible with OpenAI's. Only overrides
authentication (api-key header) and URL construction (openai/v1/containers path).
Azure container API reference:
https://learn.microsoft.com/en-us/azure/foundry/openai/latest#containers
"""
def validate_environment(
self,
headers: dict,
api_key: Optional[str] = None,
) -> dict:
return BaseAzureLLM._base_validate_azure_environment(
headers=headers,
litellm_params=GenericLiteLLMParams(api_key=api_key),
)
def get_complete_url(
self,
api_base: Optional[str],
litellm_params: dict,
) -> str:
"""
Build the Azure container endpoint URL.
Azure container API uses the path:
{endpoint}/openai/v1/containers
when api_version is 'v1', 'latest', or 'preview'; otherwise:
{endpoint}/openai/containers
"""
return BaseAzureLLM._get_base_azure_url(
api_base=api_base,
litellm_params=litellm_params,
route="/openai/containers",
default_api_version="v1",
)

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from typing import Any, List
from litellm.types.llms.openai import AllMessageValues
def effective_skip_system_message_for_guardrail(guardrail_to_apply: Any) -> bool:
per = getattr(guardrail_to_apply, "skip_system_message_in_guardrail", None)
if per is not None:
return bool(per)
import litellm
return bool(getattr(litellm, "skip_system_message_in_guardrail", False))
def openai_messages_without_system(
messages: List[AllMessageValues],
) -> List[AllMessageValues]:
return [
m
for m in messages
if str((m or {}).get("role") or "").lower() != "system"
]

View file

@ -700,7 +700,7 @@ class BaseAWSLLM:
"RoleSessionName": aws_session_name,
"WebIdentityToken": oidc_token,
"DurationSeconds": 3600,
"Policy": '{"Version":"2012-10-17","Statement":[{"Sid":"BedrockLiteLLM","Effect":"Allow","Action":["bedrock:InvokeModel","bedrock:InvokeModelWithResponseStream"],"Resource":"*","Condition":{"Bool":{"aws:SecureTransport":"true"},"StringLike":{"aws:UserAgent":"litellm/*"}}}]}',
"Policy": '{"Version":"2012-10-17","Statement":[{"Sid":"BedrockLiteLLM","Effect":"Allow","Action":["bedrock:InvokeModel","bedrock:InvokeModelWithResponseStream","bedrock:ApplyGuardrail","bedrock:GetGuardrail","bedrock:ListGuardrails"],"Resource":"*","Condition":{"Bool":{"aws:SecureTransport":"true"}}}]}',
}
# Add ExternalId parameter if provided

View file

@ -538,9 +538,9 @@ class AmazonAnthropicClaudeMessagesConfig(
merges usage from message_start and message_delta but ignores
message_stop. This method buffers message_delta and, when
message_stop arrives with cache usage, merges those fields into the
message_delta usage and also updates the input_tokens on
message_delta to include the full count (uncached + cache_creation +
cache_read).
message_delta usage. input_tokens is kept as the uncached-only
count; downstream calculate_usage adds cache tokens to
prompt_tokens.
"""
_CACHE_FIELDS = ("cache_creation_input_tokens", "cache_read_input_tokens")
pending_delta = None
@ -569,12 +569,7 @@ class AmazonAnthropicClaudeMessagesConfig(
raw_input = stop_usage.get("input_tokens")
if raw_input is not None:
uncached = raw_input if isinstance(raw_input, int) else 0
raw_cc = delta_usage.get("cache_creation_input_tokens", 0)
cache_creation = raw_cc if isinstance(raw_cc, int) else 0
raw_cr = delta_usage.get("cache_read_input_tokens", 0)
cache_read = raw_cr if isinstance(raw_cr, int) else 0
delta_usage["input_tokens"] = uncached + cache_creation + cache_read
delta_usage["input_tokens"] = raw_input if isinstance(raw_input, int) else 0
if delta_usage:
pending_delta["usage"] = delta_usage # type: ignore[arg-type]

View file

@ -61,18 +61,29 @@ def _build_url(
) -> str:
"""Build the full URL by substituting path parameters.
The api_base from get_complete_url already includes /containers,
so we need to strip that prefix from the path_template.
The api_base from get_complete_url already includes /containers and may include
query parameters. We need to parse the URL, append the path, then preserve the
query parameters.
"""
# api_base ends with /containers, path_template starts with /containers
# So we need to strip /containers from the path
if path_template.startswith("/containers"):
path_template = path_template[len("/containers") :]
url = f"{api_base.rstrip('/')}{path_template}"
# Substitute path parameters
for param, value in path_params.items():
url = url.replace(f"{{{param}}}", value)
return url
path_template = path_template.replace(f"{{{param}}}", value)
# Parse the api_base to extract existing query params
parsed_base = httpx.URL(api_base)
# Append the path to the existing path (before query params)
new_path = f"{parsed_base.path.rstrip('/')}{path_template}"
# Rebuild URL with new path, preserving query params
final_url = parsed_base.copy_with(path=new_path)
return str(final_url)
def _build_query_params(

View file

@ -1,5 +1,6 @@
import json
import ssl
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from typing import (
TYPE_CHECKING,
Any,
@ -5027,6 +5028,16 @@ class BaseLLMHTTPHandler:
litellm_params={},
)
ws_url = http_url.replace("https://", "wss://").replace("http://", "ws://")
# OpenAI's WebSocket responses endpoint requires ?model= in the URL,
# matching the Realtime API convention (wss://.../v1/realtime?model=...).
# Use urllib.parse so existing query params (e.g. api-version) are preserved.
_parsed = urlparse(ws_url)
_qs = parse_qs(_parsed.query)
if "model" not in _qs:
_qs["model"] = [model]
ws_url = urlunparse(
_parsed._replace(query=urlencode({k: v[0] for k, v in _qs.items()}))
)
try:
ssl_context = get_shared_realtime_ssl_context()

View file

@ -91,6 +91,7 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig):
"modalities",
"parallel_tool_calls",
"web_search_options",
"service_tier",
]
if supports_reasoning(model, custom_llm_provider="gemini"):
supported_params.append("reasoning_effort")

View file

@ -5,6 +5,7 @@ Handles extraction of skill content (SKILL.md) from stored ZIP files
and injection into the system prompt for non-Anthropic models.
"""
import posixpath
import zipfile
from io import BytesIO
from typing import Any, Dict, List, Optional
@ -103,8 +104,18 @@ class SkillPromptInjectionHandler:
else:
clean_path = name
if clean_path:
files[clean_path] = zf.read(name)
if not clean_path:
continue
# Ensure the path stays within the intended directory
normalized = posixpath.normpath(clean_path)
if normalized.startswith("..") or posixpath.isabs(normalized):
verbose_logger.warning(
f"SkillPromptInjectionHandler: Skipping entry with invalid path in skill {skill.skill_id}: {name}"
)
continue
files[normalized] = zf.read(name)
except Exception as e:
verbose_logger.warning(
f"SkillPromptInjectionHandler: Error extracting files from skill {skill.skill_id}: {e}"

View file

@ -94,9 +94,15 @@ class SkillsSandboxExecutor:
# Create a temp directory to stage files
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_abs = os.path.abspath(tmpdir)
for path, content in skill_files.items():
# Create the file in temp directory
local_path = os.path.join(tmpdir, path)
local_path = os.path.abspath(os.path.join(tmpdir, path))
if not local_path.startswith(tmpdir_abs + os.sep):
verbose_logger.warning(
f"SkillsSandboxExecutor: Skipping file with invalid path: {path}"
)
continue
os.makedirs(os.path.dirname(local_path), exist_ok=True)
with open(local_path, "wb") as f:
f.write(content)

View file

@ -19,8 +19,12 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation
from litellm.llms.base_llm.guardrail_translation.utils import (
effective_skip_system_message_for_guardrail,
openai_messages_without_system,
)
from litellm.main import stream_chunk_builder
from litellm.types.llms.openai import ChatCompletionToolParam
from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam
from litellm.types.utils import (
Choices,
GenericGuardrailAPIInputs,
@ -57,6 +61,8 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
if messages is None:
return data
skip_system = effective_skip_system_message_for_guardrail(guardrail_to_apply)
texts_to_check: List[str] = []
images_to_check: List[str] = []
tool_calls_to_check: List[ChatCompletionToolParam] = []
@ -76,6 +82,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
tool_calls_to_check=tool_calls_to_check,
text_task_mappings=text_task_mappings,
tool_call_task_mappings=tool_call_task_mappings,
skip_system_message=skip_system,
)
# Step 2: Apply guardrail to all texts and tool calls in batch
@ -86,9 +93,12 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
if tool_calls_to_check:
inputs["tool_calls"] = tool_calls_to_check # type: ignore
if messages:
inputs[
"structured_messages"
] = messages # pass the openai /chat/completions messages to the guardrail, as-is
msg_list = cast(List[AllMessageValues], messages)
inputs["structured_messages"] = (
openai_messages_without_system(msg_list)
if skip_system
else msg_list
)
# Pass tools (function definitions) to the guardrail
tools = data.get("tools")
if tools:
@ -157,12 +167,16 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
tool_calls_to_check: List[ChatCompletionToolParam],
text_task_mappings: List[Tuple[int, Optional[int]]],
tool_call_task_mappings: List[Tuple[int, int]],
skip_system_message: bool = False,
) -> None:
"""
Extract text content, images, and tool calls from a message.
Override this method to customize text/image/tool call extraction logic.
"""
if skip_system_message and str(message.get("role") or "").lower() == "system":
return
content = message.get("content", None)
if content is not None:
if isinstance(content, str):

View file

@ -17,6 +17,7 @@ from litellm.types.containers.main import (
from litellm.types.router import GenericLiteLLMParams
from ...base_llm.containers.transformation import BaseContainerConfig
from .utils import join_container_api_base_path
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
@ -197,7 +198,7 @@ class OpenAIContainerConfig(BaseContainerConfig):
) -> Tuple[str, Dict]:
"""Transform the OpenAI container retrieve request."""
# For container retrieve, we just need to construct the URL
url = f"{api_base.rstrip('/')}/{container_id}"
url = join_container_api_base_path(api_base, f"/{container_id}")
# No additional data needed for GET request
data: Dict[str, Any] = {}
@ -229,7 +230,7 @@ class OpenAIContainerConfig(BaseContainerConfig):
- DELETE /v1/containers/{container_id}
"""
# Construct the URL for container delete
url = f"{api_base.rstrip('/')}/{container_id}"
url = join_container_api_base_path(api_base, f"/{container_id}")
# No data needed for DELETE request
data: Dict[str, Any] = {}
@ -266,7 +267,7 @@ class OpenAIContainerConfig(BaseContainerConfig):
- GET /v1/containers/{container_id}/files
"""
# Construct the URL for container files
url = f"{api_base.rstrip('/')}/{container_id}/files"
url = join_container_api_base_path(api_base, f"/{container_id}/files")
# Prepare query parameters
params: Dict[str, Any] = {}
@ -310,7 +311,9 @@ class OpenAIContainerConfig(BaseContainerConfig):
- GET /v1/containers/{container_id}/files/{file_id}/content
"""
# Construct the URL for container file content
url = f"{api_base.rstrip('/')}/{container_id}/files/{file_id}/content"
url = join_container_api_base_path(
api_base, f"/{container_id}/files/{file_id}/content"
)
# No query parameters needed
params: Dict[str, Any] = {}

View file

@ -0,0 +1,18 @@
"""Shared helpers for OpenAI-compatible container API URL construction."""
import httpx
def join_container_api_base_path(api_base: str, path_suffix: str) -> str:
"""Append ``path_suffix`` to the path of ``api_base``, keeping the query string last.
Azure (and some bases) pass ``api_base`` like
``https://host/openai/v1/containers?api-version=v1``. Naive string concat would
produce ``...?api-version=v1/cntr_...`` which is invalid; this uses ``httpx.URL``
so the result is ``.../containers/cntr_.../files?api-version=v1``.
"""
if not path_suffix.startswith("/"):
path_suffix = f"/{path_suffix}"
parsed = httpx.URL(api_base)
new_path = f"{parsed.path.rstrip('/')}{path_suffix}"
return str(parsed.copy_with(path=new_path))

View file

@ -32,6 +32,7 @@ import litellm
from litellm import LlmProviders
from litellm._logging import verbose_logger
from litellm.constants import DEFAULT_MAX_RETRIES
from litellm.files.types import FileContentStreamingResult
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.logging_utils import track_llm_api_timing
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
@ -1751,6 +1752,92 @@ class OpenAIFilesAPI(BaseLLM):
return HttpxBinaryResponseContent(response=response.response)
async def afile_content_streaming(
self,
file_content_request: FileContentRequest,
openai_client: AsyncOpenAI,
chunk_size: int = 1024 * 1024,
) -> FileContentStreamingResult:
response_cm = openai_client.files.with_streaming_response.content(
**file_content_request
)
response = await response_cm.__aenter__()
headers = dict(response.headers)
async def _stream() -> AsyncIterator[bytes]:
exc: Optional[BaseException] = None
try:
async for chunk in response.iter_bytes(chunk_size=chunk_size):
yield chunk
except BaseException as e:
exc = e
raise
finally:
if exc is None:
await response_cm.__aexit__(None, None, None)
else:
await response_cm.__aexit__(type(exc), exc, exc.__traceback__)
return FileContentStreamingResult(stream_iterator=_stream(), headers=headers)
def file_content_streaming(
self,
_is_async: bool,
file_content_request: FileContentRequest,
api_base: str,
api_key: Optional[str],
timeout: Union[float, httpx.Timeout],
max_retries: Optional[int],
organization: Optional[str],
chunk_size: int = 1024 * 1024,
client: Optional[Union[OpenAI, AsyncOpenAI]] = None,
) -> FileContentStreamingResult:
openai_client: Optional[Union[OpenAI, AsyncOpenAI]] = self.get_openai_client(
api_key=api_key,
api_base=api_base,
timeout=timeout,
max_retries=max_retries,
organization=organization,
client=client,
_is_async=_is_async,
)
if openai_client is None:
raise ValueError(
"OpenAI client is not initialized. Make sure api_key is passed or OPENAI_API_KEY is set in the environment."
)
if _is_async is True:
if not isinstance(openai_client, AsyncOpenAI):
raise ValueError(
"OpenAI client is not an instance of AsyncOpenAI. Make sure you passed an AsyncOpenAI client."
)
return self.afile_content_streaming( # type: ignore
file_content_request=file_content_request,
openai_client=openai_client,
chunk_size=chunk_size,
)
response_cm = cast(OpenAI, openai_client).files.with_streaming_response.content(
**file_content_request
)
response = response_cm.__enter__()
headers = dict(response.headers)
def _stream() -> Iterator[bytes]:
exc: Optional[BaseException] = None
try:
yield from response.iter_bytes(chunk_size=chunk_size)
except BaseException as e:
exc = e
raise
finally:
if exc is None:
response_cm.__exit__(None, None, None)
else:
response_cm.__exit__(type(exc), exc, exc.__traceback__)
return FileContentStreamingResult(stream_iterator=_stream(), headers=headers)
async def aretrieve_file(
self,
file_id: str,
@ -3045,4 +3132,4 @@ class OpenAIAssistantsAPI(BaseLLM):
tools=tools,
)
return response
return response

View file

View file

@ -1,6 +1,8 @@
from typing import Union, Literal
from typing import Union, Literal, Optional
from enum import Enum
import warnings
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
def validate_different_content(v: Union[str, dict, list]) -> str:
@ -20,7 +22,7 @@ def validate_different_content(v: Union[str, dict, list]) -> str:
elif isinstance(v, str):
return v
raise ValueError("Content must be a string")
return v
class TextContent(BaseModel):
@ -49,6 +51,10 @@ class FunctionTool(BaseModel):
parameters: dict = {"type": "object", "properties": {}}
strict: bool = False
def model_dump(self, **kwargs) -> dict:
kwargs["exclude_unset"] = False
return super().model_dump(**kwargs)
@field_validator("parameters", mode="before")
@classmethod
def ensure_object_type(cls, v: dict) -> dict:
@ -66,6 +72,10 @@ class ChatCompletionTool(BaseModel):
type_: Literal["function"] = Field(default="function", alias="type")
function: FunctionTool
def model_dump(self, **kwargs) -> dict:
kwargs["exclude_unset"] = False
return super().model_dump(**kwargs)
class MessageToolCall(BaseModel):
id: str
@ -114,6 +124,9 @@ class SAPToolChatMessage(BaseModel):
)
ChatMessage = Union[SAPMessage, SAPUserMessage, SAPAssistantMessage, SAPToolChatMessage]
class ResponseFormat(BaseModel):
type_: Literal["text", "json_object"] = Field(default="text", alias="type")
@ -128,3 +141,607 @@ class JSONResponseSchema(BaseModel):
class ResponseFormatJSONSchema(BaseModel):
type_: Literal["json_schema"] = Field(default="json_schema", alias="type")
json_schema: JSONResponseSchema
class KeyValueListPair(BaseModel):
key: str
value: list[str]
class DocumentMetadataKeyValueListPairs(KeyValueListPair):
select_mode: Optional[list[Literal["ignoreIfKeyAbsent"]]] = None
class GroundingSearchConfig(BaseModel):
max_chunk_count: Optional[int] = Field(default=None, ge=0)
max_document_count: Optional[int] = Field(default=None, ge=0)
@model_validator(mode="after")
def validate_max_chunk_count_and_max_document_count(self):
if self.max_chunk_count is not None and self.max_document_count is not None:
raise ValueError("Cannot specify both maxChunkCount and maxDocumentCount.")
return self
class DocumentGroundingFilter(BaseModel):
id_: Optional[str] = Field(default=None, alias="id")
data_repository_type: Literal["vector", "help.sap.com"]
search_config: Optional[GroundingSearchConfig] = None
data_repositories: Optional[list[str]] = None
data_repository_metadata: Optional[list[KeyValueListPair]] = None
document_metadata: Optional[list[DocumentMetadataKeyValueListPairs]] = None
chunk_metadata: Optional[list[KeyValueListPair]] = None
class DocumentGroundingPlaceholders(BaseModel):
input: list[str] = Field(min_length=1)
output: str
class DocumentGroundingConfig(BaseModel):
filters: Optional[list[DocumentGroundingFilter]] = None
placeholders: DocumentGroundingPlaceholders
metadata_params: Optional[list[str]] = None
class GroundingModuleConfig(BaseModel):
type_: Literal["document_grounding_service"] = Field(
default="document_grounding_service", alias="type"
)
config: DocumentGroundingConfig
class Template(BaseModel):
template: list[ChatMessage]
defaults: Optional[dict[str, str]] = None
response_format: Optional[Union[ResponseFormat, ResponseFormatJSONSchema]] = None
tools: Optional[list[ChatCompletionTool]] = None
class LLMModelDetails(BaseModel):
name: str
version: str = "latest"
params: Optional[dict] = None
class PromptTemplatingModuleConfig(BaseModel):
prompt: Template
model: LLMModelDetails
class SAPMaskingProfileEntity(str, Enum):
"""
Enumerates the entity categories that can be masked by the SAP Data Privacy Integration service.
This enum lists different types of personal or sensitive information (PII) that can be detected and masked
by the data masking module, such as personal details, organizational data, contact information, and identifiers.
Values:
PERSON: Represents personal names.
ORG: Represents organizational names.
UNIVERSITY: Represents educational institutions.
LOCATION: Represents geographical locations.
EMAIL: Represents email addresses.
PHONE: Represents phone numbers.
ADDRESS: Represents physical addresses.
SAP_IDS_INTERNAL: Represents internal SAP identifiers.
SAP_IDS_PUBLIC: Represents public SAP identifiers.
URL: Represents URLs.
USERNAME_PASSWORD: Represents usernames and passwords.
NATIONAL_ID: Represents national identification numbers.
IBAN: Represents International Bank Account Numbers.
SSN: Represents Social Security Numbers.
CREDIT_CARD_NUMBER: Represents credit card numbers.
PASSPORT: Represents passport numbers.
DRIVING_LICENSE: Represents driving license numbers.
NATIONALITY: Represents nationality information.
RELIGIOUS_GROUP: Represents religious group affiliation.
POLITICAL_GROUP: Represents political group affiliation.
PRONOUNS_GENDER: Represents pronouns and gender identity.
GENDER: Represents gender information.
SEXUAL_ORIENTATION: Represents sexual orientation.
TRADE_UNION: Represents trade union membership.
SENSITIVE_DATA: Represents any other sensitive information.
"""
PERSON = "profile-person"
ORG = "profile-org"
UNIVERSITY = "profile-university"
LOCATION = "profile-location"
EMAIL = "profile-email"
PHONE = "profile-phone"
ADDRESS = "profile-address"
SAP_IDS_INTERNAL = "profile-sapids-internal"
SAP_IDS_PUBLIC = "profile-sapids-public"
URL = "profile-url"
USERNAME_PASSWORD = "profile-username-password"
NATIONAL_ID = "profile-nationalid"
IBAN = "profile-iban"
SSN = "profile-ssn"
CREDIT_CARD_NUMBER = "profile-credit-card-number"
PASSPORT = "profile-passport"
DRIVING_LICENSE = "profile-driverlicense"
NATIONALITY = "profile-nationality"
RELIGIOUS_GROUP = "profile-religious-group"
POLITICAL_GROUP = "profile-political-group"
PRONOUNS_GENDER = "profile-pronouns-gender"
GENDER = "profile-gender"
SEXUAL_ORIENTATION = "profile-sexual-orientation"
TRADE_UNION = "profile-trade-union"
SENSITIVE_DATA = "profile-sensitive-data"
ETHNICITY = "profile-ethnicity"
class DPIMethodConstant(BaseModel):
"""
Replaces the entity with the specified value followed by an incrementing number
"""
method: Literal["constant"] = "constant"
value: str
class DPIMethodFabricatedData(BaseModel):
"""
Replaces the entity with a randomly generated value appropriate to its type.
"""
method: Literal["fabricated_data"] = "fabricated_data"
class DPICustomEntity(BaseModel):
"""
regex: Regular expression to match the entity
replacement_strategy: Replacement strategy to be used for the entity
"""
regex: str
replacement_strategy: DPIMethodConstant
class DPIStandardEntity(BaseModel):
"""
type: Standard entity type to be masked
replacement_strategy: Replacement strategy to be used for the entity
"""
type_: SAPMaskingProfileEntity = Field(..., alias="type")
replacement_strategy: Optional[
Union[DPIMethodConstant, DPIMethodFabricatedData]
] = None
class MaskGroundingInput(BaseModel):
"""
Controls whether the input to the grounding module will be masked with the configuration
supplied in the masking module
"""
enabled: bool = False
class MaskingProviderConfig(BaseModel):
"""
SAP Data Privacy Integration provider for data masking.
This class implements the SAP Data Privacy Integration service, which can anonymize or pseudonymize
specified entity categories in the input data. It supports masking sensitive information like personal names,
contact details, and identifiers.
Args:
method: The method of masking to apply (anonymization or pseudonymization).
entities: A list of entity categories to be masked, such as names, locations, or emails.
allowlist: A list of strings that should not be masked.
mask_grounding_input: A flag indicating whether to mask input to the grounding module.
"""
type_: Literal["sap_data_privacy_integration"] = Field(
default="sap_data_privacy_integration", alias="type"
)
method: Literal["anonymization", "pseudonymization"]
entities: list[Union[DPIStandardEntity, DPICustomEntity]]
allowlist: Optional[list[str]] = None
mask_grounding_input: Optional[MaskGroundingInput] = None
class MaskingModuleConfig(BaseModel):
"""
Configuration for the data masking module.
Args:
providers: list of masking service provider configurations
masking_providers: list of masking provider configurations
IMPORTANT: use exactly one of the parameters to set the list of masking provider configurations.
DEPRECATED: parameter 'masking_providers' will be removed Sept 15, 2026. Use 'providers' instead.
"""
providers: Optional[list[MaskingProviderConfig]] = Field(min_length=1, default=None)
masking_providers: Optional[list[MaskingProviderConfig]] = Field(
min_length=1, default=None
)
@model_validator(mode="after")
def enforce_exactly_one_provider_list(self):
has_providers = self.providers is not None
has_masking_providers = self.masking_providers is not None
if not has_providers and not has_masking_providers:
raise ValueError(
"For SAP Masking Module Config you must provide 'providers'."
)
if has_providers and has_masking_providers:
raise ValueError(
"For SAP Masking Module Config you must set exactly one of: 'providers' or 'masking_providers', not both."
)
if has_masking_providers:
warnings.warn(
"The 'masking_providers' parameter is deprecated and will be removed on Sept 15, 2026. "
"Use 'providers' instead.",
DeprecationWarning,
stacklevel=5,
)
return self
class AzureThreshold(int, Enum):
"""
Enumerates the threshold levels for the Azure Content Safety service.
This enum defines the various threshold levels that can be used to filter
content based on its safety score. Each threshold value represents a specific
level of content moderation.
Values:
ALLOW_SAFE: Allows only Safe content.
ALLOW_SAFE_LOW: Allows Safe and Low content.
ALLOW_SAFE_LOW_MEDIUM: Allows Safe, Low, and Medium content.
ALLOW_ALL: Allows all content (Safe, Low, Medium, and High).
"""
ALLOW_SAFE = 0
ALLOW_SAFE_LOW = 2
ALLOW_SAFE_LOW_MEDIUM = 4
ALLOW_ALL = 6
class AzureContentFilter(BaseModel):
"""
Specific filter configuration for Azure Content Safety.
This class configures content filtering based on Azure's categories and
severity levels. It allows setting thresholds for hate speech, sexual content,
violence, and self-harm content.
Values:
hate: Threshold for hate speech content.
sexual: Threshold for sexual content.
violence: Threshold for violent content.
self_harm: Threshold for self-harm content.
"""
hate: Optional[Union[AzureThreshold, Literal[0, 2, 4, 6]]] = None
sexual: Optional[Union[AzureThreshold, Literal[0, 2, 4, 6]]] = None
violence: Optional[Union[AzureThreshold, Literal[0, 2, 4, 6]]] = None
self_harm: Optional[Union[AzureThreshold, Literal[0, 2, 4, 6]]] = None
class AzureContentSafetyInput(AzureContentFilter):
"""
Filter configuration for Azure Content Safety Input
Values:
hate: Threshold for hate speech content.
sexual: Threshold for sexual content.
violence: Threshold for violent content.
self_harm: Threshold for self-harm content.
prompt_shield: A flag to use prompt shield
"""
prompt_shield: Optional[bool] = False
class AzureContentSafetyOutput(AzureContentFilter):
"""
Filter configuration for Azure Content Safety Output
Values:
hate: Threshold for hate speech content.
sexual: Threshold for sexual content.
violence: Threshold for violent content.
self_harm: Threshold for self-harm content.
protected_material_code: Detect protected code content from known GitHub repositories.
The scan includes software libraries, source code, algorithms,
and other proprietary programming content.
"""
protected_material_code: Optional[bool] = False
class LlamaGuard38bFilter(BaseModel):
"""
Specific implementation of ContentFilter for Llama Guard 3. Llama Guard 3 is a
Llama-3.1-8B pretrained model, fine-tuned for content safety classification.
Args:
violent_crimes: Responses that enable, encourage, or endorse the commission of violent crimes.
non_violent_crimes: Responses that enable, encourage, or endorse the commission of non-violent crimes.
sex_crimes: Responses that enable, encourage, or endorse the commission of sex-related crimes.
child_exploitation: Responses that contain, describe, enable or endorse the sexual abuse of children.
defamation: Responses that are both verifiably false and likely to injure a living person's reputation.
specialized_advice: Responses that contain specialized financial, medical or legal advice.
privacy: Responses that contain sensitive or nonpublic personal information.
intellectual_property: Responses that may violate the intellectual property rights of any third party.
indiscriminate_weapons: Responses that enable, encourage, or endorse the creation of indiscriminate weapons.
hate: Responses that demean or dehumanize people on the basis of their sensitive, personal characteristics.
self_harm: Responses that enable, encourage, or endorse acts of intentional self-harm.
sexual_content: Responses that contain erotica.
elections: Responses that contain factually incorrect information about electoral systems and processes.
code_interpreter_abuse: Responses that seek to abuse code interpreters.
"""
violent_crimes: bool = Field(default=False)
non_violent_crimes: bool = Field(default=False)
sex_crimes: bool = Field(default=False)
child_exploitation: bool = Field(default=False)
defamation: bool = Field(default=False)
specialized_advice: bool = Field(default=False)
privacy: bool = Field(default=False)
intellectual_property: bool = Field(default=False)
indiscriminate_weapons: bool = Field(default=False)
hate: bool = Field(default=False)
self_harm: bool = Field(default=False)
sexual_content: bool = Field(default=False)
elections: bool = Field(default=False)
code_interpreter_abuse: bool = Field(default=False)
class LlamaGuard38bFilterConfig(BaseModel):
type_: Literal["llama_guard_3_8b"] = Field(default="llama_guard_3_8b", alias="type")
config: LlamaGuard38bFilter
class AzureContentSafetyInputFilterConfig(BaseModel):
type_: Literal["azure_content_safety"] = Field(
default="azure_content_safety", alias="type"
)
config: Optional[AzureContentSafetyInput] = None
class AzureContentSafetyOutputFilterConfig(BaseModel):
type_: Literal["azure_content_safety"] = Field(
default="azure_content_safety", alias="type"
)
config: Optional[AzureContentSafetyOutput] = None
class FilteringStreamOptions(BaseModel):
"""
overlap: Number of characters that should be additionally sent to content filtering services
from previous chunks as additional context.
"""
overlap: Optional[int] = Field(default=0, ge=0, le=10000)
class InputFiltering(BaseModel):
"""Module for managing and applying input content filters.
Args:
filters: List of ContentFilter objects to be applied to input content.
"""
filters: list[
Union[AzureContentSafetyInputFilterConfig, LlamaGuard38bFilterConfig]
] = Field(min_length=1)
class OutputFiltering(BaseModel):
"""Module for managing and applying output content filters.
Args:
filters: List of ContentFilter objects to be applied to output content.
stream_options: Module-specific streaming options.
"""
filters: list[
Union[AzureContentSafetyOutputFilterConfig, LlamaGuard38bFilterConfig]
] = Field(min_length=1)
stream_options: Optional[FilteringStreamOptions] = None
class FilteringModuleConfig(BaseModel):
"""Module for managing and applying content filters.
Args:
input: Module for filtering and validating input content before processing.
output: Module for filtering and validating output content after generation.
"""
input: Optional[InputFiltering] = None
output: Optional[OutputFiltering] = None
@model_validator(mode="after")
def enforce_min_properties(self) -> "FilteringModuleConfig":
"""
Ensure at least one of input or output filtering is provided.
"""
if self.input is None and self.output is None:
raise ValueError(
"For using SAP Filtering Module you must provide at least one property: input or output filters."
)
return self
class SAPDocumentTranslationApplyToSelector(BaseModel):
"""
This selector allows you to define the scope of translation, such as specific placeholders or
messages with specific roles.
For example, {"category": "placeholders",
"items": ["user_input"],
"source_language": "de-DE"}
targets the value of "user_input" in placeholder_values specified in the request payload;
and considers the value to be in German.
"""
category: Literal["placeholders", "template_roles"]
items: list[str]
source_language: str
class InputTranslationConfig(BaseModel):
"""
Configuration for input translation.
Args:
source_language: Language of the text to be translated. Example: de-DE
target_language: Language to which the text should be translated. Example: en-US
apply_to: List of selectors that define the scope of translation.
"""
source_language: Optional[str] = None
target_language: str
apply_to: Optional[list[SAPDocumentTranslationApplyToSelector]] = None
class OutputTranslationConfig(BaseModel):
source_language: Optional[str] = None
target_language: Union[str, SAPDocumentTranslationApplyToSelector]
class SAPDocumentTranslationInput(BaseModel):
"""
Configuration for input translation
Args:
type: The type of translation module (e.g., 'sap_document_translation').
translate_messages_history: If true, the messages history will be translated as well.
config: Configuration object for the translation module.
"""
type_: Literal["sap_document_translation"] = Field(
default="sap_document_translation", alias="type"
)
translate_messages_history: Optional[bool] = None
config: InputTranslationConfig
class SAPDocumentTranslationOutput(BaseModel):
"""
Configuration for output translation
Args:
type: The type of translation module (e.g., 'sap_document_translation').
config: Configuration object for the translation module.
"""
type_: Literal["sap_document_translation"] = Field(
default="sap_document_translation", alias="type"
)
config: OutputTranslationConfig
class TranslationModuleConfig(BaseModel):
"""
Configuration for translation module
Args:
input: Configuration for input translation
output: Configuration for output translation
"""
input: Optional[SAPDocumentTranslationInput] = None
output: Optional[SAPDocumentTranslationOutput] = None
@model_validator(mode="after")
def enforce_min_properties(self) -> "TranslationModuleConfig":
if self.input is None and self.output is None:
raise ValueError(
"TranslationModuleConfig requires at least one of 'input' or 'output'."
)
return self
class ModuleConfig(BaseModel):
prompt_templating: PromptTemplatingModuleConfig
filtering: Optional[FilteringModuleConfig] = None
masking: Optional[MaskingModuleConfig] = None
grounding: Optional[GroundingModuleConfig] = None
translation: Optional[TranslationModuleConfig] = None
class GlobalStreamOptions(BaseModel):
enabled: bool = False
chunk_size: Optional[int] = Field(default=None, ge=1)
delimiters: Optional[list[str]] = None
class OrchestrationConfig(BaseModel):
modules: Union[ModuleConfig, list[ModuleConfig]]
stream: Optional[GlobalStreamOptions] = None
class OrchestrationRequest(BaseModel):
config: OrchestrationConfig
placeholder_values: Optional[dict[str, str]] = None

View file

@ -11,6 +11,7 @@ from typing import (
TYPE_CHECKING,
Iterator,
AsyncIterator,
FrozenSet,
)
from functools import cached_property
import litellm
@ -31,12 +32,13 @@ else:
from ..credentials import get_token_creator
from .models import (
SAPMessage,
SAPAssistantMessage,
SAPToolChatMessage,
ChatCompletionTool,
ResponseFormatJSONSchema,
OrchestrationRequest,
ResponseFormat,
ResponseFormatJSONSchema,
SAPAssistantMessage,
SAPMessage,
SAPToolChatMessage,
SAPUserMessage,
)
from .handler import (
@ -45,9 +47,65 @@ from .handler import (
SAPStreamIterator,
)
# Keys routed outside SAP orchestration `model.params` (prompt, stream, fallbacks, etc.)
_SAP_MODEL_PARAMS_EXCLUDED_KEYS: FrozenSet[str] = frozenset(
{
"tools",
"tool_choice",
"stream_options",
"fallback_sap_modules",
"placeholder_values",
"model_version",
}
)
def validate_dict(data: dict, model) -> dict:
return model(**data).model_dump(by_alias=True)
return model(**data).model_dump(by_alias=True, exclude_unset=True)
def _messages_to_sap_template(messages: List[Dict[str, str]]) -> list: # type: ignore[type-arg]
template = []
for message in messages:
if message["role"] == "user":
template.append(validate_dict(message, SAPUserMessage))
elif message["role"] == "assistant":
template.append(validate_dict(message, SAPAssistantMessage))
elif message["role"] == "tool":
template.append(validate_dict(message, SAPToolChatMessage))
else:
template.append(validate_dict(message, SAPMessage))
return template
def _tools_response_format_and_stream(
optional_params: dict, model_params: dict
) -> Tuple[dict, dict, dict]:
tools_ = optional_params.pop("tools", [])
tools_ = [validate_dict(tool, ChatCompletionTool) for tool in tools_]
tools: dict = {"tools": tools_} if tools_ else {}
response_format = model_params.pop("response_format", {})
resp_type = response_format.get("type", None)
if resp_type:
if resp_type == "json_schema":
response_format = validate_dict(
response_format, ResponseFormatJSONSchema
)
else:
response_format = validate_dict(response_format, ResponseFormat)
response_format = {"response_format": response_format}
model_params.pop("stream", False)
stream_config: dict = {}
if "stream_options" in optional_params:
stream_options = optional_params.pop("stream_options", {})
if "chunk_size" in stream_options:
stream_config["chunk_size"] = stream_options.get("chunk_size")
if "delimiters" in stream_options:
stream_config["delimiters"] = stream_options.get("delimiters")
return tools, response_format, stream_config
class GenAIHubOrchestrationConfig(OpenAIGPTConfig):
@ -208,48 +266,25 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig):
api_base_ = f"{self.deployment_url}/v2/completion"
return api_base_
def transform_request(
def _build_prompt_module(
self,
model: str,
messages: List[Dict[str, str]], # type: ignore
optional_params: dict,
litellm_params: dict,
headers: dict,
model_name: str,
template_messages: List[Dict[str, str]],
params: dict,
) -> dict:
# Filter out parameters that are not valid model params for SAP Orchestration API
# - tools, model_version, deployment_url: handled separately
excluded_params = {"tools", "model_version", "deployment_url"}
# Filter strict for GPT models only - SAP AI Core doesn't accept it as a model param
# LangChain agents pass strict=true at top level, which fails for GPT models
# Anthropic models accept strict, so preserve it for them
if model.startswith("gpt"):
excluded_params.add("strict")
if model_name.startswith("gpt") and "strict" in params:
params.pop("strict")
model_params = {
k: v for k, v in optional_params.items() if k not in excluded_params
}
model_version = params.pop("model_version", "latest")
model_version = optional_params.pop("model_version", "latest")
template = []
for message in messages:
if message["role"] == "user":
template.append(validate_dict(message, SAPUserMessage))
elif message["role"] == "assistant":
template.append(validate_dict(message, SAPAssistantMessage))
elif message["role"] == "tool":
template.append(validate_dict(message, SAPToolChatMessage))
else:
template.append(validate_dict(message, SAPMessage))
tools_ = optional_params.pop("tools", [])
tools_ = params.pop("tools", [])
tools_ = [validate_dict(tool, ChatCompletionTool) for tool in tools_]
if tools_ != []:
tools = {"tools": tools_}
else:
tools = {}
tools = {"tools": tools_} if tools_ else {}
response_format = model_params.pop("response_format", {})
response_format = params.pop("response_format", {})
resp_type = response_format.get("type", None)
if resp_type:
if resp_type == "json_schema":
@ -259,33 +294,104 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig):
else:
response_format = validate_dict(response_format, ResponseFormat)
response_format = {"response_format": response_format}
model_params.pop("stream", False)
stream_config = {}
if "stream_options" in model_params:
# stream_config["enabled"] = True
stream_options = model_params.pop("stream_options", {})
stream_config["chunk_size"] = stream_options.get("chunk_size", 100)
if "delimiters" in stream_options:
stream_config["delimiters"] = stream_options.get("delimiters")
# else:
# stream_config["enabled"] = False
config = {
"config": {
"modules": {
"prompt_templating": {
"prompt": {"template": template, **tools, **response_format},
"model": {
"name": model,
"params": model_params,
"version": model_version,
},
},
else:
response_format = {}
placeholder_defaults = params.pop("placeholder_defaults", {})
placeholder_defaults = (
{"defaults": placeholder_defaults} if placeholder_defaults else {}
)
optional_modules = {}
optional_modules_lst = ["grounding", "masking", "filtering", "translation"]
for module in optional_modules_lst:
if params.get(module, None) is not None:
optional_modules[module] = params.pop(module)
return {
"prompt_templating": {
"prompt": {
"template": template_messages,
**placeholder_defaults,
**tools,
**response_format,
},
"stream": stream_config,
}
"model": {
"name": model_name,
"params": params,
"version": model_version,
},
},
**optional_modules,
}
return config
def transform_request(
self,
model: str,
messages: List[Dict[str, str]], # type: ignore
optional_params: dict,
litellm_params: dict,
headers: dict,
) -> dict:
optional_params = dict(optional_params)
optional_params.pop("deployment_url", None)
template = _messages_to_sap_template(messages)
placeholder_values = optional_params.pop("placeholder_values", None)
fallback_modules = optional_params.pop("fallback_sap_modules", [])
optional_params.pop("stream", None)
stream_config: dict = {}
if "stream_options" in optional_params:
stream_options = optional_params.pop("stream_options", {})
if "chunk_size" in stream_options:
stream_config["chunk_size"] = stream_options["chunk_size"]
if "delimiters" in stream_options:
stream_config["delimiters"] = stream_options["delimiters"]
optional_params.pop("tool_choice", None)
modules = [
self._build_prompt_module(
model_name=model,
template_messages=template,
params=dict(optional_params),
)
]
for modules_dict in fallback_modules:
modules_dict = dict(modules_dict)
fallback_model = modules_dict.pop("model", None)
if fallback_model is None:
raise ValueError(
"Each entry in `fallback_sap_modules` must include a 'model' key."
)
if fallback_model.startswith("sap/"):
fallback_model = fallback_model[4:]
fallback_template = modules_dict.pop("messages", [])
modules.append(
self._build_prompt_module(
model_name=fallback_model,
template_messages=fallback_template,
params=modules_dict,
)
)
config_payload: Dict[str, Any] = {
"modules": modules if len(modules) > 1 else modules[0],
}
if stream_config:
config_payload["stream"] = stream_config
request_body: Dict[str, Any] = {"config": config_payload}
if placeholder_values is not None:
request_body["placeholder_values"] = placeholder_values
body = validate_dict(request_body, OrchestrationRequest)
return body
def transform_response(
self,

View file

@ -1,5 +1,5 @@
from __future__ import annotations
from typing import Any, Callable, Dict, Final, List, Optional, Sequence, Tuple
from typing import Any, Callable, Dict, Final, List, Optional, Sequence, Tuple, Union
from datetime import datetime, timedelta, timezone
from threading import Lock
from pathlib import Path
@ -7,9 +7,11 @@ from dataclasses import dataclass
import json
import os
import tempfile
import httpx
from litellm import sap_service_key
from litellm.llms.custom_httpx.http_handler import _get_httpx_client
from litellm.llms.custom_httpx.http_handler import _get_httpx_client, HTTPHandler
from litellm._logging import verbose_logger
import litellm
AUTH_ENDPOINT_SUFFIX = "/oauth/token"
@ -28,11 +30,25 @@ def _get_home() -> str:
return os.getenv(HOME_PATH_ENV_VAR, DEFAULT_HOME_PATH)
def _get_nested(d: Dict[str, Any], path: Sequence[str]) -> Any:
def _get_nested(d: Union[Dict[str, Any], str], path: Sequence[str]) -> Any:
cur: Any = d
if isinstance(cur, str):
# This shouldn't happen if service keys are pre-parsed correctly
try:
cur = json.loads(cur)
except json.JSONDecodeError:
verbose_logger.warning(
"SAP service key or VCAP service is a string but not valid JSON."
)
return None
for k in path:
if not isinstance(cur, dict) or k not in cur:
raise KeyError(".".join(path))
if not isinstance(cur, dict):
verbose_logger.warning(
f"SAP service key or VCAP service traversal hit non-dict type '{type(cur).__name__}' at key '{k}'."
)
return None
if k not in cur:
return None
cur = cur[k]
return cur
@ -47,6 +63,13 @@ def _load_json_env(var_name: str) -> Optional[Dict[str, Any]]:
return None
def _str_or_none(value) -> Optional[str]:
try:
return str(value) if value is not None else None
except Exception:
return None
def _load_vcap() -> Dict[str, Any]:
return _load_json_env(VCAP_SERVICES_ENV_VAR) or {}
@ -59,6 +82,12 @@ def _get_vcap_service(label: str) -> Optional[Dict[str, Any]]:
return None
@dataclass
class Source:
name: str
get: Callable[[CredentialsValue], Optional[str]]
@dataclass(frozen=True)
class CredentialsValue:
name: str
@ -82,7 +111,6 @@ CREDENTIAL_VALUES: Final[List[CredentialsValue]] = [
transform_fn=lambda url: url.rstrip("/")
+ ("" if url.endswith("/v2") else "/v2"),
),
CredentialsValue("resource_group", default="default"),
CredentialsValue(
"cert_url",
("certurl",),
@ -145,81 +173,239 @@ def _env_name(name: str) -> str:
return f"AICORE_{name.upper()}"
def _resolve_value(
cred: CredentialsValue,
*,
kwargs: Dict[str, Any],
env: Dict[str, str],
config: Dict[str, Any],
service_like: Optional[Dict[str, Any]],
) -> Optional[str]:
# 1) explicit kwargs
if cred.name in kwargs and kwargs[cred.name] is not None:
return kwargs[cred.name]
def extract_credentials(source: Source) -> Dict[str, str]:
"""Extract all credentials from a source."""
credentials = {}
for cv in CREDENTIAL_VALUES:
value = source.get(cv)
if value is not None:
credentials[cv.name] = cv.transform_fn(value) if cv.transform_fn else value
return credentials
# 2) environment variables (primary name)
env_key = _env_name(cred.name)
if env_key in env and env[env_key] is not None:
return env[env_key]
# 3) config file (accept both prefixed and plain keys)
for key in (env_key, cred.name):
if key in config and config[key] is not None:
return config[key]
def resolve_credentials(sources: List[Source]) -> Dict[str, str]:
"""Extract credentials from the first source that has any defined."""
for source in sources:
credentials = extract_credentials(source)
if credentials:
verbose_logger.debug(f"Resolved SAP credentials from source {source.name}")
return credentials
raise ValueError("No credentials found in any source")
# 4) service-like source (AICORE_SERVICE_KEY first, else VCAP)
if service_like and cred.vcap_key:
def resolve_resource_group(sources: List[Source]) -> Optional[str]:
"""Find resource_group from the first source that defines it."""
rg_cred = CredentialsValue("resource_group", default="default")
for source in sources:
value = source.get(rg_cred)
if value is not None:
verbose_logger.debug(
f"Resolved GEN AI Hub resource_group from source {source.name}"
)
return value
return rg_cred.default
def _parse_service_key_once(
service_key: Optional[Union[str, dict]]
) -> Optional[Dict[str, Any]]:
"""
Pre-parse service_key if it's a string to avoid repeated JSON parsing.
Returns None if parsing fails (other credential sources may still work).
"""
if service_key is None:
return None
if isinstance(service_key, dict):
return service_key
if isinstance(service_key, str):
try:
val = _get_nested(service_like, ("credentials",) + cred.vcap_key)
if val is not None:
return val
except KeyError:
pass
return json.loads(service_key)
except json.JSONDecodeError:
verbose_logger.warning(
"SAP service key is a string but not valid JSON. Skipping this source."
)
return None
verbose_logger.warning(
f"SAP service key has unexpected type '{type(service_key).__name__}'. Expected str or dict. Ignoring."
)
return None
# 5) default
return cred.default
def _resolve_credential_from_service_key(
service_key: Optional[Union[str, dict]], cv: CredentialsValue
) -> Optional[str]:
if service_key is None:
return None
val = _str_or_none(
_get_nested(
service_key, (("credentials",) + cv.vcap_key) if cv.vcap_key else (cv.name,)
)
)
if val is None:
return _str_or_none(
_get_nested(service_key, cv.vcap_key if cv.vcap_key else (cv.name,))
)
return val
def fetch_credentials(
service_key: Optional[str] = None, profile: Optional[str] = None, **kwargs
service_key: Optional[Union[str, dict]] = None,
profile: Optional[str] = None,
**kwargs,
) -> Dict[str, str]:
"""
Resolution order per key:
Resolution order (first-source-wins):
Sources are checked in this order:
kwargs
> service key
> env (AICORE_<NAME>)
> config (AICORE_<NAME> or plain <name>)
> service-like source from JSON in $AICORE_SERVICE_KEY (same structure as a VCAP service object)
falling back to service entry in $VCAP_SERVICES with label 'aicore'
> vcap service key
> default
Important:
- Credentials are extracted from the FIRST source that provides any credential value.
- Values are NOT merged per key across sources. Except resource_group, which is merged.
Warning:
- This function does NOT validate the returned credentials just parsed it from the sources.
- Callers MUST explicitly call validate_credentials() on the returned dict
"""
config = init_conf(profile)
env = os.environ # snapshot for testability
service_like = None
if not config:
# Prefer AICORE_SERVICE_KEY if present; otherwise fall back to the VCAP service.
service_like = (
service_key
or sap_service_key
or _load_json_env(SERVICE_KEY_ENV_VAR)
or _get_vcap_service(VCAP_AICORE_SERVICE_NAME)
service_key = _parse_service_key_once(
service_key or litellm.sap_service_key or os.environ.get(SERVICE_KEY_ENV_VAR)
)
vcap_service = _get_vcap_service(VCAP_AICORE_SERVICE_NAME)
sources = [
Source("kwargs", lambda cv: _str_or_none(kwargs.get(cv.name))),
Source(
"service key",
lambda cv: _resolve_credential_from_service_key(service_key, cv),
),
Source(
"environment variables",
lambda cv: _str_or_none(os.environ.get(f"AICORE_{cv.name.upper()}")),
),
Source(
"config file",
lambda cv: _str_or_none(
config.get(f"AICORE_{cv.name.upper()}")
if config.get(f"AICORE_{cv.name.upper()}") is not None
else config.get(cv.name)
),
),
Source(
"VCAP service",
lambda cv: (
_str_or_none(
_get_nested(
vcap_service,
(("credentials",) + cv.vcap_key) if cv.vcap_key else (cv.name,),
)
)
if vcap_service
else None
),
), # type: ignore[arg-type]
]
credentials = resolve_credentials(sources)
resource_group = resolve_resource_group(sources)
if resource_group is not None:
credentials["resource_group"] = resource_group
if "cert_url" in credentials:
credentials["auth_url"] = credentials.pop("cert_url")
return credentials
def validate_credentials(
auth_url: Optional[str] = None,
base_url: Optional[str] = None,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
cert_str: Optional[str] = None,
key_str: Optional[str] = None,
cert_file_path: Optional[str] = None,
key_file_path: Optional[str] = None,
) -> None:
"""
Validate SAP AI Core credentials for completeness and consistency.
Args:
auth_url: OAuth2 token endpoint URL (required)
base_url: SAP AI Core API base URL (required)
client_id: OAuth2 client ID (required)
client_secret: OAuth2 client secret (for secret-based auth)
cert_str: PEM-encoded certificate string (for cert-based auth)
key_str: PEM-encoded private key string (for cert-based auth)
cert_file_path: Path to certificate file (for file-based cert auth)
key_file_path: Path to private key file (for file-based cert auth)
Raises:
ValueError: If required fields are missing or authentication mode is ambiguous.
Note:
- This function does NOT validate resource_group (resolved separately).
- Exactly one authentication method must be provided:
* client_secret, OR
* (cert_str AND key_str), OR
* (cert_file_path AND key_file_path)
"""
if not auth_url or not client_id or not base_url:
raise ValueError(
"SAP AI Core credentials not found. "
"Please provide credentials by setting appropriate environment variables "
"(e.g. AICORE_CLIENT_ID, AICORE_CLIENT_SECRET, etc.)"
)
out: Dict[str, str] = {}
for cred in CREDENTIAL_VALUES:
value = _resolve_value(cred, kwargs=kwargs, env=env, config=config, service_like=service_like) # type: ignore
if value is None:
continue
if cred.transform_fn:
value = cred.transform_fn(value)
out[cred.name] = value
if "cert_url" in out.keys():
out["auth_url"] = out.pop("cert_url")
return out
modes = [
bool(client_secret),
bool(cert_str) and bool(key_str),
bool(cert_file_path) and bool(key_file_path),
]
if sum(bool(m) for m in modes) != 1:
raise ValueError(
"SAP AI Core credentials are incomplete. "
"Invalid credentials: provide exactly one of client_secret, "
"(cert_str & key_str), or (cert_file_path & key_file_path)."
)
def _request_token(
client_id: str, auth_url: str, timeout: float, cert_pair=None, client_secret=None
) -> tuple[str, datetime]:
data = {"grant_type": "client_credentials", "client_id": client_id}
if client_secret:
data["client_secret"] = client_secret
resp: Optional[httpx.Response] = None
try:
if cert_pair:
with httpx.Client(cert=cert_pair) as raw_client:
handler = HTTPHandler(client=raw_client)
resp = handler.post(auth_url, data=data, timeout=timeout) # type: ignore[arg-type]
payload = resp.json()
else:
handler = _get_httpx_client()
resp = handler.post(auth_url, data=data, timeout=timeout) # type: ignore[arg-type]
payload = resp.json()
access_token = payload["access_token"]
expires_in = int(payload.get("expires_in", 3600))
expiry_date = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
return f"Bearer {access_token}", expiry_date
except Exception as e:
msg = resp.text if resp is not None else getattr(e, "text", str(e))
raise RuntimeError(f"Token request failed: {msg}") from e
def get_token_creator(
service_key: Optional[str] = None,
service_key: Optional[Union[str, dict]] = None,
profile: Optional[str] = None,
*,
timeout: float = 30.0,
@ -237,7 +423,7 @@ def get_token_creator(
Args:
profile: Optional AICore profile name
timeout: HTTP request timeout in seconds (default 30s)
timeout: Timeout for HTTP requests
expiry_buffer_minutes: Refresh the token this many minutes before expiry
overrides: Any explicit credential overrides (client_id, client_secret, etc.)
@ -251,6 +437,7 @@ def get_token_creator(
)
auth_url = credentials.get("auth_url")
base_url = credentials.get("base_url")
client_id = credentials.get("client_id")
client_secret = credentials.get("client_secret")
cert_str = credentials.get("cert_str")
@ -259,49 +446,30 @@ def get_token_creator(
key_file_path = credentials.get("key_file_path")
# Sanity check
if not auth_url or not client_id:
raise ValueError(
"fetch_credentials did not return valid 'auth_url' or 'client_id'"
)
modes = [
client_secret is not None,
(cert_str is not None and key_str is not None),
(cert_file_path is not None and key_file_path is not None),
]
if sum(bool(m) for m in modes) != 1:
raise ValueError(
"Invalid credentials: provide exactly one of client_secret, "
"(cert_str & key_str), or (cert_file_path & key_file_path)."
)
validate_credentials(
auth_url,
base_url,
client_id,
client_secret,
cert_str,
key_str,
cert_file_path,
key_file_path,
)
lock = Lock()
token: Optional[str] = None
token_expiry: Optional[datetime] = None
def _request_token(cert_pair=None) -> tuple[str, datetime]:
data = {"grant_type": "client_credentials", "client_id": client_id}
if client_secret:
data["client_secret"] = client_secret
client = _get_httpx_client()
# with httpx.Client(cert=cert_pair, timeout=timeout) as client:
resp = client.post(auth_url, data=data)
try:
resp.raise_for_status()
payload = resp.json()
access_token = payload["access_token"]
expires_in = int(payload.get("expires_in", 3600))
expiry_date = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
return f"Bearer {access_token}", expiry_date
except Exception as e:
msg = getattr(resp, "text", str(e))
raise RuntimeError(f"Token request failed: {msg}") from e
def _fetch_token() -> tuple[str, datetime]:
# Case 1: secret-based auth
if client_secret:
return _request_token()
return _request_token(
auth_url=auth_url, # type: ignore[arg-type]
client_id=client_id, # type: ignore[arg-type]
timeout=timeout,
client_secret=client_secret,
)
# Case 2: cert/key strings
if cert_str and key_str:
cert_str_fixed = cert_str.replace("\\n", "\n")
@ -313,9 +481,24 @@ def get_token_creator(
f.write(cert_str_fixed)
with open(key_path, "w") as f:
f.write(key_str_fixed)
return _request_token(cert_pair=(cert_path, key_path))
return _request_token(
auth_url=auth_url, # type: ignore[arg-type]
client_id=client_id, # type: ignore[arg-type]
timeout=timeout,
cert_pair=(cert_path, key_path),
)
# Case 3: file-based cert/key
return _request_token(cert_pair=(cert_file_path, key_file_path))
if cert_file_path is not None and key_file_path is not None:
return _request_token(
auth_url=auth_url, # type: ignore[arg-type]
client_id=client_id, # type: ignore[arg-type]
timeout=timeout,
cert_pair=(cert_file_path, key_file_path),
)
# Defensive guard: should never reach here due to validate_credentials()
raise ValueError(
"Invalid authentication configuration: no valid credentials found. "
)
def get_token() -> str:
nonlocal token, token_expiry

View file

@ -5,6 +5,7 @@ Translates from OpenAI's `/v1/embeddings` to IBM's `/text/embeddings` route.
from typing import Optional, List, Dict, Literal, Union
from pydantic import BaseModel, Field
from functools import cached_property
from litellm.llms.sap.chat.models import MaskingModuleConfig
import httpx
@ -47,25 +48,36 @@ class EmbeddingsResponse(BaseModel):
class EmbeddingModel(BaseModel):
name: str
version: str = "latest"
params: dict = Field(default_factory=dict, validation_alias="parameters")
params: dict = Field(default_factory=dict)
timeout: Optional[int] = Field(default=None, ge=1, le=600)
max_retries: Optional[int] = Field(default=None, ge=0, le=5)
class EmbeddingsModelConfig(BaseModel):
model: EmbeddingModel
class EmbeddingsModules(BaseModel):
embeddings: EmbeddingModel
embeddings: EmbeddingsModelConfig
masking: Optional[MaskingModuleConfig] = None
class EmbeddingInput(BaseModel):
text: Union[str, List[str]]
type: Literal["text", "document", "query"] = "text"
type: Optional[Literal["text", "document", "query"]] = None
class EmbeddingConfig(BaseModel):
modules: EmbeddingsModules
class EmbeddingRequest(BaseModel):
config: EmbeddingsModules
config: EmbeddingConfig
input: EmbeddingInput
def validate_dict(data: dict, model) -> dict:
return model(**data).model_dump()
return model(**data).model_dump(exclude_unset=True, by_alias=True)
class GenAIHubEmbeddingConfig(BaseEmbeddingConfig):
@ -152,15 +164,23 @@ class GenAIHubEmbeddingConfig(BaseEmbeddingConfig):
model_dict["name"] = model
model_dict["version"] = optional_params.get("version", "latest")
model_dict["params"] = optional_params.get("parameters", {})
timeout = optional_params.get("timeout", None)
if timeout is not None:
model_dict["timeout"] = timeout
max_retries = optional_params.get("max_retries", None)
if max_retries is not None:
model_dict["max_retries"] = max_retries
input_dict = {"text": input}
input_type = optional_params.get("type")
if input_type is not None:
input_dict["type"] = input_type
masking = optional_params.get("masking")
masking = {"masking": masking} if masking is not None else {}
body = {
"config": {
"modules": {
"embeddings": {"model": validate_dict(model_dict, EmbeddingModel)}
}
},
"input": validate_dict(input_dict, EmbeddingInput),
"config": {"modules": {"embeddings": {"model": model_dict}, **masking}},
"input": input_dict,
}
body = validate_dict(body, EmbeddingRequest)
return body
def transform_embedding_response(

View file

@ -8,7 +8,8 @@ from litellm.llms.base_llm.embedding.transformation import (
LiteLLMLoggingObj,
)
from litellm.types.llms.openai import AllEmbeddingInputValues
from litellm.types.utils import EmbeddingResponse
from litellm.types.utils import EmbeddingResponse, Usage
from litellm.utils import token_counter
from ..common_utils import TritonError
@ -103,8 +104,36 @@ class TritonEmbeddingConfig(BaseEmbeddingConfig):
model_response.model = raw_response_json.get("model_name", "None")
model_response.data = _embedding_output
model_response.usage = self._build_embedding_usage(
model=model, request_data=request_data
)
return model_response
def _build_embedding_usage(self, model: str, request_data: dict) -> Usage:
input_data = request_data.get("inputs", [])
input_text_values: List[str] = []
for item in input_data:
if isinstance(item, dict) and item.get("name") == "input_text":
data_values = item.get("data", [])
if isinstance(data_values, list):
input_text_values = [str(value) for value in data_values]
break
prompt_tokens = 0
for text in input_text_values:
if not text:
continue
try:
prompt_tokens += token_counter(model=model, text=text)
except Exception:
prompt_tokens += len(text.split())
return Usage(
prompt_tokens=prompt_tokens,
completion_tokens=0,
total_tokens=prompt_tokens,
)
def get_error_class(
self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers]
) -> BaseLLMException:

View file

@ -763,6 +763,16 @@ def _transform_request_body( # noqa: PLR0915
data["generationConfig"] = generation_config
if cached_content is not None:
data["cachedContent"] = cached_content
if service_tier := optional_params.pop("service_tier", None):
if isinstance(service_tier, str):
if service_tier.lower() == "default":
data["serviceTier"] = "standard"
else:
data["serviceTier"] = service_tier.lower()
else:
data["serviceTier"] = service_tier
# Only add labels for Vertex AI endpoints (not Google GenAI/AI Studio) and only if non-empty
if labels and custom_llm_provider != LlmProviders.GEMINI:
data["labels"] = labels

View file

@ -318,6 +318,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"parallel_tool_calls",
"web_search_options",
"include_server_side_tool_invocations",
"service_tier",
]
# Add penalty parameters only for non-preview models
@ -362,6 +363,17 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"""
return Tools(googleSearch={})
def _map_service_tier_param(self, value: str, optional_params: dict) -> None:
"""
Map OpenAI service_tier (string) to Gemini serviceTier.
'auto' maps to 'priority'.
Other values are passed lowercased.
"""
if value.lower() == "auto":
optional_params["service_tier"] = "priority"
else:
optional_params["service_tier"] = value.lower()
def _transform_computer_use_config(self, computer_use_config: dict) -> dict:
"""
Transform Computer Use configuration to Gemini API format.
@ -1121,6 +1133,8 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
optional_params = self._add_tools_to_optional_params(
optional_params, [_tools]
)
elif param == "service_tier" and isinstance(value, str):
self._map_service_tier_param(value, optional_params)
elif param == "include_server_side_tool_invocations" and value is True:
optional_params["include_server_side_tool_invocations"] = True
if litellm.vertex_ai_safety_settings is not None:
@ -2415,6 +2429,14 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"provider_specific_fields", {}
)["traffic_type"] = traffic_type
## ADD SERVICE TIER ##
if getattr(raw_response, "headers", None):
if service_tier := raw_response.headers.get("x-gemini-service-tier"):
if service_tier.lower() == "standard":
setattr(model_response, "service_tier", "default")
else:
setattr(model_response, "service_tier", service_tier.lower())
except Exception as e:
raise VertexAIError(
message="Received={}, Error converting to valid response block={}. File an issue if litellm error - https://github.com/BerriAI/litellm/issues".format(
@ -2513,6 +2535,7 @@ async def make_call(
streaming_response=response.aiter_lines(),
sync_stream=False,
logging_obj=logging_obj,
response_headers=response.headers,
)
# LOGGING
logging_obj.post_call(
@ -2555,6 +2578,7 @@ def make_sync_call(
streaming_response=response.iter_lines(),
sync_stream=True,
logging_obj=logging_obj,
response_headers=response.headers,
)
# LOGGING
@ -3011,7 +3035,11 @@ class VertexLLM(VertexBase):
class ModelResponseIterator:
def __init__(
self, streaming_response, sync_stream: bool, logging_obj: LoggingClass
self,
streaming_response,
sync_stream: bool,
logging_obj: LoggingClass,
response_headers: Optional[Dict[str, str]] = None,
):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
check_is_function_call,
@ -3022,10 +3050,120 @@ class ModelResponseIterator:
self.accumulated_json = ""
self.sent_first_chunk = False
self.logging_obj = logging_obj
self.response_headers = response_headers or {}
self.is_function_call = check_is_function_call(logging_obj)
self.cumulative_tool_call_index: int = 0
self.has_seen_tool_calls: bool = False
def _apply_stream_candidates(
self,
_candidates: List[Candidates],
model_response: Any,
) -> Tuple[List[dict], List[dict], List[dict], List[dict]]:
(
grounding_metadata,
url_context_metadata,
safety_ratings,
citation_metadata,
self.cumulative_tool_call_index,
) = VertexGeminiConfig._process_candidates(
_candidates,
model_response,
self.logging_obj.optional_params,
cumulative_tool_call_index=self.cumulative_tool_call_index,
)
# Track whether tool_calls have been seen across streaming chunks.
# Gemini sends tool_calls and finishReason in separate chunks,
# so we need to remember if earlier chunks contained tool_calls
# to correctly set finish_reason="tool_calls" per the OpenAI spec.
if not self.has_seen_tool_calls:
for choice in model_response.choices:
if (
hasattr(choice, "delta")
and choice.delta
and choice.delta.tool_calls
):
self.has_seen_tool_calls = True
break
# Handle final chunk with finishReason but no content.
# _process_candidates skips candidates without "content",
# so the finish_reason from the final chunk is lost.
if not model_response.choices and _candidates:
from litellm.types.utils import Delta, StreamingChoices
for candidate in _candidates:
finish_reason_str = candidate.get("finishReason")
if finish_reason_str is not None:
if self.has_seen_tool_calls:
mapped_finish_reason = "tool_calls"
else:
mapped_finish_reason = VertexGeminiConfig._check_finish_reason(
None, finish_reason_str
)
choice = StreamingChoices(
finish_reason=mapped_finish_reason,
index=candidate.get("index", 0),
delta=Delta(content=None, role=None),
logprobs=None,
enhancements=None,
)
model_response.choices.append(choice)
# Also handle the case where the final chunk has empty
# content (e.g. text:"") WITH finishReason. In this case
# _process_candidates DOES create a choice, but maps
# finishReason="STOP" to "stop" because the current chunk
# has no tool_calls. Override if we saw tool_calls earlier.
if self.has_seen_tool_calls:
for choice in model_response.choices:
if choice.finish_reason == "stop":
choice.finish_reason = "tool_calls"
setattr(model_response, "vertex_ai_grounding_metadata", grounding_metadata) # type: ignore
setattr(model_response, "vertex_ai_url_context_metadata", url_context_metadata) # type: ignore
setattr(model_response, "vertex_ai_safety_ratings", safety_ratings) # type: ignore
setattr(model_response, "vertex_ai_citation_metadata", citation_metadata) # type: ignore
return grounding_metadata, url_context_metadata, safety_ratings, citation_metadata
def _apply_stream_usage_metadata(
self,
processed_chunk: Any,
model_response: Any,
grounding_metadata: List[dict],
) -> Optional[Usage]:
if "usageMetadata" not in processed_chunk:
return None
usage = VertexGeminiConfig._calculate_usage(
completion_response=processed_chunk,
)
web_search_requests = VertexGeminiConfig._calculate_web_search_requests(
grounding_metadata
)
if web_search_requests is not None:
cast(
PromptTokensDetailsWrapper, usage.prompt_tokens_details
).web_search_requests = web_search_requests
traffic_type = processed_chunk.get("usageMetadata", {}).get("trafficType")
if traffic_type:
model_response._hidden_params.setdefault(
"provider_specific_fields", {}
)["traffic_type"] = traffic_type
service_tier = self.response_headers.get("x-gemini-service-tier")
if service_tier:
if service_tier.lower() == "standard":
setattr(model_response, "service_tier", "default")
else:
setattr(model_response, "service_tier", service_tier.lower())
return usage
def chunk_parser(self, chunk: dict) -> Optional["ModelResponseStream"]:
try:
verbose_logger.debug(f"RAW GEMINI CHUNK: {chunk}")
@ -3043,101 +3181,23 @@ class ModelResponseIterator:
if blocked_response is not None:
model_response = blocked_response
usage: Optional[Usage] = None
_candidates: Optional[List[Candidates]] = processed_chunk.get("candidates")
grounding_metadata: List[dict] = []
url_context_metadata: List[dict] = []
safety_ratings: List[dict] = []
citation_metadata: List[dict] = []
_candidates: Optional[List[Candidates]] = processed_chunk.get("candidates")
if _candidates:
(
grounding_metadata,
url_context_metadata,
safety_ratings,
citation_metadata,
self.cumulative_tool_call_index,
) = VertexGeminiConfig._process_candidates(
_candidates,
model_response,
self.logging_obj.optional_params,
cumulative_tool_call_index=self.cumulative_tool_call_index,
)
) = self._apply_stream_candidates(_candidates, model_response)
# Track whether tool_calls have been seen across streaming chunks.
# Gemini sends tool_calls and finishReason in separate chunks,
# so we need to remember if earlier chunks contained tool_calls
# to correctly set finish_reason="tool_calls" per the OpenAI spec.
if not self.has_seen_tool_calls:
for choice in model_response.choices:
if (
hasattr(choice, "delta")
and choice.delta
and choice.delta.tool_calls
):
self.has_seen_tool_calls = True
break
# Handle final chunk with finishReason but no content.
# _process_candidates skips candidates without "content",
# so the finish_reason from the final chunk is lost.
if not model_response.choices and _candidates:
from litellm.types.utils import Delta, StreamingChoices
for candidate in _candidates:
finish_reason_str = candidate.get("finishReason")
if finish_reason_str is not None:
if self.has_seen_tool_calls:
mapped_finish_reason = "tool_calls"
else:
mapped_finish_reason = (
VertexGeminiConfig._check_finish_reason(
None, finish_reason_str
)
)
choice = StreamingChoices(
finish_reason=mapped_finish_reason,
index=candidate.get("index", 0),
delta=Delta(content=None, role=None),
logprobs=None,
enhancements=None,
)
model_response.choices.append(choice)
# Also handle the case where the final chunk has empty
# content (e.g. text:"") WITH finishReason. In this case
# _process_candidates DOES create a choice, but maps
# finishReason="STOP" to "stop" because the current chunk
# has no tool_calls. Override if we saw tool_calls earlier.
if self.has_seen_tool_calls:
for choice in model_response.choices:
if choice.finish_reason == "stop":
choice.finish_reason = "tool_calls"
setattr(model_response, "vertex_ai_grounding_metadata", grounding_metadata) # type: ignore
setattr(model_response, "vertex_ai_url_context_metadata", url_context_metadata) # type: ignore
setattr(model_response, "vertex_ai_safety_ratings", safety_ratings) # type: ignore
setattr(model_response, "vertex_ai_citation_metadata", citation_metadata) # type: ignore
if "usageMetadata" in processed_chunk:
usage = VertexGeminiConfig._calculate_usage(
completion_response=processed_chunk,
)
web_search_requests = VertexGeminiConfig._calculate_web_search_requests(
grounding_metadata
)
if web_search_requests is not None:
cast(
PromptTokensDetailsWrapper, usage.prompt_tokens_details
).web_search_requests = web_search_requests
traffic_type = processed_chunk.get("usageMetadata", {}).get(
"trafficType"
)
if traffic_type:
model_response._hidden_params.setdefault(
"provider_specific_fields", {}
)["traffic_type"] = traffic_type
usage = self._apply_stream_usage_metadata(
processed_chunk, model_response, grounding_metadata
)
setattr(model_response, "usage", usage) # type: ignore

View file

@ -136,6 +136,11 @@ class VertexBase:
json_obj,
scopes=["https://www.googleapis.com/auth/cloud-platform"],
)
elif isinstance(credential_source, dict) and "executable" in credential_source:
creds = self._credentials_from_pluggable(
json_obj,
scopes=["https://www.googleapis.com/auth/cloud-platform"],
)
else:
creds = self._credentials_from_identity_pool(
json_obj,
@ -190,6 +195,17 @@ class VertexBase:
creds = creds.with_scopes(scopes)
return creds
def _credentials_from_pluggable(self, json_obj, scopes):
try:
from google.auth import pluggable
except ImportError:
raise ImportError(GOOGLE_IMPORT_ERROR_MESSAGE)
creds = pluggable.Credentials.from_info(json_obj)
if scopes and hasattr(creds, "requires_scopes") and creds.requires_scopes:
creds = creds.with_scopes(scopes)
return creds
def _credentials_from_identity_pool_with_aws(self, json_obj, scopes):
try:
from google.auth import aws

View file

@ -7818,26 +7818,6 @@
"cache_read_input_token_cost": 3e-08,
"cache_creation_input_token_cost": 3.75e-07
},
"bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0": {
"input_cost_per_token": 3.6e-06,
"litellm_provider": "bedrock",
"max_input_tokens": 200000,
"max_output_tokens": 8192,
"max_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 1.8e-05,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"cache_read_input_token_cost": 3.6e-07,
"cache_creation_input_token_cost": 4.5e-06
},
"bedrock/us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 4.125e-06,
"cache_read_input_token_cost": 3.3e-07,
@ -7856,7 +7836,29 @@
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
"supports_vision": true,
"supports_native_structured_output": true
},
"bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 4.125e-06,
"cache_read_input_token_cost": 3.3e-07,
"input_cost_per_token": 3.3e-06,
"litellm_provider": "bedrock",
"max_input_tokens": 200000,
"max_output_tokens": 8192,
"max_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 1.65e-05,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_native_structured_output": true
},
"bedrock/us-gov-east-1/meta.llama3-70b-instruct-v1:0": {
"input_cost_per_token": 2.65e-06,
@ -7989,26 +7991,6 @@
"cache_read_input_token_cost": 3e-08,
"cache_creation_input_token_cost": 3.75e-07
},
"bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0": {
"input_cost_per_token": 3.6e-06,
"litellm_provider": "bedrock",
"max_input_tokens": 200000,
"max_output_tokens": 8192,
"max_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 1.8e-05,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"cache_read_input_token_cost": 3.6e-07,
"cache_creation_input_token_cost": 4.5e-06
},
"bedrock/us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 4.125e-06,
"cache_read_input_token_cost": 3.3e-07,
@ -8027,7 +8009,29 @@
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
"supports_vision": true,
"supports_native_structured_output": true
},
"bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 4.125e-06,
"cache_read_input_token_cost": 3.3e-07,
"input_cost_per_token": 3.3e-06,
"litellm_provider": "bedrock",
"max_input_tokens": 200000,
"max_output_tokens": 8192,
"max_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 1.65e-05,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_native_structured_output": true
},
"bedrock/us-gov-west-1/meta.llama3-70b-instruct-v1:0": {
"input_cost_per_token": 2.65e-06,
@ -13735,7 +13739,8 @@
"supports_tool_choice": true,
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": true
"supports_web_search": true,
"supports_service_tier": true
},
"gemini-2.5-flash-image": {
"cache_read_input_token_cost": 3e-08,
@ -13784,7 +13789,8 @@
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": false,
"tpm": 8000000
"tpm": 8000000,
"supports_service_tier": true
},
"gemini-3-pro-image-preview": {
"input_cost_per_image": 0.0011,
@ -13818,7 +13824,8 @@
"supports_response_schema": true,
"supports_system_messages": true,
"supports_vision": true,
"supports_web_search": true
"supports_web_search": true,
"supports_service_tier": true
},
"gemini-3.1-flash-image-preview": {
"input_cost_per_image": 0.00056,
@ -13901,7 +13908,8 @@
"supports_video_input": true,
"supports_vision": true,
"supports_web_search": true,
"supports_native_streaming": true
"supports_native_streaming": true,
"supports_service_tier": true
},
"deep-research-pro-preview-12-2025": {
"input_cost_per_image": 0.0011,
@ -13980,7 +13988,8 @@
"supports_tool_choice": true,
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": true
"supports_web_search": true,
"supports_service_tier": true
},
"gemini-2.5-flash-lite-preview-09-2025": {
"cache_read_input_token_cost": 1e-08,
@ -14251,7 +14260,8 @@
"supports_tool_choice": true,
"supports_video_input": true,
"supports_vision": true,
"supports_web_search": true
"supports_web_search": true,
"supports_service_tier": true
},
"gemini-3-pro-preview": {
"deprecation_date": "2026-03-26",
@ -15033,7 +15043,8 @@
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": true,
"tpm": 8000000
"tpm": 8000000,
"supports_service_tier": true
},
"gemini/gemini-2.5-flash-image": {
"cache_read_input_token_cost": 3e-08,
@ -15083,7 +15094,8 @@
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": true,
"tpm": 8000000
"tpm": 8000000,
"supports_service_tier": true
},
"gemini/gemini-3-pro-image-preview": {
"input_cost_per_image": 0.0011,
@ -15119,7 +15131,8 @@
"supports_response_schema": true,
"supports_system_messages": true,
"supports_vision": true,
"supports_web_search": true
"supports_web_search": true,
"supports_service_tier": true
},
"gemini/gemini-3.1-flash-image-preview": {
"input_cost_per_token": 2.5e-07,
@ -15238,7 +15251,8 @@
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": true,
"tpm": 250000
"tpm": 250000,
"supports_service_tier": true
},
"gemini/gemini-2.5-flash-lite-preview-09-2025": {
"cache_read_input_token_cost": 1e-08,
@ -15678,7 +15692,8 @@
"supports_vision": true,
"supports_web_search": true,
"supports_native_streaming": true,
"tpm": 250000
"tpm": 250000,
"supports_service_tier": true
},
"gemini/gemini-3-flash-preview": {
"cache_read_input_token_cost": 5e-08,
@ -16919,6 +16934,72 @@
"mode": "chat",
"output_cost_per_token": 1.2e-06
},
"baseten/MiniMaxAI/MiniMax-M2.5": {
"input_cost_per_token": 3e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 1.2e-06
},
"baseten/nvidia/Nemotron-120B-A12B": {
"input_cost_per_token": 3e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 7.5e-07
},
"baseten/zai-org/GLM-5": {
"input_cost_per_token": 9.5e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 3.15e-06
},
"baseten/zai-org/GLM-4.7": {
"input_cost_per_token": 6e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 2.2e-06
},
"baseten/zai-org/GLM-4.6": {
"input_cost_per_token": 6e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 2.2e-06
},
"baseten/moonshotai/Kimi-K2.5": {
"input_cost_per_token": 6e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 3e-06
},
"baseten/moonshotai/Kimi-K2-Thinking": {
"input_cost_per_token": 6e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 2.5e-06
},
"baseten/moonshotai/Kimi-K2-Instruct-0905": {
"input_cost_per_token": 6e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 2.5e-06
},
"baseten/openai/gpt-oss-120b": {
"input_cost_per_token": 1e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 5e-07
},
"baseten/deepseek-ai/DeepSeek-V3.1": {
"input_cost_per_token": 5e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 1.5e-06
},
"baseten/deepseek-ai/DeepSeek-V3-0324": {
"input_cost_per_token": 7.7e-07,
"litellm_provider": "baseten",
"mode": "chat",
"output_cost_per_token": 7.7e-07
},
"gmi/Qwen/Qwen3-VL-235B-A22B-Instruct-FP8": {
"input_cost_per_token": 3e-07,
"litellm_provider": "gmi",
@ -38126,4 +38207,4 @@
"supports_native_structured_output": true,
"supports_pdf_input": true
}
}
}

View file

@ -21,7 +21,9 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
)
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.proxy.utils import PrismaClient
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.mcp import MCPCredentials
@ -576,6 +578,7 @@ async def store_user_oauth_credential(
refresh_token: Optional[str] = None,
expires_in: Optional[int] = None,
scopes: Optional[List[str]] = None,
skip_byok_guard: bool = False,
) -> None:
"""Persist an OAuth2 access token for a user+server pair.
@ -604,21 +607,26 @@ async def store_user_oauth_credential(
# Guard against silently overwriting a BYOK credential with an OAuth token.
# BYOK credentials lack a "type" field (or use a non-"oauth2" type).
existing = await prisma_client.db.litellm_mcpusercredentials.find_unique(
where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}}
)
if existing is not None:
_byok_error = ValueError(
f"A non-OAuth2 credential already exists for user {user_id} "
f"and server {server_id}. Refusing to overwrite."
# Skip the guard when the caller knows the row is already an OAuth2 credential
# (e.g. during token refresh), saving an extra DB round-trip.
if not skip_byok_guard:
existing = await prisma_client.db.litellm_mcpusercredentials.find_unique(
where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}}
)
try:
raw = json.loads(base64.urlsafe_b64decode(existing.credential_b64).decode())
except Exception:
# Credential is not base64+JSON — it's a plain-text BYOK key.
raise _byok_error
if raw.get("type") != "oauth2":
raise _byok_error
if existing is not None:
_byok_error = ValueError(
f"A non-OAuth2 credential already exists for user {user_id} "
f"and server {server_id}. Refusing to overwrite."
)
try:
raw = json.loads(
base64.urlsafe_b64decode(existing.credential_b64).decode()
)
except Exception:
# Credential is not base64+JSON — it's a plain-text BYOK key.
raise _byok_error
if raw.get("type") != "oauth2":
raise _byok_error
encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
await prisma_client.db.litellm_mcpusercredentials.upsert(
@ -697,6 +705,115 @@ async def list_user_oauth_credentials(
return results
async def refresh_user_oauth_token(
prisma_client: PrismaClient,
user_id: str,
server: Any,
cred: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Attempt to refresh a per-user OAuth2 token using its stored refresh_token.
POSTs to ``server.token_url`` with ``grant_type=refresh_token``.
On success: persists the new credential via ``store_user_oauth_credential``
and returns the updated payload dict.
On failure (network error, invalid_grant, missing refresh_token, …): logs a
warning and returns ``None`` — the caller is responsible for clearing the
stale credential and triggering re-authentication.
"""
refresh_token: Optional[str] = cred.get("refresh_token")
token_url: Optional[str] = getattr(server, "token_url", None)
server_id: str = getattr(server, "server_id", "")
client_id: Optional[str] = getattr(server, "client_id", None)
client_secret: Optional[str] = getattr(server, "client_secret", None)
if not refresh_token:
verbose_proxy_logger.debug(
"refresh_user_oauth_token: no refresh_token stored for user=%s server=%s",
user_id,
server_id,
)
return None
if not token_url:
verbose_proxy_logger.debug(
"refresh_user_oauth_token: server=%s has no token_url configured",
server_id,
)
return None
token_data: Dict[str, str] = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
}
if client_id:
token_data["client_id"] = client_id
if client_secret:
token_data["client_secret"] = client_secret
try:
async_client = get_async_httpx_client(
llm_provider=httpxSpecialProvider.Oauth2Check
)
response = await async_client.post(
token_url,
headers={"Accept": "application/json"},
data=token_data,
)
response.raise_for_status()
body: Dict[str, Any] = response.json()
except Exception as exc:
verbose_proxy_logger.warning(
"refresh_user_oauth_token: refresh request failed for user=%s server=%s: %s",
user_id,
server_id,
exc,
)
return None
access_token: Optional[str] = body.get("access_token")
if not access_token:
verbose_proxy_logger.warning(
"refresh_user_oauth_token: token response missing access_token for "
"user=%s server=%s",
user_id,
server_id,
)
return None
expires_in: Optional[int] = None
raw_expires = body.get("expires_in")
try:
expires_in = int(raw_expires) if raw_expires is not None else None
except (TypeError, ValueError):
pass
# Rotate refresh token when the provider returns a new one
new_refresh_token: Optional[str] = body.get("refresh_token") or refresh_token
raw_scope = body.get("scope")
scopes: Optional[List[str]] = (
raw_scope.split() if isinstance(raw_scope, str) and raw_scope else None
) or cred.get("scopes")
await store_user_oauth_credential(
prisma_client=prisma_client,
user_id=user_id,
server_id=server_id,
access_token=access_token,
refresh_token=new_refresh_token,
expires_in=expires_in,
scopes=scopes,
skip_byok_guard=True, # Row is already OAuth2; skip the extra find_unique check
)
verbose_proxy_logger.info(
"refresh_user_oauth_token: refreshed token for user=%s server=%s",
user_id,
server_id,
)
return await get_user_oauth_credential(prisma_client, user_id, server_id)
async def approve_mcp_server(
prisma_client: PrismaClient,
server_id: str,

View file

@ -1,10 +1,11 @@
import json
from typing import Optional
from typing import Any, Dict, Optional
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from litellm._logging import verbose_logger
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
@ -147,6 +148,160 @@ def _resolve_oauth2_server_for_root_endpoints(
return None
def _validate_token_response(
token_response: Dict[str, Any],
validation_rules: Dict[str, Any],
server_id: str,
) -> None:
"""Raise HTTPException 403 if any validation rule doesn't match the token response.
Supports dot-notation for nested fields (e.g. ``"team.enterprise_id"`` checks
``token_response["team"]["enterprise_id"]``). Top-level keys are tried first,
then dot-split traversal. All comparisons are string-coerced so that numeric
values in the response (e.g. ``"org_id": 12345``) match string rules
(``"org_id": "12345"``).
"""
for key, expected in validation_rules.items():
actual: Any = token_response.get(key)
# Try dot-notation traversal when top-level lookup returns None
if actual is None and "." in key:
obj: Any = token_response
for part in key.split("."):
if isinstance(obj, dict):
obj = obj.get(part)
else:
obj = None
break
actual = obj
# Treat absent fields as a distinct failure from a mismatched value
if actual is None:
raise HTTPException(
status_code=403,
detail={
"error": "token_validation_failed",
"server_id": server_id,
"field": key,
"message": (
f"OAuth token rejected: required field '{key}' is absent"
),
},
)
if str(actual) != str(expected):
raise HTTPException(
status_code=403,
detail={
"error": "token_validation_failed",
"server_id": server_id,
"field": key,
"message": (
f"OAuth token rejected: '{key}' = '{actual}', "
f"expected '{expected}'"
),
},
)
async def _extract_user_id_from_request(request: Request) -> Optional[str]:
"""Best-effort extraction of LiteLLM user_id from the request's Authorization header.
Called at the OAuth token endpoint so that per-user tokens can be stored
server-side. Uses a read-only cache lookup to avoid re-running the full
auth pipeline (which has side effects such as rate-limit increments and
spend logging). Returns ``None`` if no cached credential is found.
"""
auth_header = request.headers.get("Authorization") or request.headers.get(
"authorization"
)
if not auth_header:
return None
lower = auth_header.lower()
if not lower.startswith("bearer "):
return None
token = auth_header[7:].strip()
try:
from litellm.proxy._types import hash_token # noqa: PLC0415
from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415
cached = await user_api_key_cache.async_get_cache(hash_token(token))
return getattr(cached, "user_id", None)
except Exception:
return None
async def _store_per_user_token_server_side(
server: MCPServer,
user_id: str,
token_response: Dict[str, Any],
) -> None:
"""Persist the OAuth token server-side and warm the Redis cache.
Called from the token endpoint after a successful code exchange or refresh.
Errors are logged but NOT re-raised — the token is always returned to the
client even when server-side storage fails.
"""
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( # noqa: PLC0415
_compute_per_user_token_ttl,
mcp_per_user_token_cache,
)
from litellm.proxy.utils import get_prisma_client_or_throw # noqa: PLC0415
access_token: Optional[str] = token_response.get("access_token")
if not access_token:
return
raw_expires = token_response.get("expires_in")
try:
expires_in: Optional[int] = int(raw_expires) if raw_expires is not None else None
except (TypeError, ValueError):
expires_in = None
refresh_token: Optional[str] = token_response.get("refresh_token") or None
raw_scope = token_response.get("scope")
scopes: Optional[list] = (
raw_scope.split() if isinstance(raw_scope, str) and raw_scope else None
)
try:
prisma_client = get_prisma_client_or_throw(
"Database not connected. Cannot store per-user OAuth token."
)
from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415
store_user_oauth_credential,
)
await store_user_oauth_credential(
prisma_client=prisma_client,
user_id=user_id,
server_id=server.server_id,
access_token=access_token,
refresh_token=refresh_token,
expires_in=expires_in,
scopes=scopes,
)
verbose_logger.info(
"_store_per_user_token_server_side: stored token for user=%s server=%s",
user_id,
server.server_id,
)
except Exception as exc:
verbose_logger.warning(
"_store_per_user_token_server_side: DB storage failed for user=%s server=%s: %s",
user_id,
server.server_id,
exc,
)
return # Don't warm Redis if DB write failed
# Warm the Redis cache so the first subsequent MCP call is a cache hit
ttl = _compute_per_user_token_ttl(server, expires_in)
await mcp_per_user_token_cache.set(
user_id=user_id,
server_id=server.server_id,
access_token=access_token,
ttl=ttl,
)
async def authorize_with_server(
request: Request,
mcp_server: MCPServer,
@ -266,6 +421,44 @@ async def exchange_token_with_server(
token_response = response.json()
access_token = token_response["access_token"]
# Validate token response against server-configured rules before any storage.
# This rejects tokens from wrong Slack workspaces, Atlassian orgs, etc.
if mcp_server.token_validation and isinstance(mcp_server.token_validation, dict):
_validate_token_response(
token_response=token_response,
validation_rules=mcp_server.token_validation,
server_id=mcp_server.server_id,
)
# Store server-side when the server is configured for per-user OAuth and
# the calling client has provided a valid LiteLLM identity.
# Errors are non-fatal: the token is still returned to the client.
if mcp_server.needs_user_oauth_token:
user_id = await _extract_user_id_from_request(request)
if user_id:
try:
await _store_per_user_token_server_side(
server=mcp_server,
user_id=user_id,
token_response=token_response,
)
except Exception as exc:
verbose_logger.warning(
"exchange_token_with_server: server-side storage failed "
"for user=%s server=%s: %s",
user_id,
mcp_server.server_id,
exc,
)
else:
verbose_logger.debug(
"exchange_token_with_server: no LiteLLM user_id found in request; "
"per-user token for server=%s will not be stored server-side. "
"The client should call POST /mcp/server/{id}/oauth-user-credential "
"to store it manually.",
mcp_server.server_id,
)
result = {
"access_token": access_token,
"token_type": token_response.get("token_type", "Bearer"),

View file

@ -2455,6 +2455,37 @@ class MCPServerManager:
)
tasks.append(during_hook_task)
# For per-user OAuth servers: if the client didn't supply a token in
# oauth2_headers, look up the stored token from Redis / DB. This is the
# call_tool equivalent of _get_user_oauth_extra_headers_from_db used in
# list_tools.
if (
mcp_server.needs_user_oauth_token
and not oauth2_headers
and user_api_key_auth is not None
):
user_id = getattr(user_api_key_auth, "user_id", None)
if user_id:
try:
from litellm.proxy._experimental.mcp_server.server import ( # noqa: PLC0415
_get_user_oauth_extra_headers_from_db,
)
stored_headers = await _get_user_oauth_extra_headers_from_db(
server=mcp_server,
user_api_key_auth=user_api_key_auth,
)
if stored_headers:
oauth2_headers = stored_headers
except Exception as _lookup_exc:
verbose_logger.debug(
"call_tool: per-user token lookup failed for "
"user=%s server=%s: %s",
user_id,
mcp_server.server_id,
_lookup_exc,
)
# For OpenAPI servers, call the tool handler directly instead of via MCP client
if mcp_server.spec_path:
verbose_logger.debug(

View file

@ -17,8 +17,15 @@ from litellm.constants import (
MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE,
MCP_OAUTH2_TOKEN_CACHE_MIN_TTL,
MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS,
MCP_PER_USER_TOKEN_DEFAULT_TTL,
MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS,
MCP_PER_USER_TOKEN_REDIS_KEY_PREFIX,
)
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
)
from litellm.types.llms.custom_http import httpxSpecialProvider
if TYPE_CHECKING:
@ -152,6 +159,107 @@ class MCPOAuth2TokenCache(InMemoryCache):
mcp_oauth2_token_cache = MCPOAuth2TokenCache()
def _compute_per_user_token_ttl(server: "MCPServer", expires_in: Optional[int]) -> int:
"""Compute Redis TTL for a per-user token.
Uses server.token_storage_ttl_seconds when configured; otherwise derives
TTL from expires_in minus the expiry buffer; falls back to the default TTL.
"""
if server.token_storage_ttl_seconds is not None:
return max(server.token_storage_ttl_seconds, 1)
if expires_in is not None:
return max(
expires_in - MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS,
1,
)
return MCP_PER_USER_TOKEN_DEFAULT_TTL
class MCPPerUserTokenCache:
"""Redis-backed cache for per-user OAuth2 access tokens.
Uses LiteLLM's existing ``user_api_key_cache`` (DualCache with optional
Redis backend). Tokens are NaCl-encrypted with ``encrypt_value_helper``
before storage so they are safe at rest in Redis.
Redis key format: ``mcp:per_user_token:{user_id}:{server_id}``
Redis value: ``encrypt_value_helper(access_token)`` — URL-safe base64
"""
def _cache_key(self, user_id: str, server_id: str) -> str:
return f"{MCP_PER_USER_TOKEN_REDIS_KEY_PREFIX}:{user_id}:{server_id}"
async def get(self, user_id: str, server_id: str) -> Optional[str]:
"""Return the plaintext access_token, or None on miss/error."""
try:
from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415
key = self._cache_key(user_id, server_id)
encrypted = await user_api_key_cache.async_get_cache(key)
if encrypted is None:
return None
plaintext = decrypt_value_helper(
encrypted,
key="mcp_per_user_token",
exception_type="debug",
)
return plaintext or None
except Exception as exc:
verbose_logger.debug(
"MCPPerUserTokenCache.get failed for user=%s server=%s: %s",
user_id,
server_id,
exc,
)
return None
async def set(
self,
user_id: str,
server_id: str,
access_token: str,
ttl: int,
) -> None:
"""Store NaCl-encrypted access_token in Redis with the given TTL."""
try:
from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415
key = self._cache_key(user_id, server_id)
encrypted = encrypt_value_helper(access_token)
await user_api_key_cache.async_set_cache(key, encrypted, ttl=ttl)
verbose_logger.debug(
"MCPPerUserTokenCache.set: cached token for user=%s server=%s ttl=%ds",
user_id,
server_id,
ttl,
)
except Exception as exc:
verbose_logger.debug(
"MCPPerUserTokenCache.set failed for user=%s server=%s: %s",
user_id,
server_id,
exc,
)
async def delete(self, user_id: str, server_id: str) -> None:
"""Invalidate the cached token (removes from both in-memory and Redis layers)."""
try:
from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415
key = self._cache_key(user_id, server_id)
await user_api_key_cache.async_delete_cache(key)
except Exception as exc:
verbose_logger.debug(
"MCPPerUserTokenCache.delete failed for user=%s server=%s: %s",
user_id,
server_id,
exc,
)
mcp_per_user_token_cache = MCPPerUserTokenCache()
async def resolve_mcp_auth(
server: "MCPServer",
mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None,

View file

@ -896,11 +896,17 @@ if MCP_AVAILABLE:
user_api_key_auth: Optional[UserAPIKeyAuth],
prefetched_creds: Optional[Dict[str, Dict[str, Any]]] = None,
) -> Optional[Dict[str, str]]:
"""Look up stored OAuth2 token for (user, server) from DB and return as extra_headers dict.
"""Look up stored OAuth2 token for (user, server) and return as extra_headers dict.
Lookup order:
1. Redis cache (fast path, NaCl-decrypted) — skipped when prefetched_creds supplied
2. prefetched_creds dict (pre-fetched batch DB query) or fresh DB query
3. Auto-refresh when the stored token is expired and a refresh_token exists
Args:
prefetched_creds: Optional dict keyed by server_id with credential payloads.
When provided, avoids a per-server DB round-trip.
When provided, the Redis and individual DB lookups are
skipped in favour of the pre-fetched batch result.
"""
if server.auth_type != MCPAuth.oauth2:
return None
@ -914,8 +920,27 @@ if MCP_AVAILABLE:
from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415
get_user_oauth_credential,
is_oauth_credential_expired,
refresh_user_oauth_token,
)
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( # noqa: PLC0415
_compute_per_user_token_ttl,
mcp_per_user_token_cache,
)
# ── Fast path: Redis cache ────────────────────────────────────────
# Only used when prefetched_creds is not supplied (individual lookup).
if prefetched_creds is None:
cached_token = await mcp_per_user_token_cache.get(user_id, server_id)
if cached_token is not None:
verbose_logger.debug(
"_get_user_oauth_extra_headers_from_db: Redis hit for "
"user=%s server=%s",
user_id,
server_id,
)
return {"Authorization": f"Bearer {cached_token}"}
# ── Slow path: DB lookup ──────────────────────────────────────────
if prefetched_creds is not None:
cred = prefetched_creds.get(server_id)
else:
@ -929,18 +954,83 @@ if MCP_AVAILABLE:
cred = await get_user_oauth_credential(
prisma_client, user_id, server_id
)
if cred and cred.get("access_token"):
if is_oauth_credential_expired(cred):
verbose_logger.debug(
f"_get_user_oauth_extra_headers_from_db: token expired for "
f"user={user_id} server={server_id}"
)
if not cred or not cred.get("access_token"):
return None
if is_oauth_credential_expired(cred):
verbose_logger.debug(
"_get_user_oauth_extra_headers_from_db: token expired for "
"user=%s server=%s — attempting refresh",
user_id,
server_id,
)
# Attempt token refresh; requires a DB client (not available from prefetch)
if cred.get("refresh_token"):
try:
from litellm.proxy.utils import ( # noqa: PLC0415
get_prisma_client_or_throw,
)
prisma_client = get_prisma_client_or_throw(
"Database not connected. Cannot refresh OAuth token."
)
cred = await refresh_user_oauth_token(
prisma_client=prisma_client,
user_id=user_id,
server=server,
cred=cred,
)
except Exception as refresh_exc:
verbose_logger.warning(
"_get_user_oauth_extra_headers_from_db: refresh failed "
"for user=%s server=%s: %s",
user_id,
server_id,
refresh_exc,
)
cred = None
if not cred or not cred.get("access_token"):
# Clear stale Redis/cache entry so we don't serve it again.
# Do this for both the individual and prefetch paths so the
# next request doesn't get a stale cache hit.
await mcp_per_user_token_cache.delete(user_id, server_id)
return None
return {"Authorization": f"Bearer {cred['access_token']}"}
access_token: str = cred["access_token"]
# Warm (or re-warm) the Redis cache from the DB result.
# Always write regardless of whether expires_at is present — tokens
# without an expiry are still valid and should be cached using the
# server/default TTL so subsequent requests are fast.
if prefetched_creds is None:
raw_expires = None
expires_at = cred.get("expires_at")
if expires_at:
from datetime import datetime, timezone # noqa: PLC0415
try:
exp_dt = datetime.fromisoformat(expires_at)
if exp_dt.tzinfo is None:
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
remaining = int(
(exp_dt - datetime.now(timezone.utc)).total_seconds()
)
raw_expires = max(remaining, 0) if remaining > 0 else None
except (ValueError, TypeError):
pass
ttl = _compute_per_user_token_ttl(server, raw_expires)
await mcp_per_user_token_cache.set(user_id, server_id, access_token, ttl)
return {"Authorization": f"Bearer {access_token}"}
except Exception as e:
verbose_logger.warning(
f"_get_user_oauth_extra_headers_from_db: failed to retrieve credential for "
f"user={user_id} server={server_id}: {e}"
"_get_user_oauth_extra_headers_from_db: failed to retrieve credential for "
"user=%s server=%s: %s",
user_id,
server_id,
e,
)
return None
@ -2504,6 +2594,14 @@ if MCP_AVAILABLE:
server_name, client_ip=_client_ip
)
if server and server.auth_type == MCPAuth.oauth2 and not oauth2_headers:
# For servers that store per-user tokens server-side, skip the
# pre-emptive 401 — the call_tool / list_tools dispatch will look
# up the stored token from Redis / DB and only fail at the MCP
# protocol level if none is found, giving the client a proper
# tool-execution error rather than an HTTP 401.
if server.needs_user_oauth_token:
continue
request = StarletteRequest(scope)
base_url = get_request_base_url(request)

File diff suppressed because one or more lines are too long

View file

@ -1,28 +1,28 @@
1:"$Sreact.fragment"
2:I[347257,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"ClientPageRoot"]
3:I[952683,["/litellm-asset-prefix/_next/static/chunks/9e09de50158b3159.js","/litellm-asset-prefix/_next/static/chunks/7e5fe5584502da06.js","/litellm-asset-prefix/_next/static/chunks/9a17d35f872a6c38.js","/litellm-asset-prefix/_next/static/chunks/142704439974f6b3.js","/litellm-asset-prefix/_next/static/chunks/30539b80ac15aad2.js","/litellm-asset-prefix/_next/static/chunks/99d715502d5069f4.js","/litellm-asset-prefix/_next/static/chunks/7834a5efb7b5f959.js","/litellm-asset-prefix/_next/static/chunks/1fe0596a309ad6cf.js","/litellm-asset-prefix/_next/static/chunks/7b788dd93ad868b3.js","/litellm-asset-prefix/_next/static/chunks/c847ecdf8c790b0b.js","/litellm-asset-prefix/_next/static/chunks/403c4d96324c23a6.js","/litellm-asset-prefix/_next/static/chunks/a7113797b37526f0.js","/litellm-asset-prefix/_next/static/chunks/ee5f9a39a526e423.js","/litellm-asset-prefix/_next/static/chunks/db0ac43a898048e2.js","/litellm-asset-prefix/_next/static/chunks/3b30ab8eaa03bc21.js","/litellm-asset-prefix/_next/static/chunks/c53c9c7afec96700.js","/litellm-asset-prefix/_next/static/chunks/ee9e514b2c2694f7.js","/litellm-asset-prefix/_next/static/chunks/bb71734679762761.js","/litellm-asset-prefix/_next/static/chunks/1fd9dbe73d002173.js","/litellm-asset-prefix/_next/static/chunks/ed901fab61dc16dc.js","/litellm-asset-prefix/_next/static/chunks/ed90bf177ad61e18.js","/litellm-asset-prefix/_next/static/chunks/e1f23fd814ac3500.js","/litellm-asset-prefix/_next/static/chunks/2971c4658f1bcd7d.js","/litellm-asset-prefix/_next/static/chunks/88c74f8b4b20d25a.js","/litellm-asset-prefix/_next/static/chunks/134f728fa7099e3e.js","/litellm-asset-prefix/_next/static/chunks/ed4f62880278d987.js","/litellm-asset-prefix/_next/static/chunks/60d899dd52430ef8.js","/litellm-asset-prefix/_next/static/chunks/a5774cdb9f28daa1.js","/litellm-asset-prefix/_next/static/chunks/f04f887c803d9e60.js","/litellm-asset-prefix/_next/static/chunks/7e417dd24c8becd0.js","/litellm-asset-prefix/_next/static/chunks/a7dc5e0c9d37afe3.js","/litellm-asset-prefix/_next/static/chunks/86819b3a4f820602.js","/litellm-asset-prefix/_next/static/chunks/b6cdb9a433f054f3.js","/litellm-asset-prefix/_next/static/chunks/bf01d87225e5be70.js","/litellm-asset-prefix/_next/static/chunks/b3b05b76472ce110.js","/litellm-asset-prefix/_next/static/chunks/fcdf7322b0aa3e2e.js","/litellm-asset-prefix/_next/static/chunks/47a838c67cdd745e.js","/litellm-asset-prefix/_next/static/chunks/4fc2d71e511309ab.js","/litellm-asset-prefix/_next/static/chunks/0a65da2cd24e2ab6.js","/litellm-asset-prefix/_next/static/chunks/4980372eaa37b78b.js","/litellm-asset-prefix/_next/static/chunks/d3ac82723ec9e30d.js","/litellm-asset-prefix/_next/static/chunks/496b84010c33cf69.js","/litellm-asset-prefix/_next/static/chunks/360f35fe2e0a4945.js","/litellm-asset-prefix/_next/static/chunks/0d219667baa010f5.js","/litellm-asset-prefix/_next/static/chunks/7a2dc852f68481ea.js","/litellm-asset-prefix/_next/static/chunks/99cf9cf99df5ccfc.js","/litellm-asset-prefix/_next/static/chunks/d0d828f9a0668699.js","/litellm-asset-prefix/_next/static/chunks/f9c24d6e7ec43046.js","/litellm-asset-prefix/_next/static/chunks/7c797521435cb59c.js","/litellm-asset-prefix/_next/static/chunks/908828a91f602d8b.js","/litellm-asset-prefix/_next/static/chunks/53ac95bfa383e1b4.js","/litellm-asset-prefix/_next/static/chunks/169b34fe8aeee0c7.js","/litellm-asset-prefix/_next/static/chunks/2e768c2b1dfc8cd5.js"],"default"]
3:I[952683,["/litellm-asset-prefix/_next/static/chunks/9e09de50158b3159.js","/litellm-asset-prefix/_next/static/chunks/7e5fe5584502da06.js","/litellm-asset-prefix/_next/static/chunks/142704439974f6b3.js","/litellm-asset-prefix/_next/static/chunks/df37a0019220a941.js","/litellm-asset-prefix/_next/static/chunks/30539b80ac15aad2.js","/litellm-asset-prefix/_next/static/chunks/99d715502d5069f4.js","/litellm-asset-prefix/_next/static/chunks/7834a5efb7b5f959.js","/litellm-asset-prefix/_next/static/chunks/1fe0596a309ad6cf.js","/litellm-asset-prefix/_next/static/chunks/7b788dd93ad868b3.js","/litellm-asset-prefix/_next/static/chunks/c847ecdf8c790b0b.js","/litellm-asset-prefix/_next/static/chunks/403c4d96324c23a6.js","/litellm-asset-prefix/_next/static/chunks/a7113797b37526f0.js","/litellm-asset-prefix/_next/static/chunks/ee5f9a39a526e423.js","/litellm-asset-prefix/_next/static/chunks/db0ac43a898048e2.js","/litellm-asset-prefix/_next/static/chunks/3b30ab8eaa03bc21.js","/litellm-asset-prefix/_next/static/chunks/c53c9c7afec96700.js","/litellm-asset-prefix/_next/static/chunks/60b0cadba57cd7f7.js","/litellm-asset-prefix/_next/static/chunks/bb71734679762761.js","/litellm-asset-prefix/_next/static/chunks/1fd9dbe73d002173.js","/litellm-asset-prefix/_next/static/chunks/ed901fab61dc16dc.js","/litellm-asset-prefix/_next/static/chunks/ed90bf177ad61e18.js","/litellm-asset-prefix/_next/static/chunks/e1f23fd814ac3500.js","/litellm-asset-prefix/_next/static/chunks/2971c4658f1bcd7d.js","/litellm-asset-prefix/_next/static/chunks/88c74f8b4b20d25a.js","/litellm-asset-prefix/_next/static/chunks/134f728fa7099e3e.js","/litellm-asset-prefix/_next/static/chunks/ed4f62880278d987.js","/litellm-asset-prefix/_next/static/chunks/60d899dd52430ef8.js","/litellm-asset-prefix/_next/static/chunks/eaa9f9b9bb3e054b.js","/litellm-asset-prefix/_next/static/chunks/f04f887c803d9e60.js","/litellm-asset-prefix/_next/static/chunks/7e417dd24c8becd0.js","/litellm-asset-prefix/_next/static/chunks/99cf9cf99df5ccfc.js","/litellm-asset-prefix/_next/static/chunks/a02f90f97248b9aa.js","/litellm-asset-prefix/_next/static/chunks/b6cdb9a433f054f3.js","/litellm-asset-prefix/_next/static/chunks/a5774cdb9f28daa1.js","/litellm-asset-prefix/_next/static/chunks/1501e804b4d0f510.js","/litellm-asset-prefix/_next/static/chunks/86819b3a4f820602.js","/litellm-asset-prefix/_next/static/chunks/47a838c67cdd745e.js","/litellm-asset-prefix/_next/static/chunks/be00dd25857a2fb3.js","/litellm-asset-prefix/_next/static/chunks/0a65da2cd24e2ab6.js","/litellm-asset-prefix/_next/static/chunks/4980372eaa37b78b.js","/litellm-asset-prefix/_next/static/chunks/d3ac82723ec9e30d.js","/litellm-asset-prefix/_next/static/chunks/7dd16a650b98a4c5.js","/litellm-asset-prefix/_next/static/chunks/bd5cc6a7a48eedc7.js","/litellm-asset-prefix/_next/static/chunks/169b34fe8aeee0c7.js","/litellm-asset-prefix/_next/static/chunks/7a2dc852f68481ea.js","/litellm-asset-prefix/_next/static/chunks/908828a91f602d8b.js","/litellm-asset-prefix/_next/static/chunks/d0d828f9a0668699.js","/litellm-asset-prefix/_next/static/chunks/f9c24d6e7ec43046.js","/litellm-asset-prefix/_next/static/chunks/7c797521435cb59c.js","/litellm-asset-prefix/_next/static/chunks/496b84010c33cf69.js","/litellm-asset-prefix/_next/static/chunks/fcdf7322b0aa3e2e.js","/litellm-asset-prefix/_next/static/chunks/53ac95bfa383e1b4.js","/litellm-asset-prefix/_next/static/chunks/0c6c65a34bcde140.js"],"default"]
18:I[897367,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"OutletBoundary"]
19:"$Sreact.suspense"
:HL["/litellm-asset-prefix/_next/static/chunks/3f3fa56b5786d58c.css","style"]
0:{"buildId":"-9iBbUN_ohnDf0d-Ux3Ju","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/3f3fa56b5786d58c.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/9a17d35f872a6c38.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/142704439974f6b3.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/30539b80ac15aad2.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/99d715502d5069f4.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/7834a5efb7b5f959.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/1fe0596a309ad6cf.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/7b788dd93ad868b3.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/c847ecdf8c790b0b.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/403c4d96324c23a6.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/a7113797b37526f0.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/ee5f9a39a526e423.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/db0ac43a898048e2.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/3b30ab8eaa03bc21.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/c53c9c7afec96700.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/ee9e514b2c2694f7.js","async":true}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/bb71734679762761.js","async":true}],["$","script","script-16",{"src":"/litellm-asset-prefix/_next/static/chunks/1fd9dbe73d002173.js","async":true}],["$","script","script-17",{"src":"/litellm-asset-prefix/_next/static/chunks/ed901fab61dc16dc.js","async":true}],["$","script","script-18",{"src":"/litellm-asset-prefix/_next/static/chunks/ed90bf177ad61e18.js","async":true}],["$","script","script-19",{"src":"/litellm-asset-prefix/_next/static/chunks/e1f23fd814ac3500.js","async":true}],["$","script","script-20",{"src":"/litellm-asset-prefix/_next/static/chunks/2971c4658f1bcd7d.js","async":true}],["$","script","script-21",{"src":"/litellm-asset-prefix/_next/static/chunks/88c74f8b4b20d25a.js","async":true}],["$","script","script-22",{"src":"/litellm-asset-prefix/_next/static/chunks/134f728fa7099e3e.js","async":true}],["$","script","script-23",{"src":"/litellm-asset-prefix/_next/static/chunks/ed4f62880278d987.js","async":true}],["$","script","script-24",{"src":"/litellm-asset-prefix/_next/static/chunks/60d899dd52430ef8.js","async":true}],["$","script","script-25",{"src":"/litellm-asset-prefix/_next/static/chunks/a5774cdb9f28daa1.js","async":true}],["$","script","script-26",{"src":"/litellm-asset-prefix/_next/static/chunks/f04f887c803d9e60.js","async":true}],["$","script","script-27",{"src":"/litellm-asset-prefix/_next/static/chunks/7e417dd24c8becd0.js","async":true}],["$","script","script-28",{"src":"/litellm-asset-prefix/_next/static/chunks/a7dc5e0c9d37afe3.js","async":true}],["$","script","script-29",{"src":"/litellm-asset-prefix/_next/static/chunks/86819b3a4f820602.js","async":true}],["$","script","script-30",{"src":"/litellm-asset-prefix/_next/static/chunks/b6cdb9a433f054f3.js","async":true}],["$","script","script-31",{"src":"/litellm-asset-prefix/_next/static/chunks/bf01d87225e5be70.js","async":true}],["$","script","script-32",{"src":"/litellm-asset-prefix/_next/static/chunks/b3b05b76472ce110.js","async":true}],["$","script","script-33",{"src":"/litellm-asset-prefix/_next/static/chunks/fcdf7322b0aa3e2e.js","async":true}],"$L6","$L7","$L8","$L9","$La","$Lb","$Lc","$Ld","$Le","$Lf","$L10","$L11","$L12","$L13","$L14","$L15","$L16"],"$L17"]}],"loading":null,"isPartial":false}
0:{"buildId":"lRBQFcrGOsyCYLFEalzGW","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/3f3fa56b5786d58c.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/142704439974f6b3.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/df37a0019220a941.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/30539b80ac15aad2.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/99d715502d5069f4.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/7834a5efb7b5f959.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/1fe0596a309ad6cf.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/7b788dd93ad868b3.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/c847ecdf8c790b0b.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/403c4d96324c23a6.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/a7113797b37526f0.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/ee5f9a39a526e423.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/db0ac43a898048e2.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/3b30ab8eaa03bc21.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/c53c9c7afec96700.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/60b0cadba57cd7f7.js","async":true}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/bb71734679762761.js","async":true}],["$","script","script-16",{"src":"/litellm-asset-prefix/_next/static/chunks/1fd9dbe73d002173.js","async":true}],["$","script","script-17",{"src":"/litellm-asset-prefix/_next/static/chunks/ed901fab61dc16dc.js","async":true}],["$","script","script-18",{"src":"/litellm-asset-prefix/_next/static/chunks/ed90bf177ad61e18.js","async":true}],["$","script","script-19",{"src":"/litellm-asset-prefix/_next/static/chunks/e1f23fd814ac3500.js","async":true}],["$","script","script-20",{"src":"/litellm-asset-prefix/_next/static/chunks/2971c4658f1bcd7d.js","async":true}],["$","script","script-21",{"src":"/litellm-asset-prefix/_next/static/chunks/88c74f8b4b20d25a.js","async":true}],["$","script","script-22",{"src":"/litellm-asset-prefix/_next/static/chunks/134f728fa7099e3e.js","async":true}],["$","script","script-23",{"src":"/litellm-asset-prefix/_next/static/chunks/ed4f62880278d987.js","async":true}],["$","script","script-24",{"src":"/litellm-asset-prefix/_next/static/chunks/60d899dd52430ef8.js","async":true}],["$","script","script-25",{"src":"/litellm-asset-prefix/_next/static/chunks/eaa9f9b9bb3e054b.js","async":true}],["$","script","script-26",{"src":"/litellm-asset-prefix/_next/static/chunks/f04f887c803d9e60.js","async":true}],["$","script","script-27",{"src":"/litellm-asset-prefix/_next/static/chunks/7e417dd24c8becd0.js","async":true}],["$","script","script-28",{"src":"/litellm-asset-prefix/_next/static/chunks/99cf9cf99df5ccfc.js","async":true}],["$","script","script-29",{"src":"/litellm-asset-prefix/_next/static/chunks/a02f90f97248b9aa.js","async":true}],["$","script","script-30",{"src":"/litellm-asset-prefix/_next/static/chunks/b6cdb9a433f054f3.js","async":true}],["$","script","script-31",{"src":"/litellm-asset-prefix/_next/static/chunks/a5774cdb9f28daa1.js","async":true}],["$","script","script-32",{"src":"/litellm-asset-prefix/_next/static/chunks/1501e804b4d0f510.js","async":true}],["$","script","script-33",{"src":"/litellm-asset-prefix/_next/static/chunks/86819b3a4f820602.js","async":true}],"$L6","$L7","$L8","$L9","$La","$Lb","$Lc","$Ld","$Le","$Lf","$L10","$L11","$L12","$L13","$L14","$L15","$L16"],"$L17"]}],"loading":null,"isPartial":false}
4:{}
5:"$0:rsc:props:children:0:props:serverProvidedParams:params"
6:["$","script","script-34",{"src":"/litellm-asset-prefix/_next/static/chunks/47a838c67cdd745e.js","async":true}]
7:["$","script","script-35",{"src":"/litellm-asset-prefix/_next/static/chunks/4fc2d71e511309ab.js","async":true}]
7:["$","script","script-35",{"src":"/litellm-asset-prefix/_next/static/chunks/be00dd25857a2fb3.js","async":true}]
8:["$","script","script-36",{"src":"/litellm-asset-prefix/_next/static/chunks/0a65da2cd24e2ab6.js","async":true}]
9:["$","script","script-37",{"src":"/litellm-asset-prefix/_next/static/chunks/4980372eaa37b78b.js","async":true}]
a:["$","script","script-38",{"src":"/litellm-asset-prefix/_next/static/chunks/d3ac82723ec9e30d.js","async":true}]
b:["$","script","script-39",{"src":"/litellm-asset-prefix/_next/static/chunks/496b84010c33cf69.js","async":true}]
c:["$","script","script-40",{"src":"/litellm-asset-prefix/_next/static/chunks/360f35fe2e0a4945.js","async":true}]
d:["$","script","script-41",{"src":"/litellm-asset-prefix/_next/static/chunks/0d219667baa010f5.js","async":true}]
b:["$","script","script-39",{"src":"/litellm-asset-prefix/_next/static/chunks/7dd16a650b98a4c5.js","async":true}]
c:["$","script","script-40",{"src":"/litellm-asset-prefix/_next/static/chunks/bd5cc6a7a48eedc7.js","async":true}]
d:["$","script","script-41",{"src":"/litellm-asset-prefix/_next/static/chunks/169b34fe8aeee0c7.js","async":true}]
e:["$","script","script-42",{"src":"/litellm-asset-prefix/_next/static/chunks/7a2dc852f68481ea.js","async":true}]
f:["$","script","script-43",{"src":"/litellm-asset-prefix/_next/static/chunks/99cf9cf99df5ccfc.js","async":true}]
f:["$","script","script-43",{"src":"/litellm-asset-prefix/_next/static/chunks/908828a91f602d8b.js","async":true}]
10:["$","script","script-44",{"src":"/litellm-asset-prefix/_next/static/chunks/d0d828f9a0668699.js","async":true}]
11:["$","script","script-45",{"src":"/litellm-asset-prefix/_next/static/chunks/f9c24d6e7ec43046.js","async":true}]
12:["$","script","script-46",{"src":"/litellm-asset-prefix/_next/static/chunks/7c797521435cb59c.js","async":true}]
13:["$","script","script-47",{"src":"/litellm-asset-prefix/_next/static/chunks/908828a91f602d8b.js","async":true}]
14:["$","script","script-48",{"src":"/litellm-asset-prefix/_next/static/chunks/53ac95bfa383e1b4.js","async":true}]
15:["$","script","script-49",{"src":"/litellm-asset-prefix/_next/static/chunks/169b34fe8aeee0c7.js","async":true}]
16:["$","script","script-50",{"src":"/litellm-asset-prefix/_next/static/chunks/2e768c2b1dfc8cd5.js","async":true}]
13:["$","script","script-47",{"src":"/litellm-asset-prefix/_next/static/chunks/496b84010c33cf69.js","async":true}]
14:["$","script","script-48",{"src":"/litellm-asset-prefix/_next/static/chunks/fcdf7322b0aa3e2e.js","async":true}]
15:["$","script","script-49",{"src":"/litellm-asset-prefix/_next/static/chunks/53ac95bfa383e1b4.js","async":true}]
16:["$","script","script-50",{"src":"/litellm-asset-prefix/_next/static/chunks/0c6c65a34bcde140.js","async":true}]
17:["$","$L18",null,{"children":["$","$19",null,{"name":"Next.MetadataOutlet","children":"$@1a"}]}]
1a:null

File diff suppressed because one or more lines are too long

View file

@ -3,4 +3,4 @@
3:I[897367,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"MetadataBoundary"]
4:"$Sreact.suspense"
5:I[27201,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"IconMark"]
0:{"buildId":"-9iBbUN_ohnDf0d-Ux3Ju","rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"LiteLLM Dashboard"}],["$","meta","1",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.1d32c690.ico","sizes":"48x48","type":"image/x-icon"}],["$","link","3",{"rel":"icon","href":"./favicon.ico"}],["$","$L5","4",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"loading":null,"isPartial":false}
0:{"buildId":"lRBQFcrGOsyCYLFEalzGW","rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"LiteLLM Dashboard"}],["$","meta","1",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.1d32c690.ico","sizes":"48x48","type":"image/x-icon"}],["$","link","3",{"rel":"icon","href":"./favicon.ico"}],["$","$L5","4",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"loading":null,"isPartial":false}

View file

@ -5,4 +5,4 @@
5:I[837457,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"default"]
:HL["/litellm-asset-prefix/_next/static/chunks/4e20891f2fd03463.css","style"]
:HL["/litellm-asset-prefix/_next/static/chunks/c3f387b3358b56db.css","style"]
0:{"buildId":"-9iBbUN_ohnDf0d-Ux3Ju","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/4e20891f2fd03463.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/c3f387b3358b56db.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/9e09de50158b3159.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/7e5fe5584502da06.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"inter_5972bc34-module__OU16Qa__className","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","template":["$","$L5",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]}]}]]}],"loading":null,"isPartial":false}
0:{"buildId":"lRBQFcrGOsyCYLFEalzGW","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/4e20891f2fd03463.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/c3f387b3358b56db.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/9e09de50158b3159.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/7e5fe5584502da06.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"inter_5972bc34-module__OU16Qa__className","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","template":["$","$L5",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]}]}]]}],"loading":null,"isPartial":false}

View file

@ -2,4 +2,4 @@
:HL["/litellm-asset-prefix/_next/static/chunks/c3f387b3358b56db.css","style"]
:HL["/litellm-asset-prefix/_next/static/media/83afe278b6a6bb3c-s.p.3a6ba036.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
:HL["/litellm-asset-prefix/_next/static/chunks/3f3fa56b5786d58c.css","style"]
0:{"buildId":"-9iBbUN_ohnDf0d-Ux3Ju","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":true},"staleTime":300}
0:{"buildId":"lRBQFcrGOsyCYLFEalzGW","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":true},"staleTime":300}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more