From 01e83e2537401066572f18f99c70e7af94e58ec9 Mon Sep 17 00:00:00 2001
From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com>
Date: Thu, 28 May 2026 19:11:25 -0700
Subject: [PATCH 01/44] fix(ci): restore real Bedrock batch S3 bucket and role
in oai_misc_config (#29245)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The OSS-staging sync (d52fbfb45) overwrote the Bedrock batch model's
s3_bucket_name and aws_batch_role_arn with public-safe placeholders
(account 123456789012 / *_EXAMPLE role). The e2e_openai_endpoints CI job
runs the proxy with AWS account 941277531214 credentials, so on file
upload test_bedrock_batches_api failed with:
NoSuchBucket: The specified bucket does not exist
litellm-proxy-123456789012
Restore the real resources that live in account 941277531214 (verified
to exist) — the same values tests/batches_tests/test_bedrock_files_and_batches.py
already references.
Co-authored-by: Claude Opus 4.8
---
litellm/proxy/example_config_yaml/oai_misc_config.yaml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/litellm/proxy/example_config_yaml/oai_misc_config.yaml b/litellm/proxy/example_config_yaml/oai_misc_config.yaml
index 551043ec76b..0b647de8a08 100644
--- a/litellm/proxy/example_config_yaml/oai_misc_config.yaml
+++ b/litellm/proxy/example_config_yaml/oai_misc_config.yaml
@@ -23,11 +23,11 @@ model_list:
model: bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0
#########################################################
########## batch specific params ########################
- s3_bucket_name: litellm-proxy-123456789012
+ s3_bucket_name: litellm-proxy-941277531214
s3_region_name: us-west-2
s3_access_key_id: os.environ/AWS_ACCESS_KEY_ID
s3_secret_access_key: os.environ/AWS_SECRET_ACCESS_KEY
- aws_batch_role_arn: arn:aws:iam::123456789012:role/service-role/AmazonBedrockExecutionRoleForAgents_EXAMPLE
+ aws_batch_role_arn: arn:aws:iam::941277531214:role/service-role/AmazonBedrockExecutionRoleForAgents_BB9HNW6V4CV
model_info:
mode: batch
From 9918a9c78c30eee4200bc90a2079e543750b2ae5 Mon Sep 17 00:00:00 2001
From: ryan-crabbe-berri
Date: Thu, 28 May 2026 21:19:04 -0700
Subject: [PATCH 02/44] fix(guardrails): persist disable_global_guardrails on
keys (#29233)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(guardrails): restore disable_global_guardrails persistence for keys
The per-key/team "Disable Global Guardrails" toggle silently stopped
working after #17042, which removed `disable_global_guardrails` from the
key/team request models and from the premium metadata allowlist. Without
those, the UI's top-level field was dropped by pydantic and never folded
into key `metadata`, so the runtime gate always read False and global
default_on guardrails kept running.
Restore the request-model fields (KeyRequestBase, NewTeamRequest,
UpdateTeamRequest) and the `LiteLLM_ManagementEndpoint_MetadataFields_Premium`
entry so the flag is promoted into metadata again. Because the key edit
form always submits the flag (false by default), guard the UI so it is
only sent when it actually changed (edit) or is enabled (create) — this
keeps the premium gate on enabling intact while not 403-ing non-premium
users who edit unrelated key fields, mirroring how guardrails/tags are
already stripped.
* test(guardrails): cover disable_global_guardrails toggle-off + clarify premium field comment
Add a prepare_metadata_fields case asserting `disable_global_guardrails: False`
overwrites an existing `True`, and rewrite the PREMIUM_METADATA_FIELDS comment to
explain why boolean premium fields are excluded from the empty-value strip loop.
---
litellm/proxy/_types.py | 4 ++++
.../test_key_management.py | 12 +++++++++++
.../organisms/create_key_button.tsx | 6 ++++++
.../components/templates/key_info_view.tsx | 20 +++++++++++++++++--
4 files changed, 40 insertions(+), 2 deletions(-)
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 9046d522280..522e85632dc 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -1067,6 +1067,7 @@ class KeyRequestBase(GenerateRequestBase):
key: Optional[str] = None
budget_id: Optional[str] = None
tags: Optional[List[str]] = None
+ disable_global_guardrails: Optional[bool] = None
enforced_params: Optional[List[str]] = None
allowed_routes: Optional[list] = []
allowed_passthrough_routes: Optional[list] = None
@@ -1832,6 +1833,7 @@ class NewTeamRequest(TeamBase):
prompts: Optional[List[str]] = None
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
allowed_passthrough_routes: Optional[list] = None
+ disable_global_guardrails: Optional[bool] = None
secret_manager_settings: Optional[dict] = None
model_rpm_limit: Optional[Dict[str, int]] = None
rpm_limit_type: Optional[
@@ -1900,6 +1902,7 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase):
guardrails: Optional[List[str]] = None
policies: Optional[List[str]] = None
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
+ disable_global_guardrails: Optional[bool] = None
team_member_budget: Optional[float] = None
team_member_budget_duration: Optional[str] = None
team_member_rpm_limit: Optional[int] = None
@@ -4281,6 +4284,7 @@ LiteLLM_ManagementEndpoint_MetadataFields = [
]
LiteLLM_ManagementEndpoint_MetadataFields_Premium = [
+ "disable_global_guardrails",
"guardrails",
"policies",
"tags",
diff --git a/tests/proxy_admin_ui_tests/test_key_management.py b/tests/proxy_admin_ui_tests/test_key_management.py
index 933c75e4d38..4c5a045509a 100644
--- a/tests/proxy_admin_ui_tests/test_key_management.py
+++ b/tests/proxy_admin_ui_tests/test_key_management.py
@@ -853,6 +853,18 @@ def test_personal_key_generation_check():
{"tags": ["old_tag"]},
{"metadata": {"tags": ["old_tag"], "enforced_params": ["metadata.tags"]}},
),
+ (
+ {"disable_global_guardrails": True},
+ {},
+ {},
+ {"metadata": {"disable_global_guardrails": True}},
+ ),
+ (
+ {"disable_global_guardrails": False},
+ {},
+ {"disable_global_guardrails": True},
+ {"metadata": {"disable_global_guardrails": False}},
+ ),
],
)
def test_prepare_metadata_fields(
diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
index 7d3f077dafc..b590a7dc043 100644
--- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
+++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
@@ -439,6 +439,12 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
// Update the formValues with the final metadata
formValues.metadata = JSON.stringify(metadata);
+ // disable_global_guardrails is premium-gated server-side; only send it when enabled
+ // so non-premium key creation isn't blocked by that gate.
+ if (!formValues.disable_global_guardrails) {
+ delete formValues.disable_global_guardrails;
+ }
+
// Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission format
if (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) {
formValues.object_permission = {
diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx
index 60b7e31d478..2de40925b1c 100644
--- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx
+++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx
@@ -34,8 +34,15 @@ interface KeyInfoViewProps {
backButtonText?: string;
}
-// Must stay in sync with LiteLLM_ManagementEndpoint_MetadataFields_Premium
-// in litellm/proxy/_types.py — limited to fields the key-edit form submits.
+// Premium fields (from LiteLLM_ManagementEndpoint_MetadataFields_Premium in
+// litellm/proxy/_types.py) that the key-edit form submits as arrays/strings, where
+// "empty" means "unset". The loop below drops them when they're empty-and-were-empty
+// so a non-premium edit of unrelated fields doesn't trip the server's premium gate.
+//
+// Boolean premium fields (e.g. disable_global_guardrails) do NOT belong here: false is
+// a real value, not "empty", so isEmptyValue(false) is false and the loop would never
+// drop it — we'd resend false on every edit and trip the gate. Booleans get their own
+// "send only when changed" guard instead (see disable_global_guardrails below).
const PREMIUM_METADATA_FIELDS = [
"policies",
"guardrails",
@@ -174,6 +181,15 @@ export default function KeyInfoView({
}
}
+ // disable_global_guardrails is premium-gated server-side; only send it when it
+ // changed so a non-premium edit of unrelated fields isn't blocked by that gate.
+ const previousDisableGlobalGuardrails = Boolean(
+ (currentKeyData.metadata as Record | undefined)?.disable_global_guardrails,
+ );
+ if (Boolean(formValues.disable_global_guardrails) === previousDisableGlobalGuardrails) {
+ delete formValues.disable_global_guardrails;
+ }
+
// Handle max budget empty string
formValues.max_budget = mapEmptyStringToNull(formValues.max_budget);
From 2bfbf148822fb043e354e4709930bb6a5cbd886b Mon Sep 17 00:00:00 2001
From: ryan-crabbe-berri
Date: Thu, 28 May 2026 23:19:16 -0700
Subject: [PATCH 03/44] test(e2e): cover Team Admin view + member + key flows
(#29072)
* test(e2e): cover Team Admin view + member + key flows
Adds a new spec exercising the previously-uncovered team-admin manual-QA
items: viewing all team keys (including other members'), adding a member,
removing a member, and creating a team key with All Team Models. Also
seeds a dedicated invitee user so the add-member test can run in parallel
with the proxy-admin invite test without colliding on the team roster.
* test(e2e): harden team-admin member specs per review feedback
Address Greptile feedback on the Team Admin spec:
- locate the delete action via getByTestId("delete-member") instead of
the fragile svg/img .last() selector
- match the seeded removable member by user_id (members_with_roles stores
no email, so the roster renders user_id)
- assert exact success-toast strings rather than broad regexes that could
match unrelated "success" text
---
.../e2e_tests/fixtures/seed.sql | 1 +
.../tests/team-admin/teamAdmin.spec.ts | 117 ++++++++++++++++++
2 files changed, 118 insertions(+)
create mode 100644 ui/litellm-dashboard/e2e_tests/tests/team-admin/teamAdmin.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/fixtures/seed.sql b/ui/litellm-dashboard/e2e_tests/fixtures/seed.sql
index 91312e66ce0..5e5313240e6 100644
--- a/ui/litellm-dashboard/e2e_tests/fixtures/seed.sql
+++ b/ui/litellm-dashboard/e2e_tests/fixtures/seed.sql
@@ -33,6 +33,7 @@ VALUES
('e2e-internal-viewer', 'viewer@test.local', 'internal_user_viewer', '{"e2e-team-crud"}', 'scrypt:MU5CcTAi6rVK1HfY1rVPEWq6r4sxg837eq9dG4n5Q6BhDJ44442+seC6LAhLEAYr'),
('e2e-team-admin', 'teamadmin@test.local', 'internal_user', '{"e2e-team-crud","e2e-team-delete"}', 'scrypt:MU5CcTAi6rVK1HfY1rVPEWq6r4sxg837eq9dG4n5Q6BhDJ44442+seC6LAhLEAYr'),
('e2e-invitable-user', 'invitable@test.local', 'internal_user', '{}', 'scrypt:MU5CcTAi6rVK1HfY1rVPEWq6r4sxg837eq9dG4n5Q6BhDJ44442+seC6LAhLEAYr'),
+ ('e2e-invitable-by-team-admin', 'invitable-team@test.local', 'internal_user', '{}', 'scrypt:MU5CcTAi6rVK1HfY1rVPEWq6r4sxg837eq9dG4n5Q6BhDJ44442+seC6LAhLEAYr'),
('e2e-removable-member', 'removable@test.local', 'internal_user', '{"e2e-team-crud"}', 'scrypt:MU5CcTAi6rVK1HfY1rVPEWq6r4sxg837eq9dG4n5Q6BhDJ44442+seC6LAhLEAYr');
-- 5. Teams (members_with_roles is required JSON)
diff --git a/ui/litellm-dashboard/e2e_tests/tests/team-admin/teamAdmin.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/team-admin/teamAdmin.spec.ts
new file mode 100644
index 00000000000..1612e6929bd
--- /dev/null
+++ b/ui/litellm-dashboard/e2e_tests/tests/team-admin/teamAdmin.spec.ts
@@ -0,0 +1,117 @@
+import { test, expect } from "@playwright/test";
+import {
+ E2E_INTERNAL_USER_KEY_ALIAS,
+ E2E_TEAM_CRUD_ALIAS,
+ E2E_TEAM_CRUD_ID,
+ TEAM_ADMIN_STORAGE_PATH,
+} from "../../constants";
+import { Page } from "../../fixtures/pages";
+import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
+
+async function clickTeamId(page: import("@playwright/test").Page, teamId: string) {
+ const cell = page.locator("td").filter({ hasText: teamId }).first();
+ await expect(cell).toBeVisible({ timeout: 10_000 });
+ await cell.click();
+ await expect(page.getByText("Back to Teams")).toBeVisible({ timeout: 10_000 });
+}
+
+test.describe("Team Admin", () => {
+ test.use({ storageState: TEAM_ADMIN_STORAGE_PATH });
+
+ test("Team admin can see all team keys including internal user keys", async ({ page }) => {
+ // Step from the manual-QA checklist: navigate into the team info page,
+ // open the Virtual Keys tab, and confirm a key belonging to another
+ // team member (the seeded internal user) is visible.
+ await navigateToPage(page, Page.Teams);
+ await dismissFeedbackPopup(page);
+
+ await clickTeamId(page, E2E_TEAM_CRUD_ID);
+
+ await page.getByRole("tab", { name: "Virtual Keys" }).click();
+ await expect(page.getByText(E2E_INTERNAL_USER_KEY_ALIAS).first())
+ .toBeVisible({ timeout: 10_000 });
+
+ // And from the global Virtual Keys page, the same key should be visible.
+ await navigateToPage(page, Page.ApiKeys);
+ await expect(page.getByText(E2E_INTERNAL_USER_KEY_ALIAS).first())
+ .toBeVisible({ timeout: 10_000 });
+ });
+
+ test("Team admin can add a member to their team", async ({ page }) => {
+ await navigateToPage(page, Page.Teams);
+ await dismissFeedbackPopup(page);
+
+ await clickTeamId(page, E2E_TEAM_CRUD_ID);
+
+ await page.getByRole("tab", { name: "Members" }).click();
+ await page.getByRole("button", { name: /Add Member/i }).click();
+
+ const modal = page.locator(".ant-modal:visible");
+ await expect(modal).toBeVisible({ timeout: 5_000 });
+
+ // Use a dedicated invitee user so this doesn't race with the proxy-admin
+ // "Invite a user" test that adds invitable@test.local to the same team.
+ await modal.locator(".ant-select").first().click();
+ await page.keyboard.type("invitable-team@test.local");
+
+ const emailOption = page.getByRole("option", { name: "invitable-team@test.local" }).first();
+ await expect(emailOption).toBeAttached({ timeout: 10_000 });
+ await page.keyboard.press("Enter");
+
+ await modal.getByRole("button", { name: /Add Member/i }).click();
+
+ await expect(page.getByText("Team member added successfully").first())
+ .toBeVisible({ timeout: 10_000 });
+ });
+
+ test("Team admin can remove a member from their team", async ({ page }) => {
+ await navigateToPage(page, Page.Teams);
+ await dismissFeedbackPopup(page);
+
+ await clickTeamId(page, E2E_TEAM_CRUD_ID);
+
+ await page.getByRole("tab", { name: "Members" }).click();
+
+ // Seeded members appear in the roster by user_id (members_with_roles has no
+ // email), so match the row on the user_id rather than the email.
+ const row = page.locator("tr", { hasText: "e2e-removable-member" }).first();
+ await expect(row).toBeVisible({ timeout: 10_000 });
+ await row.getByTestId("delete-member").click();
+
+ const modal = page.locator(".ant-modal:visible");
+ await expect(modal).toBeVisible({ timeout: 5_000 });
+ await modal.getByRole("button", { name: /^Delete$/ }).click();
+
+ await expect(page.getByText("Team member removed successfully").first())
+ .toBeVisible({ timeout: 10_000 });
+ });
+
+ test("Team admin can create a team key with All Team Models", async ({ page }) => {
+ await navigateToPage(page, Page.ApiKeys);
+ await dismissFeedbackPopup(page);
+
+ await page.getByRole("button", { name: /Create New Key/i }).click();
+ await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 });
+
+ const keyName = `e2e-team-admin-key-${Date.now()}`;
+ await page.getByTestId("base-input").fill(keyName);
+
+ // Team selector — same locator pattern as the proxy-admin keys test.
+ const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" });
+ await teamSelect.click();
+ await page.keyboard.type(E2E_TEAM_CRUD_ALIAS);
+ await page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ALIAS).first().click();
+
+ // Models — pick "All Team Models"
+ await page.locator(".ant-select-selection-overflow").click();
+ await page.locator(".ant-select-dropdown:visible").getByText("All Team Models").click();
+ await page.keyboard.press("Escape");
+
+ await page.getByRole("button", { name: "Create Key", exact: true }).click();
+
+ await expect(page.getByText("Save your Key")).toBeVisible({ timeout: 10_000 });
+ await page.keyboard.press("Escape");
+
+ await expect(page.getByText(keyName)).toBeVisible({ timeout: 10_000 });
+ });
+});
From f27df8d516802ce4c1b32973992154fe83b851cf Mon Sep 17 00:00:00 2001
From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com>
Date: Fri, 29 May 2026 00:05:05 -0700
Subject: [PATCH 04/44] docs: hand-written CLAUDE.md; point GEMINI.md and
AGENTS.md at it (#29252)
* docs: replace generated CLAUDE.md with hand-written guidance, remove AGENTS.md
Swap the auto-generated CLAUDE.md for a concise hand-written version that captures how we actually want agents to work in this repo: minimal comments, simplicity first, meaningful tests with a high mutation kill rate, PRs based off litellm_internal_staging rather than main, and curl against a live proxy as proof of fix instead of pasted pytest output. Remove AGENTS.md so there is one source of truth for agent guidance. The customer and company name confidentiality policy, along with the MCP available_on_public_internet note, are carried over from the previous CLAUDE.md.
* fix: further clarify communication guidelines
* docs: point GEMINI.md at CLAUDE.md instead of duplicating guidance
Replace the standalone GEMINI.md copy, which had already drifted from the new CLAUDE.md, with a one-line pointer so Gemini reads the same single source of truth.
* docs: simplify PR template test checklist item
Replace the rigid "at least 1 test is a hard requirement" checklist line with "I have added meaningful tests", which matches the testing guidance in CLAUDE.md, and tidy a comma into a semicolon in the scope-isolation item.
* docs: point AGENTS.md at CLAUDE.md instead of deleting it
Keep AGENTS.md so tools that read it still resolve guidance, but collapse it to the same one-line pointer to CLAUDE.md used by GEMINI.md, keeping a single source of truth.
* fix: make AI-generated rules more concise
* fix: spelling
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* fix: make the .env usage more careful
* docs: restore MCP available_on_public_internet note to CLAUDE.md
The PR description states this note was carried over verbatim from the
previous CLAUDE.md, but it was dropped in the rewrite. Restore it so the
file matches the description and the team guidance is not lost.
* docs: restore browser storage and CI supply-chain safety notes to CLAUDE.md
These security-relevant rules were dropped in the rewrite. Restore the
sessionStorage-over-localStorage (XSS) guidance and the CI supply-chain
rules (no curl|bash, pin versions, verify checksums) so agents editing UI
or CI code are still steered away from those pitfalls.
* docs: move area-specific guidance into nested CLAUDE.md files
The MCP, browser-storage, and CI supply-chain notes are scoped to
particular parts of the tree, so move each into a nested CLAUDE.md that
Claude Code loads on demand when those files are touched: the MCP note
under the mcp_server gateway, the browser-storage rule under the UI
dashboard, and the CI supply-chain rules under .circleci. Keeps the root
CLAUDE.md focused on general guidance while the area notes surface where
they are relevant.
* docs: keep CI supply-chain note in root CLAUDE.md
CI guidance applies beyond .circleci (it also covers downloads in GitHub
workflows and any CI script), and CI work does not reliably touch a single
subtree, so a nested file under .circleci would not surface it dependably.
Keep it in the always-loaded root instead. The MCP and browser-storage
notes stay nested where they map cleanly to one area of the tree.
* fix: make it clear we prefer httpOnly
* chore: make ci rule more concise
* chore: make concise
Fix formatting and punctuation in MCP note.
* fix: don't include Claude attribution
---------
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
---
.github/pull_request_template.md | 4 +-
AGENTS.md | 307 +-----------------
CLAUDE.md | 214 +++---------
GEMINI.md | 109 +------
.../proxy/_experimental/mcp_server/CLAUDE.md | 1 +
ui/litellm-dashboard/CLAUDE.md | 1 +
6 files changed, 51 insertions(+), 585 deletions(-)
create mode 100644 litellm/proxy/_experimental/mcp_server/CLAUDE.md
create mode 100644 ui/litellm-dashboard/CLAUDE.md
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index f9ce9e5dcb8..99f79c0b272 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -10,9 +10,9 @@
**Please complete all items before asking a LiteLLM maintainer to review your PR**
-- [ ] I have Added testing in the [`tests/test_litellm/`](https://github.com/BerriAI/litellm/tree/main/tests/test_litellm) directory, **Adding at least 1 test is a hard requirement** - [see details](https://docs.litellm.ai/docs/extras/contributing_code)
+- [ ] I have added meaningful tests
- [ ] My PR passes all unit tests on [`make test-unit`](https://docs.litellm.ai/docs/extras/contributing_code)
-- [ ] My PR's scope is as isolated as possible, it only solves 1 specific problem
+- [ ] My PR's scope is as isolated as possible; it only solves 1 specific problem
- [ ] I have requested a Greptile review by commenting `@greptileai` and received a **Confidence Score of at least 4/5** before requesting a maintainer review
## Delays in PR merge?
diff --git a/AGENTS.md b/AGENTS.md
index a41fc4268d9..41921fdff4d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,306 +1 @@
-# INSTRUCTIONS FOR LITELLM
-
-This document provides comprehensive instructions for AI agents working in the LiteLLM repository.
-
-## Confidentiality: Customer and Company Names in Code
-
-The codebase is public. Before writing **any** third-party organization name into this repository — in source code, file or directory names, docstrings, comments, tests, fixtures, mock payloads, error messages, log lines, commit messages, or PR descriptions — pause and check:
-
-**Already in the codebase** (OpenAI, Anthropic, Google, Azure, Bedrock, Fireworks, and other established LLM providers / integrations) — fine to use. Quick check: `git grep -i ""` — if it returns hits in real code (not just your current diff), the name is established.
-
-**Anything else** — customers, prospects, partners, new vendor integrations, observability tools, infra vendors, or any organization name that does not already appear in the repo. STOP and surface it to the user. Ask for explicit consent before writing the name into any file, commit message, or PR description. Do not write it speculatively and clean up later. Do not substitute a placeholder and proceed. Do not assume it is safe because it "looks like" a public company. The user must approve first.
-
-**What to do instead of a customer-specific reference:**
-- If you find yourself reaching for a customer name — real or fake — step back. The code shouldn't be customer-specific in the first place. Generalize the feature, or capture the customer motivation in internal docs (Notion / Linear / the internal staging PR description), never in the repo.
-- Frame changes by the capability they add, not the customer who asked for it ("add per-team Bedrock guardrail routing", not "add routing for $CUSTOMER").
-- Standard "fake value" markers (`example.com`, `localhost`, `127.0.0.1`, `test@example.com`) and abstract identifiers (`team_a`, `user_1`, `tenant_x`) are fine — those are not customer stand-ins.
-
-## OVERVIEW
-
-LiteLLM is a unified interface for 100+ LLMs that:
-- Translates inputs to provider-specific completion, embedding, and image generation endpoints
-- Provides consistent OpenAI-format output across all providers
-- Includes retry/fallback logic across multiple deployments (Router)
-- Offers a proxy server (LLM Gateway) with budgets, rate limits, and authentication
-- Supports advanced features like function calling, streaming, caching, and observability
-
-## REPOSITORY STRUCTURE
-
-### Core Components
-- `litellm/` - Main library code
- - `llms/` - Provider-specific implementations (OpenAI, Anthropic, Azure, etc.)
- - `proxy/` - Proxy server implementation (LLM Gateway)
- - `router_utils/` - Load balancing and fallback logic
- - `types/` - Type definitions and schemas
- - `integrations/` - Third-party integrations (observability, caching, etc.)
-
-### Key Directories
-- `tests/` - Comprehensive test suites
-- `ui/litellm-dashboard/` - Admin dashboard UI
-- `enterprise/` - Enterprise-specific features
-
-Documentation lives in the separate [BerriAI/litellm-docs](https://github.com/BerriAI/litellm-docs) repository and is served at [docs.litellm.ai](https://docs.litellm.ai).
-
-## DEVELOPMENT GUIDELINES
-
-### MAKING CODE CHANGES
-
-1. **Provider Implementations**: When adding/modifying LLM providers:
- - Follow existing patterns in `litellm/llms/{provider}/`
- - Implement proper transformation classes that inherit from `BaseConfig`
- - Support both sync and async operations
- - Handle streaming responses appropriately
- - Include proper error handling with provider-specific exceptions
-
-2. **Type Safety**:
- - Use proper type hints throughout
- - Update type definitions in `litellm/types/`
- - Ensure compatibility with both Pydantic v1 and v2
-
-3. **Testing**:
- - Add tests in appropriate `tests/` subdirectories
- - Include both unit tests and integration tests
- - Test provider-specific functionality thoroughly
- - Consider adding load tests for performance-critical changes
-
-### MAKING CODE CHANGES FOR THE UI (IGNORE FOR BACKEND)
-
-1. **Always use `antd` for new UI components — Tremor is DEPRECATED**
- - We are migrating off of `@tremor/react`. Do not introduce new `Badge`, `Text`, `Card`, `Grid`, `Title`, or other imports from `@tremor/react` in any new or modified file.
- - Use `antd` equivalents: `Tag` for labels, plain ``/`
` with Tailwind classes (or `Typography.Text`) for text, `Card` from `antd`, etc. Note that `antd` has no `"yellow"` Tag color — use `"gold"` for amber/yellow.
- - The only exception is the Tremor Table component and its required Tremor Table sub components.
-
-2. **Use Common Components as much as possible**:
- - These are usually defined in the `common_components` directory
- - Use these components as much as possible and avoid building new components unless needed
-
-3. **Testing**:
- - The codebase uses **Vitest** and **React Testing Library**
- - **Query Priority Order**: Use query methods in this order: `getByRole`, `getByLabelText`, `getByPlaceholderText`, `getByText`, `getByTestId`
- - **Always use `screen`** instead of destructuring from `render()` (e.g., use `screen.getByText()` not `getByText`)
- - **Wrap user interactions in `act()`**: Always wrap `fireEvent` calls with `act()` to ensure React state updates are properly handled
- - **Use `query` methods for absence checks**: Use `queryBy*` methods (not `getBy*`) when expecting an element to NOT be present
- - **Test names must start with "should"**: All test names should follow the pattern `it("should ...")`
- - **Mock external dependencies**: Check `setupTests.ts` for global mocks and mock child components/networking calls as needed
- - **Structure tests properly**:
- - First test should verify the component renders successfully
- - Subsequent tests should focus on functionality and user interactions
- - Use `waitFor` for async operations that aren't already awaited
- - **Avoid using `querySelector`**: Prefer React Testing Library queries over direct DOM manipulation
-
-### IMPORTANT PATTERNS
-
-1. **Function/Tool Calling**:
- - LiteLLM standardizes tool calling across providers
- - OpenAI format is the standard, with transformations for other providers
- - See `litellm/llms/anthropic/chat/transformation.py` for complex tool handling
-
-2. **Streaming**:
- - All providers should support streaming where possible
- - Use consistent chunk formatting across providers
- - Handle both sync and async streaming
-
-3. **Error Handling**:
- - Use provider-specific exception classes
- - Maintain consistent error formats across providers
- - Include proper retry logic and fallback mechanisms
-
-4. **Configuration**:
- - Support both environment variables and programmatic configuration
- - Use `BaseConfig` classes for provider configurations
- - Allow dynamic parameter passing
-
-## PROXY SERVER (LLM GATEWAY)
-
-The proxy server is a critical component that provides:
-- Authentication and authorization
-- Rate limiting and budget management
-- Load balancing across multiple models/deployments
-- Observability and logging
-- Admin dashboard UI
-- Enterprise features
-
-Key files:
-- `litellm/proxy/proxy_server.py` - Main server implementation
-- `litellm/proxy/auth/` - Authentication logic
-- `litellm/proxy/management_endpoints/` - Admin API endpoints
-
-**Database (proxy)**: Use Prisma model methods (`prisma_client.db..upsert`, `.find_many`, `.find_unique`, etc.), not raw SQL (`execute_raw`/`query_raw`). See COMMON PITFALLS for details.
-
-## MCP (MODEL CONTEXT PROTOCOL) SUPPORT
-
-LiteLLM supports MCP for agent workflows:
-- MCP server integration for tool calling
-- Transformation between OpenAI and MCP tool formats
-- Support for external MCP servers (Zapier, Jira, Linear, etc.)
-- See `litellm/experimental_mcp_client/` and `litellm/proxy/_experimental/mcp_server/`
-
-## RUNNING SCRIPTS
-
-Use `uv run python script.py` to run Python scripts in the project environment (for non-test files).
-
-## GITHUB TEMPLATES
-
-When opening issues or pull requests, follow these templates:
-
-### Bug Reports (`.github/ISSUE_TEMPLATE/bug_report.yml`)
-- Describe what happened vs. expected behavior
-- Include relevant log output
-- Specify LiteLLM version
-- Indicate if you're part of an ML Ops team (helps with prioritization)
-
-### Feature Requests (`.github/ISSUE_TEMPLATE/feature_request.yml`)
-- Clearly describe the feature
-- Explain motivation and use case with concrete examples
-
-### Pull Requests (`.github/pull_request_template.md`)
-- Add at least 1 test in `tests/litellm/`
-- Ensure `make test-unit` passes
-
-
-## TESTING CONSIDERATIONS
-
-1. **Provider Tests**: Test against real provider APIs when possible
-2. **Proxy Tests**: Include authentication, rate limiting, and routing tests
-3. **Performance Tests**: Load testing for high-throughput scenarios
-4. **Integration Tests**: End-to-end workflows including tool calling
-
-## DOCUMENTATION
-
-- Keep documentation in sync with code changes
-- Update provider documentation when adding new providers
-- Include code examples for new features
-- Update changelog and release notes
-
-## SECURITY CONSIDERATIONS
-
-- Handle API keys securely
-- Validate all inputs, especially for proxy endpoints
-- Consider rate limiting and abuse prevention
-- Follow security best practices for authentication
-
-## ENTERPRISE FEATURES
-
-- Some features are enterprise-only
-- Check `enterprise/` directory for enterprise-specific code
-- Maintain compatibility between open-source and enterprise versions
-
-## COMMON PITFALLS TO AVOID
-
-1. **Breaking Changes**: LiteLLM has many users - avoid breaking existing APIs
-2. **Provider Specifics**: Each provider has unique quirks - handle them properly
-3. **Rate Limits**: Respect provider rate limits in tests
-4. **Memory Usage**: Be mindful of memory usage in streaming scenarios
-5. **Dependencies**: Keep dependencies minimal and well-justified
-6. **UI/Backend Contract Mismatch**: When adding a new entity type to the UI, always check whether the backend endpoint accepts a single value or an array. Match the UI control accordingly (single-select vs. multi-select) to avoid silently dropping user selections
-7. **Missing Tests for New Entity Types**: When adding a new entity type (e.g., in `EntityUsage`, `UsageViewSelect`), always add corresponding tests in the existing test files and update any icon/component mocks
-8. **Raw SQL in proxy DB code**: Do not use `execute_raw` or `query_raw` for proxy database access. Use Prisma model methods (e.g. `prisma_client.db.litellm_tooltable.upsert()`, `.find_many()`, `.find_unique()`) so behavior stays consistent with the schema, the client stays mockable in tests, and you avoid the pitfalls of hand-written SQL (parameter ordering, type casting, schema drift)
-
-8. **Do not hardcode model-specific flags**: Put model-specific capability flags in `model_prices_and_context_window.json` and read them via `get_model_info` (or existing helpers like `supports_reasoning`). This prevents users from needing to upgrade LiteLLM each time a new model supports a feature.
-
- **Example of BAD** (hardcoded model checks):
-
- ```python
- @staticmethod
- def _is_effort_supported_model(model: str) -> bool:
- """Check if the model supports the output_config.effort parameter..."""
- model_lower = model.lower()
- if AnthropicConfig._is_claude_4_6_model(model):
- return True
- return any(
- v in model_lower for v in ("opus-4-5", "opus_4_5", "opus-4.5", "opus_4.5")
- )
- ```
-
- **Example of GOOD** (config-driven or helper that reads from config):
-
- ```python
- if (
- "claude-3-7-sonnet" in model
- or AnthropicConfig._is_claude_4_6_model(model)
- or supports_reasoning(
- model=model,
- custom_llm_provider=self.custom_llm_provider,
- )
- ):
- ...
- ```
-
- Using helpers like `supports_reasoning` (which read from `model_prices_and_context_window.json` / `get_model_info`) allows future model updates to "just work" without code changes.
-
-9. **Never close HTTP/SDK clients on cache eviction**: Do not add `close()`, `aclose()`, or `create_task(close_fn())` inside `LLMClientCache._remove_key()` or any cache eviction path. Evicted clients may still be held by in-flight requests; closing them causes `RuntimeError: Cannot send a request, as the client has been closed.` in production after the cache TTL (1 hour) expires. Connection cleanup is handled at shutdown by `close_litellm_async_clients()`. See PR #22247 for the full incident history.
-
-## HELPFUL RESOURCES
-
-- Main documentation: https://docs.litellm.ai/ (source: [BerriAI/litellm-docs](https://github.com/BerriAI/litellm-docs))
-- Provider-specific docs: https://docs.litellm.ai/docs/providers/
-- Admin UI for testing proxy features
-
-## WHEN IN DOUBT
-
-- Follow existing patterns in the codebase
-- Check similar provider implementations
-- Ensure comprehensive test coverage
-- Update documentation appropriately
-- Consider backward compatibility impact
-
-## Cursor Cloud specific instructions
-
-### Environment
-
-- uv is installed in `~/.local/bin`; the update script ensures it is on `PATH`.
-- Python 3.12, Node 22 are pre-installed.
-- The project virtual environment lives under `.venv/`.
-
-### Running the proxy server
-
-Create a minimal config file and start the proxy:
-
-```yaml
-# config.yaml
-model_list:
- - model_name: fake-openai-endpoint
- litellm_params:
- model: openai/fake-model
- api_key: fake-key
- api_base: https://fake-api.example.com
-
-general_settings:
- master_key: sk-1234
-
-litellm_settings:
- drop_params: True
- telemetry: False
-```
-
-```bash
-uv run litellm --config config.yaml --port 4000
-```
-
-The proxy takes ~15-20 seconds to fully start (it runs Prisma migrations on boot). Wait for `/health` to return before sending requests. Without a PostgreSQL `DATABASE_URL`, the proxy connects to a default Neon dev database embedded in the `litellm-proxy-extras` package.
-
-### Running tests
-
-See `CLAUDE.md` and the `Makefile` for standard commands. Key notes:
-
-- `uv sync --group proxy-dev --extra proxy` installs the Prisma and proxy-side test dependencies used by the standard local workflow.
-- The `--timeout` pytest flag is NOT available; don't pass it.
-- Unit tests: `uv run pytest tests/test_litellm/ -x -vv -n 4`
-- **Before committing, always run `uv run black .` to format your code.** Black formatting is enforced in CI.
-- If `uv sync` fails because the lockfile is outdated, run `uv lock` and retry.
-
-### Lint
-
-```bash
-cd litellm && uv run ruff check .
-```
-
-Ruff is the primary fast linter. For the full lint suite (including mypy, black, circular imports), run `make lint` per `CLAUDE.md`.
-
-### UI Dashboard development
-
-- The UI is at `ui/litellm-dashboard/`. Run `npm run dev` from that directory for the Next.js dev server on port 3000.
-- The proxy at port 4000 serves a **pre-built** static UI from `litellm/proxy/_experimental/out/`. After making UI code changes, you must run `npm run build` in the dashboard directory and copy the output: `cp -r ui/litellm-dashboard/out/* litellm/proxy/_experimental/out/` for the proxy to serve the updated UI.
-- SVGs used as provider logos (loaded via `` tags) must NOT use `fill="currentColor"` — replace with an explicit color like `#000000` or use the `-color` variant from lobehub icons, since CSS color inheritance does not work inside `` elements.
-- Provider logos live in `ui/litellm-dashboard/public/assets/logos/` (source) and `litellm/proxy/_experimental/out/assets/logos/` (pre-built). Both locations must have the file for it to work in dev and proxy-served modes.
-- UI Vitest tests: `cd ui/litellm-dashboard && npx vitest run`
+Read @CLAUDE.md for coding guidelines
diff --git a/CLAUDE.md b/CLAUDE.md
index b9a336b8f40..3477b71a621 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,194 +1,70 @@
-# CLAUDE.md
+Do not write comments unless they are absolutely necessary to explain some very complex business logic. Please clean up if there are comments that are not absolutely necessary. Do not remove comments that are unrelated to the addition of the code of this PR
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+Explanation: code comments are, in a way, a violation of DRY code. You must update logic in two locations to change the code and "hard to change" is literally the definition of tech debt. We should instead aim to write code that is intuitive to the reader, while being both easy to maintain and high performance
-## Confidentiality: Customer and Company Names in Code
+Don't assume that the existing code is correct or the right way of doing things / good coding patterns. In fact, there are a lot of bad coding practices, overly complex code, code smells, etc. If something doesn't look right, speak up. Feel free to break existing patterns or question weird existing code to make new code high quality, as in:
+- correct
+- secure
+- performant
+- readable
+- easy to maintain/change
+- modern
+In that order of importance
-The codebase is public. Before writing **any** third-party organization name into this repository — in source code, file or directory names, docstrings, comments, tests, fixtures, mock payloads, error messages, log lines, commit messages, or PR descriptions — pause and check:
+When adding new features, add meaningful tests. Don't add tests that don't check anything substantial and is there just to make the code coverage pass. Yes, code coverage is important, but I'd rather have no signal whether the code is working than tests that don't fail when code is broken. The goal is to have tests that would fail before the feature was added/if the code was mutated in a way that breaks the feature and succeed only when the feature is fully working. I should run mutation testing and see > 90% kill rate
-**Already in the codebase** (OpenAI, Anthropic, Google, Azure, Bedrock, Fireworks, and other established LLM providers / integrations) — fine to use. Quick check: `git grep -i ""` — if it returns hits in real code (not just your current diff), the name is established.
+Same thing for bug fixes. The tests should make it so that this specific bug can never happen again without failing tests (i.e., regression)
-**Anything else** — customers, prospects, partners, new vendor integrations, observability tools, infra vendors, or any organization name that does not already appear in the repo. STOP and surface it to the user. Ask for explicit consent before writing the name into any file, commit message, or PR description. Do not write it speculatively and clean up later. Do not substitute a placeholder and proceed. Do not assume it is safe because it "looks like" a public company. The user must approve first.
+When creating PRs, don't set base to `main`. `litellm_internal_staging` serves that purpose
-**What to do instead of a customer-specific reference:**
-- If you find yourself reaching for a customer name — real or fake — step back. The code shouldn't be customer-specific in the first place. Generalize the feature, or capture the customer motivation in internal docs (Notion / Linear / the internal staging PR description), never in the repo.
-- Frame changes by the capability they add, not the customer who asked for it ("add per-team Bedrock guardrail routing", not "add routing for $CUSTOMER").
-- Standard "fake value" markers (`example.com`, `localhost`, `127.0.0.1`, `test@example.com`) and abstract identifiers (`team_a`, `user_1`, `tenant_x`) are fine — those are not customer stand-ins.
+Always use @.github/pull_request_template.md as a guide for your PR body
-## Documentation
+Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR
-Documentation lives in a separate repository: [BerriAI/litellm-docs](https://github.com/BerriAI/litellm-docs). It is served at [docs.litellm.ai](https://docs.litellm.ai). Do not create or edit documentation files in this repository — open doc PRs against `BerriAI/litellm-docs` instead.
+If you ever make public-facing PR descriptions, comments, issues, commit messages, etc., always follow these guidelines to sound less AI-y:
+- don't use emojis
+- don't use "—". Instead, reach for ";", ".", etc.
+- don't use the pattern "It's not X, it's Y", "You're not X, you're Y", etc.
+- don't use bulleted or numbered lists unless it would be nonsensical not to. Instead, prefer prose
-## Development Commands
+Don't hesitate to use values in .env to get needed API keys and other secrets, as long as you never add them to conversation history, commit them, or include them in GitHub issues / PRs
-### Installation
-- `make install-dev` - Install core development dependencies
-- `make install-proxy-dev` - Install proxy development dependencies with full feature set
-- `make install-test-deps` - Install the full local test environment and generate the Prisma client
+Run tests, format your code, and lint your code before each commit
-### Testing
-- `make test` - Run all tests
-- `make test-unit` - Run unit tests (tests/test_litellm) with 4 parallel workers
-- `make test-integration` - Run integration tests (excludes unit tests)
-- `pytest tests/` - Direct pytest execution
+Ask to commit and push your work when you're done (or if you're confident that your code is good and works, just do it)
-### Code Quality
-- `make lint` - Run all linting (Ruff, MyPy, Black, circular imports, import safety)
-- `make format` - Apply Black code formatting
-- `make lint-ruff` - Run Ruff linting only
-- `make lint-mypy` - Run MyPy type checking only
-- **Before committing, always run `uv run black .` to format your code.** Black formatting is enforced in CI.
+When you must use real LLM models to, for example, write e2e tests, write a QA runbook, etc., make sure to use the latest models (doesn't have to be smartest, can also be a modern small, fast one. No strong preference for smart vs fast here, just use something modern) as of the year and month of the current date. Do a web search as necessary to figure that out
-### Single Test Files
-- `uv run pytest tests/path/to/test_file.py -v` - Run specific test file
-- `uv run pytest tests/path/to/test_file.py::test_function -v` - Run specific test
+If you're an internal contributor, when creating a new PR, the typical flow is to branch off litellm_internal_staging and create a branch prefixed with litellm_. Do not create a branch prefixed with claude/ and generally do not have / in your branch names
-### Running Scripts
-- `uv run python script.py` - Run Python scripts (use for non-test files)
+Do not add `Co-Authored-By: Claude` or any Claude attribution to commit messages. Never use a `claude/` prefix or put a `/` in a branch name. Do not add "Generated with Claude Code" (or any similar attribution) to PR descriptions. Do not create a new PR/branch off the existing PR to fix/add something that is related and could've just been committed directly to the existing PR's branch
-### GitHub Issue & PR Templates
-When contributing to the project, use the appropriate templates:
+When working on a PR, keep the PR description in sync with new commits being made
-**Bug Reports** (`.github/ISSUE_TEMPLATE/bug_report.yml`):
-- Describe what happened vs. what you expected
-- Include relevant log output
-- Specify your LiteLLM version
+Monkeypatching attributes of a class to do testing is an anti-pattern. Prefer dependency-injecting things into classes. That way, at unit test time, you can pass a mocked dependency in
-**Feature Requests** (`.github/ISSUE_TEMPLATE/feature_request.yml`):
-- Describe the feature clearly
-- Explain the motivation and use case
+Do not put names of customers or customer company names in code, PRs, and issues. The codebase is public
-**Pull Requests** (`.github/pull_request_template.md`):
-- Add at least 1 test in `tests/litellm/`
-- Ensure `make test-unit` passes
+CI supply-chain safety: Never pipe a remote script into a shell (`curl ... | bash`, `wget ... | sh`); download the artifact to a file, verify its SHA-256 checksum, then install. Pin every external tool to a specific version with a full URL (not `latest` or `stable`). Verify checksums for all downloaded binaries, using the provider's official `.sha256` / `.sha256sum` sidecar when available. These rules apply to every download in CI
-## Architecture Overview
+## Think Before Coding
-LiteLLM is a unified interface for 100+ LLM providers with two main components:
+**Don't assume. Don't hide confusion. Surface tradeoffs.**
-### Core Library (`litellm/`)
-- **Main entry point**: `litellm/main.py` - Contains core completion() function
-- **Provider implementations**: `litellm/llms/` - Each provider has its own subdirectory
-- **Router system**: `litellm/router.py` + `litellm/router_utils/` - Load balancing and fallback logic
-- **Type definitions**: `litellm/types/` - Pydantic models and type hints
-- **Integrations**: `litellm/integrations/` - Third-party observability, caching, logging
-- **Caching**: `litellm/caching/` - Multiple cache backends (Redis, in-memory, S3, etc.)
+Before implementing:
+- State your assumptions explicitly. If uncertain, ask.
+- If multiple interpretations exist, present them. Don't pick silently.
+- If a simpler approach exists, say so. Push back when warranted.
+- If something is unclear, stop. Name what's confusing. Ask.
-### Proxy Server (`litellm/proxy/`)
-- **Main server**: `proxy_server.py` - FastAPI application
-- **Authentication**: `auth/` - API key management, JWT, OAuth2
-- **Database**: `db/` - Prisma ORM with PostgreSQL/SQLite support
-- **Management endpoints**: `management_endpoints/` - Admin APIs for keys, teams, models
-- **Pass-through endpoints**: `pass_through_endpoints/` - Provider-specific API forwarding
-- **Guardrails**: `guardrails/` - Safety and content filtering hooks
-- **UI Dashboard**: Served from `_experimental/out/` (Next.js build)
+## Simplicity First
-## Key Patterns
+**Minimum code that solves the problem. Nothing speculative.**
-### Provider Implementation
-- Providers inherit from base classes in `litellm/llms/base.py`
-- Each provider has transformation functions for input/output formatting
-- Support both sync and async operations
-- Handle streaming responses and function calling
+- No features beyond what was asked.
+- No abstractions for single-use code.
+- No "flexibility" or "configurability" that wasn't requested.
+- No error handling for impossible scenarios.
+- If you write 200 lines and it could be 50, rewrite it.
-### Error Handling
-- Provider-specific exceptions mapped to OpenAI-compatible errors
-- Fallback logic handled by Router system
-- Comprehensive logging through `litellm/_logging.py`
-
-### Configuration
-- YAML config files for proxy server (see `proxy/example_config_yaml/`)
-- Environment variables for API keys and settings
-- Database schema managed via Prisma (`proxy/schema.prisma`)
-
-## Development Notes
-
-### Code Style
-- Uses Black formatter, Ruff linter, MyPy type checker
-- Pydantic v2 for data validation
-- Async/await patterns throughout
-- Type hints required for all public APIs
-- **Avoid imports within methods** — place all imports at the top of the file (module-level). Inline imports inside functions/methods make dependencies harder to trace and hurt readability. The only exception is avoiding circular imports where absolutely necessary.
-- **Use dict spread for immutable copies** — prefer `{**original, "key": new_value}` over `dict(obj)` + mutation. The spread produces the final dict in one step and makes intent clear.
-- **Guard at resolution time** — when resolving an optional value through a fallback chain (`a or b or ""`), raise immediately if the resolved result being empty is an error. Don't pass empty strings or sentinel values downstream for the callee to deal with.
-- **Extract complex comprehensions to named helpers** — a set/dict comprehension that calls into the DB or manager (e.g. "which of these server IDs are OAuth2?") belongs in a named helper function, not inline in the caller.
-- **FastAPI parameter declarations** — mark required query/form params with `= Query(...)` / `= Form(...)` explicitly when other params in the same handler are optional. Mixing `str` (required) with `Optional[str] = None` in the same signature causes silent 422s when the required param is missing.
-
-### Testing Strategy
-- Unit tests in `tests/test_litellm/`
-- Integration tests for each provider in `tests/llm_translation/`
-- Proxy tests in `tests/proxy_unit_tests/`
-- Load tests in `tests/load_tests/`
-- **Always add tests when adding new entity types or features** — if the existing test file covers other entity types, add corresponding tests for the new one
-- **Keep monkeypatch stubs in sync with real signatures** — when a function gains a new optional parameter, update every `fake_*` / `stub_*` in tests that patch it to also accept that kwarg (even as `**kwargs`). Stale stubs fail with `unexpected keyword argument` and mask real bugs.
-- **Test all branches of name→ID resolution** — when adding server/resource lookup that resolves names to UUIDs, test: (1) name resolves and UUID is allowed, (2) name resolves but UUID is not allowed, (3) name does not resolve at all. The silent-fallback path is where access-control bugs hide.
-
-### UI / Backend Consistency
-- When wiring a new UI entity type to an existing backend endpoint, verify the backend API contract (single value vs. array, required vs. optional params) and ensure the UI controls match — e.g., use a single-select dropdown when the backend accepts a single value, not a multi-select
-
-### UI Component Library
-- **Always use `antd` for new UI components** — we are migrating off of `@tremor/react`. Do not introduce new `Badge`, `Text`, `Card`, `Grid`, `Title`, or other imports from `@tremor/react` in any new or modified file. Use `antd` equivalents: `Tag` for labels, `Typography.Text` / `Typography.Title` / `Typography.Paragraph` for textual content (avoid plain text-only ``, `
`, `` when Typography fits), and `Card` from `antd`. Note that `antd` has no `"yellow"` Tag color — use `"gold"` for amber/yellow.
-
-### MCP OAuth / OpenAPI Transport Mapping
-- **`available_on_public_internet: false` with `delegate_auth_to_upstream: true` (oauth2, interactive — not `client_credentials`)** — LiteLLM still allows the anonymous upstream PKCE path (no proxy API key for `/authorize` and matching MCP routes). The internal-only flag mainly affects other surfaces (e.g. IP-based discovery). Rely on the upstream IdP and network policy; the dashboard shows a warning when both are set, and the proxy logs a warning when the server is loaded from config or the database.
-- `TRANSPORT.OPENAPI` is a UI-only concept. The backend only accepts `"http"`, `"sse"`, or `"stdio"`. Always map it to `"http"` before any API call (including pre-OAuth temp-session calls).
-- FastAPI validation errors return `detail` as an array of `{loc, msg, type}` objects. Error extractors must handle: array (map `.msg`), string, nested `{error: string}`, and fallback.
-- When an MCP server already has `authorization_url` stored, skip OAuth discovery (`_discovery_metadata`) — the server URL for OpenAPI MCPs is the spec file, not the API base, and fetching it causes timeouts.
-- `client_id` should be optional in the `/authorize` endpoint — if the server has a stored `client_id` in credentials, use that. Never require callers to re-supply it.
-
-### MCP Credential Storage
-- OAuth credentials and BYOK credentials share the `litellm_mcpusercredentials` table, distinguished by a `"type"` field in the JSON payload (`"oauth2"` vs plain string).
-- When deleting OAuth credentials, check type before deleting to avoid accidentally deleting a BYOK credential for the same `(user_id, server_id)` pair.
-- Always pass the raw `expires_at` timestamp to the client — never set it to `None` for expired credentials. Let the frontend compute the "Expired" display state from the timestamp.
-- Use `RecordNotFoundError` (not bare `except Exception`) when catching "already deleted" in credential delete endpoints.
-
-### Browser Storage Safety (UI)
-- Never write LiteLLM access tokens or API keys to `localStorage` — use `sessionStorage` only. `localStorage` survives browser close and is readable by any injected script (XSS).
-- Shared utility functions (e.g. `extractErrorMessage`) belong in `src/utils/` — never define them inline in hooks or duplicate them across files.
-
-### Database Migrations
-- Prisma handles schema migrations
-- Migration files auto-generated with `prisma migrate dev`
-- Always test migrations against both PostgreSQL and SQLite
-
-### Proxy database access
-- **Do not write raw SQL** for proxy DB operations. Use Prisma model methods instead of `execute_raw` / `query_raw`.
-- Use the generated client: `prisma_client.db.` (e.g. `litellm_tooltable`, `litellm_usertable`) with `.upsert()`, `.find_many()`, `.find_unique()`, `.update()`, `.update_many()` as appropriate. This avoids schema/client drift, keeps code testable with simple mocks, and matches patterns used in spend logs and other proxy code.
-- **No N+1 queries.** Never query the DB inside a loop. Batch-fetch with `{"in": ids}` and distribute in-memory.
-- **Batch writes.** Use `create_many`/`update_many`/`delete_many` instead of individual calls (these return counts only; `update_many`/`delete_many` no-op silently on missing rows). When multiple separate writes target the same table (e.g. in `batch_()`), order by primary key to avoid deadlocks.
-- **Push work to the DB.** Filter, sort, group, and aggregate in SQL, not Python. Verify Prisma generates the expected SQL — e.g. prefer `group_by` over `find_many(distinct=...)` which does client-side processing.
-- **Bound large result sets.** Prisma materializes full results in memory. For results over ~10 MB, paginate with `take`/`skip` or `cursor`/`take`, always with an explicit `order`. Prefer cursor-based pagination (`skip` is O(n)). Don't paginate naturally small result sets.
-- **Limit fetched columns on wide tables.** Use `select` to fetch only needed fields — returns a partial object, so downstream code must not access unselected fields.
-- **Check index coverage.** For new or modified queries, check `schema.prisma` for a supporting index. Prefer extending an existing index (e.g. `@@index([a])` → `@@index([a, b])`) over adding a new one, unless it's a `@@unique`. Only add indexes for large/frequent queries.
-- **Keep schema files in sync.** Apply schema changes to all `schema.prisma` copies (`schema.prisma`, `litellm/proxy/`, `litellm-proxy-extras/`) with a migration under `litellm-proxy-extras/litellm_proxy_extras/migrations/`.
-
-### Setup Wizard (`litellm/setup_wizard.py`)
-- The wizard is implemented as a single `SetupWizard` class with `@staticmethod` methods — keep it that way. No module-level functions except `run_setup_wizard()` (the public entrypoint) and pure helpers (color, ANSI).
-- Use `litellm.utils.check_valid_key(model, api_key)` for credential validation — never roll a custom completion call.
-- Do not hardcode provider env-key names or model lists that already exist in the codebase. Add a `test_model` field to each provider entry to drive `check_valid_key`; set it to `None` for providers that can't be validated with a single API key (Azure, Bedrock, Ollama).
-
-### Enterprise Features
-- Enterprise-specific code in `enterprise/` directory
-- Optional features enabled via environment variables
-- Separate licensing and authentication for enterprise features
-
-### CI Supply-Chain Safety
-- **Never pipe a remote script into a shell** (`curl ... | bash`, `wget ... | sh`). Download the artifact to a file, verify its SHA-256 checksum, then install.
-- **Pin every external tool to a specific version** with a full URL (not `latest` or `stable`). Unversioned downloads silently change under you.
-- **Verify checksums for all downloaded binaries.** Use the provider's official `.sha256` / `.sha256sum` sidecar file when available; otherwise compute and hardcode the digest.
-- **Prefer reusable CircleCI commands** (`commands:` section) so a tool is installed and verified in exactly one place, then referenced everywhere with `- install_` or `- wait_for_service`.
-- **Don't add tools just because they were there before.** Audit whether an external dependency is still needed. If it can be replaced with a shell one-liner or a tool already in the image, remove it.
-- These rules apply to every download in CI: binaries, install scripts, language version managers, package repos. No exceptions.
-
-### HTTP Client Cache Safety
-- **Never close HTTP/SDK clients on cache eviction.** `LLMClientCache._remove_key()` must not call `close()`/`aclose()` on evicted clients — they may still be used by in-flight requests. Doing so causes `RuntimeError: Cannot send a request, as the client has been closed.` after the 1-hour TTL expires. Cleanup happens at shutdown via `close_litellm_async_clients()`.
-
-### Troubleshooting: DB schema out of sync after proxy restart
-`litellm-proxy-extras` runs `prisma migrate deploy` on startup using **its own** bundled migration files, which may lag behind schema changes in the current worktree. Symptoms: `Unknown column`, `Invalid prisma invocation`, or missing data on new fields.
-
-**Diagnose:** Run `\d "TableName"` in psql and compare against `schema.prisma` — missing columns confirm the issue.
-
-**Fix options:**
-1. **Create a Prisma migration** (permanent) — run `prisma migrate dev --name ` in the worktree. The generated file will be picked up by `prisma migrate deploy` on next startup.
-2. **Apply manually for local dev** — `psql -d litellm -c "ALTER TABLE ... ADD COLUMN IF NOT EXISTS ..."` after each proxy start. Fine for dev, not for production.
-3. **Update litellm-proxy-extras** — if the package is installed from PyPI, its migration directory must include the new file. Either update the package or run the migration manually until the next release ships it.
+Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
diff --git a/GEMINI.md b/GEMINI.md
index 9e950d89b33..41921fdff4d 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -1,108 +1 @@
-# GEMINI.md
-
-This file provides guidance to Gemini when working with code in this repository.
-
-## Development Commands
-
-### Installation
-- `make install-dev` - Install core development dependencies
-- `make install-proxy-dev` - Install proxy development dependencies with full feature set
-- `make install-test-deps` - Install all test dependencies
-
-### Testing
-- `make test` - Run all tests
-- `make test-unit` - Run unit tests (tests/test_litellm) with 4 parallel workers
-- `make test-integration` - Run integration tests (excludes unit tests)
-- `pytest tests/` - Direct pytest execution
-
-### Code Quality
-- `make lint` - Run all linting (Ruff, MyPy, Black, circular imports, import safety)
-- `make format` - Apply Black code formatting
-- `make lint-ruff` - Run Ruff linting only
-- `make lint-mypy` - Run MyPy type checking only
-
-### Single Test Files
-- `uv run pytest tests/path/to/test_file.py -v` - Run specific test file
-- `uv run pytest tests/path/to/test_file.py::test_function -v` - Run specific test
-
-### Running Scripts
-- `uv run python script.py` - Run Python scripts (use for non-test files)
-
-### GitHub Issue & PR Templates
-When contributing to the project, use the appropriate templates:
-
-**Bug Reports** (`.github/ISSUE_TEMPLATE/bug_report.yml`):
-- Describe what happened vs. what you expected
-- Include relevant log output
-- Specify your LiteLLM version
-
-**Feature Requests** (`.github/ISSUE_TEMPLATE/feature_request.yml`):
-- Describe the feature clearly
-- Explain the motivation and use case
-
-**Pull Requests** (`.github/pull_request_template.md`):
-- Add at least 1 test in `tests/litellm/`
-- Ensure `make test-unit` passes
-
-## Architecture Overview
-
-LiteLLM is a unified interface for 100+ LLM providers with two main components:
-
-### Core Library (`litellm/`)
-- **Main entry point**: `litellm/main.py` - Contains core completion() function
-- **Provider implementations**: `litellm/llms/` - Each provider has its own subdirectory
-- **Router system**: `litellm/router.py` + `litellm/router_utils/` - Load balancing and fallback logic
-- **Type definitions**: `litellm/types/` - Pydantic models and type hints
-- **Integrations**: `litellm/integrations/` - Third-party observability, caching, logging
-- **Caching**: `litellm/caching/` - Multiple cache backends (Redis, in-memory, S3, etc.)
-
-### Proxy Server (`litellm/proxy/`)
-- **Main server**: `proxy_server.py` - FastAPI application
-- **Authentication**: `auth/` - API key management, JWT, OAuth2
-- **Database**: `db/` - Prisma ORM with PostgreSQL/SQLite support
-- **Management endpoints**: `management_endpoints/` - Admin APIs for keys, teams, models
-- **Pass-through endpoints**: `pass_through_endpoints/` - Provider-specific API forwarding
-- **Guardrails**: `guardrails/` - Safety and content filtering hooks
-- **UI Dashboard**: Served from `_experimental/out/` (Next.js build)
-
-## Key Patterns
-
-### Provider Implementation
-- Providers inherit from base classes in `litellm/llms/base.py`
-- Each provider has transformation functions for input/output formatting
-- Support both sync and async operations
-- Handle streaming responses and function calling
-
-### Error Handling
-- Provider-specific exceptions mapped to OpenAI-compatible errors
-- Fallback logic handled by Router system
-- Comprehensive logging through `litellm/_logging.py`
-
-### Configuration
-- YAML config files for proxy server (see `proxy/example_config_yaml/`)
-- Environment variables for API keys and settings
-- Database schema managed via Prisma (`proxy/schema.prisma`)
-
-## Development Notes
-
-### Code Style
-- Uses Black formatter, Ruff linter, MyPy type checker
-- Pydantic v2 for data validation
-- Async/await patterns throughout
-- Type hints required for all public APIs
-
-### Testing Strategy
-- Unit tests in `tests/test_litellm/`
-- Integration tests for each provider in `tests/llm_translation/`
-- Proxy tests in `tests/proxy_unit_tests/`
-- Load tests in `tests/load_tests/`
-
-### Database Migrations
-- Prisma handles schema migrations
-- Migration files auto-generated with `prisma migrate dev`
-- Always test migrations against both PostgreSQL and SQLite
-
-### Enterprise Features
-- Enterprise-specific code in `enterprise/` directory
-- Optional features enabled via environment variables
-- Separate licensing and authentication for enterprise features
+Read @CLAUDE.md for coding guidelines
diff --git a/litellm/proxy/_experimental/mcp_server/CLAUDE.md b/litellm/proxy/_experimental/mcp_server/CLAUDE.md
new file mode 100644
index 00000000000..0ba8f73315f
--- /dev/null
+++ b/litellm/proxy/_experimental/mcp_server/CLAUDE.md
@@ -0,0 +1 @@
+MCP note: **`available_on_public_internet: false` with `delegate_auth_to_upstream: true` (oauth2, interactive - not `client_credentials`)** - LiteLLM still allows the anonymous upstream PKCE path (no proxy API key for `/authorize` and matching MCP routes). The internal-only flag mainly affects other surfaces (e.g. IP-based discovery). Rely on the upstream IdP and network policy; the dashboard shows a warning when both are set, and the proxy logs a warning when the server is loaded from config or the database
diff --git a/ui/litellm-dashboard/CLAUDE.md b/ui/litellm-dashboard/CLAUDE.md
new file mode 100644
index 00000000000..3d43019c749
--- /dev/null
+++ b/ui/litellm-dashboard/CLAUDE.md
@@ -0,0 +1 @@
+Never put LiteLLM tokens or API keys in `localStorage`. `localStorage` survives browser close. Prefer `httpOnly` cookies, or `sessionStorage` at most, understanding that any web storage is readable by injected scripts (XSS), and only httpOnly cookies are not
From 68852ef16518cba5dc93f69d1760b08a1bfec192 Mon Sep 17 00:00:00 2001
From: michelligabriele
Date: Fri, 29 May 2026 14:09:07 +0200
Subject: [PATCH 05/44] fix(teams): expose keys_count on /v2/team/list and wire
UI Resources badge (#28502)
---
.../management_endpoints/team_endpoints.py | 51 +++++-
.../management_endpoints/team_endpoints.py | 1 +
.../test_team_endpoints.py | 161 ++++++++++++++++++
.../src/components/OldTeams.test.tsx | 80 +++++++++
.../src/components/OldTeams.tsx | 20 ++-
.../components/key_team_helpers/key_list.tsx | 1 +
6 files changed, 305 insertions(+), 9 deletions(-)
diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py
index 0d34974fbef..8a8e703831b 100644
--- a/litellm/proxy/management_endpoints/team_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_endpoints.py
@@ -3978,11 +3978,13 @@ async def _batch_resolve_access_group_resources(
def _convert_teams_to_response_models(
teams: list,
use_deleted_table: bool,
+ keys_count_by_team: Optional[Dict[str, int]] = None,
) -> List[Union[TeamListItem, LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]]:
"""Convert raw Prisma team rows to response models."""
team_list: List[
Union[TeamListItem, LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]
] = []
+ counts = keys_count_by_team or {}
for team in teams:
try:
team_dict = team.model_dump()
@@ -3997,10 +3999,45 @@ def _convert_teams_to_response_models(
members_with_roles = []
team_dict["members_with_roles"] = members_with_roles
members_count = len(members_with_roles)
- team_list.append(TeamListItem(**team_dict, members_count=members_count))
+ keys_count = counts.get(team_dict.get("team_id") or "", 0)
+ team_list.append(
+ TeamListItem(
+ **team_dict,
+ members_count=members_count,
+ keys_count=keys_count,
+ )
+ )
return team_list
+async def _get_keys_count_by_team(
+ prisma_client: Any,
+ teams: list,
+) -> Dict[str, int]:
+ """Aggregate virtual-key counts per team for the given page of teams.
+
+ Runs a single GROUP BY against LiteLLM_VerificationToken. The IN clause is
+ bounded by page_size and uses the existing @@index([team_id]), so this is
+ one DB round-trip per page. Returns an empty map when the page has no teams.
+ """
+ page_team_ids = [
+ getattr(t, "team_id", None) for t in teams if getattr(t, "team_id", None)
+ ]
+ if not page_team_ids:
+ return {}
+
+ grouped = await prisma_client.db.litellm_verificationtoken.group_by(
+ by=["team_id"],
+ where={"team_id": {"in": page_team_ids}},
+ count={"team_id": True},
+ )
+ return {
+ row["team_id"]: row.get("_count", {}).get("team_id", 0)
+ for row in grouped
+ if row.get("team_id")
+ }
+
+
async def _enforce_list_team_v2_access(
user_api_key_dict: UserAPIKeyAuth,
user_id: Optional[str],
@@ -4228,8 +4265,16 @@ async def list_team_v2(
# Calculate total pages
total_pages = -(-total_count // page_size) # Ceiling division
- # Convert Prisma models to response models with members_count
- team_list = _convert_teams_to_response_models(teams, use_deleted_table)
+ # Aggregate virtual-key counts per team for the current page. The deleted
+ # table does not carry keys_count, so it is skipped.
+ keys_count_by_team: Dict[str, int] = {}
+ if not use_deleted_table:
+ keys_count_by_team = await _get_keys_count_by_team(prisma_client, teams)
+
+ # Convert Prisma models to response models with members_count and keys_count
+ team_list = _convert_teams_to_response_models(
+ teams, use_deleted_table, keys_count_by_team=keys_count_by_team
+ )
# Resolve resources inherited from access groups (single batch query)
if not use_deleted_table:
diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py
index cb27fd52300..0e555535874 100644
--- a/litellm/types/proxy/management_endpoints/team_endpoints.py
+++ b/litellm/types/proxy/management_endpoints/team_endpoints.py
@@ -69,6 +69,7 @@ class TeamListItem(LiteLLM_TeamTable):
"""A team item in the paginated list response, enriched with computed fields."""
members_count: int = 0
+ keys_count: int = 0
# Resources inherited from access groups (separate from direct assignments)
access_group_models: Optional[List[str]] = None
access_group_mcp_server_ids: Optional[List[str]] = None
diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
index 13bb39c35c9..d580f1f7703 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
@@ -2832,6 +2832,7 @@ async def test_list_team_v2_security_check_non_admin_user_own_teams():
]
mock_db.litellm_teamtable.find_many = AsyncMock(return_value=mock_teams)
mock_db.litellm_teamtable.count = AsyncMock(return_value=2)
+ mock_db.litellm_verificationtoken.group_by = AsyncMock(return_value=[])
with patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
@@ -2888,6 +2889,7 @@ async def test_list_team_v2_security_check_admin_user():
]
mock_db.litellm_teamtable.find_many = AsyncMock(return_value=mock_teams)
mock_db.litellm_teamtable.count = AsyncMock(return_value=2)
+ mock_db.litellm_verificationtoken.group_by = AsyncMock(return_value=[])
# Should NOT raise an exception
result = await list_team_v2(
@@ -3036,6 +3038,7 @@ async def test_list_team_v2_org_admin_sees_org_teams():
}
mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team])
mock_db.litellm_teamtable.count = AsyncMock(return_value=1)
+ mock_db.litellm_verificationtoken.group_by = AsyncMock(return_value=[])
result = await list_team_v2(
http_request=mock_request,
@@ -3211,6 +3214,7 @@ async def test_list_team_v2_org_admin_with_user_id_returns_user_teams():
}
mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team])
mock_db.litellm_teamtable.count = AsyncMock(return_value=1)
+ mock_db.litellm_verificationtoken.group_by = AsyncMock(return_value=[])
result = await list_team_v2(
http_request=mock_request,
@@ -3390,6 +3394,163 @@ async def test_list_team_v2_search_composes_with_user_id_filter():
assert where["team_id"] == {"in": ["team_a", "team_b"]}
+@pytest.mark.asyncio
+async def test_list_team_v2_populates_keys_count():
+ """
+ Test that list_team_v2 returns a keys_count per team derived from a single
+ batched group_by against LiteLLM_VerificationToken.
+ """
+ from unittest.mock import AsyncMock, Mock, patch
+
+ from fastapi import Request
+
+ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
+ from litellm.proxy.management_endpoints.team_endpoints import list_team_v2
+
+ mock_request = Mock(spec=Request)
+ mock_user_api_key_dict_admin = UserAPIKeyAuth(
+ user_role=LitellmUserRoles.PROXY_ADMIN,
+ user_id="admin_user_123",
+ )
+
+ with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client:
+ mock_db = Mock()
+ mock_prisma_client.db = mock_db
+
+ team_a = Mock()
+ team_a.team_id = "team_a"
+ team_a.model_dump = lambda: {
+ "team_id": "team_a",
+ "team_alias": "Team A",
+ "members_with_roles": [{"user_id": "u1", "role": "user"}],
+ }
+ team_b = Mock()
+ team_b.team_id = "team_b"
+ team_b.model_dump = lambda: {
+ "team_id": "team_b",
+ "team_alias": "Team B",
+ "members_with_roles": [],
+ }
+
+ mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[team_a, team_b])
+ mock_db.litellm_teamtable.count = AsyncMock(return_value=2)
+ mock_db.litellm_verificationtoken.group_by = AsyncMock(
+ return_value=[
+ {"team_id": "team_a", "_count": {"team_id": 3}},
+ # team_b intentionally absent → expect 0
+ ]
+ )
+
+ result = await list_team_v2(
+ http_request=mock_request,
+ user_id=None,
+ user_api_key_dict=mock_user_api_key_dict_admin,
+ page=1,
+ page_size=10,
+ status=None,
+ )
+
+ assert result["total"] == 2
+ by_id = {t.team_id: t for t in result["teams"]}
+ assert by_id["team_a"].keys_count == 3
+ assert by_id["team_b"].keys_count == 0
+
+ # The aggregate is one batched query, filtered by the page's team IDs.
+ group_by_kwargs = mock_db.litellm_verificationtoken.group_by.call_args.kwargs
+ assert group_by_kwargs["by"] == ["team_id"]
+ assert group_by_kwargs["where"] == {"team_id": {"in": ["team_a", "team_b"]}}
+ assert group_by_kwargs["count"] == {"team_id": True}
+
+
+@pytest.mark.asyncio
+async def test_list_team_v2_keys_count_skipped_for_empty_page():
+ """
+ When the page has no teams, the keys-count group_by must not be issued.
+ """
+ from unittest.mock import AsyncMock, Mock, patch
+
+ from fastapi import Request
+
+ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
+ from litellm.proxy.management_endpoints.team_endpoints import list_team_v2
+
+ mock_request = Mock(spec=Request)
+ mock_user_api_key_dict_admin = UserAPIKeyAuth(
+ user_role=LitellmUserRoles.PROXY_ADMIN,
+ user_id="admin_user_123",
+ )
+
+ with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client:
+ mock_db = Mock()
+ mock_prisma_client.db = mock_db
+
+ mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[])
+ mock_db.litellm_teamtable.count = AsyncMock(return_value=0)
+ mock_db.litellm_verificationtoken.group_by = AsyncMock(return_value=[])
+
+ result = await list_team_v2(
+ http_request=mock_request,
+ user_id=None,
+ user_api_key_dict=mock_user_api_key_dict_admin,
+ page=1,
+ page_size=10,
+ status=None,
+ )
+
+ assert result["total"] == 0
+ assert result["teams"] == []
+ mock_db.litellm_verificationtoken.group_by.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_list_team_v2_keys_count_skipped_for_deleted_status():
+ """
+ The deleted-table branch returns LiteLLM_DeletedTeamTable items, which do
+ not carry keys_count — group_by must not be issued.
+ """
+ from unittest.mock import AsyncMock, Mock, patch
+
+ from fastapi import Request
+
+ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
+ from litellm.proxy.management_endpoints.team_endpoints import list_team_v2
+
+ mock_request = Mock(spec=Request)
+ mock_user_api_key_dict_admin = UserAPIKeyAuth(
+ user_role=LitellmUserRoles.PROXY_ADMIN,
+ user_id="admin_user_123",
+ )
+
+ with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client:
+ mock_db = Mock()
+ mock_prisma_client.db = mock_db
+
+ mock_deleted = Mock()
+ mock_deleted.team_id = "team_d"
+ mock_deleted.model_dump = lambda: {
+ "team_id": "team_d",
+ "team_alias": "Deleted Team",
+ }
+
+ mock_db.litellm_deletedteamtable.find_many = AsyncMock(
+ return_value=[mock_deleted]
+ )
+ mock_db.litellm_deletedteamtable.count = AsyncMock(return_value=1)
+ mock_db.litellm_verificationtoken.group_by = AsyncMock(return_value=[])
+
+ result = await list_team_v2(
+ http_request=mock_request,
+ user_id=None,
+ user_api_key_dict=mock_user_api_key_dict_admin,
+ page=1,
+ page_size=10,
+ status="deleted",
+ )
+
+ assert result["total"] == 1
+ mock_db.litellm_verificationtoken.group_by.assert_not_called()
+
+
@pytest.mark.asyncio
async def test_team_member_delete_cleans_membership(mock_db_client, mock_admin_auth):
"""
diff --git a/ui/litellm-dashboard/src/components/OldTeams.test.tsx b/ui/litellm-dashboard/src/components/OldTeams.test.tsx
index 4b89820bad6..b8707c1a338 100644
--- a/ui/litellm-dashboard/src/components/OldTeams.test.tsx
+++ b/ui/litellm-dashboard/src/components/OldTeams.test.tsx
@@ -1018,3 +1018,83 @@ describe("OldTeams - organization alias display", () => {
});
});
});
+
+describe("OldTeams - Resources column keys badge", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseOrganizations.mockReturnValue({ data: [] });
+ });
+
+ it("renders keys_count from the v2 payload in the Resources badge", async () => {
+ const { container } = renderWithQueryClient(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Team With Keys")).toBeInTheDocument();
+ });
+ const cyanTag = container.querySelector(".ant-tag-cyan");
+ expect(cyanTag).not.toBeNull();
+ expect(cyanTag?.textContent).toContain("3");
+ });
+
+ it("falls back to keys.length when keys_count is absent", async () => {
+ const { container } = renderWithQueryClient(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Legacy Team")).toBeInTheDocument();
+ });
+ const cyanTag = container.querySelector(".ant-tag-cyan");
+ expect(cyanTag).not.toBeNull();
+ expect(cyanTag?.textContent).toContain("2");
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx
index da00ad911b0..8f9e5a75c20 100644
--- a/ui/litellm-dashboard/src/components/OldTeams.tsx
+++ b/ui/litellm-dashboard/src/components/OldTeams.tsx
@@ -105,6 +105,7 @@ interface TeamInfo {
interface PerTeamInfo {
keys: KeyResponse[];
+ keys_count: number;
team_info: TeamInfo;
}
@@ -364,6 +365,7 @@ const Teams: React.FC = ({
(acc, team) => {
acc[team.team_id] = {
keys: team.keys || [],
+ keys_count: team.keys_count ?? team.keys?.length ?? 0,
team_info: {
members_with_roles: team.members_with_roles || [],
},
@@ -745,7 +747,7 @@ const Teams: React.FC = ({
render: (_: unknown, record: Team) => {
const memberCount = perTeamInfo?.[record.team_id]?.team_info?.members_with_roles?.length ?? 0;
const modelCount = record.models?.length ?? 0;
- const keyCount = perTeamInfo?.[record.team_id]?.keys?.length ?? 0;
+ const keyCount = perTeamInfo?.[record.team_id]?.keys_count ?? 0;
return (
@@ -977,17 +979,23 @@ const Teams: React.FC = ({
{
+ const deleteKeyCount =
+ teamToDelete?.keys_count ?? teamToDelete?.keys?.length ?? 0;
+ return deleteKeyCount === 0
? undefined
- : `Warning: This team has ${teamToDelete?.keys?.length} keys associated with it. Deleting the team will also delete all associated keys. This action is irreversible.`
- }
+ : `Warning: This team has ${deleteKeyCount} keys associated with it. Deleting the team will also delete all associated keys. This action is irreversible.`;
+ })()}
message="Are you sure you want to delete this team and all its keys? This action cannot be undone."
resourceInformationTitle="Team Information"
resourceInformation={[
{ label: "Team ID", value: teamToDelete?.team_id, code: true },
{ label: "Team Name", value: teamToDelete?.team_alias },
- { label: "Keys", value: teamToDelete?.keys?.length },
+ {
+ label: "Keys",
+ value:
+ teamToDelete?.keys_count ?? teamToDelete?.keys?.length ?? 0,
+ },
{ label: "Members", value: teamToDelete?.members_with_roles?.length },
]}
requiredConfirmation={teamToDelete?.team_alias}
diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx
index 6b3c65aaf7b..60568da48ed 100644
--- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx
+++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx
@@ -13,6 +13,7 @@ export interface Team {
organization_id: string;
created_at: string;
keys: KeyResponse[];
+ keys_count?: number;
members_with_roles: Member[];
spend: number;
access_group_ids?: string[];
From a55817cbc6df704d90a2151165e8b19e73da53fc Mon Sep 17 00:00:00 2001
From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com>
Date: Fri, 29 May 2026 13:55:06 -0700
Subject: [PATCH 06/44] fix(anthropic): stop injecting unsupported
output_config.effort=xhigh for Claude Code on Sonnet/Opus 4.6 (#29304)
* fix(anthropic): don't inject output_config.effort=xhigh on models without xhigh
The legacy-thinking translator on the /v1/messages route mapped any
thinking.budget_tokens >= 24000 to effort=xhigh and injected it into
output_config without checking model support. Claude Code's default
thinking budget (31999) hit this bucket, so Sonnet 4.6 (and Opus 4.6)
on Bedrock/Vertex started returning
400 output_config.effort: Input should be 'low', 'medium', 'high' or 'max'
Gate the xhigh choice on _supports_effort_level(model, "xhigh"), the
same capability check the reasoning_effort path already uses. Models
that advertise xhigh (Opus 4.7) keep it; everything else falls to high.
Fixes #29282
* test(anthropic): pin Opus 4.6 in legacy-thinking xhigh-clamp regression test
Opus 4.6 (bare, bedrock/invoke, vertex_ai) has supports_adaptive_thinking
but no supports_xhigh_reasoning_effort, so it hits the same clamping path as
Sonnet 4.6. It was named in the PR scope but lacked a pinned regression
guard; add the three variants to the parametrize list.
---
.../messages/transformation.py | 4 +-
.../test_reasoning_effort_translation.py | 121 ++++++++++++++++++
2 files changed, 124 insertions(+), 1 deletion(-)
diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py
index f94232fa451..3a2c09f2183 100644
--- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py
+++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py
@@ -230,6 +230,8 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
"""Translate legacy ``thinking.type=enabled`` to adaptive for 4.6/4.7.
Caller-provided ``output_config.effort`` is never overridden.
"""
+ from litellm.llms.anthropic.chat.transformation import AnthropicConfig
+
if not AnthropicModelInfo._is_adaptive_thinking_model(model):
return
thinking = optional_params.get("thinking")
@@ -237,7 +239,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
return
budget = int(thinking.get("budget_tokens") or 0)
- if budget >= 24000:
+ if budget >= 24000 and AnthropicConfig._supports_effort_level(model, "xhigh"):
effort = "xhigh"
elif budget >= 10000:
effort = "high"
diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_effort_translation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_effort_translation.py
index 54bf0c4ac0f..09601a65811 100644
--- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_effort_translation.py
+++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_effort_translation.py
@@ -243,3 +243,124 @@ def test_reasoning_effort_in_supported_params():
assert "reasoning_effort" in config.get_supported_anthropic_messages_params(
"claude-opus-4-7"
)
+
+
+@pytest.mark.parametrize(
+ "model",
+ [
+ "claude-sonnet-4-6",
+ "bedrock/invoke/us.anthropic.claude-sonnet-4-6",
+ "vertex_ai/claude-sonnet-4-6",
+ "claude-opus-4-6",
+ "bedrock/invoke/us.anthropic.claude-opus-4-6",
+ "vertex_ai/claude-opus-4-6",
+ ],
+)
+def test_legacy_thinking_high_budget_clamps_to_high_when_xhigh_unsupported(model):
+ """Claude Code sends ``thinking.budget_tokens=31999``; Sonnet 4.6 and Opus 4.6
+ have no ``xhigh`` tier, so the translator must emit ``high`` rather than the
+ provider-invalid ``xhigh`` (regression for issue #29282)."""
+ config = AnthropicMessagesConfig()
+ optional_params = {
+ "max_tokens": 1024,
+ "thinking": {"type": "enabled", "budget_tokens": 31999},
+ }
+
+ result = config.transform_anthropic_messages_request(
+ model=model,
+ messages=[{"role": "user", "content": "Hello"}],
+ anthropic_messages_optional_request_params=optional_params,
+ litellm_params={},
+ headers={},
+ )
+
+ assert result.get("thinking") == {"type": "adaptive"}
+ assert result.get("output_config") == {"effort": "high"}
+
+
+def test_legacy_thinking_high_budget_keeps_xhigh_when_supported():
+ """Opus 4.7 advertises an ``xhigh`` tier, so the high-budget bucket keeps it."""
+ config = AnthropicMessagesConfig()
+ optional_params = {
+ "max_tokens": 1024,
+ "thinking": {"type": "enabled", "budget_tokens": 31999},
+ }
+
+ result = config.transform_anthropic_messages_request(
+ model="claude-opus-4-7",
+ messages=[{"role": "user", "content": "Hello"}],
+ anthropic_messages_optional_request_params=optional_params,
+ litellm_params={},
+ headers={},
+ )
+
+ assert result.get("thinking") == {"type": "adaptive"}
+ assert result.get("output_config") == {"effort": "xhigh"}
+
+
+@pytest.mark.parametrize(
+ "budget_tokens,expected_effort",
+ [
+ (31999, "high"),
+ (24000, "high"),
+ (10000, "high"),
+ (9999, "medium"),
+ (5000, "medium"),
+ (4999, "low"),
+ (1024, "low"),
+ ],
+)
+def test_legacy_thinking_budget_buckets_on_sonnet_46(budget_tokens, expected_effort):
+ config = AnthropicMessagesConfig()
+ optional_params = {
+ "max_tokens": 1024,
+ "thinking": {"type": "enabled", "budget_tokens": budget_tokens},
+ }
+
+ result = config.transform_anthropic_messages_request(
+ model="claude-sonnet-4-6",
+ messages=[{"role": "user", "content": "Hello"}],
+ anthropic_messages_optional_request_params=optional_params,
+ litellm_params={},
+ headers={},
+ )
+
+ assert result.get("output_config") == {"effort": expected_effort}
+
+
+def test_legacy_thinking_does_not_override_explicit_output_config():
+ config = AnthropicMessagesConfig()
+ optional_params = {
+ "max_tokens": 1024,
+ "thinking": {"type": "enabled", "budget_tokens": 31999},
+ "output_config": {"effort": "low"},
+ }
+
+ result = config.transform_anthropic_messages_request(
+ model="claude-sonnet-4-6",
+ messages=[{"role": "user", "content": "Hello"}],
+ anthropic_messages_optional_request_params=optional_params,
+ litellm_params={},
+ headers={},
+ )
+
+ assert result.get("output_config") == {"effort": "low"}
+
+
+def test_legacy_thinking_left_untouched_on_non_adaptive_model():
+ config = AnthropicMessagesConfig()
+ optional_params = {
+ "max_tokens": 1024,
+ "thinking": {"type": "enabled", "budget_tokens": 31999},
+ }
+
+ result = config.transform_anthropic_messages_request(
+ model="claude-opus-4-5",
+ messages=[{"role": "user", "content": "Hello"}],
+ anthropic_messages_optional_request_params=optional_params,
+ litellm_params={},
+ headers={},
+ )
+
+ assert result.get("thinking") == {"type": "enabled", "budget_tokens": 31999}
+ assert "output_config" not in result
From 10bda4456a5d7e968797e88114dd6b977cd003a1 Mon Sep 17 00:00:00 2001
From: ryan-crabbe-berri
Date: Fri, 29 May 2026 14:36:06 -0700
Subject: [PATCH 07/44] test(e2e): cover Internal Viewer nav, key, and
team-info gating (#29075)
* test(e2e): cover Internal Viewer nav, key, and team-info gating
Three previously-uncovered manual-QA paths for the Internal Viewer role:
- Nav only renders the read-only sections; admin-only items
(Internal Users, Organizations, Models + Endpoints) stay hidden.
- Virtual Keys page hides Create New Key, and the key detail view
hides Regenerate / Reset Spend / Delete actions.
- Team info page hides Members and Settings tabs for the viewer.
* test(e2e): scope viewer nav to sidebar, strengthen tab assertions
Address review feedback on the Internal Viewer e2e spec:
- Scope the nav test to the sidebar complementary landmark and match
items by link role + accessible name. The prior CSS nav, aside
selector grabbed the top bar (the sidebar is a complementary
landmark, not a
>
diff --git a/ui/litellm-dashboard/src/components/agents/agent_card_discovery.test.tsx b/ui/litellm-dashboard/src/components/agents/agent_card_discovery.test.tsx
new file mode 100644
index 00000000000..18b874c95ee
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/agents/agent_card_discovery.test.tsx
@@ -0,0 +1,299 @@
+import React from "react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "../../../tests/test-utils";
+import AgentCardDiscovery from "./agent_card_discovery";
+
+vi.mock("../networking", async () => {
+ const actual = await vi.importActual("../networking");
+ return {
+ ...actual,
+ discoverAgentCardCall: vi.fn(),
+ };
+});
+
+import { discoverAgentCardCall } from "../networking";
+
+const mockDiscover = discoverAgentCardCall as unknown as ReturnType;
+
+const sampleCard = {
+ protocolVersion: "1.0",
+ name: "Upstream Agent",
+ description: "An upstream agent",
+ version: "1.2.3",
+ url: "http://internal:9000",
+ capabilities: { streaming: true, pushNotifications: true },
+ skills: [
+ {
+ id: "search",
+ name: "Search",
+ description: "Search the web",
+ tags: ["search"],
+ },
+ {
+ id: "summarize",
+ name: "Summarize",
+ description: "Summarize a document",
+ tags: ["llm"],
+ },
+ ],
+ provider: { organization: "UpstreamCo", url: "https://upstream.example" },
+};
+
+describe("AgentCardDiscovery", () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ mockDiscover.mockReset();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("renders the URL input and a Re-discover button after manual entry", async () => {
+ mockDiscover.mockResolvedValue({
+ url: "https://upstream.example.com",
+ agent_card: sampleCard,
+ });
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByPlaceholderText("https://upstream-agent.example.com"),
+ ).toBeInTheDocument();
+
+ await user.type(
+ screen.getByPlaceholderText("https://upstream-agent.example.com"),
+ "https://upstream.example.com",
+ );
+ await vi.advanceTimersByTimeAsync(500);
+
+ await waitFor(() => expect(mockDiscover).toHaveBeenCalled());
+ expect(
+ await screen.findByRole("button", { name: /re-discover/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("shows an error when re-discover is clicked without a URL", async () => {
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /discover/i }));
+ expect(
+ await screen.findByText(/Enter the agent's base URL first/i),
+ ).toBeInTheDocument();
+ expect(mockDiscover).not.toHaveBeenCalled();
+ });
+
+ it("auto-discovers and renders upstream skills on success", async () => {
+ mockDiscover.mockResolvedValueOnce({
+ url: "https://upstream.example.com",
+ agent_card: sampleCard,
+ });
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(
+ screen.getByPlaceholderText("https://upstream-agent.example.com"),
+ "https://upstream.example.com",
+ );
+ await vi.advanceTimersByTimeAsync(500);
+
+ expect(await screen.findByText("Upstream card loaded")).toBeInTheDocument();
+ expect(screen.getByText("Search")).toBeInTheDocument();
+ expect(screen.getByText("Summarize")).toBeInTheDocument();
+ expect(screen.getByText(/^streaming$/i)).toBeInTheDocument();
+ expect(screen.queryByText(/pushNotifications/i)).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /use these selections/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows an inline error when discovery fails", async () => {
+ mockDiscover.mockRejectedValueOnce(new Error("upstream unreachable"));
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(
+ screen.getByPlaceholderText("https://upstream-agent.example.com"),
+ "https://nope.example",
+ );
+ await vi.advanceTimersByTimeAsync(500);
+
+ expect(await screen.findByText("Discovery failed")).toBeInTheDocument();
+ expect(screen.getByText(/upstream unreachable/)).toBeInTheDocument();
+ });
+
+ it("syncs the selected subset to the parent as the user edits", async () => {
+ mockDiscover.mockResolvedValueOnce({
+ url: "https://upstream.example.com",
+ agent_card: sampleCard,
+ });
+ const onApply = vi.fn();
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(
+ screen.getByPlaceholderText("https://upstream-agent.example.com"),
+ "https://upstream.example.com",
+ );
+ await vi.advanceTimersByTimeAsync(500);
+ await screen.findByText("Upstream card loaded");
+
+ await waitFor(() => expect(onApply).toHaveBeenCalled());
+ const initialSelection = onApply.mock.calls.at(-1)?.[0];
+ expect(initialSelection.upstream_url).toBe("https://upstream.example.com");
+ expect(initialSelection.selected_card.skills).toHaveLength(2);
+
+ const summarizeLabel = screen.getByText("Summarize").closest("label");
+ expect(summarizeLabel).toBeTruthy();
+ const summarizeCheckbox = summarizeLabel!.querySelector(
+ "input[type='checkbox']",
+ ) as HTMLInputElement;
+ await user.click(summarizeCheckbox);
+
+ await waitFor(() => {
+ const latest = onApply.mock.calls.at(-1)?.[0];
+ expect(latest.selected_card.skills).toHaveLength(1);
+ expect(latest.selected_card.skills[0].id).toBe("search");
+ });
+ });
+
+ it("hides the URL input and shows the display URL when parent-driven", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByPlaceholderText("https://upstream-agent.example.com"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "http://localhost:2024/.well-known/agent-card.json?assistant_id=agent",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("auto-discovers with discovery_mode and params from the parent plan", async () => {
+ mockDiscover.mockResolvedValueOnce({
+ url: "http://localhost:2024",
+ agent_card: sampleCard,
+ });
+ renderWithProviders(
+ ,
+ );
+
+ await vi.advanceTimersByTimeAsync(0);
+ await waitFor(() => expect(mockDiscover).toHaveBeenCalledTimes(1));
+ expect(mockDiscover).toHaveBeenCalledWith("tok", "http://localhost:2024", {
+ discovery_mode: "langgraph_platform",
+ params: { assistant_id: "agent" },
+ });
+ });
+
+ it("disables Re-discover until the parent provides a usable URL", async () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ (screen.getByRole("button", {
+ name: /discover/i,
+ }) as HTMLButtonElement).disabled,
+ ).toBe(true);
+ expect(mockDiscover).not.toHaveBeenCalled();
+ });
+
+ it("pre-selects only skills present in savedAgentCard when editing", async () => {
+ mockDiscover.mockResolvedValueOnce({
+ url: "http://localhost:2024",
+ agent_card: sampleCard,
+ });
+ const onApply = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await vi.advanceTimersByTimeAsync(0);
+ await screen.findByText("Upstream card loaded");
+
+ await waitFor(() => expect(onApply).toHaveBeenCalled());
+ const selection = onApply.mock.calls.at(-1)?.[0];
+ expect(selection.selected_card.skills).toHaveLength(1);
+ expect(selection.selected_card.skills[0].id).toBe("search");
+ expect(selection.selected_card.name).toBe("DB Agent");
+ expect(selection.selected_card.capabilities.streaming).toBe(false);
+ });
+
+ it("blocks discover when no access token is provided", async () => {
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(
+ screen.getByPlaceholderText("https://upstream-agent.example.com"),
+ "https://upstream.example.com",
+ );
+ await user.click(screen.getByRole("button", { name: /discover/i }));
+
+ expect(
+ await screen.findByText(/No access token available/i),
+ ).toBeInTheDocument();
+ expect(mockDiscover).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/agents/agent_card_discovery.tsx b/ui/litellm-dashboard/src/components/agents/agent_card_discovery.tsx
new file mode 100644
index 00000000000..ee34450d092
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/agents/agent_card_discovery.tsx
@@ -0,0 +1,511 @@
+"use client";
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ Alert,
+ Button,
+ Checkbox,
+ Collapse,
+ Empty,
+ Input,
+ Space,
+ Spin,
+ Switch,
+ Tag,
+ Tooltip,
+ Typography,
+} from "antd";
+// Empty is used in the skills panel below.
+import {
+ CheckCircleTwoTone,
+ InfoCircleOutlined,
+ LinkOutlined,
+ ReloadOutlined,
+ SearchOutlined,
+} from "@ant-design/icons";
+
+import {
+ DiscoveredAgentCard,
+ discoverAgentCardCall,
+} from "../networking";
+import {
+ ALLOWED_CAPABILITY_KEYS,
+ selectionsFromSavedAgentCard,
+ selectionsFromUpstreamCard,
+ skillId,
+} from "./agent_discovery_utils";
+
+const { Text, Paragraph } = Typography;
+const { Panel } = Collapse;
+
+export interface DiscoveredAgentCardSelection {
+ /** Full upstream card the proxy fetched, unmodified. */
+ raw_card: DiscoveredAgentCard;
+ /** Subset of the upstream card with only the user-selected skills and
+ * capabilities, plus the user-edited name/description. Suitable to send as
+ * ``agent_card_params`` on ``POST /v1/agents``. */
+ selected_card: DiscoveredAgentCard;
+ /** The base URL the user pasted in. */
+ upstream_url: string;
+}
+
+export type { DiscoveryRequestPlan } from "./agent_discovery_utils";
+import type { DiscoveryRequestPlan } from "./agent_discovery_utils";
+
+interface AgentCardDiscoveryProps {
+ accessToken: string | null;
+ /** Called whenever the upstream card or the user's selections change. Pass
+ * ``null`` when discovery is cleared or fails so the parent can reset. */
+ onApply: (selection: DiscoveredAgentCardSelection | null) => void;
+ /**
+ * Parent-supplied discovery plan. When provided the component uses these
+ * values verbatim and hides its free-form URL input — the parent is the
+ * source of truth (e.g. for LangGraph it's derived from api_base +
+ * assistant_id). When omitted the component falls back to a manual URL
+ * input that defaults to ``well_known_fallback`` mode.
+ */
+ discoveryRequest?: DiscoveryRequestPlan;
+ /** When editing an existing agent, the card stored in the DB. Upstream
+ * discovery lists everything available; only skills/capabilities present
+ * here are pre-selected. */
+ savedAgentCard?: DiscoveredAgentCard | null;
+}
+
+const AgentCardDiscovery: React.FC = ({
+ accessToken,
+ onApply,
+ discoveryRequest,
+ savedAgentCard,
+}) => {
+ // When the parent drives discovery, ``manualUrl`` is unused — the URL
+ // comes from ``discoveryRequest.url`` directly. When the parent hasn't
+ // supplied a plan, the admin types into this field manually.
+ const [manualUrl, setManualUrl] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [card, setCard] = useState(null);
+
+ const isParentDriven = discoveryRequest !== undefined;
+ const effectiveUrl = isParentDriven ? discoveryRequest!.url : manualUrl;
+
+ const [editedName, setEditedName] = useState("");
+ const [editedDescription, setEditedDescription] = useState("");
+ const [selectedSkillIds, setSelectedSkillIds] = useState>(new Set());
+ const [selectedCapabilities, setSelectedCapabilities] = useState<
+ Record
+ >({});
+
+ const onApplyRef = useRef(onApply);
+ onApplyRef.current = onApply;
+ const discoverRequestIdRef = useRef(0);
+ const lastSyncedSelectionRef = useRef(null);
+ // Hold the latest ``discoveryRequest`` in a ref so ``handleDiscover`` can
+ // read its ``discovery_mode``/``params`` without depending on the object
+ // identity itself — the parent recreates the object on every form keystroke
+ // even when the underlying values are unchanged. We use stable primitive
+ // keys (``discoveryMode`` + ``discoveryParamsKey``) as the actual deps so
+ // the callback / effect only re-run when content actually changes.
+ const discoveryRequestRef = useRef(discoveryRequest);
+ discoveryRequestRef.current = discoveryRequest;
+ // Hold ``savedAgentCard`` in a ref so ``resetSelections`` always sees the
+ // latest value without making it a dependency of ``handleDiscover``.
+ // Putting ``savedAgentCard`` directly in the callback deps means any parent
+ // re-render that hands us a new object reference (e.g. a background
+ // agent-data refresh during editing) recreates ``handleDiscover``, which
+ // re-fires the auto-discover effect and overwrites in-progress user edits.
+ const savedAgentCardRef = useRef(savedAgentCard);
+ savedAgentCardRef.current = savedAgentCard;
+
+ const resetSelections = (fresh: DiscoveredAgentCard) => {
+ const saved = savedAgentCardRef.current;
+ const initial = saved
+ ? selectionsFromSavedAgentCard(fresh, saved)
+ : selectionsFromUpstreamCard(fresh);
+ setEditedName(initial.editedName);
+ setEditedDescription(initial.editedDescription);
+ setSelectedSkillIds(initial.selectedSkillIds);
+ setSelectedCapabilities(initial.selectedCapabilities);
+ };
+
+ const discoveryMode = discoveryRequest?.discovery_mode;
+ const discoveryParamsKey = useMemo(
+ () => JSON.stringify(discoveryRequest?.params ?? null),
+ [discoveryRequest?.params],
+ );
+
+ const handleDiscover = useCallback(async () => {
+ if (!accessToken) {
+ setError("No access token available");
+ onApplyRef.current(null);
+ return;
+ }
+ const trimmed = effectiveUrl.trim();
+ if (!trimmed) {
+ setError(
+ isParentDriven
+ ? "Fill in the agent's connection details above first"
+ : "Enter the agent's base URL first",
+ );
+ setCard(null);
+ onApplyRef.current(null);
+ return;
+ }
+
+ const currentDiscoveryRequest = discoveryRequestRef.current;
+ const requestId = ++discoverRequestIdRef.current;
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await discoverAgentCardCall(
+ accessToken,
+ trimmed,
+ isParentDriven && currentDiscoveryRequest
+ ? {
+ discovery_mode: currentDiscoveryRequest.discovery_mode,
+ params: currentDiscoveryRequest.params,
+ }
+ : undefined,
+ );
+ if (requestId !== discoverRequestIdRef.current) return;
+ lastSyncedSelectionRef.current = null;
+ setCard(response.agent_card);
+ resetSelections(response.agent_card);
+ } catch (e: any) {
+ if (requestId !== discoverRequestIdRef.current) return;
+ setError(e?.message ? String(e.message) : "Failed to discover agent card");
+ setCard(null);
+ lastSyncedSelectionRef.current = null;
+ onApplyRef.current(null);
+ } finally {
+ if (requestId === discoverRequestIdRef.current) {
+ setLoading(false);
+ }
+ }
+ // ``discoveryMode`` / ``discoveryParamsKey`` are primitive proxies for
+ // ``discoveryRequest`` content; the actual object is read via the ref
+ // above so identity churn from the parent doesn't recreate this callback.
+ // ``savedAgentCard`` is intentionally NOT a dep — it's read via
+ // ``savedAgentCardRef`` inside ``resetSelections``. Including it here
+ // would recreate this callback whenever the parent hands us a new
+ // ``savedAgentCard`` object (e.g. a background refresh of agent data
+ // during editing), which would re-fire the auto-discover effect and
+ // wipe in-progress user selections.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ accessToken,
+ effectiveUrl,
+ isParentDriven,
+ discoveryMode,
+ discoveryParamsKey,
+ ]);
+
+ // Auto-discover when the URL (or parent plan) becomes available. Debounce
+ // is applied uniformly so rapid changes from a watched parent form (e.g.
+ // typing into a LangGraph api_base / assistant_id field) don't fire one
+ // HTTP request per keystroke.
+ useEffect(() => {
+ if (!accessToken) return;
+ const trimmed = effectiveUrl.trim();
+ if (!trimmed) {
+ setCard(null);
+ setError(null);
+ lastSyncedSelectionRef.current = null;
+ onApplyRef.current(null);
+ return;
+ }
+
+ const timer = window.setTimeout(() => {
+ void handleDiscover();
+ }, 400);
+ return () => window.clearTimeout(timer);
+ }, [accessToken, effectiveUrl, handleDiscover]);
+
+ const toggleSkill = (id: string, checked: boolean) => {
+ setSelectedSkillIds((prev) => {
+ const next = new Set(prev);
+ if (checked) next.add(id);
+ else next.delete(id);
+ return next;
+ });
+ };
+
+ const buildSelection = useCallback((): DiscoveredAgentCardSelection | null => {
+ if (!card) return null;
+ const skills = card.skills ?? [];
+ const filteredSkills = skills.filter((s, i) =>
+ selectedSkillIds.has(skillId(s, i)),
+ );
+
+ const selected_card: DiscoveredAgentCard = {
+ ...card,
+ name: editedName,
+ description: editedDescription,
+ skills: filteredSkills,
+ capabilities: { ...selectedCapabilities },
+ };
+
+ return {
+ raw_card: card,
+ selected_card,
+ upstream_url: effectiveUrl.trim(),
+ };
+ }, [
+ card,
+ editedDescription,
+ editedName,
+ effectiveUrl,
+ selectedCapabilities,
+ selectedSkillIds,
+ ]);
+
+ // Keep the parent form in sync as the user edits selections — no extra
+ // "apply" click needed before hitting Next.
+ useEffect(() => {
+ if (!card) return;
+ const selection = buildSelection();
+ const serialized = JSON.stringify(selection);
+ if (lastSyncedSelectionRef.current === serialized) return;
+ lastSyncedSelectionRef.current = serialized;
+ onApplyRef.current(selection);
+ }, [buildSelection, card]);
+
+ const skillCount = card?.skills?.length ?? 0;
+ const selectedSkillCount = selectedSkillIds.size;
+
+ return (
+
+
+
+ Discover from agent URL
+
+
+
+
+ {isParentDriven ? (
+ <>
+
+ Using the connection details you entered above. We'll fetch:
+
+
+ {discoveryRequest!.display_url || effectiveUrl || (
+
+ Fill in the fields above first
+
+ )}
+