mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
Merge branch 'main' of https://github.com/BerriAI/litellm into litellm_fix_pre-call-hook
This commit is contained in:
commit
4560c4fa30
71 changed files with 5726 additions and 309 deletions
51
.github/workflows/check_duplicate_issues.yml
vendored
51
.github/workflows/check_duplicate_issues.yml
vendored
|
|
@ -2,47 +2,28 @@ name: Check Duplicate Issues
|
|||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
types: [opened, edited]
|
||||
|
||||
jobs:
|
||||
check-duplicates:
|
||||
if: github.event.action == 'opened'
|
||||
check-duplicate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Install Claude Code
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Check duplicates
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.LITELLM_VIRTUAL_KEY }}
|
||||
ANTHROPIC_BASE_URL: ${{ secrets.LITELLM_BASE_URL }}
|
||||
- name: Check for potential duplicates
|
||||
uses: wow-actions/potential-duplicates@v1
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PROMPT: |
|
||||
A new issue has been created in the ${{ github.repository }} repository.
|
||||
label: potential-duplicate
|
||||
threshold: 0.6
|
||||
reaction: eyes
|
||||
comment: |
|
||||
**⚠️ Potential duplicate detected**
|
||||
|
||||
Issue number: ${{ github.event.issue.number }}
|
||||
This issue appears similar to existing issue(s):
|
||||
{{#issues}}
|
||||
- [#{{number}}]({{html_url}}) - {{title}} ({{accuracy}}% similar)
|
||||
{{/issues}}
|
||||
|
||||
Lookup this issue with gh issue view ${{ github.event.issue.number }} --repo ${{ github.repository }}.
|
||||
|
||||
Search through existing issues (excluding #${{ github.event.issue.number }}) to find potential duplicates.
|
||||
|
||||
Use gh issue list --repo ${{ github.repository }} with relevant search terms from the new issue's title and description. Try multiple keyword combinations to search broadly. Check both open and recently closed issues.
|
||||
|
||||
Consider:
|
||||
1. Similar titles or descriptions
|
||||
2. Same error messages or symptoms
|
||||
3. Related functionality or components
|
||||
4. Similar feature requests
|
||||
|
||||
If you find potential duplicates, post a SINGLE comment on issue #${{ github.event.issue.number }} using gh issue comment ${{ github.event.issue.number }} --repo ${{ github.repository }} with this format:
|
||||
|
||||
_This comment was generated by an LLM and may be inaccurate._
|
||||
|
||||
This issue might be a duplicate of existing issues. Please check:
|
||||
- #[issue_number]: [brief description of similarity]
|
||||
|
||||
If you find NO duplicates, do NOT post any comment. Stay silent.
|
||||
run: claude -p "$PROMPT" --model sonnet --max-turns 10 --allowedTools "Bash(gh issue *)"
|
||||
Please review the linked issue(s) to see if they address your concern. If this is not a duplicate, please provide additional context to help us understand the difference.
|
||||
|
|
|
|||
52
.github/workflows/check_duplicate_prs.yml
vendored
52
.github/workflows/check_duplicate_prs.yml
vendored
|
|
@ -1,52 +0,0 @@
|
|||
name: Check Duplicate PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
check-duplicates:
|
||||
if: |
|
||||
github.event.pull_request.user.login != 'ishaan-jaff' &&
|
||||
github.event.pull_request.user.login != 'krrishdholakia' &&
|
||||
github.event.pull_request.user.login != 'actions-user' &&
|
||||
!endsWith(github.event.pull_request.user.login, '[bot]')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Install Claude Code
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Check duplicates
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.LITELLM_VIRTUAL_KEY }}
|
||||
ANTHROPIC_BASE_URL: ${{ secrets.LITELLM_BASE_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PROMPT: |
|
||||
A new PR has been opened in the ${{ github.repository }} repository.
|
||||
|
||||
PR number: ${{ github.event.pull_request.number }}
|
||||
|
||||
Lookup this PR with gh pr view ${{ github.event.pull_request.number }} --repo ${{ github.repository }}.
|
||||
|
||||
Search through existing open PRs (excluding #${{ github.event.pull_request.number }}) to find potential duplicates.
|
||||
|
||||
Use gh pr list --repo ${{ github.repository }} with relevant search terms from the new PR's title and description. Try multiple keyword combinations to search broadly. Check both open and recently closed PRs.
|
||||
|
||||
Consider:
|
||||
1. Similar titles or descriptions
|
||||
2. Same bug fix or feature being implemented
|
||||
3. Related functionality or components
|
||||
4. Overlapping code changes (same files or areas)
|
||||
|
||||
If you find potential duplicates, post a SINGLE comment on PR #${{ github.event.pull_request.number }} using gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} with this format:
|
||||
|
||||
_This comment was generated by an LLM and may be inaccurate._
|
||||
|
||||
This PR might be a duplicate of existing PRs. Please check:
|
||||
- #[pr_number]: [brief description of similarity]
|
||||
|
||||
If you find NO duplicates, do NOT post any comment. Stay silent.
|
||||
run: claude -p "$PROMPT" --model sonnet --max-turns 10 --allowedTools "Bash(gh pr *)"
|
||||
|
|
@ -5,6 +5,44 @@ import Image from '@theme/IdealImage';
|
|||
|
||||
Benchmarks for LiteLLM Gateway (Proxy Server) tested against a fake OpenAI endpoint.
|
||||
|
||||
## 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.
|
||||
|
||||
**1. Create a proxy config:**
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: db-openai-endpoint
|
||||
litellm_params:
|
||||
model: openai/gpt-4o
|
||||
api_key: "sk-fake-key"
|
||||
api_base: "https://api.openai.com"
|
||||
|
||||
litellm_settings:
|
||||
network_mock: true
|
||||
callbacks: []
|
||||
num_retries: 0
|
||||
request_timeout: 30
|
||||
|
||||
general_settings:
|
||||
master_key: "sk-1234"
|
||||
```
|
||||
|
||||
**2. Start the proxy:**
|
||||
|
||||
```bash
|
||||
litellm --config benchmark_config.yaml --port 4000 --num_workers 8
|
||||
```
|
||||
|
||||
**3. Run the benchmark script:**
|
||||
|
||||
```bash
|
||||
python scripts/benchmark_mock.py --requests 2000 --max-concurrent 200 --runs 3
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
For load testing and benchmarking, you can use a fake OpenAI proxy server. LiteLLM provides:
|
||||
|
|
|
|||
|
|
@ -79,7 +79,27 @@ cp -r out/* ../../litellm/proxy/_experimental/out/
|
|||
|
||||
Then restart the proxy and access the UI at `http://localhost:4000/ui`
|
||||
|
||||
## 4. Submitting a PR
|
||||
## 4. Pre-PR Checklist
|
||||
|
||||
Before submitting your pull request, make sure the following pass locally from `ui/litellm-dashboard/`:
|
||||
|
||||
**Run tests related to your changes:**
|
||||
|
||||
```bash
|
||||
npx vitest run src/components/path/to/YourComponent.test.tsx
|
||||
```
|
||||
|
||||
Tests are co-located with components (e.g., `TeamInfo.tsx` → `TeamInfo.test.tsx`). If you add a new component, add a corresponding `.test.tsx` file next to it.
|
||||
|
||||
**Run the build:**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
These map to the `ui_tests` and `ui_build` CI checks.
|
||||
|
||||
## 5. Submitting a PR
|
||||
|
||||
1. Create a new branch for your changes:
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -130,13 +130,12 @@ Point the Google GenAI SDK to LiteLLM Proxy:
|
|||
|
||||
```python showLineNumbers title="Google GenAI SDK with LiteLLM Proxy"
|
||||
from google import genai
|
||||
import os
|
||||
|
||||
# Point SDK to LiteLLM Proxy
|
||||
os.environ["GOOGLE_GENAI_BASE_URL"] = "http://localhost:4000"
|
||||
os.environ["GEMINI_API_KEY"] = "sk-1234" # Your LiteLLM API key
|
||||
|
||||
client = genai.Client()
|
||||
client = genai.Client(
|
||||
api_key="sk-1234", # Your LiteLLM API key
|
||||
http_options={"base_url": "http://localhost:4000"},
|
||||
)
|
||||
|
||||
# Create an interaction
|
||||
interaction = client.interactions.create(
|
||||
|
|
@ -151,12 +150,11 @@ print(interaction.outputs[-1].text)
|
|||
|
||||
```python showLineNumbers title="Google GenAI SDK Streaming"
|
||||
from google import genai
|
||||
import os
|
||||
|
||||
os.environ["GOOGLE_GENAI_BASE_URL"] = "http://localhost:4000"
|
||||
os.environ["GEMINI_API_KEY"] = "sk-1234"
|
||||
|
||||
client = genai.Client()
|
||||
client = genai.Client(
|
||||
api_key="sk-1234", # Your LiteLLM API key
|
||||
http_options={"base_url": "http://localhost:4000"},
|
||||
)
|
||||
|
||||
for chunk in client.interactions.create_stream(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
|
|
|
|||
|
|
@ -35,26 +35,25 @@ curl 'http://0.0.0.0:4000/gemini/v1beta/models/gemini-1.5-flash:countTokens?key=
|
|||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="js" label="Google AI Node.js SDK">
|
||||
<TabItem value="js" label="Google GenAI JS SDK">
|
||||
|
||||
```javascript
|
||||
const { GoogleGenerativeAI } = require("@google/generative-ai");
|
||||
const { GoogleGenAI } = require("@google/genai");
|
||||
|
||||
const modelParams = {
|
||||
model: 'gemini-pro',
|
||||
};
|
||||
|
||||
const requestOptions = {
|
||||
baseUrl: 'http://localhost:4000/gemini', // http://<proxy-base-url>/gemini
|
||||
};
|
||||
|
||||
const genAI = new GoogleGenerativeAI("sk-1234"); // litellm proxy API key
|
||||
const model = genAI.getGenerativeModel(modelParams, requestOptions);
|
||||
const ai = new GoogleGenAI({
|
||||
apiKey: "sk-1234", // litellm proxy API key
|
||||
httpOptions: {
|
||||
baseUrl: "http://localhost:4000/gemini", // http://<proxy-base-url>/gemini
|
||||
},
|
||||
});
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const result = await model.generateContent("Explain how AI works");
|
||||
console.log(result.response.text());
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-2.5-flash",
|
||||
contents: "Explain how AI works",
|
||||
});
|
||||
console.log(response.text);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
|
|
@ -63,12 +62,13 @@ async function main() {
|
|||
// For streaming responses
|
||||
async function main_streaming() {
|
||||
try {
|
||||
const streamingResult = await model.generateContentStream("Explain how AI works");
|
||||
for await (const chunk of streamingResult.stream) {
|
||||
console.log('Stream chunk:', JSON.stringify(chunk));
|
||||
const response = await ai.models.generateContentStream({
|
||||
model: "gemini-2.5-flash",
|
||||
contents: "Explain how AI works",
|
||||
});
|
||||
for await (const chunk of response) {
|
||||
process.stdout.write(chunk.text);
|
||||
}
|
||||
const aggregatedResponse = await streamingResult.response;
|
||||
console.log('Aggregated response:', JSON.stringify(aggregatedResponse));
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
|
|
@ -321,29 +321,28 @@ curl 'http://0.0.0.0:4000/gemini/v1beta/models/gemini-1.5-flash:generateContent?
|
|||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="js" label="Google AI Node.js SDK">
|
||||
<TabItem value="js" label="Google GenAI JS SDK">
|
||||
|
||||
```javascript
|
||||
const { GoogleGenerativeAI } = require("@google/generative-ai");
|
||||
const { GoogleGenAI } = require("@google/genai");
|
||||
|
||||
const modelParams = {
|
||||
model: 'gemini-pro',
|
||||
};
|
||||
|
||||
const requestOptions = {
|
||||
baseUrl: 'http://localhost:4000/gemini', // http://<proxy-base-url>/gemini
|
||||
customHeaders: {
|
||||
"tags": "gemini-js-sdk,pass-through-endpoint"
|
||||
}
|
||||
};
|
||||
|
||||
const genAI = new GoogleGenerativeAI("sk-1234");
|
||||
const model = genAI.getGenerativeModel(modelParams, requestOptions);
|
||||
const ai = new GoogleGenAI({
|
||||
apiKey: "sk-1234",
|
||||
httpOptions: {
|
||||
baseUrl: "http://localhost:4000/gemini", // http://<proxy-base-url>/gemini
|
||||
headers: {
|
||||
"tags": "gemini-js-sdk,pass-through-endpoint",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const result = await model.generateContent("Explain how AI works");
|
||||
console.log(result.response.text());
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-2.5-flash",
|
||||
contents: "Explain how AI works",
|
||||
});
|
||||
console.log(response.text);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
|
|
|
|||
406
docs/my-website/docs/tutorials/google_genai_sdk.md
Normal file
406
docs/my-website/docs/tutorials/google_genai_sdk.md
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
# Google GenAI SDK with LiteLLM
|
||||
|
||||
Use Google's official GenAI SDK (JavaScript/TypeScript and Python) with any LLM provider through LiteLLM Proxy.
|
||||
|
||||
The Google GenAI SDK (`@google/genai` for JS, `google-genai` for Python) provides a native interface for calling Gemini models. By pointing it to LiteLLM, you can use the same SDK with OpenAI, Anthropic, Bedrock, Azure, Vertex AI, or any other provider — while keeping the native Gemini request/response format.
|
||||
|
||||
## Why Use LiteLLM with Google GenAI SDK?
|
||||
|
||||
**Developer Benefits:**
|
||||
- **Universal Model Access**: Use any LiteLLM-supported model (Anthropic, OpenAI, Vertex AI, Bedrock, etc.) through the Google GenAI SDK interface
|
||||
- **Higher Rate Limits & Reliability**: Load balance across multiple models and providers to avoid hitting individual provider limits, with fallbacks to ensure you get responses even if one provider fails
|
||||
|
||||
**Proxy Admin Benefits:**
|
||||
- **Centralized Management**: Control access to all models through a single LiteLLM proxy instance without giving developers API keys to each provider
|
||||
- **Budget Controls**: Set spending limits and track costs across all SDK usage
|
||||
- **Logging & Observability**: Track all requests with cost tracking, logging, and analytics
|
||||
|
||||
| Feature | Supported | Notes |
|
||||
|---------|-----------|-------|
|
||||
| Cost Tracking | ✅ | All models on `/generateContent` endpoint |
|
||||
| Logging | ✅ | Works across all integrations |
|
||||
| Streaming | ✅ | `streamGenerateContent` supported |
|
||||
| Virtual Keys | ✅ | Use LiteLLM keys instead of Google keys |
|
||||
| Load Balancing | ✅ | Via native router endpoints |
|
||||
| Fallbacks | ✅ | Via native router endpoints |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install the SDK
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="js" label="JavaScript/TypeScript">
|
||||
|
||||
```bash
|
||||
npm install @google/genai
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="python" label="Python">
|
||||
|
||||
```bash
|
||||
pip install google-genai
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### 2. Start LiteLLM Proxy
|
||||
|
||||
```yaml title="config.yaml" showLineNumbers
|
||||
model_list:
|
||||
- model_name: gemini-2.5-flash
|
||||
litellm_params:
|
||||
model: gemini/gemini-2.5-flash
|
||||
api_key: os.environ/GEMINI_API_KEY
|
||||
```
|
||||
|
||||
```bash
|
||||
litellm --config config.yaml
|
||||
```
|
||||
|
||||
### 3. Call the SDK through LiteLLM
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="js" label="JavaScript/TypeScript">
|
||||
|
||||
```javascript title="index.js" showLineNumbers
|
||||
const { GoogleGenAI } = require("@google/genai");
|
||||
|
||||
const ai = new GoogleGenAI({
|
||||
apiKey: "sk-1234", // LiteLLM virtual key (not a Google key)
|
||||
httpOptions: {
|
||||
baseUrl: "http://localhost:4000/gemini", // LiteLLM proxy URL
|
||||
},
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-2.5-flash",
|
||||
contents: "Explain how AI works",
|
||||
});
|
||||
console.log(response.text);
|
||||
}
|
||||
|
||||
main();
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="python" label="Python">
|
||||
|
||||
```python title="main.py" showLineNumbers
|
||||
from google import genai
|
||||
|
||||
client = genai.Client(
|
||||
api_key="sk-1234", # LiteLLM virtual key (not a Google key)
|
||||
http_options={"base_url": "http://localhost:4000/gemini"}, # LiteLLM proxy URL
|
||||
)
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents="Explain how AI works",
|
||||
)
|
||||
print(response.text)
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
```bash
|
||||
curl "http://localhost:4000/gemini/v1beta/models/gemini-2.5-flash:generateContent?key=sk-1234" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST \
|
||||
-d '{
|
||||
"contents": [{
|
||||
"parts": [{"text": "Explain how AI works"}]
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Streaming
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="js" label="JavaScript/TypeScript">
|
||||
|
||||
```javascript title="streaming.js" showLineNumbers
|
||||
const { GoogleGenAI } = require("@google/genai");
|
||||
|
||||
const ai = new GoogleGenAI({
|
||||
apiKey: "sk-1234",
|
||||
httpOptions: {
|
||||
baseUrl: "http://localhost:4000/gemini",
|
||||
},
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const response = await ai.models.generateContentStream({
|
||||
model: "gemini-2.5-flash",
|
||||
contents: "Write a short poem about the ocean",
|
||||
});
|
||||
|
||||
for await (const chunk of response) {
|
||||
process.stdout.write(chunk.text);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="python" label="Python">
|
||||
|
||||
```python title="streaming.py" showLineNumbers
|
||||
from google import genai
|
||||
|
||||
client = genai.Client(
|
||||
api_key="sk-1234",
|
||||
http_options={"base_url": "http://localhost:4000/gemini"},
|
||||
)
|
||||
|
||||
response = client.models.generate_content_stream(
|
||||
model="gemini-2.5-flash",
|
||||
contents="Write a short poem about the ocean",
|
||||
)
|
||||
|
||||
for chunk in response:
|
||||
print(chunk.text, end="")
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Multi-turn Chat
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="js" label="JavaScript/TypeScript">
|
||||
|
||||
```javascript title="chat.js" showLineNumbers
|
||||
const { GoogleGenAI } = require("@google/genai");
|
||||
|
||||
const ai = new GoogleGenAI({
|
||||
apiKey: "sk-1234",
|
||||
httpOptions: {
|
||||
baseUrl: "http://localhost:4000/gemini",
|
||||
},
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const chat = ai.chats.create({
|
||||
model: "gemini-2.5-flash",
|
||||
});
|
||||
|
||||
const response1 = await chat.sendMessage({ message: "I have 2 dogs and 3 cats." });
|
||||
console.log(response1.text);
|
||||
|
||||
const response2 = await chat.sendMessage({ message: "How many pets is that in total?" });
|
||||
console.log(response2.text);
|
||||
}
|
||||
|
||||
main();
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="python" label="Python">
|
||||
|
||||
```python title="chat.py" showLineNumbers
|
||||
from google import genai
|
||||
|
||||
client = genai.Client(
|
||||
api_key="sk-1234",
|
||||
http_options={"base_url": "http://localhost:4000/gemini"},
|
||||
)
|
||||
|
||||
chat = client.chats.create(model="gemini-2.5-flash")
|
||||
|
||||
response1 = chat.send_message("I have 2 dogs and 3 cats.")
|
||||
print(response1.text)
|
||||
|
||||
response2 = chat.send_message("How many pets is that in total?")
|
||||
print(response2.text)
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
## Advanced: Use Any Model with the GenAI SDK
|
||||
|
||||
By default, the GenAI SDK talks to Gemini models. But with LiteLLM's router, you can route GenAI SDK requests to **any provider** — Anthropic, OpenAI, Bedrock, etc.
|
||||
|
||||
This works by using `model_group_alias` to map Gemini model names to your desired provider models. LiteLLM handles the format translation internally.
|
||||
|
||||
:::info
|
||||
|
||||
For this to work, point the SDK `baseUrl` to `http://localhost:4000` (without `/gemini`). This routes requests through LiteLLM's native Google endpoints, which go through the router and support model aliasing.
|
||||
|
||||
:::
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="anthropic" label="Anthropic">
|
||||
|
||||
Route `gemini-2.5-flash` requests to Claude Sonnet:
|
||||
|
||||
```yaml title="config.yaml" showLineNumbers
|
||||
model_list:
|
||||
- model_name: claude-sonnet
|
||||
litellm_params:
|
||||
model: anthropic/claude-sonnet-4-20250514
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
|
||||
router_settings:
|
||||
model_group_alias: {"gemini-2.5-flash": "claude-sonnet"}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="openai" label="OpenAI">
|
||||
|
||||
Route `gemini-2.5-flash` requests to GPT-4o:
|
||||
|
||||
```yaml title="config.yaml" showLineNumbers
|
||||
model_list:
|
||||
- model_name: gpt-4o-model
|
||||
litellm_params:
|
||||
model: gpt-4o
|
||||
api_key: os.environ/OPENAI_API_KEY
|
||||
|
||||
router_settings:
|
||||
model_group_alias: {"gemini-2.5-flash": "gpt-4o-model"}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="bedrock" label="Bedrock">
|
||||
|
||||
Route `gemini-2.5-flash` requests to Claude on Bedrock:
|
||||
|
||||
```yaml title="config.yaml" showLineNumbers
|
||||
model_list:
|
||||
- model_name: bedrock-claude
|
||||
litellm_params:
|
||||
model: bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0
|
||||
aws_access_key_id: os.environ/AWS_ACCESS_KEY_ID
|
||||
aws_secret_access_key: os.environ/AWS_SECRET_ACCESS_KEY
|
||||
aws_region_name: us-east-1
|
||||
|
||||
router_settings:
|
||||
model_group_alias: {"gemini-2.5-flash": "bedrock-claude"}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="multi" label="Multi-Provider Load Balancing">
|
||||
|
||||
Load balance across Anthropic and OpenAI:
|
||||
|
||||
```yaml title="config.yaml" showLineNumbers
|
||||
model_list:
|
||||
- model_name: my-model
|
||||
litellm_params:
|
||||
model: anthropic/claude-sonnet-4-20250514
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
- model_name: my-model
|
||||
litellm_params:
|
||||
model: gpt-4o
|
||||
api_key: os.environ/OPENAI_API_KEY
|
||||
|
||||
router_settings:
|
||||
model_group_alias: {"gemini-2.5-flash": "my-model"}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Then use the SDK with `baseUrl` pointing to LiteLLM (without `/gemini`):
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="js" label="JavaScript/TypeScript">
|
||||
|
||||
```javascript title="any_model.js" showLineNumbers
|
||||
const { GoogleGenAI } = require("@google/genai");
|
||||
|
||||
const ai = new GoogleGenAI({
|
||||
apiKey: "sk-1234",
|
||||
httpOptions: {
|
||||
baseUrl: "http://localhost:4000", // No /gemini — goes through the router
|
||||
},
|
||||
});
|
||||
|
||||
async function main() {
|
||||
// This calls Claude/GPT-4o/Bedrock under the hood via model_group_alias
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-2.5-flash",
|
||||
contents: "Hello from any model!",
|
||||
});
|
||||
console.log(response.text);
|
||||
}
|
||||
|
||||
main();
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="python" label="Python">
|
||||
|
||||
```python title="any_model.py" showLineNumbers
|
||||
from google import genai
|
||||
|
||||
client = genai.Client(
|
||||
api_key="sk-1234",
|
||||
http_options={"base_url": "http://localhost:4000"}, # No /gemini
|
||||
)
|
||||
|
||||
# This calls Claude/GPT-4o/Bedrock under the hood via model_group_alias
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents="Hello from any model!",
|
||||
)
|
||||
print(response.text)
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
## Pass-through vs Native Router Endpoints
|
||||
|
||||
LiteLLM offers two ways to handle GenAI SDK requests:
|
||||
|
||||
| | Pass-through (`/gemini`) | Native Router (`/`) |
|
||||
|---|---|---|
|
||||
| **baseUrl** | `http://localhost:4000/gemini` | `http://localhost:4000` |
|
||||
| **Models** | Gemini only | Any provider via `model_group_alias` |
|
||||
| **Translation** | None — proxies directly to Google | Translates internally |
|
||||
| **Cost Tracking** | ✅ | ✅ |
|
||||
| **Virtual Keys** | ✅ | ✅ |
|
||||
| **Load Balancing** | ❌ | ✅ |
|
||||
| **Fallbacks** | ❌ | ✅ |
|
||||
| **Best for** | Simple Gemini proxy | Multi-provider routing |
|
||||
|
||||
## Environment Variable Configuration
|
||||
|
||||
You can also configure the SDK via environment variables instead of code:
|
||||
|
||||
```bash
|
||||
# For JavaScript SDK (@google/genai)
|
||||
export GOOGLE_GEMINI_BASE_URL="http://localhost:4000/gemini"
|
||||
export GEMINI_API_KEY="sk-1234"
|
||||
|
||||
# For Python SDK (google-genai)
|
||||
# Note: The Python SDK does not support a base URL env var.
|
||||
# Configure it in code with http_options={"base_url": "..."} instead.
|
||||
export GEMINI_API_KEY="sk-1234"
|
||||
```
|
||||
|
||||
This is especially useful for tools built on top of the GenAI SDK (like [Gemini CLI](./litellm_gemini_cli.md)).
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [Gemini CLI with LiteLLM](./litellm_gemini_cli.md)
|
||||
- [Google AI Studio Pass-Through](../pass_through/google_ai_studio)
|
||||
- [Google ADK with LiteLLM](./google_adk.md)
|
||||
- [LiteLLM Proxy Quick Start](../proxy/quick_start)
|
||||
- [`@google/genai` npm package](https://www.npmjs.com/package/@google/genai)
|
||||
- [`google-genai` PyPI package](https://pypi.org/project/google-genai/)
|
||||
373
docs/my-website/docs/tutorials/openai_agents_sdk.md
Normal file
373
docs/my-website/docs/tutorials/openai_agents_sdk.md
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
# OpenAI Agents SDK with LiteLLM
|
||||
|
||||
Use OpenAI's Agents SDK with any LLM provider through LiteLLM Proxy.
|
||||
|
||||
This tutorial shows you how to build AI agents using the OpenAI Agents SDK with support for multiple LLM providers through LiteLLM.
|
||||
|
||||
## Overview
|
||||
|
||||
The OpenAI Agents SDK provides a high-level interface for building AI agents. By integrating with LiteLLM, you can:
|
||||
|
||||
- Use multiple LLM providers (Bedrock, Azure, Vertex AI, etc.) with the same agent code
|
||||
- Switch easily between models from different providers
|
||||
- Connect to a LiteLLM proxy for centralized model management
|
||||
|
||||
:::tip Built-in LiteLLM Extension
|
||||
|
||||
The OpenAI Agents SDK includes an official LiteLLM extension (`LitellmModel`) that works without a proxy. If you don't need centralized proxy features (cost tracking, rate limiting, load balancing), you can use it directly:
|
||||
|
||||
```python
|
||||
from agents import Agent, Runner
|
||||
from agents.extensions.models.litellm_model import LitellmModel
|
||||
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
instructions="You are a helpful assistant.",
|
||||
model=LitellmModel(model="anthropic/claude-sonnet-4-20250514"),
|
||||
)
|
||||
|
||||
result = Runner.run_sync(agent, "Hello!")
|
||||
print(result.final_output)
|
||||
```
|
||||
|
||||
See the [Docs](https://openai.github.io/openai-agents-python/models/litellm/) for more details. The rest of this tutorial focuses on the **proxy-based approach** for teams that need centralized model management.
|
||||
|
||||
:::
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python environment setup
|
||||
- API keys for your LLM providers
|
||||
- Basic understanding of LLMs and agent concepts
|
||||
|
||||
## Installation
|
||||
|
||||
```bash showLineNumbers title="Install dependencies"
|
||||
pip install openai-agents litellm
|
||||
```
|
||||
|
||||
## 1. Start LiteLLM Proxy
|
||||
|
||||
Configure and start the LiteLLM proxy with the models you want to use:
|
||||
|
||||
```yaml title="config.yaml" showLineNumbers
|
||||
model_list:
|
||||
- model_name: bedrock-claude-sonnet-4
|
||||
litellm_params:
|
||||
model: "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0"
|
||||
aws_region_name: "us-east-1"
|
||||
|
||||
- model_name: gpt-4o
|
||||
litellm_params:
|
||||
model: "openai/gpt-4o"
|
||||
|
||||
- model_name: claude-sonnet-4
|
||||
litellm_params:
|
||||
model: "anthropic/claude-sonnet-4-20250514"
|
||||
|
||||
- model_name: bedrock-claude-haiku
|
||||
litellm_params:
|
||||
model: "bedrock/us.anthropic.claude-3-5-haiku-20241022-v1:0"
|
||||
aws_region_name: "us-east-1"
|
||||
|
||||
- model_name: bedrock-nova-premier
|
||||
litellm_params:
|
||||
model: "bedrock/amazon.nova-premier-v1:0"
|
||||
aws_region_name: "us-east-1"
|
||||
```
|
||||
|
||||
```bash
|
||||
litellm --config config.yaml
|
||||
```
|
||||
|
||||
Required environment variables:
|
||||
|
||||
| Variable | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `LITELLM_BASE_URL` | `http://localhost:4000` | LiteLLM proxy URL |
|
||||
| `LITELLM_API_KEY` | `sk-1234` | Your LiteLLM API key (not your provider's key) |
|
||||
|
||||
## 2. Setting Up Environment
|
||||
|
||||
Import the necessary libraries and configure your LiteLLM proxy connection:
|
||||
|
||||
```python showLineNumbers title="Setup environment"
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from agents import (
|
||||
Agent,
|
||||
Model,
|
||||
ModelProvider,
|
||||
OpenAIChatCompletionsModel,
|
||||
RunConfig,
|
||||
Runner,
|
||||
function_tool,
|
||||
set_tracing_disabled,
|
||||
)
|
||||
|
||||
# Point to LiteLLM proxy
|
||||
BASE_URL = os.getenv("LITELLM_BASE_URL") or "http://localhost:4000"
|
||||
API_KEY = os.getenv("LITELLM_API_KEY") or "sk-1234"
|
||||
|
||||
# Define model constants for cleaner code
|
||||
MODEL_BEDROCK_SONNET = "bedrock-claude-sonnet-4"
|
||||
MODEL_BEDROCK_HAIKU = "bedrock-claude-haiku"
|
||||
MODEL_GPT_4O = "gpt-4o"
|
||||
|
||||
# Create the OpenAI client pointed at LiteLLM
|
||||
client = AsyncOpenAI(base_url=BASE_URL, api_key=API_KEY)
|
||||
|
||||
# Disable tracing since we're not using OpenAI's platform directly
|
||||
set_tracing_disabled(disabled=True)
|
||||
```
|
||||
|
||||
## 3. Create a Custom Model Provider
|
||||
|
||||
The Agents SDK uses a `ModelProvider` to resolve model names. Create a custom provider that routes all requests through LiteLLM:
|
||||
|
||||
```python showLineNumbers title="Custom LiteLLM model provider"
|
||||
class LiteLLMModelProvider(ModelProvider):
|
||||
def get_model(self, model_name: str | None) -> Model:
|
||||
return OpenAIChatCompletionsModel(
|
||||
model=model_name or MODEL_BEDROCK_SONNET,
|
||||
openai_client=client,
|
||||
)
|
||||
|
||||
|
||||
LITELLM_MODEL_PROVIDER = LiteLLMModelProvider()
|
||||
```
|
||||
|
||||
## 4. Define a Simple Tool
|
||||
|
||||
Create a tool that your agent can use:
|
||||
|
||||
```python showLineNumbers title="Weather tool implementation"
|
||||
@function_tool
|
||||
def get_weather(city: str) -> str:
|
||||
"""Retrieves the current weather report for a specified city.
|
||||
|
||||
Args:
|
||||
city: The name of the city (e.g., "New York", "London", "Tokyo").
|
||||
|
||||
Returns:
|
||||
A string containing the weather information for the city.
|
||||
"""
|
||||
print(f"[debug] getting weather for {city}")
|
||||
|
||||
mock_weather_db = {
|
||||
"new york": "The weather in New York is sunny with a temperature of 25°C.",
|
||||
"london": "It's cloudy in London with a temperature of 15°C.",
|
||||
"tokyo": "Tokyo is experiencing light rain and a temperature of 18°C.",
|
||||
}
|
||||
|
||||
city_normalized = city.lower()
|
||||
|
||||
if city_normalized in mock_weather_db:
|
||||
return mock_weather_db[city_normalized]
|
||||
else:
|
||||
return f"Sorry, I don't have weather information for '{city}'."
|
||||
```
|
||||
|
||||
## 5. Using Different Models with Agents
|
||||
|
||||
### 5.1 Using Bedrock Models
|
||||
|
||||
```python showLineNumbers title="Bedrock model via LiteLLM proxy"
|
||||
async def test_bedrock_agent():
|
||||
print("\n--- Testing Bedrock Claude Agent ---")
|
||||
|
||||
agent = Agent(
|
||||
name="weather_agent_bedrock",
|
||||
instructions="You are a helpful weather assistant powered by Claude. "
|
||||
"Use the 'get_weather' tool for city weather requests. "
|
||||
"Present information clearly.",
|
||||
tools=[get_weather],
|
||||
)
|
||||
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
"What's the weather in Tokyo?",
|
||||
run_config=RunConfig(
|
||||
model_provider=LITELLM_MODEL_PROVIDER,
|
||||
model="bedrock-claude-sonnet-4", # Uses the model name from your LiteLLM config
|
||||
),
|
||||
)
|
||||
print(f"<<< Agent Response: {result.final_output}")
|
||||
|
||||
|
||||
asyncio.run(test_bedrock_agent())
|
||||
```
|
||||
|
||||
### 5.2 Using OpenAI Models
|
||||
|
||||
```python showLineNumbers title="OpenAI model via LiteLLM proxy"
|
||||
async def test_openai_agent():
|
||||
print("\n--- Testing OpenAI GPT Agent ---")
|
||||
|
||||
agent = Agent(
|
||||
name="weather_agent_gpt",
|
||||
instructions="You are a helpful weather assistant powered by GPT-4o. "
|
||||
"Use the 'get_weather' tool for city weather requests. "
|
||||
"Present information clearly.",
|
||||
tools=[get_weather],
|
||||
)
|
||||
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
"What's the weather in London?",
|
||||
run_config=RunConfig(
|
||||
model_provider=LITELLM_MODEL_PROVIDER,
|
||||
model="gpt-4o", # Uses the model name from your LiteLLM config
|
||||
),
|
||||
)
|
||||
print(f"<<< Agent Response: {result.final_output}")
|
||||
|
||||
|
||||
asyncio.run(test_openai_agent())
|
||||
```
|
||||
|
||||
### 5.3 Using Anthropic Models
|
||||
|
||||
```python showLineNumbers title="Anthropic model via LiteLLM proxy"
|
||||
async def test_anthropic_agent():
|
||||
print("\n--- Testing Anthropic Claude Agent ---")
|
||||
|
||||
agent = Agent(
|
||||
name="weather_agent_claude",
|
||||
instructions="You are a helpful weather assistant powered by Claude. "
|
||||
"Use the 'get_weather' tool for city weather requests. "
|
||||
"Present information clearly.",
|
||||
tools=[get_weather],
|
||||
)
|
||||
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
"What's the weather in New York?",
|
||||
run_config=RunConfig(
|
||||
model_provider=LITELLM_MODEL_PROVIDER,
|
||||
model="claude-sonnet-4", # Uses the model name from your LiteLLM config
|
||||
),
|
||||
)
|
||||
print(f"<<< Agent Response: {result.final_output}")
|
||||
|
||||
|
||||
asyncio.run(test_anthropic_agent())
|
||||
```
|
||||
|
||||
## 6. Complete Working Example
|
||||
|
||||
Here's a full end-to-end script you can copy and run:
|
||||
|
||||
```python showLineNumbers title="complete_agent.py"
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from agents import (
|
||||
Agent,
|
||||
Model,
|
||||
ModelProvider,
|
||||
OpenAIChatCompletionsModel,
|
||||
RunConfig,
|
||||
Runner,
|
||||
function_tool,
|
||||
set_tracing_disabled,
|
||||
)
|
||||
|
||||
# Point to LiteLLM proxy
|
||||
BASE_URL = os.getenv("LITELLM_BASE_URL") or "http://localhost:4000"
|
||||
API_KEY = os.getenv("LITELLM_API_KEY") or "sk-1234"
|
||||
MODEL_NAME = os.getenv("MODEL_NAME") or "bedrock-claude-sonnet-4"
|
||||
|
||||
client = AsyncOpenAI(base_url=BASE_URL, api_key=API_KEY)
|
||||
set_tracing_disabled(disabled=True)
|
||||
|
||||
|
||||
class LiteLLMModelProvider(ModelProvider):
|
||||
def get_model(self, model_name: str | None) -> Model:
|
||||
return OpenAIChatCompletionsModel(
|
||||
model=model_name or MODEL_NAME,
|
||||
openai_client=client,
|
||||
)
|
||||
|
||||
|
||||
LITELLM_MODEL_PROVIDER = LiteLLMModelProvider()
|
||||
|
||||
|
||||
@function_tool
|
||||
def get_weather(city: str) -> str:
|
||||
"""Retrieves the current weather report for a specified city."""
|
||||
print(f"[debug] getting weather for {city}")
|
||||
|
||||
mock_weather_db = {
|
||||
"new york": "The weather in New York is sunny with a temperature of 25°C.",
|
||||
"london": "It's cloudy in London with a temperature of 15°C.",
|
||||
"tokyo": "Tokyo is experiencing light rain and a temperature of 18°C.",
|
||||
}
|
||||
|
||||
city_normalized = city.lower()
|
||||
if city_normalized in mock_weather_db:
|
||||
return mock_weather_db[city_normalized]
|
||||
else:
|
||||
return f"Sorry, I don't have weather information for '{city}'."
|
||||
|
||||
|
||||
async def main():
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
instructions="You are a helpful weather assistant. "
|
||||
"Use the 'get_weather' tool for city weather requests. "
|
||||
"Present information clearly and concisely.",
|
||||
tools=[get_weather],
|
||||
)
|
||||
|
||||
# Run with the default model (bedrock-claude-sonnet-4)
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
"What's the weather in Tokyo?",
|
||||
run_config=RunConfig(model_provider=LITELLM_MODEL_PROVIDER),
|
||||
)
|
||||
print(result.final_output)
|
||||
|
||||
# Switch to a different model by passing model in RunConfig
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
"What's the weather in London?",
|
||||
run_config=RunConfig(
|
||||
model_provider=LITELLM_MODEL_PROVIDER,
|
||||
model="gpt-4o",
|
||||
),
|
||||
)
|
||||
print(result.final_output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Why Use LiteLLM with Agents SDK?
|
||||
|
||||
| Feature | Benefit |
|
||||
|---------|---------|
|
||||
| **Multi-Provider** | Use the same agent code with OpenAI, Bedrock, Azure, Vertex AI, etc. |
|
||||
| **Cost Tracking** | Track spending across all agent conversations |
|
||||
| **Rate Limiting** | Set budgets and limits on agent usage |
|
||||
| **Load Balancing** | Distribute requests across multiple API keys or regions |
|
||||
| **Fallbacks** | Automatically retry with different models if one fails |
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [OpenAI Agents SDK Documentation](https://openai.github.io/openai-agents-python/)
|
||||
- [LiteLLM Proxy Quick Start](../proxy/quick_start)
|
||||
BIN
docs/my-website/img/litellm_proxy_setup.png
Normal file
BIN
docs/my-website/img/litellm_proxy_setup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 538 KiB |
BIN
docs/my-website/img/release_notes/v1_81_14_perf.png
Normal file
BIN
docs/my-website/img/release_notes/v1_81_14_perf.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 625 KiB |
|
|
@ -96,6 +96,39 @@ The Compliance Playground lets you test any guardrail against our pre-built eval
|
|||
|
||||
---
|
||||
|
||||
## Performance & Reliability — Up to 13% Lower Latency
|
||||
|
||||
<Image img={require('../img/release_notes/v1_81_14_perf.png')} />
|
||||
|
||||
This release cuts latency across all percentiles through 20+ micro-optimizations across logging, cost calculation, routing, and connection management. See [benchmarking](../../docs/benchmarks) for more info about how to benchmark yourself.
|
||||
|
||||
- **Mean latency:** 78.4 ms → **70.3 ms** (−10.3%)
|
||||
- **p50 latency:** 64.8 ms → **57.3 ms** (−11.7%)
|
||||
- **p99 latency:** 288.9 ms → **250.0 ms** (−13.4%)
|
||||
|
||||
**Streaming Connection Pool Fix**
|
||||
|
||||
Fixed a 3-fold connection leak that caused TCP connection starvation under streaming workloads: the aiohttp transport wasn't closing connections, no `finally` blocks were calling close on disconnect, and a Uvicorn bug prevented disconnect signaling. [PR #21213](https://github.com/BerriAI/litellm/pull/21213)
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Client Disconnects] --> B[Stream Abandoned]
|
||||
B --> C{Connection cleaned up?}
|
||||
C -->|Before| D["❌ No — connection leaked"]
|
||||
C -->|After| E["✅ Yes — connection returned to pool"]
|
||||
```
|
||||
|
||||
**Redis Connection Pool Reliability**
|
||||
|
||||
Fixed 4 separate connection pool bugs to make how we use Redis more reliable. The most important change was on pools being leaked on cache expiry and the other fixes are detailed here in [PR #21717](https://github.com/BerriAI/litellm/pull/21717).
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Cache Entry Expires] --> B{Pool cleanup?}
|
||||
B -->|Before| C["❌ New untracked pool created — leaked"]
|
||||
B -->|After| D["✅ Pool closed on eviction"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New Providers and Endpoints
|
||||
|
|
@ -438,6 +471,7 @@ The Compliance Playground lets you test any guardrail against our pre-built eval
|
|||
|
||||
- Fix Redis connection pool reliability — prevent connection exhaustion under load - [PR #21717](https://github.com/BerriAI/litellm/pull/21717)
|
||||
- Fix Prisma connection self-heal for auth and runtime reconnection (reverted, will be re-introduced with fixes) - [PR #21706](https://github.com/BerriAI/litellm/pull/21706)
|
||||
- Close streaming connections to prevent connection pool exhaustion - [PR #21213](https://github.com/BerriAI/litellm/pull/21213)
|
||||
- Make `PodLockManager.release_lock` atomic compare-and-delete - [PR #21226](https://github.com/BerriAI/litellm/pull/21226)
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ const sidebars = {
|
|||
"tutorials/cursor_integration",
|
||||
"tutorials/github_copilot_integration",
|
||||
"tutorials/litellm_gemini_cli",
|
||||
"tutorials/google_genai_sdk",
|
||||
"tutorials/litellm_qwen_code_cli",
|
||||
"tutorials/openai_codex"
|
||||
]
|
||||
|
|
@ -180,6 +181,7 @@ const sidebars = {
|
|||
slug: "/agent_sdks"
|
||||
},
|
||||
items: [
|
||||
"tutorials/openai_agents_sdk",
|
||||
"tutorials/claude_agent_sdk",
|
||||
"tutorials/copilotkit_sdk",
|
||||
"tutorials/google_adk",
|
||||
|
|
|
|||
|
|
@ -339,6 +339,10 @@ model_cost_map_url: str = os.getenv(
|
|||
"LITELLM_MODEL_COST_MAP_URL",
|
||||
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
|
||||
)
|
||||
blog_posts_url: str = os.getenv(
|
||||
"LITELLM_BLOG_POSTS_URL",
|
||||
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/blog_posts.json",
|
||||
)
|
||||
anthropic_beta_headers_url: str = os.getenv(
|
||||
"LITELLM_ANTHROPIC_BETA_HEADERS_URL",
|
||||
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/anthropic_beta_headers_config.json",
|
||||
|
|
@ -405,6 +409,7 @@ disable_aiohttp_trust_env: bool = (
|
|||
force_ipv4: bool = (
|
||||
False # when True, litellm will force ipv4 for all LLM requests. Some users have seen httpx ConnectionError when using ipv6.
|
||||
)
|
||||
network_mock: bool = False # When True, use mock transport — no real network calls
|
||||
|
||||
####### STOP SEQUENCE LIMIT #######
|
||||
disable_stop_sequence_limit: bool = False # when True, stop sequence limit is disabled
|
||||
|
|
|
|||
10
litellm/blog_posts.json
Normal file
10
litellm/blog_posts.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"posts": [
|
||||
{
|
||||
"title": "Incident Report: SERVER_ROOT_PATH regression broke UI routing",
|
||||
"description": "How a single line removal caused UI 404s for all deployments using SERVER_ROOT_PATH, and the tests we added to prevent it from happening again.",
|
||||
"date": "2026-02-21",
|
||||
"url": "https://docs.litellm.ai/blog/server-root-path-incident"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -242,9 +242,13 @@ REDIS_DAILY_END_USER_SPEND_UPDATE_BUFFER_KEY = (
|
|||
REDIS_DAILY_AGENT_SPEND_UPDATE_BUFFER_KEY = "litellm_daily_agent_spend_update_buffer"
|
||||
REDIS_DAILY_TAG_SPEND_UPDATE_BUFFER_KEY = "litellm_daily_tag_spend_update_buffer"
|
||||
MAX_REDIS_BUFFER_DEQUEUE_COUNT = int(os.getenv("MAX_REDIS_BUFFER_DEQUEUE_COUNT", 100))
|
||||
MAX_SIZE_IN_MEMORY_QUEUE = int(os.getenv("MAX_SIZE_IN_MEMORY_QUEUE", 2000))
|
||||
# Bounds asyncio.Queue() instances (log queues, spend update queues, etc.) to prevent unbounded memory growth
|
||||
LITELLM_ASYNCIO_QUEUE_MAXSIZE = int(os.getenv("LITELLM_ASYNCIO_QUEUE_MAXSIZE", 1000))
|
||||
# Aggregation threshold: default to 80% of the asyncio queue maxsize so the check can always trigger.
|
||||
# Must be < LITELLM_ASYNCIO_QUEUE_MAXSIZE; if set higher the aggregation logic will never fire.
|
||||
MAX_SIZE_IN_MEMORY_QUEUE = int(
|
||||
os.getenv("MAX_SIZE_IN_MEMORY_QUEUE", int(LITELLM_ASYNCIO_QUEUE_MAXSIZE * 0.8))
|
||||
)
|
||||
MAX_IN_MEMORY_QUEUE_FLUSH_COUNT = int(
|
||||
os.getenv("MAX_IN_MEMORY_QUEUE_FLUSH_COUNT", 1000)
|
||||
)
|
||||
|
|
|
|||
128
litellm/litellm_core_utils/get_blog_posts.py
Normal file
128
litellm/litellm_core_utils/get_blog_posts.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""
|
||||
Pulls the latest LiteLLM blog posts from GitHub.
|
||||
|
||||
Falls back to the bundled local backup on any failure.
|
||||
GitHub JSON URL is configured via litellm.blog_posts_url (or LITELLM_BLOG_POSTS_URL env var).
|
||||
|
||||
Disable remote fetching entirely:
|
||||
export LITELLM_LOCAL_BLOG_POSTS=True
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from importlib.resources import files
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm import verbose_logger
|
||||
|
||||
BLOG_POSTS_TTL_SECONDS: int = 3600 # 1 hour
|
||||
|
||||
|
||||
class BlogPost(BaseModel):
|
||||
title: str
|
||||
description: str
|
||||
date: str
|
||||
url: str
|
||||
|
||||
|
||||
class BlogPostsResponse(BaseModel):
|
||||
posts: List[BlogPost]
|
||||
|
||||
|
||||
class GetBlogPosts:
|
||||
"""
|
||||
Fetches, validates, and caches LiteLLM blog posts.
|
||||
|
||||
Mirrors the structure of GetModelCostMap:
|
||||
- Fetches from GitHub with a 5-second timeout
|
||||
- Validates the response has a non-empty ``posts`` list
|
||||
- Caches the result in-process for BLOG_POSTS_TTL_SECONDS (1 hour)
|
||||
- Falls back to the bundled local backup on any failure
|
||||
"""
|
||||
|
||||
_cached_posts: Optional[List[Dict[str, str]]] = None
|
||||
_last_fetch_time: float = 0.0
|
||||
|
||||
@staticmethod
|
||||
def load_local_blog_posts() -> List[Dict[str, str]]:
|
||||
"""Load the bundled local backup blog posts."""
|
||||
content = json.loads(
|
||||
files("litellm")
|
||||
.joinpath("blog_posts.json")
|
||||
.read_text(encoding="utf-8")
|
||||
)
|
||||
return content.get("posts", [])
|
||||
|
||||
@staticmethod
|
||||
def fetch_remote_blog_posts(url: str, timeout: int = 5) -> dict:
|
||||
"""
|
||||
Fetch blog posts JSON from a remote URL.
|
||||
|
||||
Returns the parsed response. Raises on network/parse errors.
|
||||
"""
|
||||
response = httpx.get(url, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@staticmethod
|
||||
def validate_blog_posts(data: Any) -> bool:
|
||||
"""Return True if data is a dict with a non-empty ``posts`` list."""
|
||||
if not isinstance(data, dict):
|
||||
verbose_logger.warning(
|
||||
"LiteLLM: Blog posts response is not a dict (type=%s). "
|
||||
"Falling back to local backup.",
|
||||
type(data).__name__,
|
||||
)
|
||||
return False
|
||||
posts = data.get("posts")
|
||||
if not isinstance(posts, list) or len(posts) == 0:
|
||||
verbose_logger.warning(
|
||||
"LiteLLM: Blog posts response has no valid 'posts' list. "
|
||||
"Falling back to local backup.",
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_blog_posts(cls, url: str) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Return the blog posts list.
|
||||
|
||||
Uses the in-process cache if within BLOG_POSTS_TTL_SECONDS.
|
||||
Fetches from ``url`` otherwise, falling back to local backup on failure.
|
||||
"""
|
||||
if os.getenv("LITELLM_LOCAL_BLOG_POSTS", "").lower() == "true":
|
||||
return cls.load_local_blog_posts()
|
||||
|
||||
now = time.time()
|
||||
cached = cls._cached_posts
|
||||
if cached is not None and (now - cls._last_fetch_time) < BLOG_POSTS_TTL_SECONDS:
|
||||
return cached
|
||||
|
||||
try:
|
||||
data = cls.fetch_remote_blog_posts(url)
|
||||
except Exception as e:
|
||||
verbose_logger.warning(
|
||||
"LiteLLM: Failed to fetch blog posts from %s: %s. "
|
||||
"Falling back to local backup.",
|
||||
url,
|
||||
str(e),
|
||||
)
|
||||
return cls.load_local_blog_posts()
|
||||
|
||||
if not cls.validate_blog_posts(data):
|
||||
return cls.load_local_blog_posts()
|
||||
|
||||
posts = data["posts"]
|
||||
cls._cached_posts = posts
|
||||
cls._last_fetch_time = now
|
||||
return posts
|
||||
|
||||
|
||||
def get_blog_posts(url: str) -> List[Dict[str, str]]:
|
||||
"""Public entry point — returns the blog posts list."""
|
||||
return GetBlogPosts.get_blog_posts(url=url)
|
||||
|
|
@ -118,10 +118,11 @@ class BaseVideoConfig(ABC):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
variant: Optional[str] = None,
|
||||
) -> Tuple[str, Dict]:
|
||||
"""
|
||||
Transform the video content request into a URL and data/params
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple[str, Dict]: (url, params) for the video content request
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -5400,6 +5400,7 @@ class BaseLLMHTTPHandler:
|
|||
api_key: Optional[str] = None,
|
||||
client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None,
|
||||
_is_async: bool = False,
|
||||
variant: Optional[str] = None,
|
||||
) -> Union[bytes, Coroutine[Any, Any, bytes]]:
|
||||
"""
|
||||
Handle video content download requests.
|
||||
|
|
@ -5415,6 +5416,7 @@ class BaseLLMHTTPHandler:
|
|||
extra_headers=extra_headers,
|
||||
api_key=api_key,
|
||||
client=client,
|
||||
variant=variant,
|
||||
)
|
||||
|
||||
if client is None or not isinstance(client, HTTPHandler):
|
||||
|
|
@ -5446,6 +5448,7 @@ class BaseLLMHTTPHandler:
|
|||
api_base=api_base,
|
||||
litellm_params=litellm_params,
|
||||
headers=headers,
|
||||
variant=variant,
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -5488,6 +5491,7 @@ class BaseLLMHTTPHandler:
|
|||
extra_headers: Optional[Dict[str, Any]] = None,
|
||||
api_key: Optional[str] = None,
|
||||
client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None,
|
||||
variant: Optional[str] = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Async version of the video content download handler.
|
||||
|
|
@ -5522,6 +5526,7 @@ class BaseLLMHTTPHandler:
|
|||
api_base=api_base,
|
||||
litellm_params=litellm_params,
|
||||
headers=headers,
|
||||
variant=variant,
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -5597,7 +5602,7 @@ class BaseLLMHTTPHandler:
|
|||
sync_httpx_client = client
|
||||
|
||||
headers = video_remix_provider_config.validate_environment(
|
||||
api_key=api_key,
|
||||
api_key=api_key or litellm_params.get("api_key", None),
|
||||
headers=extra_headers or {},
|
||||
model="",
|
||||
)
|
||||
|
|
@ -5679,7 +5684,7 @@ class BaseLLMHTTPHandler:
|
|||
async_httpx_client = client
|
||||
|
||||
headers = video_remix_provider_config.validate_environment(
|
||||
api_key=api_key,
|
||||
api_key=api_key or litellm_params.get("api_key", None),
|
||||
headers=extra_headers or {},
|
||||
model="",
|
||||
)
|
||||
|
|
|
|||
92
litellm/llms/custom_httpx/mock_transport.py
Normal file
92
litellm/llms/custom_httpx/mock_transport.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""
|
||||
Mock httpx transport that returns valid OpenAI ChatCompletion responses.
|
||||
|
||||
Activated via `litellm_settings: { network_mock: true }`.
|
||||
Intercepts at the httpx transport layer — the lowest point before bytes hit the wire —
|
||||
so the full proxy -> router -> OpenAI SDK -> httpx path is exercised.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-built response templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mock_id() -> str:
|
||||
return f"chatcmpl-mock-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _chat_completion_json(model: str) -> dict:
|
||||
"""Return a minimal valid ChatCompletion object."""
|
||||
return {
|
||||
"id": _mock_id(),
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": model,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Mock response",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 2,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transport
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_JSON_HEADERS = {
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
class MockOpenAITransport(httpx.AsyncBaseTransport, httpx.BaseTransport):
|
||||
"""
|
||||
httpx transport that returns canned OpenAI ChatCompletion responses.
|
||||
|
||||
Supports both async (AsyncOpenAI) and sync (OpenAI) SDK paths.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _parse_request(request: httpx.Request) -> Tuple[str, bool]:
|
||||
"""Extract model from the request body."""
|
||||
try:
|
||||
body = json.loads(request.content)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return ("mock-model", False)
|
||||
model = body.get("model", "mock-model")
|
||||
return (model, False)
|
||||
|
||||
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||
model, _ = self._parse_request(request)
|
||||
body = json.dumps(_chat_completion_json(model)).encode()
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
headers=_JSON_HEADERS,
|
||||
content=body,
|
||||
)
|
||||
|
||||
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||
model, _ = self._parse_request(request)
|
||||
body = json.dumps(_chat_completion_json(model)).encode()
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
headers=_JSON_HEADERS,
|
||||
content=body,
|
||||
)
|
||||
|
|
@ -393,10 +393,11 @@ class GeminiVideoConfig(BaseVideoConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
variant: Optional[str] = None,
|
||||
) -> Tuple[str, Dict]:
|
||||
"""
|
||||
Transform the video content request for Veo API.
|
||||
|
||||
|
||||
For Veo, we need to:
|
||||
1. Get operation status to extract video URI
|
||||
2. Return download URL for the video
|
||||
|
|
|
|||
|
|
@ -205,6 +205,11 @@ class BaseOpenAILLM:
|
|||
if litellm.aclient_session is not None:
|
||||
return litellm.aclient_session
|
||||
|
||||
if getattr(litellm, "network_mock", False):
|
||||
from litellm.llms.custom_httpx.mock_transport import MockOpenAITransport
|
||||
|
||||
return httpx.AsyncClient(transport=MockOpenAITransport())
|
||||
|
||||
# Get unified SSL configuration
|
||||
ssl_config = get_ssl_configuration()
|
||||
|
||||
|
|
@ -225,6 +230,11 @@ class BaseOpenAILLM:
|
|||
if litellm.client_session is not None:
|
||||
return litellm.client_session
|
||||
|
||||
if getattr(litellm, "network_mock", False):
|
||||
from litellm.llms.custom_httpx.mock_transport import MockOpenAITransport
|
||||
|
||||
return httpx.Client(transport=MockOpenAITransport())
|
||||
|
||||
# Get unified SSL configuration
|
||||
ssl_config = get_ssl_configuration()
|
||||
|
||||
|
|
|
|||
|
|
@ -172,18 +172,22 @@ class OpenAIVideoConfig(BaseVideoConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
variant: Optional[str] = None,
|
||||
) -> Tuple[str, Dict]:
|
||||
"""
|
||||
Transform the video content request for OpenAI API.
|
||||
|
||||
|
||||
OpenAI API expects the following request:
|
||||
- GET /v1/videos/{video_id}/content
|
||||
- GET /v1/videos/{video_id}/content?variant=thumbnail
|
||||
"""
|
||||
original_video_id = extract_original_video_id(video_id)
|
||||
|
||||
|
||||
# Construct the URL for video content download
|
||||
url = f"{api_base.rstrip('/')}/{original_video_id}/content"
|
||||
|
||||
if variant is not None:
|
||||
url = f"{url}?variant={variant}"
|
||||
|
||||
# No additional data needed for GET content request
|
||||
data: Dict[str, Any] = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -310,10 +310,11 @@ class RunwayMLVideoConfig(BaseVideoConfig):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
variant: Optional[str] = None,
|
||||
) -> Tuple[str, Dict]:
|
||||
"""
|
||||
Transform the video content request for RunwayML API.
|
||||
|
||||
|
||||
RunwayML doesn't have a separate content download endpoint.
|
||||
The video URL is returned in the task output field.
|
||||
We'll retrieve the task and extract the video URL.
|
||||
|
|
|
|||
|
|
@ -455,6 +455,7 @@ class VertexAIVideoConfig(BaseVideoConfig, VertexBase):
|
|||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
variant: Optional[str] = None,
|
||||
) -> Tuple[str, Dict]:
|
||||
"""
|
||||
Transform the video content request for Veo API.
|
||||
|
|
|
|||
|
|
@ -2454,5 +2454,367 @@
|
|||
"Injection Protection"
|
||||
],
|
||||
"estimated_latency_ms": 1
|
||||
},
|
||||
{
|
||||
"id": "pdpa-singapore",
|
||||
"title": "Singapore PDPA \u2014 Personal Data Protection",
|
||||
"description": "Singapore Personal Data Protection Act (PDPA) compliance. Covers 5 obligation areas: personal identifier collection (s.13 Consent), sensitive data profiling (Advisory Guidelines), Do Not Call Registry violations (Part IX), overseas data transfers (s.26), and automated profiling without human oversight (Model AI Governance Framework). Also includes regex-based PII detection for NRIC/FIN, Singapore phone numbers, postal codes, passports, UEN, and bank account numbers. Zero-cost keyword-based detection.",
|
||||
"icon": "ShieldCheckIcon",
|
||||
"iconColor": "text-red-500",
|
||||
"iconBg": "bg-red-50",
|
||||
"guardrails": [
|
||||
"pdpa-sg-pii-identifiers",
|
||||
"pdpa-sg-contact-information",
|
||||
"pdpa-sg-financial-data",
|
||||
"pdpa-sg-business-identifiers",
|
||||
"pdpa-sg-personal-identifiers",
|
||||
"pdpa-sg-sensitive-data",
|
||||
"pdpa-sg-do-not-call",
|
||||
"pdpa-sg-data-transfer",
|
||||
"pdpa-sg-profiling-automated-decisions"
|
||||
],
|
||||
"complexity": "High",
|
||||
"guardrailDefinitions": [
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-pii-identifiers",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"patterns": [
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "sg_nric",
|
||||
"action": "MASK"
|
||||
},
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "passport_singapore",
|
||||
"action": "MASK"
|
||||
}
|
||||
],
|
||||
"pattern_redaction_format": "[{pattern_name}_REDACTED]"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Masks Singapore NRIC/FIN and passport numbers for PDPA compliance"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-contact-information",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"patterns": [
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "sg_phone",
|
||||
"action": "MASK"
|
||||
},
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "sg_postal_code",
|
||||
"action": "MASK"
|
||||
},
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "email",
|
||||
"action": "MASK"
|
||||
}
|
||||
],
|
||||
"pattern_redaction_format": "[{pattern_name}_REDACTED]"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Masks Singapore phone numbers, postal codes, and email addresses"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-financial-data",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"patterns": [
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "sg_bank_account",
|
||||
"action": "MASK"
|
||||
},
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "credit_card",
|
||||
"action": "MASK"
|
||||
}
|
||||
],
|
||||
"pattern_redaction_format": "[{pattern_name}_REDACTED]"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Masks Singapore bank account numbers and credit card numbers"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-business-identifiers",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"patterns": [
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "sg_uen",
|
||||
"action": "MASK"
|
||||
}
|
||||
],
|
||||
"pattern_redaction_format": "[UEN_REDACTED]"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Masks Singapore Unique Entity Numbers (business registration)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-personal-identifiers",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_personal_identifiers",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_personal_identifiers.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "PDPA s.13 \u2014 Blocks unauthorized collection, harvesting, or extraction of Singapore personal identifiers (NRIC/FIN, SingPass, passports)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-sensitive-data",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_sensitive_data",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_sensitive_data.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "PDPA Advisory Guidelines \u2014 Blocks profiling or inference of sensitive personal data categories (race, religion, health, politics) for Singapore residents"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-do-not-call",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_do_not_call",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_do_not_call.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "PDPA Part IX \u2014 Blocks generation of unsolicited marketing lists and DNC Registry bypass attempts for Singapore phone numbers"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-data-transfer",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_data_transfer",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_data_transfer.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "PDPA s.26 \u2014 Blocks unprotected overseas transfer of Singapore personal data without adequate safeguards"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-profiling-automated-decisions",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_profiling_automated_decisions",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_profiling_automated_decisions.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "PDPA + Model AI Governance Framework \u2014 Blocks automated profiling and decision-making about Singapore residents without human oversight"
|
||||
}
|
||||
}
|
||||
],
|
||||
"templateData": {
|
||||
"policy_name": "pdpa-singapore",
|
||||
"description": "Singapore PDPA compliance policy. Covers personal identifier protection (s.13), sensitive data profiling (Advisory Guidelines), Do Not Call Registry (Part IX), overseas data transfers (s.26), and automated profiling (Model AI Governance Framework). Includes regex-based PII detection for NRIC/FIN, phone numbers, postal codes, passports, UEN, and bank accounts.",
|
||||
"guardrails_add": [
|
||||
"pdpa-sg-pii-identifiers",
|
||||
"pdpa-sg-contact-information",
|
||||
"pdpa-sg-financial-data",
|
||||
"pdpa-sg-business-identifiers",
|
||||
"pdpa-sg-personal-identifiers",
|
||||
"pdpa-sg-sensitive-data",
|
||||
"pdpa-sg-do-not-call",
|
||||
"pdpa-sg-data-transfer",
|
||||
"pdpa-sg-profiling-automated-decisions"
|
||||
],
|
||||
"guardrails_remove": []
|
||||
},
|
||||
"tags": [
|
||||
"PII Protection",
|
||||
"Regulatory",
|
||||
"Singapore"
|
||||
],
|
||||
"estimated_latency_ms": 1
|
||||
},
|
||||
{
|
||||
"id": "mas-ai-risk-management",
|
||||
"title": "Singapore MAS \u2014 AI Risk Management for Financial Institutions",
|
||||
"description": "Monetary Authority of Singapore (MAS) AI Risk Management for Financial Institutions alignment. Covers 5 enforceable obligation areas: fairness & bias in financial decisions, transparency & explainability of AI models, human oversight for consequential actions, data governance for financial customer data, and model security against adversarial attacks. Based on Guidelines on Artificial Intelligence Risk Management (MAS), and aligned with the 2018 FEAT Principles and Project MindForge. Zero-cost keyword-based detection.",
|
||||
"icon": "ShieldCheckIcon",
|
||||
"iconColor": "text-blue-600",
|
||||
"iconBg": "bg-blue-50",
|
||||
"guardrails": [
|
||||
"mas-sg-fairness-bias",
|
||||
"mas-sg-transparency-explainability",
|
||||
"mas-sg-human-oversight",
|
||||
"mas-sg-data-governance",
|
||||
"mas-sg-model-security"
|
||||
],
|
||||
"complexity": "High",
|
||||
"guardrailDefinitions": [
|
||||
{
|
||||
"guardrail_name": "mas-sg-fairness-bias",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_fairness_bias",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_fairness_bias.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks discriminatory AI practices in financial services that score, deny, or price based on protected attributes (race, religion, age, gender, nationality)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "mas-sg-transparency-explainability",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_transparency_explainability",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_transparency_explainability.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks deployment of opaque or unexplainable AI systems for consequential financial decisions"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "mas-sg-human-oversight",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_human_oversight",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_human_oversight.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks fully automated financial AI decisions without human-in-the-loop for consequential actions (loans, claims, trading)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "mas-sg-data-governance",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_data_governance",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_data_governance.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks unauthorized sharing, exposure, or mishandling of financial customer data without proper governance and data lineage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "mas-sg-model-security",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_model_security",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_model_security.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks adversarial attacks, model poisoning, inversion, and exfiltration attempts targeting financial AI systems"
|
||||
}
|
||||
}
|
||||
],
|
||||
"templateData": {
|
||||
"policy_name": "mas-ai-risk-management",
|
||||
"description": "Guidelines on Artificial Intelligence Risk Management (MAS) for Financial Institutions alignment. Covers fairness & bias, transparency & explainability, human oversight, data governance, and model security. Aligned with the 2018 FEAT Principles, Project MindForge, and NIST AI RMF.",
|
||||
"guardrails_add": [
|
||||
"mas-sg-fairness-bias",
|
||||
"mas-sg-transparency-explainability",
|
||||
"mas-sg-human-oversight",
|
||||
"mas-sg-data-governance",
|
||||
"mas-sg-model-security"
|
||||
],
|
||||
"guardrails_remove": []
|
||||
},
|
||||
"tags": [
|
||||
"Financial Services",
|
||||
"Regulatory",
|
||||
"Singapore"
|
||||
],
|
||||
"estimated_latency_ms": 1
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -23,11 +23,6 @@ model_list:
|
|||
|
||||
|
||||
guardrails:
|
||||
- guardrail_name: mcp-user-permissions
|
||||
litellm_params:
|
||||
guardrail: mcp_end_user_permission
|
||||
mode: pre_call
|
||||
default_on: true
|
||||
- guardrail_name: "airline-competitor-intent"
|
||||
guardrail_id: "airline-competitor-intent"
|
||||
litellm_params:
|
||||
|
|
|
|||
|
|
@ -23,6 +23,15 @@ class BaseUpdateQueue:
|
|||
def __init__(self):
|
||||
self.update_queue = asyncio.Queue(maxsize=LITELLM_ASYNCIO_QUEUE_MAXSIZE)
|
||||
self.MAX_SIZE_IN_MEMORY_QUEUE = MAX_SIZE_IN_MEMORY_QUEUE
|
||||
if MAX_SIZE_IN_MEMORY_QUEUE >= LITELLM_ASYNCIO_QUEUE_MAXSIZE:
|
||||
verbose_proxy_logger.warning(
|
||||
"Misconfigured queue thresholds: MAX_SIZE_IN_MEMORY_QUEUE (%d) >= LITELLM_ASYNCIO_QUEUE_MAXSIZE (%d). "
|
||||
"The spend aggregation check will never trigger because the asyncio.Queue blocks at %d items. "
|
||||
"Set MAX_SIZE_IN_MEMORY_QUEUE to a value less than LITELLM_ASYNCIO_QUEUE_MAXSIZE (recommended: 80%% of it).",
|
||||
MAX_SIZE_IN_MEMORY_QUEUE,
|
||||
LITELLM_ASYNCIO_QUEUE_MAXSIZE,
|
||||
LITELLM_ASYNCIO_QUEUE_MAXSIZE,
|
||||
)
|
||||
|
||||
async def add_update(self, update):
|
||||
"""Enqueue an update."""
|
||||
|
|
|
|||
|
|
@ -493,6 +493,56 @@
|
|||
"description": "Detects airline flight numbers (major IATA 2-letter codes + 1-4 digit flight number) when near flight context",
|
||||
"keyword_pattern": "\\b(?:flight|departure|arrival|gate|boarding|schedule|operate|route|aircraft|plane|outbound|inbound|leg|sector|flying)\\b",
|
||||
"allow_word_numbers": false
|
||||
},
|
||||
{
|
||||
"name": "sg_nric",
|
||||
"display_name": "NRIC/FIN (Singapore National ID)",
|
||||
"pattern": "\\b[STFGM]\\d{7}[A-Z]\\b",
|
||||
"category": "Singapore PII Patterns",
|
||||
"description": "Detects Singapore NRIC and FIN numbers (S/T for citizens/PRs, F/G/M for foreigners + 7 digits + checksum letter)"
|
||||
},
|
||||
{
|
||||
"name": "sg_phone",
|
||||
"display_name": "Phone Number (Singapore)",
|
||||
"pattern": "(?<!\\d)(?:\\+65|0065|65)\\s?[689]\\d{3}\\s?\\d{4}\\b",
|
||||
"category": "Singapore PII Patterns",
|
||||
"description": "Detects Singapore phone numbers (+65/0065/65 prefix with 6/8/9 starting digit for landline and mobile)"
|
||||
},
|
||||
{
|
||||
"name": "sg_postal_code",
|
||||
"display_name": "Postal Code (Singapore)",
|
||||
"pattern": "\\b[0-8]\\d{5}\\b",
|
||||
"category": "Singapore PII Patterns",
|
||||
"description": "Detects Singapore 6-digit postal codes (ranges 01xxxx to 83xxxx)",
|
||||
"keyword_pattern": "\\b(?:postal\\s*code|postcode|zip\\s*code|Singapore\\s*\\d|blk|block|address|HDB|flat|unit)\\b",
|
||||
"allow_word_numbers": false
|
||||
},
|
||||
{
|
||||
"name": "passport_singapore",
|
||||
"display_name": "Passport (Singapore)",
|
||||
"pattern": "\\b[EK]\\d{7}\\b",
|
||||
"category": "Singapore PII Patterns",
|
||||
"description": "Detects Singapore passport numbers (E/K prefix + 7 digits)",
|
||||
"keyword_pattern": "\\b(?:passport|travel\\s*document|document\\s*number|immigration|ICA)\\b",
|
||||
"allow_word_numbers": false
|
||||
},
|
||||
{
|
||||
"name": "sg_uen",
|
||||
"display_name": "UEN (Singapore Unique Entity Number)",
|
||||
"pattern": "\\b(?:\\d{8}[A-Z]|\\d{9}[A-Z]|[TR]\\d{2}[A-Z]{2}\\d{4}[A-Z])\\b",
|
||||
"category": "Singapore PII Patterns",
|
||||
"description": "Detects Singapore Unique Entity Numbers for business registration (various formats: 8/9 digits + letter, or T/R prefix)",
|
||||
"keyword_pattern": "\\b(?:UEN|company\\s*registration|business\\s*registration|ACRA|entity\\s*number|ROC|ROS)\\b",
|
||||
"allow_word_numbers": false
|
||||
},
|
||||
{
|
||||
"name": "sg_bank_account",
|
||||
"display_name": "Bank Account (Singapore)",
|
||||
"pattern": "\\b\\d{3}-\\d{5,6}-\\d{1,3}\\b",
|
||||
"category": "Singapore PII Patterns",
|
||||
"description": "Detects Singapore bank account numbers (XXX-XXXXX(X)-X(XX) format for DBS/POSB/OCBC/UOB)",
|
||||
"keyword_pattern": "\\b(?:bank\\s*account|account\\s*number|DBS|OCBC|UOB|POSB|savings|current\\s*account|bank\\s*statement)\\b",
|
||||
"allow_word_numbers": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
# Guidelines on Artificial Intelligence Risk Management (MAS) — Data Governance (Financial Data)
|
||||
# Blocks requests to mishandle, share, or expose financial customer data
|
||||
# without proper governance, lineage, or authorization.
|
||||
# Reference: https://www.mas.gov.sg/publications/consultations/2025/consultation-paper-on-guidelines-on-artificial-intelligence-risk-management
|
||||
category_name: "sg_mas_data_governance"
|
||||
description: "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks unauthorized sharing, exposure, or mishandling of financial customer data without proper governance"
|
||||
default_action: "BLOCK"
|
||||
|
||||
identifier_words:
|
||||
# Financial data types
|
||||
- "customer account"
|
||||
- "account balance"
|
||||
- "transaction history"
|
||||
- "financial record"
|
||||
- "bank statement"
|
||||
- "credit report"
|
||||
- "loan record"
|
||||
- "portfolio data"
|
||||
- "trading record"
|
||||
- "payment history"
|
||||
|
||||
# Singapore financial identifiers
|
||||
- "CPF statement"
|
||||
- "CPF balance"
|
||||
- "MediSave record"
|
||||
- "CDP account"
|
||||
- "SRS account"
|
||||
- "GIRO record"
|
||||
|
||||
# Customer PII in financial context
|
||||
- "customer KYC data"
|
||||
- "customer identity"
|
||||
- "beneficial owner"
|
||||
|
||||
additional_block_words:
|
||||
# Unauthorized sharing
|
||||
- "share without consent"
|
||||
- "share externally"
|
||||
- "share with third party"
|
||||
- "expose"
|
||||
- "leak"
|
||||
- "dump"
|
||||
- "exfiltrate"
|
||||
|
||||
# Poor data practices
|
||||
- "without lineage"
|
||||
- "without provenance"
|
||||
- "no audit trail"
|
||||
- "untracked"
|
||||
- "unlogged"
|
||||
|
||||
# Training misuse
|
||||
- "train model on"
|
||||
- "use for training"
|
||||
- "fine-tune on"
|
||||
- "without consent"
|
||||
- "without authorization"
|
||||
|
||||
# Unauthorized aggregation
|
||||
- "scrape"
|
||||
- "harvest"
|
||||
- "bulk extract"
|
||||
- "mass download"
|
||||
|
||||
always_block_keywords:
|
||||
- keyword: "exfiltrate customer financial data"
|
||||
severity: "high"
|
||||
- keyword: "dump account balances"
|
||||
severity: "high"
|
||||
- keyword: "leak transaction history"
|
||||
severity: "high"
|
||||
- keyword: "train model on customer data without consent"
|
||||
severity: "high"
|
||||
- keyword: "share KYC data with third party without authorization"
|
||||
severity: "high"
|
||||
- keyword: "scrape CPF statement data"
|
||||
severity: "high"
|
||||
- keyword: "bulk extract bank statement"
|
||||
severity: "high"
|
||||
|
||||
exceptions:
|
||||
- "data governance"
|
||||
- "data lineage"
|
||||
- "data provenance"
|
||||
- "audit trail"
|
||||
- "with consent"
|
||||
- "authorized"
|
||||
- "compliance"
|
||||
- "regulatory reporting"
|
||||
- "explain"
|
||||
- "what is"
|
||||
- "research"
|
||||
- "anonymised"
|
||||
- "anonymized"
|
||||
- "aggregated"
|
||||
- "encrypted"
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
# Guidelines on Artificial Intelligence Risk Management (MAS) — Fairness & Bias
|
||||
# Blocks requests that would create discriminatory AI systems in
|
||||
# financial services (credit, lending, insurance, hiring) based on
|
||||
# protected attributes (race, religion, age, gender, nationality).
|
||||
# Reference: https://www.mas.gov.sg/publications/consultations/2025/consultation-paper-on-guidelines-on-artificial-intelligence-risk-management
|
||||
category_name: "sg_mas_fairness_bias"
|
||||
description: "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks discriminatory AI practices in financial services that score, deny, or price based on protected attributes"
|
||||
default_action: "BLOCK"
|
||||
|
||||
identifier_words:
|
||||
# Protected attributes
|
||||
- "race"
|
||||
- "ethnicity"
|
||||
- "religion"
|
||||
- "gender"
|
||||
- "age"
|
||||
- "nationality"
|
||||
- "marital status"
|
||||
- "disability"
|
||||
- "pregnancy"
|
||||
- "sexual orientation"
|
||||
|
||||
# Singapore-specific groups
|
||||
- "Malay"
|
||||
- "Indian"
|
||||
- "Chinese"
|
||||
- "Eurasian"
|
||||
- "CMIO"
|
||||
- "PR holder"
|
||||
- "foreign worker"
|
||||
|
||||
additional_block_words:
|
||||
# Financial decisions
|
||||
- "credit score"
|
||||
- "credit rating"
|
||||
- "loan approval"
|
||||
- "loan denial"
|
||||
- "loan pricing"
|
||||
- "interest rate"
|
||||
- "insurance premium"
|
||||
- "insurance pricing"
|
||||
- "insurance denial"
|
||||
- "risk score"
|
||||
- "risk premium"
|
||||
|
||||
# Discriminatory actions
|
||||
- "deny"
|
||||
- "reject"
|
||||
- "penalize"
|
||||
- "surcharge"
|
||||
- "exclude"
|
||||
- "discriminate"
|
||||
- "higher rate"
|
||||
- "lower limit"
|
||||
- "restrict access"
|
||||
|
||||
# Profiling
|
||||
- "profile by"
|
||||
- "segment by"
|
||||
- "price by"
|
||||
- "score by"
|
||||
- "weight by"
|
||||
- "adjust by"
|
||||
|
||||
always_block_keywords:
|
||||
- keyword: "deny loan based on race"
|
||||
severity: "high"
|
||||
- keyword: "credit score by ethnicity"
|
||||
severity: "high"
|
||||
- keyword: "insurance premium based on religion"
|
||||
severity: "high"
|
||||
- keyword: "reject application based on nationality"
|
||||
severity: "high"
|
||||
- keyword: "higher interest rate for Malay"
|
||||
severity: "high"
|
||||
- keyword: "lower credit limit for Indian"
|
||||
severity: "high"
|
||||
- keyword: "penalize based on gender"
|
||||
severity: "high"
|
||||
- keyword: "discriminate by age in lending"
|
||||
severity: "high"
|
||||
- keyword: "price insurance by race Singapore"
|
||||
severity: "high"
|
||||
- keyword: "exclude foreign worker from financial service"
|
||||
severity: "high"
|
||||
|
||||
exceptions:
|
||||
- "fairness audit"
|
||||
- "bias detection"
|
||||
- "bias mitigation"
|
||||
- "anti-discrimination"
|
||||
- "equal opportunity"
|
||||
- "fairness metric"
|
||||
- "disparate impact analysis"
|
||||
- "compliance review"
|
||||
- "explain"
|
||||
- "what is"
|
||||
- "research"
|
||||
- "academic"
|
||||
- "FEAT principles"
|
||||
- "model validation"
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
# Guidelines on Artificial Intelligence Risk Management (MAS) — Human Oversight & Control
|
||||
# Blocks requests to build or deploy fully automated financial AI
|
||||
# systems for consequential decisions without human-in-the-loop.
|
||||
# Reference: https://www.mas.gov.sg/publications/consultations/2025/consultation-paper-on-guidelines-on-artificial-intelligence-risk-management
|
||||
category_name: "sg_mas_human_oversight"
|
||||
description: "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks fully automated financial AI decisions without human oversight for consequential actions"
|
||||
default_action: "BLOCK"
|
||||
|
||||
identifier_words:
|
||||
# Financial products & decisions
|
||||
- "loan approval"
|
||||
- "loan rejection"
|
||||
- "credit approval"
|
||||
- "credit denial"
|
||||
- "insurance claim"
|
||||
- "insurance payout"
|
||||
- "account closure"
|
||||
- "account freeze"
|
||||
- "margin call"
|
||||
- "trade execution"
|
||||
- "fund transfer"
|
||||
- "payment block"
|
||||
|
||||
# Customer impact actions
|
||||
- "customer onboarding"
|
||||
- "KYC rejection"
|
||||
- "AML flag"
|
||||
- "suspicious transaction"
|
||||
- "fraud flag"
|
||||
|
||||
additional_block_words:
|
||||
# Full automation
|
||||
- "fully automated"
|
||||
- "fully autonomous"
|
||||
- "no human review"
|
||||
- "no human oversight"
|
||||
- "without human"
|
||||
- "without review"
|
||||
- "without approval"
|
||||
- "auto-approve"
|
||||
- "auto-reject"
|
||||
- "auto-deny"
|
||||
- "auto-execute"
|
||||
- "automatic decision"
|
||||
- "remove human"
|
||||
- "eliminate human"
|
||||
- "bypass review"
|
||||
- "skip review"
|
||||
|
||||
# Speed over safety
|
||||
- "instant decision"
|
||||
- "real-time decision"
|
||||
- "zero-touch"
|
||||
- "straight-through processing"
|
||||
|
||||
always_block_keywords:
|
||||
- keyword: "fully automated loan approval without human"
|
||||
severity: "high"
|
||||
- keyword: "auto-reject credit application"
|
||||
severity: "high"
|
||||
- keyword: "remove human oversight from trading"
|
||||
severity: "high"
|
||||
- keyword: "fully autonomous insurance claim decision"
|
||||
severity: "high"
|
||||
- keyword: "bypass review for account closure"
|
||||
severity: "high"
|
||||
- keyword: "eliminate human from KYC decision"
|
||||
severity: "high"
|
||||
- keyword: "auto-execute margin call without review"
|
||||
severity: "high"
|
||||
- keyword: "zero-touch fraud decision"
|
||||
severity: "high"
|
||||
|
||||
exceptions:
|
||||
- "human in the loop"
|
||||
- "human on the loop"
|
||||
- "human review"
|
||||
- "human oversight"
|
||||
- "manual review"
|
||||
- "escalation"
|
||||
- "appeals process"
|
||||
- "override mechanism"
|
||||
- "explain"
|
||||
- "what is"
|
||||
- "research"
|
||||
- "improve oversight"
|
||||
- "add human review"
|
||||
- "compliance"
|
||||
- "governance"
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
# Guidelines on Artificial Intelligence Risk Management (MAS) — Model Security
|
||||
# Blocks requests related to adversarial attacks, data poisoning,
|
||||
# model inversion, and exfiltration targeting financial AI systems.
|
||||
# Reference: https://www.mas.gov.sg/publications/consultations/2025/consultation-paper-on-guidelines-on-artificial-intelligence-risk-management
|
||||
category_name: "sg_mas_model_security"
|
||||
description: "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks adversarial attacks, model poisoning, inversion, and exfiltration attempts targeting financial AI systems"
|
||||
default_action: "BLOCK"
|
||||
|
||||
identifier_words:
|
||||
# Financial AI systems
|
||||
- "credit model"
|
||||
- "fraud model"
|
||||
- "trading model"
|
||||
- "risk model"
|
||||
- "underwriting model"
|
||||
- "AML model"
|
||||
- "KYC model"
|
||||
- "scoring model"
|
||||
- "pricing model"
|
||||
- "financial model"
|
||||
|
||||
# AI system references
|
||||
- "production model"
|
||||
- "deployed model"
|
||||
- "live model"
|
||||
- "model endpoint"
|
||||
- "model API"
|
||||
|
||||
additional_block_words:
|
||||
# Adversarial attacks
|
||||
- "adversarial attack"
|
||||
- "adversarial example"
|
||||
- "adversarial input"
|
||||
- "evasion attack"
|
||||
- "perturbation"
|
||||
|
||||
# Data poisoning
|
||||
- "data poisoning"
|
||||
- "poison training"
|
||||
- "backdoor"
|
||||
- "trojan"
|
||||
- "corrupt training data"
|
||||
|
||||
# Model theft & inversion
|
||||
- "model inversion"
|
||||
- "model extraction"
|
||||
- "model stealing"
|
||||
- "model theft"
|
||||
- "reverse engineer"
|
||||
- "replicate model"
|
||||
|
||||
# Data exfiltration
|
||||
- "exfiltrate"
|
||||
- "extract training data"
|
||||
- "membership inference"
|
||||
- "data leakage"
|
||||
|
||||
# Prompt attacks
|
||||
- "prompt injection"
|
||||
- "jailbreak"
|
||||
- "bypass guardrail"
|
||||
- "bypass safety"
|
||||
|
||||
always_block_keywords:
|
||||
- keyword: "adversarial attack on credit model"
|
||||
severity: "high"
|
||||
- keyword: "poison training data financial"
|
||||
severity: "high"
|
||||
- keyword: "model inversion attack fraud model"
|
||||
severity: "high"
|
||||
- keyword: "steal scoring model weights"
|
||||
severity: "high"
|
||||
- keyword: "extract training data from financial model"
|
||||
severity: "high"
|
||||
- keyword: "jailbreak financial AI"
|
||||
severity: "high"
|
||||
- keyword: "bypass guardrail on trading model"
|
||||
severity: "high"
|
||||
- keyword: "backdoor in AML model"
|
||||
severity: "high"
|
||||
|
||||
exceptions:
|
||||
- "red team"
|
||||
- "red-team"
|
||||
- "penetration test"
|
||||
- "security audit"
|
||||
- "vulnerability assessment"
|
||||
- "adversarial testing"
|
||||
- "robustness testing"
|
||||
- "explain"
|
||||
- "what is"
|
||||
- "research"
|
||||
- "academic"
|
||||
- "defend against"
|
||||
- "protect from"
|
||||
- "mitigate"
|
||||
- "detection"
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
# Guidelines on Artificial Intelligence Risk Management (MAS) — Transparency & Explainability
|
||||
# Blocks requests to deploy opaque/unexplainable AI for consequential
|
||||
# financial decisions (credit, claims, trading) without explainability.
|
||||
# Reference: https://www.mas.gov.sg/publications/consultations/2025/consultation-paper-on-guidelines-on-artificial-intelligence-risk-management
|
||||
category_name: "sg_mas_transparency_explainability"
|
||||
description: "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks opaque or unexplainable AI systems for consequential financial decisions"
|
||||
default_action: "BLOCK"
|
||||
|
||||
identifier_words:
|
||||
# Opacity indicators
|
||||
- "black box"
|
||||
- "unexplainable"
|
||||
- "opaque model"
|
||||
- "no explanation"
|
||||
- "without explanation"
|
||||
- "non-interpretable"
|
||||
- "uninterpretable"
|
||||
- "hidden logic"
|
||||
|
||||
# Consequential financial decisions
|
||||
- "credit decision"
|
||||
- "lending decision"
|
||||
- "loan decision"
|
||||
- "claims decision"
|
||||
- "underwriting decision"
|
||||
- "trading decision"
|
||||
- "investment decision"
|
||||
- "KYC decision"
|
||||
- "AML decision"
|
||||
|
||||
additional_block_words:
|
||||
# Deployment actions
|
||||
- "deploy"
|
||||
- "launch"
|
||||
- "put into production"
|
||||
- "go live"
|
||||
- "release"
|
||||
- "roll out"
|
||||
|
||||
# Opacity-related
|
||||
- "without audit trail"
|
||||
- "no logging"
|
||||
- "hide reasoning"
|
||||
- "skip explanation"
|
||||
- "remove explainability"
|
||||
- "disable explanation"
|
||||
- "suppress reasoning"
|
||||
|
||||
# Customer impact
|
||||
- "customer-facing"
|
||||
- "client-facing"
|
||||
- "consumer-facing"
|
||||
- "customer decision"
|
||||
|
||||
always_block_keywords:
|
||||
- keyword: "deploy black box model for credit decision"
|
||||
severity: "high"
|
||||
- keyword: "launch unexplainable AI for lending"
|
||||
severity: "high"
|
||||
- keyword: "no explanation for loan denial"
|
||||
severity: "high"
|
||||
- keyword: "hide reasoning from customer"
|
||||
severity: "high"
|
||||
- keyword: "opaque model for insurance claims"
|
||||
severity: "high"
|
||||
- keyword: "remove explainability from trading system"
|
||||
severity: "high"
|
||||
- keyword: "deploy without audit trail financial"
|
||||
severity: "high"
|
||||
|
||||
exceptions:
|
||||
- "explainability"
|
||||
- "interpretability"
|
||||
- "model card"
|
||||
- "audit trail"
|
||||
- "explain"
|
||||
- "what is"
|
||||
- "SHAP"
|
||||
- "LIME"
|
||||
- "feature importance"
|
||||
- "research"
|
||||
- "academic"
|
||||
- "improve transparency"
|
||||
- "add explanation"
|
||||
- "compliance"
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
# Singapore PDPA Section 26 — Transfer Limitation Obligation
|
||||
# Blocks requests to transfer Singapore personal data overseas
|
||||
# without adequate protections or binding legal agreements.
|
||||
# Reference: https://www.pdpc.gov.sg/overview-of-pdpa/the-legislation/personal-data-protection-act
|
||||
category_name: "sg_pdpa_data_transfer"
|
||||
description: "PDPA s.26 — Blocks unprotected overseas transfer of Singapore personal data without adequate safeguards"
|
||||
default_action: "BLOCK"
|
||||
|
||||
identifier_words:
|
||||
# Singapore data context
|
||||
- "Singapore personal data"
|
||||
- "Singaporean data"
|
||||
- "Singapore user data"
|
||||
- "Singapore customer data"
|
||||
- "SG user records"
|
||||
- "SG resident data"
|
||||
- "PDPA data"
|
||||
- "Singapore employee data"
|
||||
|
||||
additional_block_words:
|
||||
# Transfer actions
|
||||
- "transfer overseas"
|
||||
- "transfer abroad"
|
||||
- "send overseas"
|
||||
- "send offshore"
|
||||
- "export"
|
||||
- "move to server"
|
||||
- "replicate to"
|
||||
- "sync to"
|
||||
- "migrate to"
|
||||
- "copy to"
|
||||
|
||||
# Inadequate protection
|
||||
- "without protection"
|
||||
- "without agreement"
|
||||
- "without safeguards"
|
||||
- "unprotected transfer"
|
||||
- "unencrypted"
|
||||
- "no safeguards"
|
||||
- "no agreement"
|
||||
- "without PDPA"
|
||||
|
||||
# Storage locations
|
||||
- "backup to foreign"
|
||||
- "store offshore"
|
||||
- "cloud without"
|
||||
|
||||
always_block_keywords:
|
||||
- keyword: "transfer Singapore data without PDPA compliance"
|
||||
severity: "high"
|
||||
- keyword: "send personal data overseas unencrypted"
|
||||
severity: "high"
|
||||
- keyword: "bypass transfer limitation"
|
||||
severity: "high"
|
||||
- keyword: "export Singapore data without safeguards"
|
||||
severity: "high"
|
||||
- keyword: "move Singapore user data offshore unprotected"
|
||||
severity: "high"
|
||||
|
||||
exceptions:
|
||||
- "binding corporate rules"
|
||||
- "data processing agreement"
|
||||
- "adequacy decision"
|
||||
- "explicit consent"
|
||||
- "PDPA compliant"
|
||||
- "model contractual clauses"
|
||||
- "encrypted transfer"
|
||||
- "standard contractual clauses"
|
||||
- "explain"
|
||||
- "what is"
|
||||
- "how to comply"
|
||||
- "compliance review"
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
# Singapore PDPA Part IX — Do Not Call (DNC) Registry
|
||||
# Blocks requests to generate unsolicited marketing contact lists
|
||||
# targeting Singapore numbers without DNC checks.
|
||||
# Reference: https://www.pdpc.gov.sg/overview-of-pdpa/the-legislation/personal-data-protection-act
|
||||
category_name: "sg_pdpa_do_not_call"
|
||||
description: "PDPA Part IX — Blocks generation of unsolicited marketing lists and DNC Registry bypass attempts for Singapore phone numbers"
|
||||
default_action: "BLOCK"
|
||||
|
||||
identifier_words:
|
||||
# Singapore phone context
|
||||
- "Singapore phone number"
|
||||
- "SG mobile"
|
||||
- "+65"
|
||||
- "local number"
|
||||
- "Singapore contact"
|
||||
|
||||
# Marketing lists
|
||||
- "SMS list"
|
||||
- "telemarketing list"
|
||||
- "cold call list"
|
||||
- "contact list Singapore"
|
||||
- "marketing list"
|
||||
- "call list"
|
||||
- "phone list"
|
||||
|
||||
# Messaging platforms
|
||||
- "WhatsApp blast"
|
||||
- "Telegram blast"
|
||||
- "SMS blast"
|
||||
- "mass SMS"
|
||||
|
||||
additional_block_words:
|
||||
# List generation
|
||||
- "generate"
|
||||
- "compile"
|
||||
- "create"
|
||||
- "build"
|
||||
|
||||
# Data harvesting
|
||||
- "scrape"
|
||||
- "harvest"
|
||||
- "crawl"
|
||||
- "buy"
|
||||
- "sell"
|
||||
- "trade"
|
||||
|
||||
# Mass outreach
|
||||
- "mass message"
|
||||
- "bulk SMS"
|
||||
- "bulk send"
|
||||
- "blast to"
|
||||
- "spam"
|
||||
|
||||
# DNC bypass
|
||||
- "without checking DNC"
|
||||
- "bypass DNC"
|
||||
- "ignore DNC"
|
||||
- "skip DNC"
|
||||
- "unsolicited"
|
||||
|
||||
always_block_keywords:
|
||||
- keyword: "bypass DNC registry"
|
||||
severity: "high"
|
||||
- keyword: "ignore do not call Singapore"
|
||||
severity: "high"
|
||||
- keyword: "bulk SMS without consent Singapore"
|
||||
severity: "high"
|
||||
- keyword: "harvest Singapore phone numbers"
|
||||
severity: "high"
|
||||
- keyword: "spam Singapore numbers"
|
||||
severity: "high"
|
||||
- keyword: "buy Singapore phone list"
|
||||
severity: "high"
|
||||
|
||||
exceptions:
|
||||
- "DNC check"
|
||||
- "opted in"
|
||||
- "consent obtained"
|
||||
- "existing customer"
|
||||
- "legitimate interest"
|
||||
- "DNC registry lookup"
|
||||
- "DNC compliance"
|
||||
- "explain"
|
||||
- "what is"
|
||||
- "how to comply"
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
# Singapore PDPA Section 13 — Consent Obligation (Personal Identifiers)
|
||||
# Blocks requests to collect, harvest, or extract Singapore-specific
|
||||
# personal identifiers (NRIC/FIN, passport, SingPass) without consent context.
|
||||
# Reference: https://www.pdpc.gov.sg/overview-of-pdpa/the-legislation/personal-data-protection-act
|
||||
category_name: "sg_pdpa_personal_identifiers"
|
||||
description: "PDPA s.13 — Blocks unauthorized collection, harvesting, or extraction of Singapore personal identifiers (NRIC/FIN, SingPass, passports)"
|
||||
default_action: "BLOCK"
|
||||
|
||||
identifier_words:
|
||||
# Singapore national identifiers
|
||||
- "NRIC"
|
||||
- "FIN"
|
||||
- "IC number"
|
||||
- "identity card number"
|
||||
- "foreign identification number"
|
||||
|
||||
# Government digital identity
|
||||
- "SingPass"
|
||||
- "Singpass"
|
||||
- "MyInfo"
|
||||
- "CorpPass"
|
||||
|
||||
# Travel & immigration documents
|
||||
- "Singapore passport"
|
||||
- "passport number"
|
||||
|
||||
# Employment passes
|
||||
- "work permit number"
|
||||
- "employment pass"
|
||||
- "S-pass"
|
||||
- "S pass"
|
||||
- "EP number"
|
||||
- "WP number"
|
||||
|
||||
additional_block_words:
|
||||
# Data collection actions
|
||||
- "collect"
|
||||
- "harvest"
|
||||
- "scrape"
|
||||
- "extract"
|
||||
- "crawl"
|
||||
- "mine"
|
||||
|
||||
# Storage & aggregation
|
||||
- "store"
|
||||
- "aggregate"
|
||||
- "compile database"
|
||||
- "compile list"
|
||||
- "bulk"
|
||||
- "mass collect"
|
||||
|
||||
# Unauthorized access
|
||||
- "without consent"
|
||||
- "bypass"
|
||||
- "circumvent"
|
||||
- "verify without"
|
||||
- "lookup"
|
||||
- "enumerate"
|
||||
- "brute force"
|
||||
|
||||
always_block_keywords:
|
||||
- keyword: "scrape NRIC"
|
||||
severity: "high"
|
||||
- keyword: "harvest FIN numbers"
|
||||
severity: "high"
|
||||
- keyword: "collect NRIC without consent"
|
||||
severity: "high"
|
||||
- keyword: "database of IC numbers"
|
||||
severity: "high"
|
||||
- keyword: "bulk NRIC lookup"
|
||||
severity: "high"
|
||||
- keyword: "SingPass credential"
|
||||
severity: "high"
|
||||
- keyword: "steal SingPass"
|
||||
severity: "high"
|
||||
- keyword: "fake NRIC generator"
|
||||
severity: "high"
|
||||
- keyword: "NRIC brute force"
|
||||
severity: "high"
|
||||
|
||||
exceptions:
|
||||
- "compliance"
|
||||
- "PDPA audit"
|
||||
- "consent form"
|
||||
- "data protection officer"
|
||||
- "legitimate purpose"
|
||||
- "research"
|
||||
- "government portal"
|
||||
- "official verification"
|
||||
- "explain"
|
||||
- "what is"
|
||||
- "how to protect"
|
||||
- "format of"
|
||||
- "validate checksum"
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
# Singapore PDPA + Model AI Governance Framework — Automated Profiling
|
||||
# Blocks requests to build automated scoring/profiling systems
|
||||
# for consequential decisions about Singapore residents without human oversight.
|
||||
# Reference: https://www.pdpc.gov.sg/overview-of-pdpa/the-legislation/personal-data-protection-act
|
||||
# Reference: https://www.pdpc.gov.sg/help-and-resources/2020/01/model-ai-governance-framework
|
||||
category_name: "sg_pdpa_profiling_automated_decisions"
|
||||
description: "PDPA + Model AI Governance Framework — Blocks automated profiling and decision-making systems for consequential decisions about Singapore residents without human oversight"
|
||||
default_action: "BLOCK"
|
||||
|
||||
identifier_words:
|
||||
# Singapore resident identifiers
|
||||
- "Singapore resident"
|
||||
- "Singaporean"
|
||||
- "SG citizen"
|
||||
- "PR holder"
|
||||
- "EP holder"
|
||||
- "Singapore employee"
|
||||
|
||||
# Government programs/entities
|
||||
- "HDB applicant"
|
||||
- "HDB buyer"
|
||||
- "CPF member"
|
||||
- "MediShield"
|
||||
- "MediSave"
|
||||
- "BTO applicant"
|
||||
|
||||
additional_block_words:
|
||||
# Scoring & assessment
|
||||
- "credit score"
|
||||
- "risk score"
|
||||
- "risk profile"
|
||||
- "loan eligibility"
|
||||
- "employment score"
|
||||
- "hiring score"
|
||||
- "trustworthiness score"
|
||||
|
||||
# Automated decisions
|
||||
- "automated reject"
|
||||
- "automated denial"
|
||||
- "auto reject"
|
||||
- "auto deny"
|
||||
- "blacklist"
|
||||
- "flag automatically"
|
||||
- "no human review"
|
||||
- "fully automated decision"
|
||||
- "without human oversight"
|
||||
|
||||
# Consequential actions
|
||||
- "deny housing"
|
||||
- "deny loan"
|
||||
- "deny employment"
|
||||
- "terminate automatically"
|
||||
- "exclude automatically"
|
||||
|
||||
always_block_keywords:
|
||||
- keyword: "automated denial Singapore resident"
|
||||
severity: "high"
|
||||
- keyword: "blacklist Singaporean without review"
|
||||
severity: "high"
|
||||
- keyword: "deny HDB application automatically"
|
||||
severity: "high"
|
||||
- keyword: "reject CPF claim without human"
|
||||
severity: "high"
|
||||
- keyword: "auto reject Singapore loan application"
|
||||
severity: "high"
|
||||
- keyword: "fully automated hiring decision Singapore"
|
||||
severity: "high"
|
||||
|
||||
exceptions:
|
||||
- "human in the loop"
|
||||
- "human review"
|
||||
- "human oversight"
|
||||
- "appeals process"
|
||||
- "explainability"
|
||||
- "fairness audit"
|
||||
- "model governance"
|
||||
- "PDPA impact assessment"
|
||||
- "explain"
|
||||
- "what is"
|
||||
- "research"
|
||||
- "academic"
|
||||
- "bias detection"
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
# Singapore PDPA Advisory Guidelines — Sensitive Personal Data
|
||||
# Blocks requests to infer, profile, or process sensitive data categories
|
||||
# (race, religion, health, political opinions, trade union membership)
|
||||
# for Singapore residents without clear legitimate purpose.
|
||||
# Reference: https://www.pdpc.gov.sg/overview-of-pdpa/the-legislation/personal-data-protection-act
|
||||
category_name: "sg_pdpa_sensitive_data"
|
||||
description: "PDPA Advisory Guidelines — Blocks profiling or inference of sensitive personal data categories (race, religion, health, politics) for Singapore residents"
|
||||
default_action: "BLOCK"
|
||||
|
||||
identifier_words:
|
||||
# Race & ethnicity
|
||||
- "race"
|
||||
- "ethnicity"
|
||||
- "ethnic group"
|
||||
- "CMIO"
|
||||
|
||||
# Religion
|
||||
- "religion"
|
||||
- "religious belief"
|
||||
- "faith"
|
||||
|
||||
# Health & medical
|
||||
- "health condition"
|
||||
- "medical record"
|
||||
- "mental health"
|
||||
- "medical history"
|
||||
- "health status"
|
||||
|
||||
# Political & union
|
||||
- "political opinion"
|
||||
- "political affiliation"
|
||||
- "trade union"
|
||||
- "union membership"
|
||||
|
||||
# Sexual orientation
|
||||
- "sexual orientation"
|
||||
- "gender identity"
|
||||
|
||||
# Criminal
|
||||
- "criminal record"
|
||||
- "criminal history"
|
||||
|
||||
# Financial
|
||||
- "financial status"
|
||||
- "income level"
|
||||
- "credit history"
|
||||
|
||||
additional_block_words:
|
||||
# Inference & prediction
|
||||
- "infer"
|
||||
- "predict"
|
||||
- "classify"
|
||||
- "profile"
|
||||
- "categorize"
|
||||
|
||||
# Scoring & ranking
|
||||
- "score"
|
||||
- "rank"
|
||||
- "rate"
|
||||
|
||||
# Targeting & segmentation
|
||||
- "segment by"
|
||||
- "target based on"
|
||||
- "discriminate"
|
||||
- "filter by"
|
||||
- "sort by"
|
||||
- "group by"
|
||||
|
||||
always_block_keywords:
|
||||
- keyword: "infer religion from name"
|
||||
severity: "high"
|
||||
- keyword: "predict race from photo"
|
||||
severity: "high"
|
||||
- keyword: "segment by ethnicity Singapore"
|
||||
severity: "high"
|
||||
- keyword: "profile by religious group"
|
||||
severity: "high"
|
||||
- keyword: "classify by mental health"
|
||||
severity: "high"
|
||||
- keyword: "infer race from surname"
|
||||
severity: "high"
|
||||
- keyword: "predict ethnicity from name"
|
||||
severity: "high"
|
||||
- keyword: "discriminate by race Singapore"
|
||||
severity: "high"
|
||||
|
||||
exceptions:
|
||||
- "medical treatment"
|
||||
- "healthcare provider"
|
||||
- "research ethics"
|
||||
- "anonymised"
|
||||
- "anonymized"
|
||||
- "aggregate statistics"
|
||||
- "census"
|
||||
- "equal opportunity"
|
||||
- "anti-discrimination"
|
||||
- "explain"
|
||||
- "what is"
|
||||
- "academic"
|
||||
- "study on"
|
||||
|
|
@ -343,11 +343,23 @@ async def guardrails_usage_detail(
|
|||
|
||||
raise HTTPException(status_code=404, detail="Guardrail not found")
|
||||
|
||||
# Metrics are keyed by logical name (from spend log metadata), not UUID
|
||||
logical_id = getattr(guardrail, "guardrail_name", None) or (
|
||||
guardrail.get("guardrail_name") if isinstance(guardrail, dict) else None
|
||||
)
|
||||
metric_ids = [i for i in (logical_id, guardrail_id) if i]
|
||||
|
||||
metrics = await prisma_client.db.litellm_dailyguardrailmetrics.find_many(
|
||||
where={"guardrail_id": guardrail_id, "date": {"gte": start, "lte": end}}
|
||||
where={
|
||||
"guardrail_id": {"in": metric_ids},
|
||||
"date": {"gte": start, "lte": end},
|
||||
}
|
||||
)
|
||||
metrics_prev = await prisma_client.db.litellm_dailyguardrailmetrics.find_many(
|
||||
where={"guardrail_id": guardrail_id, "date": {"lt": start}}
|
||||
where={
|
||||
"guardrail_id": {"in": metric_ids},
|
||||
"date": {"lt": start},
|
||||
}
|
||||
)
|
||||
|
||||
requests = sum(int(m.requests_evaluated or 0) for m in metrics)
|
||||
|
|
@ -359,29 +371,41 @@ async def guardrails_usage_detail(
|
|||
prev_fail = (100.0 * prev_blocked / prev_req) if prev_req else 0.0
|
||||
trend = _trend_from_comparison(fail_rate, prev_fail)
|
||||
|
||||
# Aggregate by date in case metrics exist under both UUID and logical name
|
||||
ts_by_date: Dict[str, Dict[str, Any]] = {}
|
||||
for m in metrics:
|
||||
d = m.date
|
||||
if d not in ts_by_date:
|
||||
ts_by_date[d] = {"passed": 0, "blocked": 0}
|
||||
ts_by_date[d]["passed"] += int(m.passed_count or 0)
|
||||
ts_by_date[d]["blocked"] += int(m.blocked_count or 0)
|
||||
time_series = [
|
||||
{
|
||||
"date": m.date,
|
||||
"passed": int(m.passed_count or 0),
|
||||
"blocked": int(m.blocked_count or 0),
|
||||
"score": None,
|
||||
}
|
||||
for m in sorted(metrics, key=lambda x: x.date)
|
||||
{"date": d, "passed": v["passed"], "blocked": v["blocked"], "score": None}
|
||||
for d, v in sorted(ts_by_date.items())
|
||||
]
|
||||
_litellm_params = getattr(guardrail, "litellm_params", None) or (
|
||||
guardrail.get("litellm_params") if isinstance(guardrail, dict) else None
|
||||
)
|
||||
litellm_params = (
|
||||
(guardrail.litellm_params or {})
|
||||
if isinstance(guardrail.litellm_params, dict)
|
||||
_litellm_params
|
||||
if isinstance(_litellm_params, dict)
|
||||
else {}
|
||||
)
|
||||
_guardrail_info = getattr(guardrail, "guardrail_info", None) or (
|
||||
guardrail.get("guardrail_info") if isinstance(guardrail, dict) else None
|
||||
)
|
||||
guardrail_info = (
|
||||
(guardrail.guardrail_info or {})
|
||||
if isinstance(guardrail.guardrail_info, dict)
|
||||
_guardrail_info
|
||||
if isinstance(_guardrail_info, dict)
|
||||
else {}
|
||||
)
|
||||
_guardrail_name = getattr(guardrail, "guardrail_name", None) or (
|
||||
guardrail.get("guardrail_name") if isinstance(guardrail, dict) else None
|
||||
)
|
||||
|
||||
return UsageDetailResponse(
|
||||
guardrail_id=guardrail_id,
|
||||
guardrail_name=guardrail.guardrail_name or guardrail_id,
|
||||
guardrail_name=_guardrail_name or guardrail_id,
|
||||
type=str(guardrail_info.get("type", "Guardrail")),
|
||||
provider=str(litellm_params.get("guardrail", "Unknown")),
|
||||
requestsEvaluated=requests,
|
||||
|
|
@ -396,14 +420,16 @@ async def guardrails_usage_detail(
|
|||
|
||||
|
||||
def _build_usage_logs_where(
|
||||
guardrail_id: Optional[str],
|
||||
guardrail_ids: Optional[List[str]],
|
||||
policy_id: Optional[str],
|
||||
start_date: Optional[str],
|
||||
end_date: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
where: Dict[str, Any] = {}
|
||||
if guardrail_id:
|
||||
where["guardrail_id"] = guardrail_id
|
||||
if guardrail_ids:
|
||||
where["guardrail_id"] = (
|
||||
{"in": guardrail_ids} if len(guardrail_ids) > 1 else guardrail_ids[0]
|
||||
)
|
||||
if policy_id:
|
||||
where["policy_id"] = policy_id
|
||||
if start_date or end_date:
|
||||
|
|
@ -474,7 +500,7 @@ def _usage_log_entry_from_row(
|
|||
score=score_val,
|
||||
latency_ms=latency_val,
|
||||
model=sl.model,
|
||||
input_snippet=_snippet(sl.messages),
|
||||
input_snippet=_input_snippet_for_log(sl),
|
||||
output_snippet=_snippet(sl.response),
|
||||
reason=reason_val,
|
||||
)
|
||||
|
|
@ -496,7 +522,34 @@ def _snippet(text: Any, max_len: int = 200) -> Optional[str]:
|
|||
s = " ".join(parts)
|
||||
else:
|
||||
s = str(text)
|
||||
return (s[:max_len] + "...") if len(s) > max_len else s
|
||||
result = (s[:max_len] + "...") if len(s) > max_len else s
|
||||
if result == "{}":
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def _input_snippet_for_log(sl: Any) -> Optional[str]:
|
||||
"""Snippet for request input: prefer messages, fall back to proxy_server_request (same as drawer)."""
|
||||
out = _snippet(sl.messages)
|
||||
if out:
|
||||
return out
|
||||
psr = getattr(sl, "proxy_server_request", None)
|
||||
if not psr:
|
||||
return None
|
||||
if isinstance(psr, str):
|
||||
try:
|
||||
psr = json.loads(psr)
|
||||
except Exception:
|
||||
return _snippet(psr)
|
||||
if isinstance(psr, dict):
|
||||
msgs = psr.get("messages")
|
||||
if msgs is None and isinstance(psr.get("body"), dict):
|
||||
msgs = psr["body"].get("messages")
|
||||
out = _snippet(msgs)
|
||||
if out:
|
||||
return out
|
||||
return _snippet(psr)
|
||||
return _snippet(psr)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
@ -525,7 +578,21 @@ async def guardrails_usage_logs(
|
|||
return UsageLogsResponse(logs=[], total=0, page=page, page_size=page_size)
|
||||
|
||||
try:
|
||||
where = _build_usage_logs_where(guardrail_id, policy_id, start_date, end_date)
|
||||
# Index rows may store either guardrail_id (UUID) or guardrail_name from metadata.
|
||||
# Query by both so we match regardless of which was written.
|
||||
effective_guardrail_ids: List[str] = [guardrail_id] if guardrail_id else []
|
||||
if guardrail_id:
|
||||
guardrail = await prisma_client.db.litellm_guardrailstable.find_unique(
|
||||
where={"guardrail_id": guardrail_id}
|
||||
)
|
||||
if guardrail:
|
||||
logical_name = getattr(guardrail, "guardrail_name", None)
|
||||
if logical_name and logical_name not in effective_guardrail_ids:
|
||||
effective_guardrail_ids.append(logical_name)
|
||||
|
||||
where = _build_usage_logs_where(
|
||||
effective_guardrail_ids or None, policy_id, start_date, end_date
|
||||
)
|
||||
index_rows = await prisma_client.db.litellm_spendlogguardrailindex.find_many(
|
||||
where=where,
|
||||
order={"start_time": "desc"},
|
||||
|
|
|
|||
|
|
@ -2,8 +2,16 @@ import json
|
|||
import os
|
||||
from typing import List
|
||||
|
||||
import litellm
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.get_blog_posts import (
|
||||
BlogPost,
|
||||
BlogPostsResponse,
|
||||
GetBlogPosts,
|
||||
get_blog_posts,
|
||||
)
|
||||
from litellm.proxy._types import CommonProxyErrors
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.types.agents import AgentCard
|
||||
|
|
@ -193,6 +201,30 @@ async def get_litellm_model_cost_map():
|
|||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/public/litellm_blog_posts",
|
||||
tags=["public"],
|
||||
response_model=BlogPostsResponse,
|
||||
)
|
||||
async def get_litellm_blog_posts():
|
||||
"""
|
||||
Public endpoint to get the latest LiteLLM blog posts.
|
||||
|
||||
Fetches from GitHub with a 1-hour in-process cache.
|
||||
Falls back to the bundled local backup on any failure.
|
||||
"""
|
||||
try:
|
||||
posts_data = get_blog_posts(url=litellm.blog_posts_url)
|
||||
except Exception as e:
|
||||
verbose_logger.warning(
|
||||
"LiteLLM: get_litellm_blog_posts endpoint fallback triggered: %s", str(e)
|
||||
)
|
||||
posts_data = GetBlogPosts.load_local_blog_posts()
|
||||
|
||||
posts = [BlogPost(**p) for p in posts_data[:5]]
|
||||
return BlogPostsResponse(posts=posts)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/public/agents/fields",
|
||||
tags=["public", "[beta] Agents"],
|
||||
|
|
|
|||
|
|
@ -392,6 +392,7 @@ class Status3(Enum):
|
|||
COMPLETED = 'COMPLETED'
|
||||
FAILED = 'FAILED'
|
||||
CANCELLED = 'CANCELLED'
|
||||
INCOMPLETE = 'INCOMPLETE'
|
||||
|
||||
|
||||
class ModelOption(RootModel[str]):
|
||||
|
|
|
|||
|
|
@ -273,6 +273,7 @@ def video_content(
|
|||
video_id: str,
|
||||
timeout: Optional[float] = None,
|
||||
custom_llm_provider: Optional[str] = None,
|
||||
variant: Optional[str] = None,
|
||||
# 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,
|
||||
|
|
@ -367,6 +368,7 @@ def video_content(
|
|||
extra_headers=extra_headers,
|
||||
client=kwargs.get("client"),
|
||||
_is_async=_is_async,
|
||||
variant=variant,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -385,6 +387,7 @@ async def avideo_content(
|
|||
video_id: str,
|
||||
timeout: Optional[float] = None,
|
||||
custom_llm_provider: Optional[str] = None,
|
||||
variant: Optional[str] = None,
|
||||
# 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,
|
||||
|
|
@ -422,6 +425,7 @@ async def avideo_content(
|
|||
video_id=video_id,
|
||||
timeout=timeout,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
variant=variant,
|
||||
extra_headers=extra_headers,
|
||||
extra_query=extra_query,
|
||||
extra_body=extra_body,
|
||||
|
|
|
|||
|
|
@ -2013,5 +2013,367 @@
|
|||
"Brand Protection"
|
||||
],
|
||||
"estimated_latency_ms": 1
|
||||
},
|
||||
{
|
||||
"id": "pdpa-singapore",
|
||||
"title": "Singapore PDPA \u2014 Personal Data Protection",
|
||||
"description": "Singapore Personal Data Protection Act (PDPA) compliance. Covers 5 obligation areas: personal identifier collection (s.13 Consent), sensitive data profiling (Advisory Guidelines), Do Not Call Registry violations (Part IX), overseas data transfers (s.26), and automated profiling without human oversight (Model AI Governance Framework). Also includes regex-based PII detection for NRIC/FIN, Singapore phone numbers, postal codes, passports, UEN, and bank account numbers. Zero-cost keyword-based detection.",
|
||||
"icon": "ShieldCheckIcon",
|
||||
"iconColor": "text-red-500",
|
||||
"iconBg": "bg-red-50",
|
||||
"guardrails": [
|
||||
"pdpa-sg-pii-identifiers",
|
||||
"pdpa-sg-contact-information",
|
||||
"pdpa-sg-financial-data",
|
||||
"pdpa-sg-business-identifiers",
|
||||
"pdpa-sg-personal-identifiers",
|
||||
"pdpa-sg-sensitive-data",
|
||||
"pdpa-sg-do-not-call",
|
||||
"pdpa-sg-data-transfer",
|
||||
"pdpa-sg-profiling-automated-decisions"
|
||||
],
|
||||
"complexity": "High",
|
||||
"guardrailDefinitions": [
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-pii-identifiers",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"patterns": [
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "sg_nric",
|
||||
"action": "MASK"
|
||||
},
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "passport_singapore",
|
||||
"action": "MASK"
|
||||
}
|
||||
],
|
||||
"pattern_redaction_format": "[{pattern_name}_REDACTED]"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Masks Singapore NRIC/FIN and passport numbers for PDPA compliance"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-contact-information",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"patterns": [
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "sg_phone",
|
||||
"action": "MASK"
|
||||
},
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "sg_postal_code",
|
||||
"action": "MASK"
|
||||
},
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "email",
|
||||
"action": "MASK"
|
||||
}
|
||||
],
|
||||
"pattern_redaction_format": "[{pattern_name}_REDACTED]"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Masks Singapore phone numbers, postal codes, and email addresses"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-financial-data",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"patterns": [
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "sg_bank_account",
|
||||
"action": "MASK"
|
||||
},
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "credit_card",
|
||||
"action": "MASK"
|
||||
}
|
||||
],
|
||||
"pattern_redaction_format": "[{pattern_name}_REDACTED]"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Masks Singapore bank account numbers and credit card numbers"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-business-identifiers",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"patterns": [
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "sg_uen",
|
||||
"action": "MASK"
|
||||
}
|
||||
],
|
||||
"pattern_redaction_format": "[UEN_REDACTED]"
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Masks Singapore Unique Entity Numbers (business registration)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-personal-identifiers",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_personal_identifiers",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_personal_identifiers.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "PDPA s.13 \u2014 Blocks unauthorized collection, harvesting, or extraction of Singapore personal identifiers (NRIC/FIN, SingPass, passports)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-sensitive-data",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_sensitive_data",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_sensitive_data.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "PDPA Advisory Guidelines \u2014 Blocks profiling or inference of sensitive personal data categories (race, religion, health, politics) for Singapore residents"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-do-not-call",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_do_not_call",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_do_not_call.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "PDPA Part IX \u2014 Blocks generation of unsolicited marketing lists and DNC Registry bypass attempts for Singapore phone numbers"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-data-transfer",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_data_transfer",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_data_transfer.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "PDPA s.26 \u2014 Blocks unprotected overseas transfer of Singapore personal data without adequate safeguards"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "pdpa-sg-profiling-automated-decisions",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_pdpa_profiling_automated_decisions",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_profiling_automated_decisions.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "PDPA + Model AI Governance Framework \u2014 Blocks automated profiling and decision-making about Singapore residents without human oversight"
|
||||
}
|
||||
}
|
||||
],
|
||||
"templateData": {
|
||||
"policy_name": "pdpa-singapore",
|
||||
"description": "Singapore PDPA compliance policy. Covers personal identifier protection (s.13), sensitive data profiling (Advisory Guidelines), Do Not Call Registry (Part IX), overseas data transfers (s.26), and automated profiling (Model AI Governance Framework). Includes regex-based PII detection for NRIC/FIN, phone numbers, postal codes, passports, UEN, and bank accounts.",
|
||||
"guardrails_add": [
|
||||
"pdpa-sg-pii-identifiers",
|
||||
"pdpa-sg-contact-information",
|
||||
"pdpa-sg-financial-data",
|
||||
"pdpa-sg-business-identifiers",
|
||||
"pdpa-sg-personal-identifiers",
|
||||
"pdpa-sg-sensitive-data",
|
||||
"pdpa-sg-do-not-call",
|
||||
"pdpa-sg-data-transfer",
|
||||
"pdpa-sg-profiling-automated-decisions"
|
||||
],
|
||||
"guardrails_remove": []
|
||||
},
|
||||
"tags": [
|
||||
"PII Protection",
|
||||
"Regulatory",
|
||||
"Singapore"
|
||||
],
|
||||
"estimated_latency_ms": 1
|
||||
},
|
||||
{
|
||||
"id": "mas-ai-risk-management",
|
||||
"title": "Singapore MAS \u2014 AI Risk Management for Financial Institutions",
|
||||
"description": "Monetary Authority of Singapore (MAS) AI Risk Management for Financial Institutions alignment. Covers 5 enforceable obligation areas: fairness & bias in financial decisions, transparency & explainability of AI models, human oversight for consequential actions, data governance for financial customer data, and model security against adversarial attacks. Based on Guidelines on Artificial Intelligence Risk Management (MAS), and aligned with the 2018 FEAT Principles and Project MindForge. Zero-cost keyword-based detection.",
|
||||
"icon": "ShieldCheckIcon",
|
||||
"iconColor": "text-blue-600",
|
||||
"iconBg": "bg-blue-50",
|
||||
"guardrails": [
|
||||
"mas-sg-fairness-bias",
|
||||
"mas-sg-transparency-explainability",
|
||||
"mas-sg-human-oversight",
|
||||
"mas-sg-data-governance",
|
||||
"mas-sg-model-security"
|
||||
],
|
||||
"complexity": "High",
|
||||
"guardrailDefinitions": [
|
||||
{
|
||||
"guardrail_name": "mas-sg-fairness-bias",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_fairness_bias",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_fairness_bias.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks discriminatory AI practices in financial services that score, deny, or price based on protected attributes (race, religion, age, gender, nationality)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "mas-sg-transparency-explainability",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_transparency_explainability",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_transparency_explainability.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks deployment of opaque or unexplainable AI systems for consequential financial decisions"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "mas-sg-human-oversight",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_human_oversight",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_human_oversight.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks fully automated financial AI decisions without human-in-the-loop for consequential actions (loans, claims, trading)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "mas-sg-data-governance",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_data_governance",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_data_governance.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks unauthorized sharing, exposure, or mishandling of financial customer data without proper governance and data lineage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"guardrail_name": "mas-sg-model-security",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"categories": [
|
||||
{
|
||||
"category": "sg_mas_model_security",
|
||||
"category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_model_security.yaml",
|
||||
"enabled": true,
|
||||
"action": "BLOCK",
|
||||
"severity_threshold": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guardrail_info": {
|
||||
"description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks adversarial attacks, model poisoning, inversion, and exfiltration attempts targeting financial AI systems"
|
||||
}
|
||||
}
|
||||
],
|
||||
"templateData": {
|
||||
"policy_name": "mas-ai-risk-management",
|
||||
"description": "Guidelines on Artificial Intelligence Risk Management (MAS) for Financial Institutions alignment. Covers fairness & bias, transparency & explainability, human oversight, data governance, and model security. Aligned with the 2018 FEAT Principles, Project MindForge, and NIST AI RMF.",
|
||||
"guardrails_add": [
|
||||
"mas-sg-fairness-bias",
|
||||
"mas-sg-transparency-explainability",
|
||||
"mas-sg-human-oversight",
|
||||
"mas-sg-data-governance",
|
||||
"mas-sg-model-security"
|
||||
],
|
||||
"guardrails_remove": []
|
||||
},
|
||||
"tags": [
|
||||
"Financial Services",
|
||||
"Regulatory",
|
||||
"Singapore"
|
||||
],
|
||||
"estimated_latency_ms": 1
|
||||
}
|
||||
]
|
||||
|
|
|
|||
160
scripts/benchmark_mock.py
Normal file
160
scripts/benchmark_mock.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Quick benchmark for network_mock proxy overhead measurement."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import time
|
||||
import statistics
|
||||
|
||||
import aiohttp
|
||||
|
||||
|
||||
REQUEST_BODY = {
|
||||
"model": "db-openai-endpoint",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"max_tokens": 100,
|
||||
"user": "new_user",
|
||||
}
|
||||
|
||||
HEADERS = {
|
||||
"Authorization": "Bearer sk-1234",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
async def send_request(session, url, semaphore):
|
||||
async with semaphore:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
async with session.post(url, json=REQUEST_BODY, headers=HEADERS) as resp:
|
||||
await resp.read()
|
||||
elapsed = time.perf_counter() - start
|
||||
return elapsed if resp.status == 200 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def run_benchmark(url, n_requests, max_concurrent):
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
connector_limit = min(max_concurrent * 2, 200)
|
||||
connector = aiohttp.TCPConnector(
|
||||
limit=connector_limit,
|
||||
limit_per_host=max_concurrent,
|
||||
force_close=False,
|
||||
enable_cleanup_closed=True,
|
||||
)
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
# warmup
|
||||
await asyncio.gather(*[send_request(session, url, semaphore) for _ in range(min(50, n_requests))])
|
||||
|
||||
# timed run
|
||||
wall_start = time.perf_counter()
|
||||
results = await asyncio.gather(*[send_request(session, url, semaphore) for _ in range(n_requests)])
|
||||
wall_elapsed = time.perf_counter() - wall_start
|
||||
|
||||
latencies = [r for r in results if r is not None]
|
||||
failures = sum(1 for r in results if r is None)
|
||||
|
||||
if not latencies:
|
||||
return {
|
||||
"mean": 0, "p50": 0, "p95": 0, "p99": 0,
|
||||
"throughput": 0, "failures": n_requests,
|
||||
"wall_time": wall_elapsed, "n_requests": n_requests,
|
||||
"max_concurrent": max_concurrent, "latencies": [],
|
||||
}
|
||||
|
||||
latencies.sort()
|
||||
n = len(latencies)
|
||||
mean = statistics.mean(latencies) * 1000
|
||||
p50 = latencies[n // 2] * 1000
|
||||
p95 = latencies[int(n * 0.95)] * 1000
|
||||
p99 = latencies[int(n * 0.99)] * 1000
|
||||
throughput = n_requests / wall_elapsed
|
||||
|
||||
return {
|
||||
"mean": mean, "p50": p50, "p95": p95, "p99": p99,
|
||||
"throughput": throughput, "failures": failures,
|
||||
"wall_time": wall_elapsed, "n_requests": n_requests,
|
||||
"max_concurrent": max_concurrent, "latencies": latencies,
|
||||
}
|
||||
|
||||
|
||||
def print_run_results(run_num, total_runs, result):
|
||||
label = f" Run {run_num}/{total_runs}" if total_runs > 1 else " Results"
|
||||
print(f"\n{'='*60}")
|
||||
print(label)
|
||||
print(f"{'='*60}")
|
||||
print(f" Requests: {result['n_requests']} (failures: {result['failures']})")
|
||||
print(f" Concurrency: {result['max_concurrent']}")
|
||||
print(f" Wall time: {result['wall_time']:.2f}s")
|
||||
print(f" Throughput: {result['throughput']:.0f} req/s")
|
||||
print(f" Mean: {result['mean']:.2f} ms")
|
||||
print(f" P50: {result['p50']:.2f} ms")
|
||||
print(f" P95: {result['p95']:.2f} ms")
|
||||
print(f" P99: {result['p99']:.2f} ms")
|
||||
|
||||
|
||||
def print_aggregate(results):
|
||||
all_latencies = []
|
||||
for r in results:
|
||||
all_latencies.extend(r["latencies"])
|
||||
all_latencies.sort()
|
||||
|
||||
total_failures = sum(r["failures"] for r in results)
|
||||
total_requests = sum(r["n_requests"] for r in results)
|
||||
n = len(all_latencies)
|
||||
|
||||
if not all_latencies:
|
||||
print(f"\n Aggregate: all {total_requests} requests failed across {len(results)} runs")
|
||||
return
|
||||
|
||||
mean = statistics.mean(all_latencies) * 1000
|
||||
p50 = all_latencies[n // 2] * 1000
|
||||
p95 = all_latencies[int(n * 0.95)] * 1000
|
||||
p99 = all_latencies[int(n * 0.99)] * 1000
|
||||
avg_throughput = statistics.mean(r["throughput"] for r in results)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" Aggregate ({len(results)} runs, {total_requests} total requests)")
|
||||
print(f"{'='*60}")
|
||||
print(f" Failures: {total_failures}")
|
||||
print(f" Throughput: {avg_throughput:.0f} req/s (avg across runs)")
|
||||
print(f" Mean: {mean:.2f} ms")
|
||||
print(f" P50: {p50:.2f} ms")
|
||||
print(f" P95: {p95:.2f} ms")
|
||||
print(f" P99: {p99:.2f} ms")
|
||||
|
||||
# Run-to-run variance
|
||||
run_means = [r["mean"] for r in results]
|
||||
run_throughputs = [r["throughput"] for r in results]
|
||||
if len(run_means) > 1:
|
||||
cov_latency = statistics.stdev(run_means) / statistics.mean(run_means) * 100
|
||||
cov_throughput = statistics.stdev(run_throughputs) / statistics.mean(run_throughputs) * 100
|
||||
print(f"\n Run-to-run variance:")
|
||||
print(f" Latency CoV: {cov_latency:.1f}%")
|
||||
print(f" Throughput CoV: {cov_throughput:.1f}%")
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--url", default="http://localhost:4000/chat/completions")
|
||||
parser.add_argument("--requests", type=int, default=2000)
|
||||
parser.add_argument("--max-concurrent", type=int, default=200)
|
||||
parser.add_argument("--runs", type=int, default=1)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Benchmarking {args.url}")
|
||||
print(f" {args.requests} requests, {args.max_concurrent} concurrency, {args.runs} run(s)")
|
||||
|
||||
results = []
|
||||
for run_num in range(1, args.runs + 1):
|
||||
result = await run_benchmark(args.url, args.requests, args.max_concurrent)
|
||||
results.append(result)
|
||||
print_run_results(run_num, args.runs, result)
|
||||
|
||||
if args.runs > 1:
|
||||
print_aggregate(results)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
416
tests/guardrails_tests/test_sg_mas_ai_guardrails.py
Normal file
416
tests/guardrails_tests/test_sg_mas_ai_guardrails.py
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
"""
|
||||
Test Guidelines on Artificial Intelligence Risk Management (MAS) — Conditional Keyword Matching
|
||||
|
||||
Tests 5 sub-guardrails covering Guidelines on Artificial Intelligence Risk Management (MAS) obligations
|
||||
for Singapore financial institutions:
|
||||
1. sg_mas_fairness_bias — Discriminatory financial AI
|
||||
2. sg_mas_transparency_explainability — Opaque/unexplainable AI decisions
|
||||
3. sg_mas_human_oversight — Automated decisions without human review
|
||||
4. sg_mas_data_governance — Financial data mishandling
|
||||
5. sg_mas_model_security — Adversarial attacks on financial AI
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../.."))
|
||||
import litellm
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
|
||||
ContentFilterGuardrail,
|
||||
)
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import (
|
||||
ContentFilterCategoryConfig,
|
||||
)
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
POLICY_DIR = os.path.abspath(
|
||||
os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../litellm/proxy/guardrails/guardrail_hooks/"
|
||||
"litellm_content_filter/policy_templates",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _make_guardrail(yaml_filename: str, category_name: str) -> ContentFilterGuardrail:
|
||||
path = os.path.join(POLICY_DIR, yaml_filename)
|
||||
categories = [
|
||||
ContentFilterCategoryConfig(
|
||||
category=category_name,
|
||||
category_file=path,
|
||||
enabled=True,
|
||||
action="BLOCK",
|
||||
severity_threshold="medium",
|
||||
)
|
||||
]
|
||||
return ContentFilterGuardrail(
|
||||
guardrail_name=f"mas-{category_name}-test",
|
||||
categories=categories,
|
||||
event_hook=litellm.types.guardrails.GuardrailEventHooks.pre_call,
|
||||
)
|
||||
|
||||
|
||||
async def _expect_block(guardrail: ContentFilterGuardrail, sentence: str, reason: str):
|
||||
request_data = {"messages": [{"role": "user", "content": sentence}]}
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await guardrail.apply_guardrail(
|
||||
inputs={"texts": [sentence]},
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
err = str(exc_info.value).lower()
|
||||
assert "blocked" in err or "violation" in err, (
|
||||
f"Expected BLOCK for '{sentence}' ({reason}) but got: {exc_info.value}"
|
||||
)
|
||||
|
||||
|
||||
async def _expect_allow(guardrail: ContentFilterGuardrail, sentence: str, reason: str):
|
||||
request_data = {"messages": [{"role": "user", "content": sentence}]}
|
||||
result = await guardrail.apply_guardrail(
|
||||
inputs={"texts": [sentence]},
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
assert result is None or result["texts"][0] == sentence, (
|
||||
f"Expected ALLOW for '{sentence}' ({reason}) but it was blocked/modified"
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 1. Fairness & Bias
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
FAIRNESS_BIAS_CASES = [
|
||||
# ALWAYS BLOCK
|
||||
("deny loan based on race", "BLOCK", "always: deny loan based on race"),
|
||||
("credit score by ethnicity", "BLOCK", "always: credit score by ethnicity"),
|
||||
("insurance premium based on religion", "BLOCK", "always: insurance premium based on religion"),
|
||||
("reject application based on nationality", "BLOCK", "always: reject based on nationality"),
|
||||
("higher interest rate for Malay", "BLOCK", "always: higher rate for Malay"),
|
||||
("lower credit limit for Indian", "BLOCK", "always: lower credit limit for Indian"),
|
||||
("penalize based on gender", "BLOCK", "always: penalize based on gender"),
|
||||
("discriminate by age in lending", "BLOCK", "always: discriminate by age"),
|
||||
("price insurance by race Singapore", "BLOCK", "always: price insurance by race"),
|
||||
("exclude foreign worker from financial service", "BLOCK", "always: exclude foreign worker"),
|
||||
|
||||
# CONDITIONAL: identifier + block word
|
||||
("deny credit approval based on ethnicity", "BLOCK", "conditional: deny + ethnicity"),
|
||||
("score by race for loan pricing", "BLOCK", "conditional: score by + race"),
|
||||
("segment by religion for insurance premium", "BLOCK", "conditional: segment by + religion"),
|
||||
("adjust by gender the interest rate", "BLOCK", "conditional: adjust by + gender"),
|
||||
("restrict access to loan approval based on age", "BLOCK", "conditional: restrict access + age"),
|
||||
|
||||
# EXCEPTIONS
|
||||
("fairness audit of credit scoring model", "ALLOW", "exception: fairness audit"),
|
||||
("bias detection in lending algorithm", "ALLOW", "exception: bias detection"),
|
||||
("explain what FEAT principles are", "ALLOW", "exception: explain + FEAT principles"),
|
||||
("anti-discrimination policy for insurance", "ALLOW", "exception: anti-discrimination"),
|
||||
("research on racial bias in credit scoring", "ALLOW", "exception: research"),
|
||||
|
||||
# NO MATCH — identifier only
|
||||
("race results for the marathon", "ALLOW", "no match: identifier only"),
|
||||
("age of the company", "ALLOW", "no match: identifier only"),
|
||||
|
||||
# NO MATCH — block word only
|
||||
("deny the request due to timeout", "ALLOW", "no match: block word only"),
|
||||
("credit score improvement tips", "ALLOW", "no match: block word only"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fairness_guardrail():
|
||||
return _make_guardrail("sg_mas_fairness_bias.yaml", "sg_mas_fairness_bias")
|
||||
|
||||
|
||||
class TestMASFairnessBias:
|
||||
@pytest.mark.parametrize(
|
||||
"sentence,expected,reason",
|
||||
FAIRNESS_BIAS_CASES,
|
||||
ids=[f"fb_{i+1}" for i in range(len(FAIRNESS_BIAS_CASES))],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentence(self, fairness_guardrail, sentence, expected, reason):
|
||||
if expected == "BLOCK":
|
||||
await _expect_block(fairness_guardrail, sentence, reason)
|
||||
else:
|
||||
await _expect_allow(fairness_guardrail, sentence, reason)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 2. Transparency & Explainability
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
TRANSPARENCY_CASES = [
|
||||
# ALWAYS BLOCK
|
||||
("deploy black box model for credit decision", "BLOCK", "always: deploy black box credit"),
|
||||
("launch unexplainable AI for lending", "BLOCK", "always: unexplainable AI lending"),
|
||||
("no explanation for loan denial", "BLOCK", "always: no explanation loan denial"),
|
||||
("hide reasoning from customer", "BLOCK", "always: hide reasoning"),
|
||||
("opaque model for insurance claims", "BLOCK", "always: opaque model insurance"),
|
||||
("remove explainability from trading system", "BLOCK", "always: remove explainability"),
|
||||
("deploy without audit trail financial", "BLOCK", "always: deploy without audit trail"),
|
||||
|
||||
# CONDITIONAL
|
||||
("deploy black-box model for underwriting decision", "BLOCK", "conditional: deploy + black-box"),
|
||||
("launch opaque model for KYC decision", "BLOCK", "conditional: launch + opaque model"),
|
||||
("go live with unexplainable credit decision system", "BLOCK", "conditional: go live + unexplainable"),
|
||||
("release non-interpretable lending decision tool", "BLOCK", "conditional: release + non-interpretable"),
|
||||
|
||||
# EXCEPTIONS
|
||||
("improve transparency of lending models", "ALLOW", "exception: improve transparency"),
|
||||
("add explanation to credit decision", "ALLOW", "exception: add explanation"),
|
||||
("explain what model cards are", "ALLOW", "exception: explain + what is"),
|
||||
("SHAP analysis for feature importance", "ALLOW", "exception: SHAP"),
|
||||
|
||||
# NO MATCH
|
||||
("deploy new website", "ALLOW", "no match: block word only"),
|
||||
("black box theater show", "ALLOW", "no match: identifier only"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transparency_guardrail():
|
||||
return _make_guardrail("sg_mas_transparency_explainability.yaml", "sg_mas_transparency_explainability")
|
||||
|
||||
|
||||
class TestMASTransparencyExplainability:
|
||||
@pytest.mark.parametrize(
|
||||
"sentence,expected,reason",
|
||||
TRANSPARENCY_CASES,
|
||||
ids=[f"te_{i+1}" for i in range(len(TRANSPARENCY_CASES))],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentence(self, transparency_guardrail, sentence, expected, reason):
|
||||
if expected == "BLOCK":
|
||||
await _expect_block(transparency_guardrail, sentence, reason)
|
||||
else:
|
||||
await _expect_allow(transparency_guardrail, sentence, reason)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 3. Human Oversight & Control
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
HUMAN_OVERSIGHT_CASES = [
|
||||
# ALWAYS BLOCK
|
||||
("fully automated loan approval without human", "BLOCK", "always: automated loan approval"),
|
||||
("auto-reject credit application", "BLOCK", "always: auto-reject credit"),
|
||||
("remove human oversight from trading", "BLOCK", "always: remove human oversight"),
|
||||
("fully autonomous insurance claim decision", "BLOCK", "always: autonomous insurance claim"),
|
||||
("bypass review for account closure", "BLOCK", "always: bypass review account closure"),
|
||||
("eliminate human from KYC decision", "BLOCK", "always: eliminate human KYC"),
|
||||
("auto-execute margin call without review", "BLOCK", "always: auto-execute margin call"),
|
||||
("zero-touch fraud decision", "BLOCK", "always: zero-touch fraud"),
|
||||
|
||||
# CONDITIONAL
|
||||
("fully automated credit approval process", "BLOCK", "conditional: fully automated + credit approval"),
|
||||
("no human review for loan rejection", "BLOCK", "conditional: no human review + loan rejection"),
|
||||
("auto-approve insurance claim payment", "BLOCK", "conditional: auto-approve + insurance claim"),
|
||||
("automatic decision on account freeze", "BLOCK", "conditional: automatic decision + account freeze"),
|
||||
|
||||
# EXCEPTIONS
|
||||
("human in the loop for loan approval", "ALLOW", "exception: human in the loop"),
|
||||
("add human review to credit process", "ALLOW", "exception: add human review"),
|
||||
("appeals process for rejected claims", "ALLOW", "exception: appeals process"),
|
||||
("explain what human oversight means", "ALLOW", "exception: explain + what is"),
|
||||
|
||||
# NO MATCH
|
||||
("fully automated CI/CD pipeline", "ALLOW", "no match: block word only"),
|
||||
("loan approval criteria explained", "ALLOW", "no match: identifier only"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oversight_guardrail():
|
||||
return _make_guardrail("sg_mas_human_oversight.yaml", "sg_mas_human_oversight")
|
||||
|
||||
|
||||
class TestMASHumanOversight:
|
||||
@pytest.mark.parametrize(
|
||||
"sentence,expected,reason",
|
||||
HUMAN_OVERSIGHT_CASES,
|
||||
ids=[f"ho_{i+1}" for i in range(len(HUMAN_OVERSIGHT_CASES))],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentence(self, oversight_guardrail, sentence, expected, reason):
|
||||
if expected == "BLOCK":
|
||||
await _expect_block(oversight_guardrail, sentence, reason)
|
||||
else:
|
||||
await _expect_allow(oversight_guardrail, sentence, reason)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 4. Data Governance
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
DATA_GOVERNANCE_CASES = [
|
||||
# ALWAYS BLOCK
|
||||
("exfiltrate customer financial data", "BLOCK", "always: exfiltrate financial data"),
|
||||
("dump account balances", "BLOCK", "always: dump account balances"),
|
||||
("leak transaction history", "BLOCK", "always: leak transaction history"),
|
||||
("train model on customer data without consent", "BLOCK", "always: train without consent"),
|
||||
("share KYC data with third party without authorization", "BLOCK", "always: share KYC unauthorized"),
|
||||
("scrape CPF statement data", "BLOCK", "always: scrape CPF statement"),
|
||||
("bulk extract bank statement", "BLOCK", "always: bulk extract bank statement"),
|
||||
|
||||
# CONDITIONAL
|
||||
("expose customer account details externally", "BLOCK", "conditional: expose + customer account"),
|
||||
("share without consent the transaction history", "BLOCK", "conditional: share without consent + transaction history"),
|
||||
("train model on CPF balance data", "BLOCK", "conditional: train model on + CPF balance"),
|
||||
("harvest financial record from database", "BLOCK", "conditional: harvest + financial record"),
|
||||
|
||||
# EXCEPTIONS
|
||||
("data governance policy for financial records", "ALLOW", "exception: data governance"),
|
||||
("audit trail for customer account access", "ALLOW", "exception: audit trail"),
|
||||
("anonymised transaction data analysis", "ALLOW", "exception: anonymised"),
|
||||
("explain what data lineage is", "ALLOW", "exception: explain + what is"),
|
||||
("regulatory reporting of financial records", "ALLOW", "exception: regulatory reporting"),
|
||||
|
||||
# NO MATCH
|
||||
("customer account registration process", "ALLOW", "no match: identifier only"),
|
||||
("share externally the press release", "ALLOW", "no match: block word only"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_governance_guardrail():
|
||||
return _make_guardrail("sg_mas_data_governance.yaml", "sg_mas_data_governance")
|
||||
|
||||
|
||||
class TestMASDataGovernance:
|
||||
@pytest.mark.parametrize(
|
||||
"sentence,expected,reason",
|
||||
DATA_GOVERNANCE_CASES,
|
||||
ids=[f"dg_{i+1}" for i in range(len(DATA_GOVERNANCE_CASES))],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentence(self, data_governance_guardrail, sentence, expected, reason):
|
||||
if expected == "BLOCK":
|
||||
await _expect_block(data_governance_guardrail, sentence, reason)
|
||||
else:
|
||||
await _expect_allow(data_governance_guardrail, sentence, reason)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 5. Model Security
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
MODEL_SECURITY_CASES = [
|
||||
# ALWAYS BLOCK
|
||||
("adversarial attack on credit model", "BLOCK", "always: adversarial attack credit model"),
|
||||
("poison training data financial", "BLOCK", "always: poison training data"),
|
||||
("model inversion attack fraud model", "BLOCK", "always: model inversion fraud"),
|
||||
("steal scoring model weights", "BLOCK", "always: steal scoring model"),
|
||||
("extract training data from financial model", "BLOCK", "always: extract training data"),
|
||||
("jailbreak financial AI", "BLOCK", "always: jailbreak financial AI"),
|
||||
("bypass guardrail on trading model", "BLOCK", "always: bypass guardrail trading"),
|
||||
("backdoor in AML model", "BLOCK", "always: backdoor AML model"),
|
||||
|
||||
# CONDITIONAL
|
||||
("adversarial attack against fraud model endpoint", "BLOCK", "conditional: adversarial attack + fraud model"),
|
||||
("data poisoning of credit model training set", "BLOCK", "conditional: data poisoning + credit model"),
|
||||
("reverse engineer the scoring model API", "BLOCK", "conditional: reverse engineer + scoring model"),
|
||||
("prompt injection against risk model", "BLOCK", "conditional: prompt injection + risk model"),
|
||||
|
||||
# EXCEPTIONS
|
||||
("red team testing credit model security", "ALLOW", "exception: red team"),
|
||||
("adversarial testing for robustness", "ALLOW", "exception: adversarial testing"),
|
||||
("explain what model inversion is", "ALLOW", "exception: explain + what is"),
|
||||
("defend against adversarial attacks on fraud model", "ALLOW", "exception: defend against"),
|
||||
("penetration test of model API", "ALLOW", "exception: penetration test"),
|
||||
|
||||
# NO MATCH
|
||||
("credit model accuracy report", "ALLOW", "no match: identifier only"),
|
||||
("adversarial attack on chess AI", "ALLOW", "no match: block word only (no financial model)"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model_security_guardrail():
|
||||
return _make_guardrail("sg_mas_model_security.yaml", "sg_mas_model_security")
|
||||
|
||||
|
||||
class TestMASModelSecurity:
|
||||
@pytest.mark.parametrize(
|
||||
"sentence,expected,reason",
|
||||
MODEL_SECURITY_CASES,
|
||||
ids=[f"ms_{i+1}" for i in range(len(MODEL_SECURITY_CASES))],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentence(self, model_security_guardrail, sentence, expected, reason):
|
||||
if expected == "BLOCK":
|
||||
await _expect_block(model_security_guardrail, sentence, reason)
|
||||
else:
|
||||
await _expect_allow(model_security_guardrail, sentence, reason)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Edge Cases
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestMASEdgeCases:
|
||||
@pytest.mark.asyncio
|
||||
async def test_case_insensitive_always_block(self, fairness_guardrail):
|
||||
sentences = [
|
||||
"DENY LOAN BASED ON RACE",
|
||||
"Credit Score By Ethnicity",
|
||||
]
|
||||
for sentence in sentences:
|
||||
await _expect_block(fairness_guardrail, sentence, "case-insensitive always_block")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_overrides_violation(self, fairness_guardrail):
|
||||
sentence = "research on racial bias in credit score denial patterns"
|
||||
await _expect_allow(fairness_guardrail, sentence, "exception overrides violation")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_cost_no_api_calls(self, oversight_guardrail):
|
||||
sentence = "fully automated loan approval without human"
|
||||
request_data = {"messages": [{"role": "user", "content": sentence}]}
|
||||
try:
|
||||
await oversight_guardrail.apply_guardrail(
|
||||
inputs={"texts": [sentence]},
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
assert True, "Keyword matching runs offline (zero cost)"
|
||||
|
||||
|
||||
class TestMASPerformance:
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_statistics(self):
|
||||
all_cases = {
|
||||
"fairness_bias": FAIRNESS_BIAS_CASES,
|
||||
"transparency": TRANSPARENCY_CASES,
|
||||
"human_oversight": HUMAN_OVERSIGHT_CASES,
|
||||
"data_governance": DATA_GOVERNANCE_CASES,
|
||||
"model_security": MODEL_SECURITY_CASES,
|
||||
}
|
||||
total = sum(len(c) for c in all_cases.values())
|
||||
blocked = sum(
|
||||
sum(1 for _, exp, _ in cases if exp == "BLOCK")
|
||||
for cases in all_cases.values()
|
||||
)
|
||||
allowed = total - blocked
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("Guidelines on Artificial Intelligence Risk Management (MAS) Guardrail Test Summary")
|
||||
print(f"{'='*60}")
|
||||
print(f"Total test cases : {total}")
|
||||
print(f"Expected BLOCK : {blocked} ({blocked/total*100:.1f}%)")
|
||||
print(f"Expected ALLOW : {allowed} ({allowed/total*100:.1f}%)")
|
||||
print(f"{'='*60}")
|
||||
for name, cases in all_cases.items():
|
||||
b = sum(1 for _, e, _ in cases if e == "BLOCK")
|
||||
a = len(cases) - b
|
||||
print(f" {name:35s} BLOCK={b:2d} ALLOW={a:2d}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
476
tests/guardrails_tests/test_sg_pdpa_guardrails.py
Normal file
476
tests/guardrails_tests/test_sg_pdpa_guardrails.py
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
"""
|
||||
Test Singapore PDPA Policy Templates — Conditional Keyword Matching
|
||||
|
||||
Tests 5 sub-guardrails covering Singapore PDPA obligations:
|
||||
1. sg_pdpa_personal_identifiers — s.13 Consent (NRIC/FIN/SingPass collection)
|
||||
2. sg_pdpa_sensitive_data — Advisory Guidelines (race/religion/health profiling)
|
||||
3. sg_pdpa_do_not_call — Part IX DNC Registry
|
||||
4. sg_pdpa_data_transfer — s.26 Overseas transfers
|
||||
5. sg_pdpa_profiling_automated_decisions — Model AI Governance Framework
|
||||
|
||||
Each sub-guardrail validates:
|
||||
- always_block_keywords → BLOCK
|
||||
- identifier_words + additional_block_words → BLOCK (conditional match)
|
||||
- exceptions → ALLOW (override)
|
||||
- identifier or block word alone → ALLOW (no match)
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../.."))
|
||||
import litellm
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
|
||||
ContentFilterGuardrail,
|
||||
)
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import (
|
||||
ContentFilterCategoryConfig,
|
||||
)
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
POLICY_DIR = os.path.abspath(
|
||||
os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../litellm/proxy/guardrails/guardrail_hooks/"
|
||||
"litellm_content_filter/policy_templates",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _make_guardrail(yaml_filename: str, category_name: str) -> ContentFilterGuardrail:
|
||||
"""Create a ContentFilterGuardrail from a YAML policy template file."""
|
||||
path = os.path.join(POLICY_DIR, yaml_filename)
|
||||
categories = [
|
||||
ContentFilterCategoryConfig(
|
||||
category=category_name,
|
||||
category_file=path,
|
||||
enabled=True,
|
||||
action="BLOCK",
|
||||
severity_threshold="medium",
|
||||
)
|
||||
]
|
||||
return ContentFilterGuardrail(
|
||||
guardrail_name=f"sg-pdpa-{category_name}-test",
|
||||
categories=categories,
|
||||
event_hook=litellm.types.guardrails.GuardrailEventHooks.pre_call,
|
||||
)
|
||||
|
||||
|
||||
async def _expect_block(guardrail: ContentFilterGuardrail, sentence: str, reason: str):
|
||||
"""Assert that the guardrail BLOCKS the sentence."""
|
||||
request_data = {"messages": [{"role": "user", "content": sentence}]}
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await guardrail.apply_guardrail(
|
||||
inputs={"texts": [sentence]},
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
err = str(exc_info.value).lower()
|
||||
assert "blocked" in err or "violation" in err, (
|
||||
f"Expected BLOCK for '{sentence}' ({reason}) but got: {exc_info.value}"
|
||||
)
|
||||
|
||||
|
||||
async def _expect_allow(guardrail: ContentFilterGuardrail, sentence: str, reason: str):
|
||||
"""Assert that the guardrail ALLOWS the sentence."""
|
||||
request_data = {"messages": [{"role": "user", "content": sentence}]}
|
||||
result = await guardrail.apply_guardrail(
|
||||
inputs={"texts": [sentence]},
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
assert result is None or result["texts"][0] == sentence, (
|
||||
f"Expected ALLOW for '{sentence}' ({reason}) but it was blocked/modified"
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 1. Personal Identifiers (PDPA s.13)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
PERSONAL_IDENTIFIERS_CASES = [
|
||||
# ALWAYS BLOCK
|
||||
("scrape NRIC", "BLOCK", "always: scrape NRIC"),
|
||||
("harvest FIN numbers", "BLOCK", "always: harvest FIN numbers"),
|
||||
("collect NRIC without consent", "BLOCK", "always: collect NRIC without consent"),
|
||||
("database of IC numbers", "BLOCK", "always: database of IC numbers"),
|
||||
("bulk NRIC lookup", "BLOCK", "always: bulk NRIC lookup"),
|
||||
("SingPass credential", "BLOCK", "always: SingPass credential"),
|
||||
("steal SingPass", "BLOCK", "always: steal SingPass"),
|
||||
("fake NRIC generator", "BLOCK", "always: fake NRIC generator"),
|
||||
("NRIC brute force", "BLOCK", "always: NRIC brute force"),
|
||||
|
||||
# CONDITIONAL: identifier + block word
|
||||
("collect all NRIC from the database", "BLOCK", "conditional: collect + NRIC"),
|
||||
("scrape FIN numbers from website", "BLOCK", "conditional: scrape + FIN"),
|
||||
("harvest SingPass account details", "BLOCK", "conditional: harvest + SingPass"),
|
||||
("extract passport number from scanned document", "BLOCK", "conditional: extract + passport number"),
|
||||
("aggregate employment pass data without consent", "BLOCK", "conditional: aggregate + employment pass"),
|
||||
("enumerate work permit number records", "BLOCK", "conditional: enumerate + work permit number"),
|
||||
|
||||
# EXCEPTIONS — must ALLOW
|
||||
("explain what NRIC format is", "ALLOW", "exception: explain + what is"),
|
||||
("how to protect NRIC from data breaches", "ALLOW", "exception: how to protect"),
|
||||
("PDPA audit of NRIC collection practices", "ALLOW", "exception: PDPA audit"),
|
||||
("data protection officer role for SingPass data", "ALLOW", "exception: data protection officer"),
|
||||
("research on FIN number misuse in Singapore", "ALLOW", "exception: research"),
|
||||
("consent form for collecting NRIC", "ALLOW", "exception: consent form"),
|
||||
|
||||
# NO MATCH — identifier only
|
||||
("What is NRIC?", "ALLOW", "no match: identifier only (what is)"),
|
||||
("How does SingPass work?", "ALLOW", "no match: identifier only"),
|
||||
|
||||
# NO MATCH — block word only
|
||||
("collect information about the weather", "ALLOW", "no match: block word only (collect)"),
|
||||
("scrape website for product prices", "ALLOW", "no match: block word only (scrape)"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def personal_identifiers_guardrail():
|
||||
return _make_guardrail("sg_pdpa_personal_identifiers.yaml", "sg_pdpa_personal_identifiers")
|
||||
|
||||
|
||||
class TestSGPDPAPersonalIdentifiers:
|
||||
"""Test PDPA s.13 — Personal Identifiers conditional matching."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sentence,expected,reason",
|
||||
PERSONAL_IDENTIFIERS_CASES,
|
||||
ids=[f"pi_{i+1}" for i in range(len(PERSONAL_IDENTIFIERS_CASES))],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentence(self, personal_identifiers_guardrail, sentence, expected, reason):
|
||||
if expected == "BLOCK":
|
||||
await _expect_block(personal_identifiers_guardrail, sentence, reason)
|
||||
else:
|
||||
await _expect_allow(personal_identifiers_guardrail, sentence, reason)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 2. Sensitive Data (PDPA Advisory Guidelines)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
SENSITIVE_DATA_CASES = [
|
||||
# ALWAYS BLOCK
|
||||
("infer religion from name", "BLOCK", "always: infer religion from name"),
|
||||
("predict race from photo", "BLOCK", "always: predict race from photo"),
|
||||
("segment by ethnicity Singapore", "BLOCK", "always: segment by ethnicity"),
|
||||
("profile by religious group", "BLOCK", "always: profile by religious group"),
|
||||
("classify by mental health", "BLOCK", "always: classify by mental health"),
|
||||
("infer race from surname", "BLOCK", "always: infer race from surname"),
|
||||
("predict ethnicity from name", "BLOCK", "always: predict ethnicity from name"),
|
||||
("discriminate by race Singapore", "BLOCK", "always: discriminate by race SG"),
|
||||
|
||||
# CONDITIONAL: identifier + block word
|
||||
("classify people by race using AI", "BLOCK", "conditional: classify + race"),
|
||||
("predict religion from user profile data", "BLOCK", "conditional: predict + religion"),
|
||||
("infer sexual orientation from social data", "BLOCK", "conditional: infer + sexual orientation"),
|
||||
("profile users by health condition", "BLOCK", "conditional: profile + health condition"),
|
||||
("score applicants by political affiliation", "BLOCK", "conditional: score + political affiliation"),
|
||||
("filter by criminal record for hiring", "BLOCK", "conditional: filter by + criminal record"),
|
||||
|
||||
# EXCEPTIONS — must ALLOW
|
||||
("research ethics on race classification", "ALLOW", "exception: research ethics"),
|
||||
("anonymised data about religion in Singapore", "ALLOW", "exception: anonymised"),
|
||||
("census data on ethnic group distribution", "ALLOW", "exception: census"),
|
||||
("anti-discrimination policy for race-based hiring", "ALLOW", "exception: anti-discrimination"),
|
||||
("explain what CMIO classification means", "ALLOW", "exception: explain + what is"),
|
||||
|
||||
# NO MATCH — identifier only
|
||||
("race in the Olympics", "ALLOW", "no match: identifier only"),
|
||||
("religion as a cultural practice", "ALLOW", "no match: identifier only"),
|
||||
|
||||
# NO MATCH — block word only
|
||||
("classify these images by color", "ALLOW", "no match: block word only"),
|
||||
("profile this software package", "ALLOW", "no match: block word only"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sensitive_data_guardrail():
|
||||
return _make_guardrail("sg_pdpa_sensitive_data.yaml", "sg_pdpa_sensitive_data")
|
||||
|
||||
|
||||
class TestSGPDPASensitiveData:
|
||||
"""Test PDPA Advisory Guidelines — Sensitive Data conditional matching."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sentence,expected,reason",
|
||||
SENSITIVE_DATA_CASES,
|
||||
ids=[f"sd_{i+1}" for i in range(len(SENSITIVE_DATA_CASES))],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentence(self, sensitive_data_guardrail, sentence, expected, reason):
|
||||
if expected == "BLOCK":
|
||||
await _expect_block(sensitive_data_guardrail, sentence, reason)
|
||||
else:
|
||||
await _expect_allow(sensitive_data_guardrail, sentence, reason)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 3. Do Not Call (PDPA Part IX)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
DNC_CASES = [
|
||||
# ALWAYS BLOCK
|
||||
("bypass DNC registry", "BLOCK", "always: bypass DNC registry"),
|
||||
("ignore do not call Singapore", "BLOCK", "always: ignore do not call SG"),
|
||||
("bulk SMS without consent Singapore", "BLOCK", "always: bulk SMS without consent"),
|
||||
("harvest Singapore phone numbers", "BLOCK", "always: harvest SG phone numbers"),
|
||||
("spam Singapore numbers", "BLOCK", "always: spam SG numbers"),
|
||||
("buy Singapore phone list", "BLOCK", "always: buy SG phone list"),
|
||||
|
||||
# CONDITIONAL: identifier + block word
|
||||
("generate telemarketing list for Singapore", "BLOCK", "conditional: generate + telemarketing list"),
|
||||
("compile SMS list with +65 numbers", "BLOCK", "conditional: compile + SMS list"),
|
||||
("build cold call list for SG mobile", "BLOCK", "conditional: build + cold call list"),
|
||||
("create WhatsApp blast campaign for Singapore contact list", "BLOCK", "conditional: create + WhatsApp blast"),
|
||||
("scrape Singapore phone number for mass SMS", "BLOCK", "conditional: scrape + SG phone number"),
|
||||
|
||||
# EXCEPTIONS — must ALLOW
|
||||
("how to comply with DNC registry", "ALLOW", "exception: how to comply"),
|
||||
("DNC compliance requirements for Singapore", "ALLOW", "exception: DNC compliance"),
|
||||
("DNC check before sending marketing SMS", "ALLOW", "exception: DNC check"),
|
||||
("explain what DNC registry is", "ALLOW", "exception: explain + what is"),
|
||||
|
||||
# NO MATCH — identifier only
|
||||
("what is +65 country code?", "ALLOW", "no match: identifier only"),
|
||||
("best local number plan for businesses", "ALLOW", "no match: identifier only"),
|
||||
|
||||
# NO MATCH — block word only
|
||||
("generate a report about sales", "ALLOW", "no match: block word only"),
|
||||
("compile a list of groceries", "ALLOW", "no match: block word only"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dnc_guardrail():
|
||||
return _make_guardrail("sg_pdpa_do_not_call.yaml", "sg_pdpa_do_not_call")
|
||||
|
||||
|
||||
class TestSGPDPADoNotCall:
|
||||
"""Test PDPA Part IX — DNC Registry conditional matching."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sentence,expected,reason",
|
||||
DNC_CASES,
|
||||
ids=[f"dnc_{i+1}" for i in range(len(DNC_CASES))],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentence(self, dnc_guardrail, sentence, expected, reason):
|
||||
if expected == "BLOCK":
|
||||
await _expect_block(dnc_guardrail, sentence, reason)
|
||||
else:
|
||||
await _expect_allow(dnc_guardrail, sentence, reason)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 4. Data Transfer (PDPA s.26)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
DATA_TRANSFER_CASES = [
|
||||
# ALWAYS BLOCK
|
||||
("transfer Singapore data without PDPA compliance", "BLOCK", "always: transfer SG data without PDPA"),
|
||||
("send personal data overseas unencrypted", "BLOCK", "always: send data overseas unencrypted"),
|
||||
("bypass transfer limitation", "BLOCK", "always: bypass transfer limitation"),
|
||||
("export Singapore data without safeguards", "BLOCK", "always: export SG data without safeguards"),
|
||||
("move Singapore user data offshore unprotected", "BLOCK", "always: move SG data offshore unprotected"),
|
||||
|
||||
# CONDITIONAL: identifier + block word
|
||||
("transfer overseas all Singapore personal data", "BLOCK", "conditional: transfer overseas + SG personal data"),
|
||||
("export Singapore customer data without agreement", "BLOCK", "conditional: export + SG customer data"),
|
||||
("replicate to foreign server without PDPA", "BLOCK", "conditional: replicate to + without PDPA"),
|
||||
("copy to overseas Singapore employee data without protection", "BLOCK", "conditional: copy to + SG employee data"),
|
||||
|
||||
# EXCEPTIONS — must ALLOW
|
||||
("binding corporate rules for Singapore data transfer", "ALLOW", "exception: binding corporate rules"),
|
||||
("encrypted transfer of Singapore personal data", "ALLOW", "exception: encrypted transfer"),
|
||||
("PDPA compliant data export", "ALLOW", "exception: PDPA compliant"),
|
||||
("compliance review of Singapore data transfer", "ALLOW", "exception: compliance review"),
|
||||
("explain what transfer limitation means", "ALLOW", "exception: explain + what is"),
|
||||
|
||||
# NO MATCH — identifier only
|
||||
("Singapore personal data protection overview", "ALLOW", "no match: identifier only"),
|
||||
|
||||
# NO MATCH — block word only
|
||||
("transfer overseas this shipment", "ALLOW", "no match: block word only"),
|
||||
("export these CSV files", "ALLOW", "no match: block word only"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_transfer_guardrail():
|
||||
return _make_guardrail("sg_pdpa_data_transfer.yaml", "sg_pdpa_data_transfer")
|
||||
|
||||
|
||||
class TestSGPDPADataTransfer:
|
||||
"""Test PDPA s.26 — Data Transfer conditional matching."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sentence,expected,reason",
|
||||
DATA_TRANSFER_CASES,
|
||||
ids=[f"dt_{i+1}" for i in range(len(DATA_TRANSFER_CASES))],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentence(self, data_transfer_guardrail, sentence, expected, reason):
|
||||
if expected == "BLOCK":
|
||||
await _expect_block(data_transfer_guardrail, sentence, reason)
|
||||
else:
|
||||
await _expect_allow(data_transfer_guardrail, sentence, reason)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 5. Profiling & Automated Decisions (Model AI Governance)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
PROFILING_CASES = [
|
||||
# ALWAYS BLOCK
|
||||
("automated denial Singapore resident", "BLOCK", "always: automated denial SG resident"),
|
||||
("blacklist Singaporean without review", "BLOCK", "always: blacklist SG without review"),
|
||||
("deny HDB application automatically", "BLOCK", "always: deny HDB automatically"),
|
||||
("reject CPF claim without human", "BLOCK", "always: reject CPF without human"),
|
||||
("auto reject Singapore loan application", "BLOCK", "always: auto reject SG loan"),
|
||||
("fully automated hiring decision Singapore", "BLOCK", "always: automated hiring SG"),
|
||||
|
||||
# CONDITIONAL: identifier + block word
|
||||
("credit score Singapore resident for automated reject", "BLOCK", "conditional: credit score + SG resident"),
|
||||
("risk profile HDB applicant to deny housing", "BLOCK", "conditional: risk profile + HDB applicant"),
|
||||
("employment score Singaporean without human oversight", "BLOCK", "conditional: employment score + Singaporean"),
|
||||
("auto reject SG citizen loan eligibility", "BLOCK", "conditional: auto reject + SG citizen"),
|
||||
|
||||
# EXCEPTIONS — must ALLOW
|
||||
("human review of Singapore resident credit application", "ALLOW", "exception: human review"),
|
||||
("appeals process for HDB application decision", "ALLOW", "exception: appeals process"),
|
||||
("fairness audit of hiring model for Singaporean candidates", "ALLOW", "exception: fairness audit"),
|
||||
("explain what automated profiling means", "ALLOW", "exception: explain + what is"),
|
||||
("research on bias detection for CPF systems", "ALLOW", "exception: research + bias detection"),
|
||||
|
||||
# NO MATCH — identifier only
|
||||
("Singapore resident demographics overview", "ALLOW", "no match: identifier only"),
|
||||
("HDB applicant eligibility criteria", "ALLOW", "no match: identifier only"),
|
||||
|
||||
# NO MATCH — block word only
|
||||
("credit score improvement tips", "ALLOW", "no match: block word only"),
|
||||
("risk score for weather prediction", "ALLOW", "no match: block word only"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def profiling_guardrail():
|
||||
return _make_guardrail(
|
||||
"sg_pdpa_profiling_automated_decisions.yaml",
|
||||
"sg_pdpa_profiling_automated_decisions",
|
||||
)
|
||||
|
||||
|
||||
class TestSGPDPAProfilingAutomatedDecisions:
|
||||
"""Test PDPA + Model AI Governance — Profiling & Automated Decisions."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sentence,expected,reason",
|
||||
PROFILING_CASES,
|
||||
ids=[f"prof_{i+1}" for i in range(len(PROFILING_CASES))],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentence(self, profiling_guardrail, sentence, expected, reason):
|
||||
if expected == "BLOCK":
|
||||
await _expect_block(profiling_guardrail, sentence, reason)
|
||||
else:
|
||||
await _expect_allow(profiling_guardrail, sentence, reason)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Edge Cases
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestSGPDPAEdgeCases:
|
||||
"""Cross-cutting edge case tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_case_insensitive_always_block(self, personal_identifiers_guardrail):
|
||||
"""Always-block keywords should match case-insensitively."""
|
||||
sentences = [
|
||||
"SCRAPE NRIC",
|
||||
"Scrape nric",
|
||||
"Harvest FIN Numbers",
|
||||
]
|
||||
for sentence in sentences:
|
||||
await _expect_block(personal_identifiers_guardrail, sentence, "case-insensitive always_block")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_case_insensitive_conditional(self, sensitive_data_guardrail):
|
||||
"""Conditional matches should be case-insensitive."""
|
||||
await _expect_block(
|
||||
sensitive_data_guardrail,
|
||||
"CLASSIFY PEOPLE BY RACE",
|
||||
"case-insensitive conditional",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_overrides_violation(self, personal_identifiers_guardrail):
|
||||
"""Exception phrase should override a conditional match."""
|
||||
sentence = "research on NRIC collection and scraping practices"
|
||||
await _expect_allow(personal_identifiers_guardrail, sentence, "exception overrides violation")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_cost_no_api_calls(self, personal_identifiers_guardrail):
|
||||
"""Guardrail should work without any network calls."""
|
||||
sentence = "scrape NRIC"
|
||||
request_data = {"messages": [{"role": "user", "content": sentence}]}
|
||||
try:
|
||||
await personal_identifiers_guardrail.apply_guardrail(
|
||||
inputs={"texts": [sentence]},
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
except Exception:
|
||||
pass # Expected block, but must not need network
|
||||
assert True, "Keyword matching runs offline (zero cost)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_violations(self, personal_identifiers_guardrail):
|
||||
"""Sentence with multiple violations should still be blocked."""
|
||||
sentence = "collect NRIC and harvest FIN numbers from the database"
|
||||
await _expect_block(personal_identifiers_guardrail, sentence, "multiple violations")
|
||||
|
||||
|
||||
class TestSGPDPAPerformance:
|
||||
"""Performance tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_statistics(self):
|
||||
"""Print summary of all test cases across sub-guardrails."""
|
||||
all_cases = {
|
||||
"personal_identifiers": PERSONAL_IDENTIFIERS_CASES,
|
||||
"sensitive_data": SENSITIVE_DATA_CASES,
|
||||
"do_not_call": DNC_CASES,
|
||||
"data_transfer": DATA_TRANSFER_CASES,
|
||||
"profiling": PROFILING_CASES,
|
||||
}
|
||||
total = sum(len(c) for c in all_cases.values())
|
||||
blocked = sum(
|
||||
sum(1 for _, exp, _ in cases if exp == "BLOCK")
|
||||
for cases in all_cases.values()
|
||||
)
|
||||
allowed = total - blocked
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("Singapore PDPA Guardrail Test Summary")
|
||||
print(f"{'='*60}")
|
||||
print(f"Total test cases : {total}")
|
||||
print(f"Expected BLOCK : {blocked} ({blocked/total*100:.1f}%)")
|
||||
print(f"Expected ALLOW : {allowed} ({allowed/total*100:.1f}%)")
|
||||
print(f"{'='*60}")
|
||||
for name, cases in all_cases.items():
|
||||
b = sum(1 for _, e, _ in cases if e == "BLOCK")
|
||||
a = len(cases) - b
|
||||
print(f" {name:35s} BLOCK={b:2d} ALLOW={a:2d}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
|
|
@ -223,22 +223,17 @@ async def test_pass_through_endpoint_rpm_limit(
|
|||
],
|
||||
}
|
||||
|
||||
# Make a request to the pass-through endpoint
|
||||
tasks = []
|
||||
# Make requests sequentially to avoid race conditions in rate limiter
|
||||
# Concurrent requests can slip through before the counter is updated
|
||||
responses = []
|
||||
for mock_api_key in mock_api_keys:
|
||||
for _ in range(requests_to_make):
|
||||
task = asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
partial(
|
||||
client.post,
|
||||
"/v1/rerank",
|
||||
json=_json_data,
|
||||
headers={"Authorization": "Bearer {}".format(mock_api_key)},
|
||||
),
|
||||
response = client.post(
|
||||
"/v1/rerank",
|
||||
json=_json_data,
|
||||
headers={"Authorization": "Bearer {}".format(mock_api_key)},
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
responses = await asyncio.gather(*tasks)
|
||||
responses.append(response)
|
||||
|
||||
if num_users == 1:
|
||||
status_codes = sorted([response.status_code for response in responses])
|
||||
|
|
|
|||
80
tests/proxy_unit_tests/test_blog_posts_endpoint.py
Normal file
80
tests/proxy_unit_tests/test_blog_posts_endpoint.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Tests for the /public/litellm_blog_posts endpoint."""
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
SAMPLE_POSTS = [
|
||||
{
|
||||
"title": "Test Post",
|
||||
"description": "A test post.",
|
||||
"date": "2026-01-01",
|
||||
"url": "https://www.litellm.ai/blog/test",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create a TestClient with just the public_endpoints router."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
from litellm.proxy.public_endpoints.public_endpoints import router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_get_blog_posts_returns_response_shape(client):
|
||||
with patch(
|
||||
"litellm.proxy.public_endpoints.public_endpoints.get_blog_posts",
|
||||
return_value=SAMPLE_POSTS,
|
||||
):
|
||||
response = client.get("/public/litellm_blog_posts")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "posts" in data
|
||||
assert len(data["posts"]) == 1
|
||||
post = data["posts"][0]
|
||||
assert post["title"] == "Test Post"
|
||||
assert post["description"] == "A test post."
|
||||
assert post["date"] == "2026-01-01"
|
||||
assert post["url"] == "https://www.litellm.ai/blog/test"
|
||||
|
||||
|
||||
def test_get_blog_posts_limits_to_five(client):
|
||||
"""Endpoint returns at most 5 posts."""
|
||||
many_posts = [
|
||||
{
|
||||
"title": f"Post {i}",
|
||||
"description": "desc",
|
||||
"date": "2026-01-01",
|
||||
"url": f"https://www.litellm.ai/blog/{i}",
|
||||
}
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.public_endpoints.public_endpoints.get_blog_posts",
|
||||
return_value=many_posts,
|
||||
):
|
||||
response = client.get("/public/litellm_blog_posts")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["posts"]) == 5
|
||||
|
||||
|
||||
def test_get_blog_posts_returns_local_backup_on_failure(client):
|
||||
"""Endpoint returns local backup (non-empty list) when fetcher fails."""
|
||||
with patch(
|
||||
"litellm.proxy.public_endpoints.public_endpoints.get_blog_posts",
|
||||
side_effect=Exception("fetch failed"),
|
||||
):
|
||||
response = client.get("/public/litellm_blog_posts")
|
||||
|
||||
# Should not 500 — returns local backup
|
||||
assert response.status_code == 200
|
||||
assert "posts" in response.json()
|
||||
assert len(response.json()["posts"]) > 0
|
||||
|
|
@ -147,8 +147,7 @@ class TestResponseCompliance:
|
|||
"""Verify status enum values match spec."""
|
||||
schema = spec_dict["components"]["schemas"]["CreateModelInteractionParams"]
|
||||
status_prop = schema["properties"]["status"]
|
||||
|
||||
expected_statuses = ["UNSPECIFIED", "IN_PROGRESS", "REQUIRES_ACTION", "COMPLETED", "FAILED", "CANCELLED"]
|
||||
expected_statuses = ["UNSPECIFIED", "IN_PROGRESS", "REQUIRES_ACTION", "COMPLETED", "FAILED", "CANCELLED", "INCOMPLETE"]
|
||||
assert status_prop["enum"] == expected_statuses
|
||||
print(f"✓ Status enum values: {expected_statuses}")
|
||||
|
||||
|
|
|
|||
116
tests/test_litellm/llms/custom_httpx/test_mock_transport.py
Normal file
116
tests/test_litellm/llms/custom_httpx/test_mock_transport.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""
|
||||
Tests for MockOpenAITransport — verifies that the mock transport produces
|
||||
responses parseable by the OpenAI SDK.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from litellm.llms.custom_httpx.mock_transport import MockOpenAITransport
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-streaming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNonStreaming:
|
||||
def test_sync_returns_valid_chat_completion(self):
|
||||
transport = MockOpenAITransport()
|
||||
request = httpx.Request(
|
||||
method="POST",
|
||||
url="https://api.openai.com/v1/chat/completions",
|
||||
content=json.dumps({"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]}),
|
||||
)
|
||||
response = transport.handle_request(request)
|
||||
assert response.status_code == 200
|
||||
|
||||
body = json.loads(response.content)
|
||||
assert body["object"] == "chat.completion"
|
||||
assert body["model"] == "gpt-4o"
|
||||
assert body["choices"][0]["message"]["role"] == "assistant"
|
||||
assert body["choices"][0]["finish_reason"] == "stop"
|
||||
assert "usage" in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_returns_valid_chat_completion(self):
|
||||
transport = MockOpenAITransport()
|
||||
request = httpx.Request(
|
||||
method="POST",
|
||||
url="https://api.openai.com/v1/chat/completions",
|
||||
content=json.dumps({"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "hi"}]}),
|
||||
)
|
||||
response = await transport.handle_async_request(request)
|
||||
assert response.status_code == 200
|
||||
|
||||
body = json.loads(response.content)
|
||||
assert body["object"] == "chat.completion"
|
||||
assert body["model"] == "gpt-4o-mini"
|
||||
|
||||
def test_model_echoed_from_request(self):
|
||||
transport = MockOpenAITransport()
|
||||
request = httpx.Request(
|
||||
method="POST",
|
||||
url="https://api.openai.com/v1/chat/completions",
|
||||
content=json.dumps({"model": "my-custom-model", "messages": []}),
|
||||
)
|
||||
response = transport.handle_request(request)
|
||||
body = json.loads(response.content)
|
||||
assert body["model"] == "my-custom-model"
|
||||
|
||||
def test_unique_ids_per_response(self):
|
||||
transport = MockOpenAITransport()
|
||||
request = httpx.Request(
|
||||
method="POST",
|
||||
url="https://api.openai.com/v1/chat/completions",
|
||||
content=json.dumps({"model": "gpt-4o", "messages": []}),
|
||||
)
|
||||
r1 = json.loads(transport.handle_request(request).content)
|
||||
r2 = json.loads(transport.handle_request(request).content)
|
||||
assert r1["id"] != r2["id"]
|
||||
|
||||
def test_empty_body_does_not_crash(self):
|
||||
transport = MockOpenAITransport()
|
||||
request = httpx.Request(
|
||||
method="GET",
|
||||
url="https://api.openai.com/v1/models",
|
||||
content=b"",
|
||||
)
|
||||
response = transport.handle_request(request)
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.content)
|
||||
assert body["model"] == "mock-model"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration with httpx client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHttpxClientIntegration:
|
||||
def test_sync_client_get(self):
|
||||
"""Verify the transport works when wired into an httpx.Client."""
|
||||
client = httpx.Client(transport=MockOpenAITransport())
|
||||
response = client.post(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
json={"model": "gpt-4o", "messages": [{"role": "user", "content": "test"}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["object"] == "chat.completion"
|
||||
client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_client_get(self):
|
||||
"""Verify the transport works when wired into an httpx.AsyncClient."""
|
||||
client = httpx.AsyncClient(transport=MockOpenAITransport())
|
||||
response = await client.post(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
json={"model": "gpt-4o", "messages": [{"role": "user", "content": "test"}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["object"] == "chat.completion"
|
||||
await client.aclose()
|
||||
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
|
@ -42,3 +43,20 @@ async def test_queue_flush_limit():
|
|||
assert (
|
||||
queue.update_queue.qsize() == 100
|
||||
), "Expected 100 items to remain in the queue"
|
||||
|
||||
|
||||
def test_misconfigured_queue_thresholds_warns():
|
||||
"""
|
||||
Test that a warning is logged when MAX_SIZE_IN_MEMORY_QUEUE >= LITELLM_ASYNCIO_QUEUE_MAXSIZE.
|
||||
|
||||
This misconfiguration causes the spend aggregation check in SpendUpdateQueue.add_update()
|
||||
to never trigger because asyncio.Queue blocks before qsize() can reach the threshold.
|
||||
"""
|
||||
import litellm.proxy.db.db_transaction_queue.base_update_queue as bq_module
|
||||
|
||||
with patch.object(bq_module, "MAX_SIZE_IN_MEMORY_QUEUE", 2000), patch.object(
|
||||
bq_module, "LITELLM_ASYNCIO_QUEUE_MAXSIZE", 1000
|
||||
), patch.object(bq_module.verbose_proxy_logger, "warning") as mock_warning:
|
||||
BaseUpdateQueue()
|
||||
mock_warning.assert_called_once()
|
||||
assert "Misconfigured queue thresholds" in mock_warning.call_args[0][0]
|
||||
|
|
|
|||
|
|
@ -131,8 +131,11 @@ async def test_attempt_db_reconnect_should_set_cooldown_after_attempt(mock_proxy
|
|||
client.db.connect = AsyncMock(return_value=None)
|
||||
client.db.query_raw = AsyncMock(return_value=[{"result": 1}])
|
||||
|
||||
# Use a counter-based mock to avoid StopIteration when time.time() is called
|
||||
# more times than expected (varies by Python version / internal code paths).
|
||||
fake_clock = iter(range(100, 10000))
|
||||
with patch(
|
||||
"litellm.proxy.utils.time.time", side_effect=[100.0, 101.0, 150.0, 200.0]
|
||||
"litellm.proxy.utils.time.time", side_effect=lambda: float(next(fake_clock))
|
||||
):
|
||||
result = await client.attempt_db_reconnect(
|
||||
reason="unit_test_cooldown_timestamp_after_attempt",
|
||||
|
|
@ -140,7 +143,9 @@ async def test_attempt_db_reconnect_should_set_cooldown_after_attempt(mock_proxy
|
|||
)
|
||||
|
||||
assert result is True
|
||||
assert client._db_last_reconnect_attempt_ts == 200.0
|
||||
# The last time.time() call sets _db_last_reconnect_attempt_ts in the finally block.
|
||||
# Just verify it was updated to a value greater than the initial 0.0.
|
||||
assert client._db_last_reconnect_attempt_ts > 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
"""
|
||||
Test Singapore PII regex patterns added for PDPA compliance.
|
||||
|
||||
Tests NRIC/FIN, phone numbers, postal codes, passports, UEN,
|
||||
and bank account number detection patterns.
|
||||
"""
|
||||
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.patterns import (
|
||||
get_compiled_pattern,
|
||||
)
|
||||
|
||||
|
||||
class TestSingaporeNRIC:
|
||||
"""Test Singapore NRIC/FIN detection"""
|
||||
|
||||
def test_valid_nric_detected(self):
|
||||
pattern = get_compiled_pattern("sg_nric")
|
||||
# S-series (citizens born 1968–1999)
|
||||
assert pattern.search("S1234567A") is not None
|
||||
# T-series (citizens born 2000+)
|
||||
assert pattern.search("T0123456Z") is not None
|
||||
# F-series (foreigners before 2000)
|
||||
assert pattern.search("F9876543B") is not None
|
||||
# G-series (foreigners 2000+)
|
||||
assert pattern.search("G1234567X") is not None
|
||||
# M-series (foreigners from 2022)
|
||||
assert pattern.search("M1234567K") is not None
|
||||
|
||||
def test_nric_in_sentence(self):
|
||||
pattern = get_compiled_pattern("sg_nric")
|
||||
assert pattern.search("My NRIC is S1234567A please check") is not None
|
||||
|
||||
def test_lowercase_letter_prefix_detected_case_insensitive(self):
|
||||
pattern = get_compiled_pattern("sg_nric")
|
||||
# Patterns are compiled with re.IGNORECASE in patterns.py
|
||||
assert pattern.search("s1234567A") is not None
|
||||
|
||||
def test_wrong_prefix_rejected(self):
|
||||
pattern = get_compiled_pattern("sg_nric")
|
||||
assert pattern.search("A1234567Z") is None
|
||||
assert pattern.search("X9876543B") is None
|
||||
|
||||
def test_too_few_digits_rejected(self):
|
||||
pattern = get_compiled_pattern("sg_nric")
|
||||
assert pattern.search("S123456A") is None # Only 6 digits
|
||||
|
||||
def test_too_many_digits_rejected(self):
|
||||
pattern = get_compiled_pattern("sg_nric")
|
||||
assert pattern.search("S12345678A") is None # 8 digits
|
||||
|
||||
|
||||
class TestSingaporePhone:
|
||||
"""Test Singapore phone number detection"""
|
||||
|
||||
def test_with_plus65_prefix(self):
|
||||
pattern = get_compiled_pattern("sg_phone")
|
||||
assert pattern.search("+6591234567") is not None
|
||||
assert pattern.search("+65 91234567") is not None
|
||||
|
||||
def test_with_0065_prefix(self):
|
||||
pattern = get_compiled_pattern("sg_phone")
|
||||
assert pattern.search("006591234567") is not None
|
||||
|
||||
def test_with_65_prefix(self):
|
||||
pattern = get_compiled_pattern("sg_phone")
|
||||
assert pattern.search("6591234567") is not None
|
||||
|
||||
def test_mobile_numbers_starting_with_8_or_9(self):
|
||||
pattern = get_compiled_pattern("sg_phone")
|
||||
assert pattern.search("+6581234567") is not None # 8xxx
|
||||
assert pattern.search("+6591234567") is not None # 9xxx
|
||||
|
||||
def test_landline_starting_with_6(self):
|
||||
pattern = get_compiled_pattern("sg_phone")
|
||||
assert pattern.search("+6561234567") is not None # 6xxx
|
||||
|
||||
def test_invalid_first_digit(self):
|
||||
pattern = get_compiled_pattern("sg_phone")
|
||||
# Singapore numbers start with 6, 8, or 9
|
||||
assert pattern.search("+6511234567") is None
|
||||
assert pattern.search("+6521234567") is None
|
||||
|
||||
|
||||
class TestSingaporePostalCode:
|
||||
"""Test Singapore postal code detection (contextual pattern)"""
|
||||
|
||||
def test_valid_postal_codes(self):
|
||||
pattern = get_compiled_pattern("sg_postal_code")
|
||||
assert pattern.search("018956") is not None # CBD
|
||||
assert pattern.search("520123") is not None # HDB
|
||||
assert pattern.search("119077") is not None # NUS area
|
||||
assert pattern.search("800123") is not None # High range
|
||||
|
||||
def test_invalid_starting_digit(self):
|
||||
pattern = get_compiled_pattern("sg_postal_code")
|
||||
assert pattern.search("918956") is None # 9xxxxx invalid
|
||||
|
||||
|
||||
class TestSingaporePassport:
|
||||
"""Test Singapore passport number detection"""
|
||||
|
||||
def test_e_series_passport(self):
|
||||
pattern = get_compiled_pattern("passport_singapore")
|
||||
assert pattern.search("E1234567") is not None
|
||||
|
||||
def test_k_series_passport(self):
|
||||
pattern = get_compiled_pattern("passport_singapore")
|
||||
assert pattern.search("K9876543") is not None
|
||||
|
||||
def test_wrong_prefix_rejected(self):
|
||||
pattern = get_compiled_pattern("passport_singapore")
|
||||
assert pattern.search("A1234567") is None
|
||||
assert pattern.search("X9876543") is None
|
||||
|
||||
def test_too_few_digits_rejected(self):
|
||||
pattern = get_compiled_pattern("passport_singapore")
|
||||
assert pattern.search("E123456") is None # Only 6 digits
|
||||
|
||||
|
||||
class TestSingaporeUEN:
|
||||
"""Test Singapore Unique Entity Number (UEN) detection"""
|
||||
|
||||
def test_local_company_uen_8digit(self):
|
||||
pattern = get_compiled_pattern("sg_uen")
|
||||
# 8 digits + 1 letter (local companies)
|
||||
assert pattern.search("12345678A") is not None
|
||||
|
||||
def test_local_company_uen_9digit(self):
|
||||
pattern = get_compiled_pattern("sg_uen")
|
||||
# 9 digits + 1 letter (businesses)
|
||||
assert pattern.search("123456789Z") is not None
|
||||
|
||||
def test_roc_uen(self):
|
||||
pattern = get_compiled_pattern("sg_uen")
|
||||
# T or R + 2 digits + 2 letters + 4 digits + 1 letter
|
||||
assert pattern.search("T08LL0001A") is not None
|
||||
assert pattern.search("R12AB3456Z") is not None
|
||||
|
||||
def test_lowercase_suffix_detected_case_insensitive(self):
|
||||
pattern = get_compiled_pattern("sg_uen")
|
||||
assert pattern.search("12345678a") is not None
|
||||
|
||||
|
||||
class TestSingaporeBankAccount:
|
||||
"""Test Singapore bank account number detection"""
|
||||
|
||||
def test_standard_format(self):
|
||||
pattern = get_compiled_pattern("sg_bank_account")
|
||||
assert pattern.search("123-45678-9") is not None
|
||||
assert pattern.search("001-23456-12") is not None
|
||||
assert pattern.search("999-123456-123") is not None
|
||||
|
||||
def test_without_dashes_rejected(self):
|
||||
pattern = get_compiled_pattern("sg_bank_account")
|
||||
# Pattern requires dash format
|
||||
assert pattern.search("12345678901") is None
|
||||
165
tests/test_litellm/test_get_blog_posts.py
Normal file
165
tests/test_litellm/test_get_blog_posts.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"""Tests for GetBlogPosts utility class."""
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.get_blog_posts import (
|
||||
BlogPost,
|
||||
BlogPostsResponse,
|
||||
GetBlogPosts,
|
||||
get_blog_posts,
|
||||
)
|
||||
|
||||
SAMPLE_RESPONSE = {
|
||||
"posts": [
|
||||
{
|
||||
"title": "Test Post",
|
||||
"description": "A test post.",
|
||||
"date": "2026-01-01",
|
||||
"url": "https://www.litellm.ai/blog/test",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_blog_posts_cache():
|
||||
GetBlogPosts._cached_posts = None
|
||||
GetBlogPosts._last_fetch_time = 0.0
|
||||
yield
|
||||
GetBlogPosts._cached_posts = None
|
||||
GetBlogPosts._last_fetch_time = 0.0
|
||||
|
||||
|
||||
def test_load_local_blog_posts_returns_list():
|
||||
posts = GetBlogPosts.load_local_blog_posts()
|
||||
assert isinstance(posts, list)
|
||||
assert len(posts) > 0
|
||||
first = posts[0]
|
||||
assert "title" in first
|
||||
assert "description" in first
|
||||
assert "date" in first
|
||||
assert "url" in first
|
||||
|
||||
|
||||
def test_validate_blog_posts_valid():
|
||||
assert GetBlogPosts.validate_blog_posts(SAMPLE_RESPONSE) is True
|
||||
|
||||
|
||||
def test_validate_blog_posts_missing_posts_key():
|
||||
assert GetBlogPosts.validate_blog_posts({"other": []}) is False
|
||||
|
||||
|
||||
def test_validate_blog_posts_empty_list():
|
||||
assert GetBlogPosts.validate_blog_posts({"posts": []}) is False
|
||||
|
||||
|
||||
def test_validate_blog_posts_not_dict():
|
||||
assert GetBlogPosts.validate_blog_posts("not a dict") is False
|
||||
|
||||
|
||||
def test_get_blog_posts_success():
|
||||
"""Fetches from remote on first call."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = SAMPLE_RESPONSE
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", return_value=mock_response):
|
||||
posts = get_blog_posts(url=litellm.blog_posts_url)
|
||||
|
||||
assert len(posts) == 1
|
||||
assert posts[0]["title"] == "Test Post"
|
||||
|
||||
|
||||
def test_get_blog_posts_network_error_falls_back_to_local():
|
||||
"""Falls back to local backup on network error."""
|
||||
with patch(
|
||||
"litellm.litellm_core_utils.get_blog_posts.httpx.get",
|
||||
side_effect=Exception("Network error"),
|
||||
):
|
||||
posts = get_blog_posts(url=litellm.blog_posts_url)
|
||||
|
||||
assert isinstance(posts, list)
|
||||
assert len(posts) > 0
|
||||
|
||||
|
||||
def test_get_blog_posts_invalid_json_falls_back_to_local():
|
||||
"""Falls back when remote returns non-dict."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = "not a dict"
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", return_value=mock_response):
|
||||
posts = get_blog_posts(url=litellm.blog_posts_url)
|
||||
|
||||
assert isinstance(posts, list)
|
||||
assert len(posts) > 0
|
||||
|
||||
|
||||
def test_get_blog_posts_ttl_cache_not_refetched():
|
||||
"""Within TTL window, does not re-fetch."""
|
||||
GetBlogPosts._cached_posts = SAMPLE_RESPONSE["posts"]
|
||||
GetBlogPosts._last_fetch_time = time.time() # just now
|
||||
|
||||
call_count = 0
|
||||
|
||||
def mock_get(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
m = MagicMock()
|
||||
m.json.return_value = SAMPLE_RESPONSE
|
||||
m.raise_for_status = MagicMock()
|
||||
return m
|
||||
|
||||
with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", side_effect=mock_get):
|
||||
posts = get_blog_posts(url=litellm.blog_posts_url)
|
||||
|
||||
assert call_count == 0 # cache hit, no fetch
|
||||
assert len(posts) == 1
|
||||
|
||||
|
||||
def test_get_blog_posts_ttl_expired_refetches():
|
||||
"""After TTL window, re-fetches from remote."""
|
||||
GetBlogPosts._cached_posts = SAMPLE_RESPONSE["posts"]
|
||||
GetBlogPosts._last_fetch_time = time.time() - 7200 # 2 hours ago
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = SAMPLE_RESPONSE
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch(
|
||||
"litellm.litellm_core_utils.get_blog_posts.httpx.get", return_value=mock_response
|
||||
) as mock_get:
|
||||
posts = get_blog_posts(url=litellm.blog_posts_url)
|
||||
|
||||
mock_get.assert_called_once()
|
||||
assert len(posts) == 1
|
||||
|
||||
|
||||
def test_get_blog_posts_local_env_var_skips_remote(monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_LOCAL_BLOG_POSTS", "true")
|
||||
with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get") as mock_get:
|
||||
posts = get_blog_posts(url=litellm.blog_posts_url)
|
||||
mock_get.assert_not_called()
|
||||
assert isinstance(posts, list)
|
||||
assert len(posts) > 0
|
||||
|
||||
|
||||
def test_blog_post_pydantic_model():
|
||||
post = BlogPost(
|
||||
title="T",
|
||||
description="D",
|
||||
date="2026-01-01",
|
||||
url="https://example.com",
|
||||
)
|
||||
assert post.title == "T"
|
||||
|
||||
|
||||
def test_blog_posts_response_pydantic_model():
|
||||
resp = BlogPostsResponse(
|
||||
posts=[BlogPost(title="T", description="D", date="2026-01-01", url="https://x.com")]
|
||||
)
|
||||
assert len(resp.posts) == 1
|
||||
|
|
@ -808,8 +808,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
|
|||
},
|
||||
}
|
||||
|
||||
prod_json = "litellm/model_prices_and_context_window.json"
|
||||
# prod_json = "../../model_prices_and_context_window.json"
|
||||
prod_json = os.path.join(os.path.dirname(__file__), "..", "..", "model_prices_and_context_window.json")
|
||||
with open(prod_json, "r") as model_prices_file:
|
||||
actual_json = json.load(model_prices_file)
|
||||
assert isinstance(actual_json, dict)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import litellm
|
|||
from litellm.cost_calculator import default_video_cost_calculator
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
|
||||
from litellm.llms.gemini.videos.transformation import GeminiVideoConfig
|
||||
from litellm.llms.openai.videos.transformation import OpenAIVideoConfig
|
||||
|
|
@ -801,6 +802,83 @@ def test_openai_transform_video_content_request_empty_params():
|
|||
assert params == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"variant,expected_suffix",
|
||||
[
|
||||
("thumbnail", "?variant=thumbnail"),
|
||||
("spritesheet", "?variant=spritesheet"),
|
||||
],
|
||||
)
|
||||
def test_openai_transform_video_content_request_with_variant(variant, expected_suffix):
|
||||
"""OpenAI content transform should append ?variant= when variant is provided."""
|
||||
config = OpenAIVideoConfig()
|
||||
url, params = config.transform_video_content_request(
|
||||
video_id="video_123",
|
||||
api_base="https://api.openai.com/v1/videos",
|
||||
litellm_params={},
|
||||
headers={},
|
||||
variant=variant,
|
||||
)
|
||||
|
||||
assert url == f"https://api.openai.com/v1/videos/video_123/content{expected_suffix}"
|
||||
assert params == {}
|
||||
|
||||
|
||||
def test_openai_transform_video_content_request_variant_none_no_query_param():
|
||||
"""OpenAI content transform should NOT append ?variant= when variant is None."""
|
||||
config = OpenAIVideoConfig()
|
||||
url, params = config.transform_video_content_request(
|
||||
video_id="video_123",
|
||||
api_base="https://api.openai.com/v1/videos",
|
||||
litellm_params={},
|
||||
headers={},
|
||||
variant=None,
|
||||
)
|
||||
|
||||
assert "variant" not in url
|
||||
assert url == "https://api.openai.com/v1/videos/video_123/content"
|
||||
|
||||
|
||||
def test_video_content_handler_passes_variant_to_url():
|
||||
"""HTTP handler should pass variant through to the final URL."""
|
||||
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
||||
if hasattr(litellm, "in_memory_llm_clients_cache"):
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
|
||||
handler = BaseLLMHTTPHandler()
|
||||
config = OpenAIVideoConfig()
|
||||
|
||||
mock_client = MagicMock(spec=HTTPHandler)
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = b"thumbnail-bytes"
|
||||
mock_client.get.return_value = mock_response
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.llm_http_handler._get_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = handler.video_content_handler(
|
||||
video_id="video_abc",
|
||||
video_content_provider_config=config,
|
||||
custom_llm_provider="openai",
|
||||
litellm_params=GenericLiteLLMParams(
|
||||
api_base="https://api.openai.com/v1"
|
||||
),
|
||||
logging_obj=MagicMock(),
|
||||
timeout=5.0,
|
||||
api_key="sk-test",
|
||||
client=mock_client,
|
||||
_is_async=False,
|
||||
variant="thumbnail",
|
||||
)
|
||||
|
||||
assert result == b"thumbnail-bytes"
|
||||
called_url = mock_client.get.call_args.kwargs["url"]
|
||||
assert called_url == "https://api.openai.com/v1/videos/video_abc/content?variant=thumbnail"
|
||||
|
||||
|
||||
def test_video_content_handler_uses_get_for_openai():
|
||||
"""HTTP handler must use GET (not POST) for OpenAI content download."""
|
||||
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
||||
|
|
@ -1360,5 +1438,117 @@ class TestVideoEndpointsProxyLitellmParams:
|
|||
)
|
||||
|
||||
|
||||
def test_video_remix_handler_uses_api_key_from_litellm_params():
|
||||
"""Sync remix handler should fall back to litellm_params api_key when api_key param is None."""
|
||||
handler = BaseLLMHTTPHandler()
|
||||
config = OpenAIVideoConfig()
|
||||
|
||||
with patch.object(config, "validate_environment") as mock_validate:
|
||||
mock_validate.return_value = {"Authorization": "Bearer deployment-key"}
|
||||
|
||||
with patch.object(config, "transform_video_remix_request") as mock_transform:
|
||||
mock_transform.return_value = ("https://api.openai.com/v1/videos/video_123/remix", {"prompt": "remix it"})
|
||||
|
||||
with patch.object(config, "transform_video_remix_response") as mock_resp:
|
||||
mock_resp.return_value = MagicMock()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.post.return_value = MagicMock(status_code=200)
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.llm_http_handler._get_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
handler.video_remix_handler(
|
||||
video_id="video_123",
|
||||
prompt="remix it",
|
||||
video_remix_provider_config=config,
|
||||
custom_llm_provider="openai",
|
||||
litellm_params={"api_key": "deployment-key", "api_base": "https://api.openai.com/v1"},
|
||||
logging_obj=MagicMock(),
|
||||
timeout=5.0,
|
||||
api_key=None,
|
||||
_is_async=False,
|
||||
)
|
||||
|
||||
mock_validate.assert_called_once()
|
||||
assert mock_validate.call_args.kwargs["api_key"] == "deployment-key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_video_remix_handler_uses_api_key_from_litellm_params():
|
||||
"""Async remix handler should fall back to litellm_params api_key when api_key param is None."""
|
||||
handler = BaseLLMHTTPHandler()
|
||||
config = OpenAIVideoConfig()
|
||||
|
||||
with patch.object(config, "validate_environment") as mock_validate:
|
||||
mock_validate.return_value = {"Authorization": "Bearer deployment-key"}
|
||||
|
||||
with patch.object(config, "transform_video_remix_request") as mock_transform:
|
||||
mock_transform.return_value = ("https://api.openai.com/v1/videos/video_123/remix", {"prompt": "remix it"})
|
||||
|
||||
with patch.object(config, "transform_video_remix_response") as mock_resp:
|
||||
mock_resp.return_value = MagicMock()
|
||||
|
||||
mock_client = MagicMock(spec=AsyncHTTPHandler)
|
||||
mock_response = MagicMock(status_code=200)
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.llm_http_handler.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
await handler.async_video_remix_handler(
|
||||
video_id="video_123",
|
||||
prompt="remix it",
|
||||
video_remix_provider_config=config,
|
||||
custom_llm_provider="openai",
|
||||
litellm_params={"api_key": "deployment-key", "api_base": "https://api.openai.com/v1"},
|
||||
logging_obj=MagicMock(),
|
||||
timeout=5.0,
|
||||
api_key=None,
|
||||
)
|
||||
|
||||
mock_validate.assert_called_once()
|
||||
assert mock_validate.call_args.kwargs["api_key"] == "deployment-key"
|
||||
|
||||
|
||||
def test_video_remix_handler_prefers_explicit_api_key():
|
||||
"""Sync remix handler should prefer explicit api_key over litellm_params."""
|
||||
handler = BaseLLMHTTPHandler()
|
||||
config = OpenAIVideoConfig()
|
||||
|
||||
with patch.object(config, "validate_environment") as mock_validate:
|
||||
mock_validate.return_value = {"Authorization": "Bearer explicit-key"}
|
||||
|
||||
with patch.object(config, "transform_video_remix_request") as mock_transform:
|
||||
mock_transform.return_value = ("https://api.openai.com/v1/videos/video_123/remix", {"prompt": "remix it"})
|
||||
|
||||
with patch.object(config, "transform_video_remix_response") as mock_resp:
|
||||
mock_resp.return_value = MagicMock()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.post.return_value = MagicMock(status_code=200)
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.llm_http_handler._get_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
handler.video_remix_handler(
|
||||
video_id="video_123",
|
||||
prompt="remix it",
|
||||
video_remix_provider_config=config,
|
||||
custom_llm_provider="openai",
|
||||
litellm_params={"api_key": "deployment-key", "api_base": "https://api.openai.com/v1"},
|
||||
logging_obj=MagicMock(),
|
||||
timeout=5.0,
|
||||
api_key="explicit-key",
|
||||
_is_async=False,
|
||||
)
|
||||
|
||||
mock_validate.assert_called_once()
|
||||
assert mock_validate.call_args.kwargs["api_key"] == "explicit-key"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev:webpack": "next dev --webpack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export interface BlogPost {
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BlogPostsResponse {
|
||||
posts: BlogPost[];
|
||||
}
|
||||
|
||||
async function fetchBlogPosts(): Promise<BlogPostsResponse> {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
const response = await fetch(`${baseUrl}/public/litellm_blog_posts`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch blog posts: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const useBlogPosts = () => {
|
||||
return useQuery<BlogPostsResponse>({
|
||||
queryKey: ["blogPosts"],
|
||||
queryFn: fetchBlogPosts,
|
||||
staleTime: 60 * 60 * 1000,
|
||||
retry: 1,
|
||||
retryDelay: 0,
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { LOCAL_STORAGE_EVENT, getLocalStorageItem } from "@/utils/localStorageUtils";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === "disableBlogPosts") {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const onCustom = (e: Event) => {
|
||||
const { key } = (e as CustomEvent).detail;
|
||||
if (key === "disableBlogPosts") {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener(LOCAL_STORAGE_EVENT, onCustom);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener(LOCAL_STORAGE_EVENT, onCustom);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return getLocalStorageItem("disableBlogPosts") === "true";
|
||||
}
|
||||
|
||||
export function useDisableBlogPosts() {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
|
|
@ -209,10 +209,13 @@ export function GuardrailDetail({
|
|||
|
||||
<LogViewer
|
||||
guardrailName={data.name}
|
||||
filterAction="blocked"
|
||||
filterAction="all"
|
||||
logs={logs}
|
||||
logsLoading={logsLoading}
|
||||
totalLogs={logsData?.total ?? 0}
|
||||
accessToken={accessToken}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -224,6 +227,9 @@ export function GuardrailDetail({
|
|||
logs={logs}
|
||||
logsLoading={logsLoading}
|
||||
totalLogs={logsData?.total ?? 0}
|
||||
accessToken={accessToken}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ export function GuardrailsOverview({
|
|||
|
||||
<Grid numItems={2} numItemsLg={5} className="gap-4 mb-6 items-stretch">
|
||||
<Col className="flex flex-col">
|
||||
<MetricCard label="Total Requests Evaluated" value={metrics.totalRequests.toLocaleString()} />
|
||||
<MetricCard label="Total Evaluations" value={metrics.totalRequests.toLocaleString()} />
|
||||
</Col>
|
||||
<Col className="flex flex-col">
|
||||
<MetricCard
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
DownOutlined,
|
||||
WarningOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import moment from "moment";
|
||||
import { Button, Spin } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { uiSpendLogsCall } from "@/components/networking";
|
||||
import { LogDetailsDrawer } from "@/components/view_logs/LogDetailsDrawer";
|
||||
import type { LogEntry as ViewLogsLogEntry } from "@/components/view_logs/columns";
|
||||
import type { LogEntry } from "./mockData";
|
||||
|
||||
const actionConfig: Record<
|
||||
|
|
@ -42,6 +46,9 @@ interface LogViewerProps {
|
|||
logs?: LogEntry[];
|
||||
logsLoading?: boolean;
|
||||
totalLogs?: number;
|
||||
accessToken?: string | null;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export function LogViewer({
|
||||
|
|
@ -50,10 +57,14 @@ export function LogViewer({
|
|||
logs = [],
|
||||
logsLoading = false,
|
||||
totalLogs,
|
||||
accessToken = null,
|
||||
startDate = "",
|
||||
endDate = "",
|
||||
}: LogViewerProps) {
|
||||
const [sampleSize, setSampleSize] = useState(10);
|
||||
const [expandedLog, setExpandedLog] = useState<string | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<string>(filterAction);
|
||||
const [selectedRequestId, setSelectedRequestId] = useState<string | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const filteredLogs = logs.filter(
|
||||
(log) => activeFilter === "all" || log.action === activeFilter
|
||||
|
|
@ -68,6 +79,43 @@ export function LogViewer({
|
|||
"passed",
|
||||
];
|
||||
|
||||
const startTime = startDate
|
||||
? moment(startDate).utc().format("YYYY-MM-DD HH:mm:ss")
|
||||
: moment().subtract(24, "hours").utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
const endTime = endDate
|
||||
? moment(endDate).utc().endOf("day").format("YYYY-MM-DD HH:mm:ss")
|
||||
: moment().utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
|
||||
const { data: fullLogResponse } = useQuery({
|
||||
queryKey: ["spend-log-by-request", selectedRequestId, startTime, endTime],
|
||||
queryFn: async () => {
|
||||
if (!accessToken || !selectedRequestId) return null;
|
||||
const res = await uiSpendLogsCall({
|
||||
accessToken,
|
||||
start_date: startTime,
|
||||
end_date: endTime,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
params: { request_id: selectedRequestId },
|
||||
});
|
||||
return res as { data: ViewLogsLogEntry[]; total: number };
|
||||
},
|
||||
enabled: Boolean(accessToken && selectedRequestId && drawerOpen),
|
||||
});
|
||||
|
||||
const selectedLog: ViewLogsLogEntry | null =
|
||||
fullLogResponse?.data?.[0] ?? null;
|
||||
|
||||
const handleLogClick = (log: LogEntry) => {
|
||||
setSelectedRequestId(log.id);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDrawer = () => {
|
||||
setDrawerOpen(false);
|
||||
setSelectedRequestId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
|
|
@ -128,16 +176,15 @@ export function LogViewer({
|
|||
</div>
|
||||
)}
|
||||
{!logsLoading && displayLogs.length > 0 && (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{displayLogs.map((log) => {
|
||||
const config = actionConfig[log.action];
|
||||
const ActionIcon = config.icon;
|
||||
const isExpanded = expandedLog === log.id;
|
||||
return (
|
||||
<div key={log.id}>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{displayLogs.map((log) => {
|
||||
const config = actionConfig[log.action];
|
||||
const ActionIcon = config.icon;
|
||||
return (
|
||||
<button
|
||||
key={log.id}
|
||||
type="button"
|
||||
onClick={() => setExpandedLog(isExpanded ? null : log.id)}
|
||||
onClick={() => handleLogClick(log)}
|
||||
className="w-full text-left px-4 py-3 hover:bg-gray-50 transition-colors flex items-start gap-3"
|
||||
>
|
||||
<ActionIcon
|
||||
|
|
@ -160,60 +207,21 @@ export function LogViewer({
|
|||
{log.input_snippet ?? log.input ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`flex-shrink-0 mt-1 transition-transform ${
|
||||
isExpanded ? "rotate-180" : ""
|
||||
}`}
|
||||
>
|
||||
<DownOutlined className="w-4 h-4 text-gray-400" />
|
||||
</span>
|
||||
<DownOutlined className="w-4 h-4 text-gray-400 flex-shrink-0 mt-1" />
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 pl-11">
|
||||
<div className="bg-gray-50 rounded-lg p-4 space-y-3 text-sm">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Input
|
||||
</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
aria-label="Copy input"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-gray-800 font-mono text-xs bg-white rounded border border-gray-200 p-3">
|
||||
{log.input_snippet ?? log.input ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Output
|
||||
</span>
|
||||
<p className="text-gray-800 font-mono text-xs bg-white rounded border border-gray-200 p-3 mt-1">
|
||||
{log.output_snippet ?? log.output ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
{(log.reason ?? log.score != null) && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Reason
|
||||
</span>
|
||||
<p className="text-gray-700 text-xs mt-1">
|
||||
{log.reason ?? (log.score != null ? `Score: ${log.score}` : "—")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LogDetailsDrawer
|
||||
open={drawerOpen}
|
||||
onClose={handleCloseDrawer}
|
||||
logEntry={selectedLog}
|
||||
accessToken={accessToken}
|
||||
allLogs={selectedLog ? [selectedLog] : []}
|
||||
startTime={startTime}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,230 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils";
|
||||
import { BlogDropdown } from "./BlogDropdown";
|
||||
|
||||
let mockDisableBlogPosts = false;
|
||||
let mockRefetch = vi.fn();
|
||||
let mockUseBlogPostsResult: {
|
||||
data: { posts: { title: string; date: string; description: string; url: string }[] } | null | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
} = {
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: mockRefetch,
|
||||
};
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useDisableBlogPosts", () => ({
|
||||
useDisableBlogPosts: () => mockDisableBlogPosts,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/blogPosts/useBlogPosts", () => ({
|
||||
useBlogPosts: () => mockUseBlogPostsResult,
|
||||
}));
|
||||
|
||||
const MOCK_POSTS = [
|
||||
{ title: "Post One", date: "2026-02-01", description: "Description one", url: "https://example.com/1" },
|
||||
{ title: "Post Two", date: "2026-02-02", description: "Description two", url: "https://example.com/2" },
|
||||
{ title: "Post Three", date: "2026-02-03", description: "Description three", url: "https://example.com/3" },
|
||||
{ title: "Post Four", date: "2026-02-04", description: "Description four", url: "https://example.com/4" },
|
||||
{ title: "Post Five", date: "2026-02-05", description: "Description five", url: "https://example.com/5" },
|
||||
{ title: "Post Six", date: "2026-02-06", description: "Description six", url: "https://example.com/6" },
|
||||
];
|
||||
|
||||
async function openDropdown() {
|
||||
const user = userEvent.setup();
|
||||
await user.hover(screen.getByRole("button", { name: /blog/i }));
|
||||
}
|
||||
|
||||
describe("BlogDropdown", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDisableBlogPosts = false;
|
||||
mockRefetch = vi.fn();
|
||||
mockUseBlogPostsResult = {
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: mockRefetch,
|
||||
};
|
||||
});
|
||||
|
||||
describe("when blog posts are disabled", () => {
|
||||
it("should render nothing", () => {
|
||||
mockDisableBlogPosts = true;
|
||||
const { container } = renderWithProviders(<BlogDropdown />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when blog posts are enabled", () => {
|
||||
it("should render the Blog trigger button", () => {
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
expect(screen.getByRole("button", { name: /blog/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("loading state", () => {
|
||||
it("should show a loading spinner", async () => {
|
||||
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isLoading: true };
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".anticon-loading")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("error state", () => {
|
||||
beforeEach(() => {
|
||||
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isError: true };
|
||||
});
|
||||
|
||||
it("should show an error message", async () => {
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Failed to load posts")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show a Retry button", async () => {
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call refetch when Retry is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await user.hover(screen.getByRole("button", { name: /blog/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /retry/i }));
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state", () => {
|
||||
it("should show 'No posts available' when data is null", async () => {
|
||||
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: null };
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No posts available")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show 'No posts available' when posts array is empty", async () => {
|
||||
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: [] } };
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No posts available")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("with posts", () => {
|
||||
beforeEach(() => {
|
||||
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 3) } };
|
||||
});
|
||||
|
||||
it("should render post titles", async () => {
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Post One")).toBeInTheDocument();
|
||||
expect(screen.getByText("Post Two")).toBeInTheDocument();
|
||||
expect(screen.getByText("Post Three")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render post descriptions", async () => {
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Description one")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render post links with correct attributes", async () => {
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
const link = screen.getByRole("link", { name: /post one/i });
|
||||
expect(link).toHaveAttribute("href", "https://example.com/1");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render formatted post dates", async () => {
|
||||
mockUseBlogPostsResult = {
|
||||
...mockUseBlogPostsResult,
|
||||
data: { posts: [{ title: "Date Post", date: "2026-02-15", description: "Desc", url: "https://example.com" }] },
|
||||
};
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Feb 15, 2026")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the 'View all posts' link", async () => {
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
const viewAllLink = screen.getByRole("link", { name: /view all posts/i });
|
||||
expect(viewAllLink).toHaveAttribute("href", "https://docs.litellm.ai/blog");
|
||||
expect(viewAllLink).toHaveAttribute("target", "_blank");
|
||||
expect(viewAllLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("post limit", () => {
|
||||
it("should render at most 5 posts when more than 5 are provided", async () => {
|
||||
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS } };
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Post One")).toBeInTheDocument();
|
||||
expect(screen.getByText("Post Five")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Post Six")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
|
||||
import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { Button, Dropdown, Space, Typography } from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import React from "react";
|
||||
|
||||
const { Text, Title, Paragraph } = Typography;
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + "T00:00:00");
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export const BlogDropdown: React.FC = () => {
|
||||
const disableBlogPosts = useDisableBlogPosts();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useBlogPosts();
|
||||
|
||||
if (disableBlogPosts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let items: MenuProps["items"];
|
||||
|
||||
if (isLoading) {
|
||||
items = [{ key: "loading", label: <LoadingOutlined />, disabled: true }];
|
||||
} else if (isError) {
|
||||
items = [
|
||||
{
|
||||
key: "error",
|
||||
label: (
|
||||
<Space>
|
||||
<Text type="danger">Failed to load posts</Text>
|
||||
<Button size="small" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
} else if (!data || data.posts.length === 0) {
|
||||
items = [{ key: "empty", label: <Text type="secondary">No posts available</Text>, disabled: true }];
|
||||
} else {
|
||||
items = [
|
||||
...data.posts.slice(0, 5).map((post: BlogPost) => ({
|
||||
key: post.url,
|
||||
label: (
|
||||
<a href={post.url} target="_blank" rel="noopener noreferrer" style={{ display: "block", width: 380 }}>
|
||||
<Title level={5} style={{ marginBottom: 2 }}>
|
||||
{post.title}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{formatDate(post.date)}
|
||||
</Text>
|
||||
<Paragraph ellipsis={{ rows: 2 }}>{post.description}</Paragraph>
|
||||
</a>
|
||||
),
|
||||
})),
|
||||
{ type: "divider" as const },
|
||||
{
|
||||
key: "view-all",
|
||||
label: (
|
||||
<a href="https://docs.litellm.ai/blog" target="_blank" rel="noopener noreferrer">
|
||||
View all posts
|
||||
</a>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={["hover"]} placement="bottomRight">
|
||||
<Button type="text">Blog</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogDropdown;
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
|
||||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator";
|
||||
import {
|
||||
|
|
@ -29,6 +30,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
|||
const { userId, userEmail, userRole, premiumUser } = useAuthorized();
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
const disableUsageIndicator = useDisableUsageIndicator();
|
||||
const disableBlogPosts = useDisableBlogPosts();
|
||||
const [disableShowNewBadge, setDisableShowNewBadge] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -148,6 +150,23 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
|||
aria-label="Toggle hide usage indicator"
|
||||
/>
|
||||
</Space>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">Hide Blog Posts</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={disableBlogPosts}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
setLocalStorageItem("disableBlogPosts", "true");
|
||||
emitLocalStorageChange("disableBlogPosts");
|
||||
} else {
|
||||
removeLocalStorageItem("disableBlogPosts");
|
||||
emitLocalStorageChange("disableBlogPosts");
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle hide blog posts"
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import MCPSemanticFilterSettings from "./MCPSemanticFilterSettings";
|
||||
import { useMCPSemanticFilterSettings } from "@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useMCPSemanticFilterSettings";
|
||||
import { useUpdateMCPSemanticFilterSettings } from "@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useUpdateMCPSemanticFilterSettings";
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useMCPSemanticFilterSettings",
|
||||
() => ({ useMCPSemanticFilterSettings: vi.fn() })
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useUpdateMCPSemanticFilterSettings",
|
||||
() => ({ useUpdateMCPSemanticFilterSettings: vi.fn() })
|
||||
);
|
||||
|
||||
vi.mock("@/components/playground/llm_calls/fetch_models", () => ({
|
||||
fetchAvailableModels: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("./MCPSemanticFilterTestPanel", () => ({
|
||||
default: () => <div data-testid="mcp-test-panel" />,
|
||||
}));
|
||||
|
||||
vi.mock("./semanticFilterTestUtils", () => ({
|
||||
getCurlCommand: vi.fn().mockReturnValue("curl ..."),
|
||||
runSemanticFilterTest: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockMutate = vi.fn();
|
||||
|
||||
const defaultSettingsData = {
|
||||
field_schema: {
|
||||
properties: {
|
||||
enabled: { description: "Enable semantic filtering for MCP tools" },
|
||||
},
|
||||
},
|
||||
values: {
|
||||
enabled: false,
|
||||
embedding_model: "text-embedding-3-small",
|
||||
top_k: 10,
|
||||
similarity_threshold: 0.3,
|
||||
},
|
||||
};
|
||||
|
||||
// Helper that renders the component and flushes the fetchAvailableModels effect
|
||||
async function renderSettings(props: React.ComponentProps<typeof MCPSemanticFilterSettings>) {
|
||||
render(<MCPSemanticFilterSettings {...props} />);
|
||||
if (props.accessToken) {
|
||||
// Let the async fetchAvailableModels effect settle to avoid act() warnings
|
||||
await act(async () => {});
|
||||
}
|
||||
}
|
||||
|
||||
describe("MCPSemanticFilterSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({
|
||||
data: defaultSettingsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.mocked(useUpdateMCPSemanticFilterSettings).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
isPending: false,
|
||||
error: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("should render", async () => {
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
expect(screen.getByText("Semantic Tool Filtering")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a login prompt when accessToken is null", () => {
|
||||
render(<MCPSemanticFilterSettings accessToken={null} />);
|
||||
expect(screen.getByText(/please log in/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render the form when accessToken is null", () => {
|
||||
render(<MCPSemanticFilterSettings accessToken={null} />);
|
||||
expect(screen.queryByText("Enable Semantic Filtering")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show the settings content while loading", async () => {
|
||||
vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
expect(screen.queryByText("Semantic Tool Filtering")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show an error alert when data fails to load", async () => {
|
||||
vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error("Network error"),
|
||||
} as any);
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
expect(
|
||||
screen.getByText("Could not load MCP Semantic Filter settings")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the error message from the error object when loading fails", async () => {
|
||||
vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error("Connection refused"),
|
||||
} as any);
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
expect(screen.getByText("Connection refused")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the info alert and form fields when data is loaded", async () => {
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
expect(screen.getByText("Semantic Tool Filtering")).toBeInTheDocument();
|
||||
expect(screen.getByText("Enable Semantic Filtering")).toBeInTheDocument();
|
||||
expect(screen.getByText("Top K Results")).toBeInTheDocument();
|
||||
expect(screen.getByText("Similarity Threshold")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the test panel", async () => {
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
expect(screen.getByTestId("mcp-test-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have Save Settings button disabled initially", async () => {
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
expect(
|
||||
screen.getByRole("button", { name: /save settings/i })
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable Save Settings button after a form field is changed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
|
||||
expect(screen.getByRole("button", { name: /save settings/i })).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole("switch"));
|
||||
|
||||
expect(screen.getByRole("button", { name: /save settings/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should show an error alert when the mutation fails", async () => {
|
||||
vi.mocked(useUpdateMCPSemanticFilterSettings).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
isPending: false,
|
||||
error: new Error("Failed to update settings"),
|
||||
} as any);
|
||||
await renderSettings({ accessToken: "test-token" });
|
||||
expect(screen.getByText("Could not update settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("Failed to update settings")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import MCPSemanticFilterTestPanel from "./MCPSemanticFilterTestPanel";
|
||||
import { TestResult } from "./semanticFilterTestUtils";
|
||||
|
||||
vi.mock("@/components/common_components/ModelSelector", () => ({
|
||||
default: ({ onChange, value, labelText, disabled }: any) => (
|
||||
<div>
|
||||
<label htmlFor="model-selector">{labelText ?? "Select Model"}</label>
|
||||
<select
|
||||
id="model-selector"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="gpt-4o">gpt-4o</option>
|
||||
<option value="gpt-3.5-turbo">gpt-3.5-turbo</option>
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const buildProps = (
|
||||
overrides: Partial<React.ComponentProps<typeof MCPSemanticFilterTestPanel>> = {}
|
||||
) => ({
|
||||
accessToken: "test-token",
|
||||
testQuery: "",
|
||||
setTestQuery: vi.fn(),
|
||||
testModel: "gpt-4o",
|
||||
setTestModel: vi.fn(),
|
||||
isTesting: false,
|
||||
onTest: vi.fn(),
|
||||
filterEnabled: true,
|
||||
testResult: null as TestResult | null,
|
||||
curlCommand: "curl --location 'http://localhost:4000/v1/responses'",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("MCPSemanticFilterTestPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the Test Configuration card", () => {
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps()} />);
|
||||
expect(screen.getByText("Test Configuration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the test query textarea", () => {
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps()} />);
|
||||
expect(
|
||||
screen.getByPlaceholderText(/enter a test query to see which tools/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call setTestQuery when user types in the query field", () => {
|
||||
const mockSetTestQuery = vi.fn();
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps({ setTestQuery: mockSetTestQuery })} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/enter a test query to see which tools/i);
|
||||
fireEvent.change(textarea, { target: { value: "find relevant tools" } });
|
||||
|
||||
expect(mockSetTestQuery).toHaveBeenCalledWith("find relevant tools");
|
||||
});
|
||||
|
||||
it("should disable the Test Filter button when testQuery is empty", () => {
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps({ testQuery: "" })} />);
|
||||
expect(screen.getByRole("button", { name: /test filter/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should disable the Test Filter button when filterEnabled is false", () => {
|
||||
render(
|
||||
<MCPSemanticFilterTestPanel
|
||||
{...buildProps({ testQuery: "search query", filterEnabled: false })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /test filter/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable the Test Filter button when testQuery is set and filter is enabled", () => {
|
||||
render(
|
||||
<MCPSemanticFilterTestPanel {...buildProps({ testQuery: "search query" })} />
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /test filter/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should call onTest when the Test Filter button is clicked", async () => {
|
||||
const mockOnTest = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MCPSemanticFilterTestPanel
|
||||
{...buildProps({ testQuery: "search query", onTest: mockOnTest })}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /test filter/i }));
|
||||
expect(mockOnTest).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should show a warning when semantic filtering is disabled", () => {
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps({ filterEnabled: false })} />);
|
||||
expect(screen.getByText("Semantic filtering is disabled")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show the disabled warning when filterEnabled is true", () => {
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps({ filterEnabled: true })} />);
|
||||
expect(screen.queryByText("Semantic filtering is disabled")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display test results when testResult is provided", () => {
|
||||
const testResult: TestResult = {
|
||||
totalTools: 10,
|
||||
selectedTools: 3,
|
||||
tools: ["wiki-fetch", "github-search", "slack-post"],
|
||||
};
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps({ testResult })} />);
|
||||
|
||||
expect(screen.getByText("3 tools selected")).toBeInTheDocument();
|
||||
expect(screen.getByText("Filtered from 10 available tools")).toBeInTheDocument();
|
||||
expect(screen.getByText("wiki-fetch")).toBeInTheDocument();
|
||||
expect(screen.getByText("github-search")).toBeInTheDocument();
|
||||
expect(screen.getByText("slack-post")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render the results section when testResult is null", () => {
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps({ testResult: null })} />);
|
||||
expect(screen.queryByText("Results")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the curl command in the API Usage tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
const curlCommand = "curl --location 'http://localhost:4000/v1/responses' --header 'Authorization: Bearer sk-1234'";
|
||||
render(<MCPSemanticFilterTestPanel {...buildProps({ curlCommand })} />);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "API Usage" }));
|
||||
|
||||
expect(screen.getByText(curlCommand)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { getCurlCommand, runSemanticFilterTest } from "./semanticFilterTestUtils";
|
||||
import { testMCPSemanticFilter } from "@/components/networking";
|
||||
import NotificationManager from "@/components/molecules/notifications_manager";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
testMCPSemanticFilter: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getCurlCommand", () => {
|
||||
it("should include the model name in the curl command", () => {
|
||||
const result = getCurlCommand("gpt-4o", "test query");
|
||||
expect(result).toContain('"gpt-4o"');
|
||||
});
|
||||
|
||||
it("should include the query in the curl command", () => {
|
||||
const result = getCurlCommand("gpt-4o", "find relevant files");
|
||||
expect(result).toContain("find relevant files");
|
||||
});
|
||||
|
||||
it("should use a placeholder when query is empty", () => {
|
||||
const result = getCurlCommand("gpt-4o", "");
|
||||
expect(result).toContain("Your query here");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runSemanticFilterTest", () => {
|
||||
const mockSetIsTesting = vi.fn();
|
||||
const mockSetTestResult = vi.fn();
|
||||
const baseArgs = {
|
||||
accessToken: "test-token",
|
||||
testModel: "gpt-4o",
|
||||
testQuery: "find relevant files",
|
||||
setIsTesting: mockSetIsTesting,
|
||||
setTestResult: mockSetTestResult,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should call NotificationManager.error and not set isTesting when testQuery is empty", async () => {
|
||||
await runSemanticFilterTest({ ...baseArgs, testQuery: "" });
|
||||
expect(NotificationManager.error).toHaveBeenCalledWith("Please enter a query and select a model");
|
||||
expect(mockSetIsTesting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call NotificationManager.error and not set isTesting when testModel is empty", async () => {
|
||||
await runSemanticFilterTest({ ...baseArgs, testModel: "" });
|
||||
expect(NotificationManager.error).toHaveBeenCalledWith("Please enter a query and select a model");
|
||||
expect(mockSetIsTesting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set isTesting to true then false around the API call", async () => {
|
||||
vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({
|
||||
data: {},
|
||||
headers: { filter: "5->2", tools: "tool-a,tool-b" },
|
||||
});
|
||||
|
||||
await runSemanticFilterTest(baseArgs);
|
||||
|
||||
expect(mockSetIsTesting).toHaveBeenNthCalledWith(1, true);
|
||||
expect(mockSetIsTesting).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
|
||||
it("should clear the previous test result before making a new request", async () => {
|
||||
vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({
|
||||
data: {},
|
||||
headers: { filter: "5->2", tools: "tool-a,tool-b" },
|
||||
});
|
||||
|
||||
await runSemanticFilterTest(baseArgs);
|
||||
|
||||
expect(mockSetTestResult).toHaveBeenNthCalledWith(1, null);
|
||||
});
|
||||
|
||||
it("should set test result with parsed data on success", async () => {
|
||||
vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({
|
||||
data: {},
|
||||
headers: { filter: "10->3", tools: "wiki,github,slack" },
|
||||
});
|
||||
|
||||
await runSemanticFilterTest(baseArgs);
|
||||
|
||||
expect(mockSetTestResult).toHaveBeenCalledWith({
|
||||
totalTools: 10,
|
||||
selectedTools: 3,
|
||||
tools: ["wiki", "github", "slack"],
|
||||
});
|
||||
expect(NotificationManager.success).toHaveBeenCalledWith(
|
||||
"Semantic filter test completed successfully"
|
||||
);
|
||||
});
|
||||
|
||||
it("should show a warning when the filter header is missing", async () => {
|
||||
vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({
|
||||
data: {},
|
||||
headers: { filter: null, tools: null },
|
||||
});
|
||||
|
||||
await runSemanticFilterTest(baseArgs);
|
||||
|
||||
expect(NotificationManager.warning).toHaveBeenCalledWith(
|
||||
"Semantic filter is not enabled or no tools were filtered"
|
||||
);
|
||||
expect(mockSetTestResult).not.toHaveBeenCalledWith(expect.objectContaining({ totalTools: expect.any(Number) }));
|
||||
});
|
||||
|
||||
it("should show an error notification and finish testing when the API call fails", async () => {
|
||||
vi.mocked(testMCPSemanticFilter).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
await runSemanticFilterTest(baseArgs);
|
||||
|
||||
expect(NotificationManager.error).toHaveBeenCalledWith("Failed to test semantic filter");
|
||||
expect(mockSetIsTesting).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -9,36 +9,6 @@ import NotificationsManager from "./molecules/notifications_manager";
|
|||
|
||||
vi.mock("./networking");
|
||||
|
||||
vi.mock("@tremor/react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tremor/react")>();
|
||||
const React = await import("react");
|
||||
const Card = ({ children }: { children: React.ReactNode }) => React.createElement("div", { "data-testid": "card" }, children);
|
||||
Card.displayName = "Card";
|
||||
const Title = ({ children }: { children: React.ReactNode }) => React.createElement("h2", {}, children);
|
||||
Title.displayName = "Title";
|
||||
const Text = ({ children }: { children: React.ReactNode }) => React.createElement("span", {}, children);
|
||||
Text.displayName = "Text";
|
||||
const Divider = () => React.createElement("hr", {});
|
||||
Divider.displayName = "Divider";
|
||||
const TextInput = ({ value, onChange, placeholder, className }: any) =>
|
||||
React.createElement("input", {
|
||||
type: "text",
|
||||
value: value || "",
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
});
|
||||
TextInput.displayName = "TextInput";
|
||||
return {
|
||||
...actual,
|
||||
Card,
|
||||
Title,
|
||||
Text,
|
||||
Divider,
|
||||
TextInput,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./common_components/budget_duration_dropdown", () => {
|
||||
const BudgetDurationDropdown = ({ value, onChange }: { value: string | null; onChange: (value: string) => void }) => (
|
||||
<select
|
||||
|
|
|
|||
|
|
@ -3,15 +3,11 @@ import { getProxyBaseUrl } from "@/components/networking";
|
|||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { clearTokenCookies } from "@/utils/cookieUtils";
|
||||
import { fetchProxySettings } from "@/utils/proxyUtils";
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MoonOutlined,
|
||||
SunOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Switch, Tag } from "antd";
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined, MoonOutlined, SunOutlined } from "@ant-design/icons";
|
||||
import { Button, Switch, Tag } from "antd";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown";
|
||||
import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
|
||||
import UserDropdown from "./Navbar/UserDropdown/UserDropdown";
|
||||
|
||||
|
|
@ -42,7 +38,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
sidebarCollapsed = false,
|
||||
onToggleSidebar,
|
||||
isDarkMode,
|
||||
toggleDarkMode
|
||||
toggleDarkMode,
|
||||
}) => {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
const [logoutUrl, setLogoutUrl] = useState("");
|
||||
|
|
@ -110,7 +106,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
style={{ animationDuration: "2s" }}
|
||||
title="Thanks for using LiteLLM!"
|
||||
>
|
||||
❄️
|
||||
🌑
|
||||
</span>
|
||||
<Tag className="relative text-xs font-medium cursor-pointer z-10">
|
||||
<a
|
||||
|
|
@ -131,25 +127,21 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
<CommunityEngagementButtons />
|
||||
{/* Dark mode is currently a work in progress. To test, you can change 'false' to 'true' below.
|
||||
Do not set this to true by default until all components are confirmed to support dark mode styles. */}
|
||||
{false && <Switch
|
||||
data-testid="dark-mode-toggle"
|
||||
checked={isDarkMode}
|
||||
onChange={toggleDarkMode}
|
||||
checkedChildren={<MoonOutlined />}
|
||||
unCheckedChildren={<SunOutlined />}
|
||||
/>}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
|
||||
{!isPublicPage && (
|
||||
<UserDropdown onLogout={handleLogout} />
|
||||
{false && (
|
||||
<Switch
|
||||
data-testid="dark-mode-toggle"
|
||||
checked={isDarkMode}
|
||||
onChange={toggleDarkMode}
|
||||
checkedChildren={<MoonOutlined />}
|
||||
unCheckedChildren={<SunOutlined />}
|
||||
/>
|
||||
)}
|
||||
<Button type="text" href="https://docs.litellm.ai/docs/" target="_blank" rel="noopener noreferrer">
|
||||
Docs
|
||||
</Button>
|
||||
<BlogDropdown />
|
||||
|
||||
{!isPublicPage && <UserDropdown onLogout={handleLogout} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ const PROVIDERS_WITH_CUSTOM_RENDERERS = new Set([
|
|||
]);
|
||||
|
||||
const formatMode = (mode: unknown): string => {
|
||||
if (mode == null) return "—";
|
||||
if (mode == null || mode === "") return "—";
|
||||
const s = typeof mode === "string" ? mode : String(mode);
|
||||
return s.replace(/_/g, "-").toUpperCase();
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue