= ({
+ {classifierType === "jev" && }
{usesLlmClassifier(classifierType) && (
@@ -591,6 +601,10 @@ const ClassificationMethodConfig: React.FC = ({
/>
)}
+
+ )}
+ {usesClassifierContext(classifierType) && (
+
- (["llm", "heuristic_first", "hybrid", "capability", "llm_v2"] as const).some((type) => type === classifierType);
-
export type ClassifierFallback = "heuristic" | "default_model";
export const DEFAULT_CLASSIFIER_FALLBACK: ClassifierFallback = "heuristic";
@@ -200,7 +186,7 @@ export const heuristicScoringRole = (value: ComplexityRouterConfigValue): Heuris
// Derived, never written into the value, so undoing a tier edit reverts the form with nothing left behind.
export const effectiveClassifierType = (
value: Pick,
-): ClassifierType => (value.custom_tier_set ? "llm" : value.classifier_type);
+): ClassifierType => (value.custom_tier_set && value.classifier_type !== "jev" ? "llm" : value.classifier_type);
const rowOrigin = (row: TierRow, editing: boolean): string => {
if (!editing) return row.id;
@@ -251,8 +237,8 @@ const TierSetToolbar: React.FC<{
{editing && (
- Add or remove tiers to define your own set. Every custom tier needs a definition the LLM classifier routes on,
- and an edited set requires the LLM classification method
+ Add or remove tiers to define your own set. Every custom tier needs a definition the classifier routes on, and
+ an edited set requires the LLM or JEV classification method
)}
{editing && keywordRulesError && (
@@ -271,7 +257,7 @@ const FallbackTierField: React.FC<{
Fallback Tier
-
+
@@ -377,6 +363,7 @@ export interface ComplexityRouterConfigValue {
capability_classifier_config?: CapabilitySettings;
llm_v2_config?: FuseSettings;
classifier_llm_config?: ClassifierLLMConfig;
+ jev_classifier_config?: JevClassifierConfig;
classifier_context_window_size?: number;
classifier_context_budget_chars?: number;
classifier_context_per_turn_chars?: number;
@@ -641,7 +628,11 @@ const ComplexityRouterConfig: React.FC
= ({
{!customTierSet && (
-
+
)}
{tierRows.map((row, index) => {
diff --git a/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.integration.test.tsx
new file mode 100644
index 00000000000..aae32f09959
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.integration.test.tsx
@@ -0,0 +1,158 @@
+import React, { useState } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils";
+import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import ClassificationMethodConfig from "./ClassificationMethodConfig";
+import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs";
+import JevEditor from "./JevClassifierConfig";
+import { type ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
+import {
+ buildUpdatedComplexityRouterConfig,
+ hydrateComplexityRouterConfig,
+} from "../edit_auto_router/edit_auto_router_modal";
+import { applyTierSetAction } from "./tier_set_actions";
+import { testAutoRouterRouting } from "../networking";
+import { buildSavedJevConnectionTestRequest } from "./build_auto_router_routing_test_request";
+
+vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
+ default: vi.fn(() => ({
+ isLoading: false,
+ isAuthorized: true,
+ token: "token",
+ accessToken: "token",
+ userId: "user",
+ userEmail: "user@example.com",
+ userRole: "Admin",
+ userRoleLabel: "Admin",
+ isViewOnly: false,
+ premiumUser: false,
+ disabledPersonalKeyCreation: false,
+ showSSOBanner: false,
+ })),
+}));
+
+vi.mock("@/components/networking", async (importOriginal) => ({
+ ...(await importOriginal()),
+ getComplexityScorerDefaults: vi.fn(async () => ({
+ tier_boundaries: {},
+ token_thresholds: {},
+ dimension_weights: {},
+ })),
+ testAutoRouterRouting: vi.fn(async () => ({ status: "error", error: "fixture" })),
+}));
+
+const initial: ComplexityRouterConfigValue = {
+ classifier_type: "llm",
+ classifier_llm_config: { model: "judge", timeout_ms: 1000 },
+ tiers: { SIMPLE: ["fast"], MEDIUM: ["mid"], COMPLEX: ["strong"], REASONING: ["reasoner"] },
+};
+
+function Form() {
+ const [value, setValue] = useState(initial);
+ return (
+
+ {}}
+ />
+
+ setValue(
+ applyTierSetAction(value, [], {
+ kind: "patch",
+ id: "SIMPLE",
+ patch: { name: "QUICK", definition: "Quick tasks" },
+ }).value,
+ )
+ }
+ >
+ Customize tiers
+
+
+ setValue(hydrateComplexityRouterConfig(buildUpdatedComplexityRouterConfig({}, value), undefined))
+ }
+ >
+ Save and reload
+
+ {
+ const request = buildSavedJevConnectionTestRequest(buildUpdatedComplexityRouterConfig({}, value));
+ if (request) void testAutoRouterRouting("token", request);
+ }}
+ >
+ Probe current config
+
+
+ );
+}
+
+describe("JEV classifier editor", () => {
+ afterEach(() => vi.mocked(useAuthorized).mockReset());
+ it("uses built-in JEV without a license and preserves custom tiers and context through reload", () => {
+ renderWithProviders();
+ expect(screen.getByLabelText("Classifier Model")).toBeInTheDocument();
+ expect(screen.getByText("Reasoning Effort")).toBeInTheDocument();
+ expect(screen.getByText("Classifier Prompt")).toBeInTheDocument();
+ expect(screen.getByRole("switch", { name: "Use images for classification" })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("radio", { name: /JEV Classifier/ }));
+ expect(screen.getByRole("tab", { name: "Complexity" })).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByLabelText("JEV Model")).toHaveValue("jev-latest");
+ expect(screen.getByLabelText("JEV Instructions")).toBeDisabled();
+ expect(screen.queryByLabelText("Classifier Model")).not.toBeInTheDocument();
+ expect(screen.queryByText("Reasoning Effort")).not.toBeInTheDocument();
+ expect(screen.queryByText("Classifier Prompt")).not.toBeInTheDocument();
+ expect(screen.queryByRole("switch", { name: "Use images for classification" })).not.toBeInTheDocument();
+ fireEvent.change(screen.getByLabelText("JEV Model"), { target: { value: "jev-test" } });
+ fireEvent.change(screen.getByLabelText("JEV Timeout (ms)"), { target: { value: "4200" } });
+ fireEvent.change(screen.getByLabelText("Context Window Size"), { target: { value: "6" } });
+ fireEvent.change(screen.getByLabelText("Circuit breaker cooldown (seconds)"), { target: { value: "50" } });
+ fireEvent.click(screen.getByRole("switch", { name: "Classifier circuit breaker" }));
+ fireEvent.click(screen.getByRole("button", { name: "Customize tiers" }));
+ fireEvent.click(screen.getByRole("button", { name: "Save and reload" }));
+ expect(screen.getByRole("radio", { name: /JEV Classifier/ })).toBeChecked();
+ expect(screen.getByLabelText("JEV Model")).toHaveValue("jev-test");
+ expect(screen.getByLabelText("JEV Timeout (ms)")).toHaveValue(4200);
+ expect(screen.getByLabelText("Context Window Size")).toHaveValue("6");
+ expect(screen.getByRole("switch", { name: "Classifier circuit breaker" })).not.toBeChecked();
+ fireEvent.click(screen.getByRole("button", { name: "Probe current config" }));
+ expect(testAutoRouterRouting).toHaveBeenCalledWith(
+ "token",
+ expect.objectContaining({
+ complexity_router_config: expect.objectContaining({
+ classifier_type: "jev",
+ jev_classifier_config: {
+ model: "jev-test",
+ timeout_ms: 4200,
+ circuit_breaker_enabled: false,
+ circuit_breaker_cooldown_seconds: 50,
+ },
+ tiers: expect.objectContaining({ QUICK: ["fast"] }),
+ }),
+ }),
+ );
+ });
+
+ it("allows licensed instructions and can restore built-in instructions", () => {
+ const authorized = useAuthorized();
+ vi.mocked(useAuthorized).mockReturnValue({ ...authorized, premiumUser: true });
+ const LicensedForm = () => {
+ const [value, setValue] = useState({
+ ...initial,
+ classifier_type: "jev",
+ jev_classifier_config: { model: "jev-latest", timeout_ms: 3000, instructions: "Existing instructions" },
+ });
+ return ;
+ };
+ renderWithProviders( );
+ expect(screen.getByLabelText("JEV Instructions")).toBeEnabled();
+ fireEvent.change(screen.getByLabelText("JEV Instructions"), { target: { value: "New instructions" } });
+ expect(screen.getByLabelText("JEV Instructions")).toHaveValue("New instructions");
+ fireEvent.click(screen.getByRole("button", { name: "Restore built-in JEV instructions" }));
+ expect(screen.getByLabelText("JEV Instructions")).toHaveValue("");
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.tsx b/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.tsx
new file mode 100644
index 00000000000..25286eaef07
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.tsx
@@ -0,0 +1,88 @@
+import React, { useId } from "react";
+import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { SimpleTooltip } from "@/components/ui/tooltip";
+import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig";
+import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
+import { defaultJevClassifierConfig } from "./jev_classifier_config";
+
+export default function JevClassifierConfig({
+ value,
+ onChange,
+}: {
+ value: ComplexityRouterConfigValue;
+ onChange: (value: ComplexityRouterConfigValue) => void;
+}) {
+ const id = useId();
+ const { premiumUser } = useAuthorized();
+ const config = value.jev_classifier_config ?? defaultJevClassifierConfig();
+ const update = (patch: Partial) =>
+ onChange({ ...value, jev_classifier_config: { ...config, ...patch } });
+
+ return (
+
+
+ Uses TypeSafe System One Choice evaluation with your configured tiers
+
+
+ JEV Model
+ update({ model: event.target.value })} />
+
+
+ JEV Timeout (ms)
+ update({ timeout_ms: Number(event.target.value) })}
+ />
+
+
+ update({
+ circuit_breaker_enabled: next.circuit_breaker_enabled,
+ circuit_breaker_cooldown_seconds: next.circuit_breaker_cooldown_seconds,
+ })
+ }
+ />
+
+
JEV Instructions
+
+
+
+
+ {config.instructions && (
+
update({ instructions: undefined })}>
+ Restore built-in JEV instructions
+
+ )}
+
+ Built-in JEV is available without a license and uses the shipped tier criteria
+ {!premiumUser && (
+ <>
+ . Custom instructions require LiteLLM Enterprise. Get a trial key{" "}
+
+ here
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/add_model/JevConnectionTest.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/JevConnectionTest.integration.test.tsx
new file mode 100644
index 00000000000..8f0ad88eb65
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/add_model/JevConnectionTest.integration.test.tsx
@@ -0,0 +1,148 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { fireEvent, renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
+import AutoRouterConnectionTest from "./auto_router_connection_test";
+import AutoRouterRoutingTest from "./AutoRouterRoutingTest";
+import { buildAutoRouterTestTargets } from "./build_auto_router_test_targets";
+import {
+ buildSavedJevConnectionTestRequest,
+ JEV_CONNECTION_TEST_PROMPT,
+} from "./build_auto_router_routing_test_request";
+import { buildComplexityRouterConfig } from "./build_complexity_router_config";
+
+vi.mock(
+ "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults",
+ async () => await import("../../../tests/mocks/complexityScorerDefaults"),
+);
+
+const config = buildComplexityRouterConfig({
+ classifierType: "jev",
+ jevClassifierConfig: { model: "jev-latest", timeout_ms: 3000 },
+ tiers: { SIMPLE: ["fast"], MEDIUM: ["mid"], COMPLEX: ["strong"], REASONING: ["reasoner"] },
+ defaultModel: undefined,
+ planModeMinTier: undefined,
+ tierLabels: undefined,
+ classifierLlmConfig: undefined,
+ classifierContextWindowSize: undefined,
+ classifierContextBudgetChars: undefined,
+ classifierContextIncludeAssistantTurns: undefined,
+ classifierFallback: undefined,
+ classificationPrompt: undefined,
+ classificationExamples: undefined,
+ heuristicFirstMaxTier: undefined,
+ classificationMode: undefined,
+ sessionAffinity: false,
+ deploymentAffinity: true,
+ customTechnicalKeywords: [],
+ keywordTierRules: [],
+ semanticMatchingEnabled: false,
+ embeddingModel: undefined,
+ matchThreshold: 0.5,
+ escalationKeywords: [],
+ adaptive: false,
+ adaptiveWeights: { quality: 0.3, cost: 0.7 },
+ tierDistancePenalty: 0.5,
+ adaptiveEligible: "all",
+ returnRawModelName: false,
+});
+const request = buildSavedJevConnectionTestRequest(JSON.stringify(config), "fast", "my-router");
+const targets = buildAutoRouterTestTargets({
+ tiers: Object.entries(config.tiers),
+ semanticMatchingEnabled: false,
+ embeddingModel: undefined,
+});
+const response = (cause: string) => ({
+ routed_model: "fast",
+ routed_model_configured: true,
+ routing_decision: {
+ cause,
+ tier: "SIMPLE",
+ classifier_model: "jev-latest",
+ classifier_confidence: 0.8,
+ classifier_probabilities: { SIMPLE: 0.8, REASONING: 0.2 },
+ classifier_cost: 0.00001234,
+ },
+});
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe("JEV network probes", () => {
+ it.each(["jev_classifier", "classifier_fallback", "default_model_fallback", "keyword_match"])(
+ "probes the routing endpoint independently of tier models and checks the cause %s",
+ async (cause) => {
+ const fetchMock = vi.fn(
+ async (input) =>
+ new Response(JSON.stringify(String(input).endsWith("/auto_router/test_routing") ? response(cause) : {})),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ const onTestComplete = vi.fn();
+ renderWithProviders(
+ ,
+ );
+ await waitFor(() => expect(onTestComplete).toHaveBeenCalledOnce());
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/auto_router/test_routing"),
+ expect.objectContaining({
+ method: "POST",
+ body: expect.any(String),
+ }),
+ );
+ const routingCall = fetchMock.mock.calls.find(([url]) => String(url).endsWith("/auto_router/test_routing"));
+ expect(JSON.parse(String(routingCall?.[1]?.body))).toEqual({
+ prompt: JEV_CONNECTION_TEST_PROMPT,
+ complexity_router_config: config,
+ default_model: "fast",
+ router_name: "my-router",
+ });
+ expect(fetchMock).toHaveBeenCalledTimes(5);
+ expect(screen.getAllByTestId("test-status-success")).toHaveLength(4);
+ expect(screen.getByRole("status", { name: "JEV connection" })).toHaveTextContent(
+ cause === "jev_classifier"
+ ? "JEV classification succeeded"
+ : `JEV was not reached successfully (routing cause: ${cause})`,
+ );
+ },
+ );
+
+ it("shows routing diagnostics from the real networking response", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response(JSON.stringify(response("jev_classifier")))),
+ );
+ renderWithProviders(
+ ,
+ );
+ fireEvent.change(screen.getByTestId("auto-router-routing-test-prompt"), { target: { value: "Hello" } });
+ fireEvent.click(screen.getByTestId("auto-router-routing-test-send"));
+ expect(await screen.findByText("JEV classifier")).toBeInTheDocument();
+ expect(screen.getByText("jev-latest")).toBeInTheDocument();
+ expect(screen.getByText("80.0%")).toBeInTheDocument();
+ expect(screen.getByText("SIMPLE: 80.0%")).toBeInTheDocument();
+ expect(screen.getByText("REASONING: 20.0%")).toBeInTheDocument();
+ expect(screen.getByText("$0.00001234")).toBeInTheDocument();
+ });
+
+ it("reports a classifier endpoint error while still checking downstream models", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input) =>
+ String(input).endsWith("/auto_router/test_routing")
+ ? new Response(JSON.stringify({ detail: "JEV classifier unavailable" }), { status: 503 })
+ : new Response("{}"),
+ ),
+ );
+ renderWithProviders( );
+ expect(await screen.findByText("JEV classifier unavailable")).toBeInTheDocument();
+ expect(screen.getAllByTestId("test-status-success")).toHaveLength(4);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/add_model/NonReasoningTierToggle.tsx b/ui/litellm-dashboard/src/components/add_model/NonReasoningTierToggle.tsx
index 5ca0d5517af..c373d360ba1 100644
--- a/ui/litellm-dashboard/src/components/add_model/NonReasoningTierToggle.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/NonReasoningTierToggle.tsx
@@ -39,7 +39,7 @@ const NonReasoningTierToggle: React.FC<{
Adds NON_REASONING below Simple, for operational agent traffic that relays or reformats information rather than
reasoning about it. Escalation still moves up out of it when a request needs more.
- {!available && " Requires the LLM classification method."}
+ {!available && " Requires the LLM or JEV classification method"}
>
diff --git a/ui/litellm-dashboard/src/components/add_model/TierConfigIntro.tsx b/ui/litellm-dashboard/src/components/add_model/TierConfigIntro.tsx
index 4b14307dda5..7d6e0d997d1 100644
--- a/ui/litellm-dashboard/src/components/add_model/TierConfigIntro.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/TierConfigIntro.tsx
@@ -4,6 +4,9 @@ import { type ComplexityRouterConfigValue, heuristicScoringRole, usesLlmClassifi
import { restrictedBy } from "./TierRestrictions";
const tierConfigIntroText = (value: ComplexityRouterConfigValue): string => {
+ if (value.classifier_type === "jev") {
+ return "JEV classifies each request with TypeSafe System One Choice evaluation and routes it to a tier. Configure which models handle each tier";
+ }
if (value.classifier_type === "heuristic_v2") {
return "The complexity router classifies each request with a calibrated local four-tier model (no API calls). Configure which model(s) handle each tier.";
}
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
index 126d9ba2311..8a4f6e4eac9 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
@@ -57,7 +57,11 @@ import {
import { activeTierName, activeTierRows, getCustomTierRowsError, resolveComplexityDefaultModel } from "./tier_rows";
import { tierRowLabel } from "./complexity_router_tiers";
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
-import AutoRouterConnectionTest from "./auto_router_connection_test";
+import { AutoRouterConnectionTestDialog } from "./auto_router_connection_test";
+import {
+ buildAutoRouterRoutingTestRequest,
+ JEV_CONNECTION_TEST_PROMPT,
+} from "./build_auto_router_routing_test_request";
import AutoRouterRoutingTest from "./AutoRouterRoutingTest";
import { toast } from "@/lib/toast";
import {
@@ -405,6 +409,7 @@ const AddAutoRouterTab: React.FC = ({
classificationMode: complexityRouterConfig.classification_mode,
tierLabels: complexityRouterConfig.tier_labels,
classifierType: complexityRouterConfig.classifier_type,
+ jevClassifierConfig: complexityRouterConfig.jev_classifier_config,
capabilityClassifierConfig: complexityRouterConfig.capability_classifier_config,
llmV2Config: complexityRouterConfig.llm_v2_config,
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
@@ -839,41 +844,31 @@ const AddAutoRouterTab: React.FC = ({
- {
- if (!open) {
- setIsTestModalVisible(false);
- setIsTestingConnection(false);
- }
+ onClose={() => {
+ setIsTestModalVisible(false);
+ setIsTestingConnection(false);
}}
- >
-
-
- Connection Test Results
-
- {isTestModalVisible && (
- setIsTestingConnection(false)}
- />
- )}
-
- {" "}
- {
- setIsTestModalVisible(false);
- setIsTestingConnection(false);
- }}
- >
- Close
-
-
-
-
+ testId={connectionTestId}
+ accessToken={accessToken}
+ targets={testTargets}
+ jevRequest={
+ effectiveClassifierType(complexityRouterConfig) === "jev"
+ ? buildAutoRouterRoutingTestRequest({
+ prompt: JEV_CONNECTION_TEST_PROMPT,
+ config: buildComplexityRouterConfig(complexityRouterConfigParams),
+ defaultModel: resolveComplexityDefaultModel(
+ complexityRouterConfig,
+ complexityRouterConfig.default_model,
+ ),
+ routerName: watchedName,
+ teamId: requiresTeamScope ? watchedTeamId ?? undefined : undefined,
+ })
+ : undefined
+ }
+ onTestComplete={() => setIsTestingConnection(false)}
+ />
);
};
diff --git a/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.tsx b/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.tsx
index 6ff9b8c8f83..83ce3d30f0e 100644
--- a/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.tsx
@@ -1,12 +1,20 @@
import React from "react";
import { CircleCheck, CircleX, LoaderCircle } from "lucide-react";
-import { testModelGroupConnection, ModelGroupConnectionResult } from "../networking";
+import {
+ testModelGroupConnection,
+ ModelGroupConnectionResult,
+ testAutoRouterRouting,
+ AutoRouterRoutingTestRequest,
+} from "../networking";
import { AutoRouterTestTarget } from "./build_auto_router_test_targets";
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
interface AutoRouterConnectionTestProps {
accessToken: string;
targets: AutoRouterTestTarget[];
+ jevRequest?: AutoRouterRoutingTestRequest;
onTestComplete?: () => void;
}
@@ -20,15 +28,36 @@ const cleanErrorMessage = (error: string): string => {
const AutoRouterConnectionTest: React.FC = ({
accessToken,
targets,
+ jevRequest,
onTestComplete,
}) => {
const [results, setResults] = React.useState(() => targets.map(() => ({ status: "pending" })));
+ const [jevResult, setJevResult] = React.useState({ status: "pending" });
React.useEffect(() => {
let cancelled = false;
+ const probeJev = async () => {
+ if (!jevRequest) return;
+ const response = await testAutoRouterRouting(accessToken, jevRequest);
+ if (cancelled) return;
+ if (response.status === "error") {
+ setJevResult(response);
+ return;
+ }
+ const decision = response.result.routing_decision;
+ setJevResult(
+ decision.cause === "jev_classifier"
+ ? { status: "success" }
+ : {
+ status: "error",
+ error: `JEV was not reached successfully (routing cause: ${decision.cause ?? "unknown"})`,
+ },
+ );
+ };
const run = async () => {
- await Promise.all(
- targets.map(async (target, index) => {
+ await Promise.all([
+ probeJev(),
+ ...targets.map(async (target, index) => {
const result = target.requestParams
? await testModelGroupConnection(accessToken, target.modelGroup, target.mode, target.requestParams)
: await testModelGroupConnection(accessToken, target.modelGroup, target.mode);
@@ -37,7 +66,7 @@ const AutoRouterConnectionTest: React.FC = ({
result.status === "error" ? { status: "error", error: cleanErrorMessage(result.error) } : result;
setResults((prev) => prev.map((r, i) => (i === index ? cleaned : r)));
}),
- );
+ ]);
if (!cancelled && onTestComplete) onTestComplete();
};
run();
@@ -47,7 +76,7 @@ const AutoRouterConnectionTest: React.FC = ({
// eslint-disable-next-line react-hooks/exhaustive-deps -- probes run once per mount; the parent remounts via `key` to start a fresh test, and re-running on prop identity changes would refire paid requests
}, []);
- if (targets.length === 0) {
+ if (targets.length === 0 && !jevRequest) {
return (
No complexity tiers are configured yet, so there is nothing to test.
@@ -61,6 +90,16 @@ const AutoRouterConnectionTest: React.FC = ({
Test Connection sends a minimal request to every configured tier, classifier, default, and embedding model. The
classifier probe includes its reasoning effort override.
+ {jevRequest && (
+
+
JEV Classifier
+
+ {jevResult.status === "pending" && "Testing JEV classification"}
+ {jevResult.status === "success" && "JEV classification succeeded"}
+ {jevResult.status === "error" && jevResult.error}
+
+
+ )}
{targets.map((target, index) => {
const result = results[index] ?? { status: "pending" };
return (
@@ -100,3 +139,26 @@ const AutoRouterConnectionTest: React.FC = ({
};
export default AutoRouterConnectionTest;
+
+export function AutoRouterConnectionTestDialog({
+ open,
+ onClose,
+ testId,
+ ...props
+}: AutoRouterConnectionTestProps & { open: boolean; onClose: () => void; testId: number }) {
+ return (
+ !next && onClose()}>
+
+
+ Connection Test Results
+
+ {open && }
+
+
+ Close
+
+
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.test.ts b/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.test.ts
index 6678a3585c0..2aa02e40b5f 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.test.ts
@@ -1,4 +1,9 @@
-import { buildAutoRouterRoutingTestRequest } from "./build_auto_router_routing_test_request";
+import { describe, expect, it } from "vitest";
+import {
+ buildAutoRouterRoutingTestRequest,
+ buildSavedJevConnectionTestRequest,
+ JEV_CONNECTION_TEST_PROMPT,
+} from "./build_auto_router_routing_test_request";
import { ComplexityRouterConfigPayload } from "./build_complexity_router_config";
const CONFIG = {
@@ -15,6 +20,36 @@ const params = {
};
describe("buildAutoRouterRoutingTestRequest", () => {
+ it.each(["object", "json"])("probes saved JEV %s configuration with custom tiers and team context", (format) => {
+ const config = {
+ classifier_type: "jev",
+ jev_classifier_config: { model: "jev-test", timeout_ms: 900 },
+ tiers: { QUICK: ["fast"], DEEP: ["strong"] },
+ tier_definitions: { QUICK: "Simple questions", DEEP: "Complex questions" },
+ fallback_tier: "DEEP",
+ classifier_context_window_size: 4,
+ };
+ expect(
+ buildSavedJevConnectionTestRequest(
+ format === "json" ? JSON.stringify(config) : config,
+ "strong",
+ "saved-router",
+ "team-1",
+ ),
+ ).toEqual({
+ prompt: JEV_CONNECTION_TEST_PROMPT,
+ complexity_router_config: config,
+ default_model: "strong",
+ router_name: "saved-router",
+ team_id: "team-1",
+ });
+ });
+ it.each([undefined, null, "not json", "[]", {}, { classifier_type: "llm", tiers: {} }, { classifier_type: "jev" }])(
+ "does not build a JEV probe for invalid or other classifier configurations: %j",
+ (config) => {
+ expect(buildSavedJevConnectionTestRequest(config)).toBeUndefined();
+ },
+ );
it("sends the prompt with the config being edited", () => {
const request = buildAutoRouterRoutingTestRequest(params);
diff --git a/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.ts b/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.ts
index 219dcbf6070..022bd8ad539 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.ts
@@ -1,5 +1,38 @@
import { AutoRouterRoutingTestRequest } from "../networking";
import { ComplexityRouterConfigPayload } from "./build_complexity_router_config";
+import { z } from "zod";
+
+export const JEV_CONNECTION_TEST_PROMPT = "What is 2 plus 2?";
+
+export const buildSavedJevConnectionTestRequest = (
+ rawConfig: unknown,
+ defaultModel?: string,
+ routerName?: string,
+ teamId?: string,
+): AutoRouterRoutingTestRequest | undefined => {
+ const parsed: unknown =
+ typeof rawConfig === "string"
+ ? (() => {
+ try {
+ return JSON.parse(rawConfig) as unknown;
+ } catch {
+ return undefined;
+ }
+ })()
+ : rawConfig;
+ const result = z
+ .object({ classifier_type: z.literal("jev"), tiers: z.record(z.unknown()) })
+ .passthrough()
+ .safeParse(parsed);
+ if (!result.success) return undefined;
+ return {
+ prompt: JEV_CONNECTION_TEST_PROMPT,
+ complexity_router_config: result.data,
+ ...(defaultModel && { default_model: defaultModel }),
+ ...(routerName && { router_name: routerName }),
+ ...(teamId && { team_id: teamId }),
+ };
+};
export interface BuildAutoRouterRoutingTestRequestParams {
prompt: string;
diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
index 6e6e7a3c6cd..e03ec22b79f 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
@@ -1,3 +1,4 @@
+import { describe, expect, it } from "vitest";
import {
buildComplexityRouterConfig,
getPlanModeTierError,
@@ -24,6 +25,11 @@ const tiers = {
const baseParams: BuildComplexityRouterConfigParams = {
tiers,
+ defaultModel: undefined,
+ planModeMinTier: undefined,
+ classificationExamples: undefined,
+ heuristicFirstMaxTier: undefined,
+ classificationMode: undefined,
tierLabels: undefined,
classifierType: "heuristic",
classifierLlmConfig: undefined,
@@ -48,6 +54,94 @@ const baseParams: BuildComplexityRouterConfigParams = {
};
describe("buildComplexityRouterConfig", () => {
+ it("accepts built-in JEV defaults without an LLM classifier model", () => {
+ expect(getClassifierModelError({ classifier_type: "jev" })).toBeNull();
+ });
+
+ it.each([
+ { model: "" },
+ { model: " " },
+ { timeout_ms: 0 },
+ { timeout_ms: 1.5 },
+ { timeout_ms: Number.NaN },
+ { circuit_breaker_cooldown_seconds: -1 },
+ { circuit_breaker_cooldown_seconds: Number.POSITIVE_INFINITY },
+ ])("rejects invalid JEV settings before saving or testing: %j", (patch) => {
+ expect(
+ getClassifierModelError({
+ classifier_type: "jev",
+ jev_classifier_config: { model: "jev-latest", timeout_ms: 3000, ...patch },
+ }),
+ ).toBe("Enter a JEV model, a positive whole-number timeout and a positive cooldown");
+ });
+
+ it.each([false, true])("serializes JEV with shared context and no LLM config, custom tiers: %s", (custom) => {
+ const config = buildComplexityRouterConfig({
+ ...baseParams,
+ classifierType: "jev",
+ jevClassifierConfig: {
+ model: "jev-test",
+ timeout_ms: 4500,
+ instructions: " Choose the configured tier ",
+ circuit_breaker_enabled: false,
+ circuit_breaker_cooldown_seconds: 12.5,
+ },
+ classifierLlmConfig: { model: "stale", timeout_ms: 30 },
+ classificationPrompt: "stale prompt",
+ classificationExamples: "stale examples",
+ classifierContextWindowSize: 4,
+ classifierContextBudgetChars: 2000,
+ classifierContextIncludeAssistantTurns: true,
+ classifierFallback: "default_model",
+ ...(custom && {
+ customTierSet: {
+ tiers: [
+ { id: "quick", name: "QUICK", definition: "Short answers", models: ["fast"] },
+ { id: "review", name: "REVIEW", definition: "Deep review", models: ["strong"] },
+ ],
+ fallback_tier_id: "quick",
+ },
+ }),
+ });
+ expect(config.classifier_type).toBe("jev");
+ expect(config.jev_classifier_config).toEqual({
+ model: "jev-test",
+ timeout_ms: 4500,
+ instructions: "Choose the configured tier",
+ circuit_breaker_enabled: false,
+ circuit_breaker_cooldown_seconds: 12.5,
+ });
+ expect(config.classifier_context_window_size).toBe(4);
+ expect(config.classifier_context_budget_chars).toBe(2000);
+ expect(config.classifier_context_include_assistant_turns).toBe(true);
+ expect(config).not.toHaveProperty("classifier_llm_config");
+ expect(config).not.toHaveProperty("classification_prompt");
+ expect(config).not.toHaveProperty("classification_examples");
+ if (custom) {
+ expect(config.tiers).toEqual({ QUICK: ["fast"], REVIEW: ["strong"] });
+ expect(config.fallback_tier).toBe("QUICK");
+ } else {
+ expect(config.classifier_fallback).toBe("default_model");
+ expect(config.tiers).toEqual(tiers);
+ }
+ });
+
+ it("omits blank JEV instructions and ignores stale JEV settings when saving LLM", () => {
+ const jev = buildComplexityRouterConfig({
+ ...baseParams,
+ classifierType: "jev",
+ jevClassifierConfig: { model: "jev-latest", timeout_ms: 3000, instructions: " " },
+ });
+ expect(jev.jev_classifier_config).toEqual({ model: "jev-latest", timeout_ms: 3000 });
+ const llm = buildComplexityRouterConfig({
+ ...baseParams,
+ classifierType: "llm",
+ classifierLlmConfig: { model: "judge", timeout_ms: 1000 },
+ jevClassifierConfig: jev.jev_classifier_config,
+ });
+ expect(llm).not.toHaveProperty("jev_classifier_config");
+ });
+
it.each(["capability", "llm_v2", "heuristic"] as const)(
"disables the removed overrides only for forecast creates: %s",
(classifierType) => {
diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
index 8a377c17ad7..0b844b8ddd5 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
@@ -6,6 +6,11 @@ import {
} from "./forecast_classifier_config";
import type { ModelGroup } from "../llm_calls/fetch_models";
import { KeywordTierRule } from "./KeywordTierRules";
+import {
+ type JevClassifierConfig,
+ jevClassifierConfigSchema,
+ normalizeJevClassifierConfig,
+} from "./jev_classifier_config";
import {
type CustomTierSet,
type TierRow,
@@ -44,6 +49,7 @@ import {
effectiveTierLabel,
heuristicScoringRoleFor,
usesLlmClassifier,
+ usesClassifierContext,
} from "./ComplexityRouterConfig";
export type ClassifierVisionConfig = { enabled?: boolean; max_images?: number };
@@ -133,7 +139,7 @@ const scorerKnobPayload = ({
};
export interface StoredComplexityRouterConfig {
- tiers?: Partial>;
+ tiers?: Record;
enable_non_reasoning_tier?: boolean;
tier_model_configs?: unknown;
default_model?: string | null;
@@ -147,6 +153,7 @@ export interface StoredComplexityRouterConfig {
capability_classifier_config?: unknown;
llm_v2_config?: unknown;
classifier_llm_config?: ClassifierLLMConfig;
+ jev_classifier_config?: unknown;
classifier_context_window_size?: unknown;
classifier_context_budget_chars?: unknown;
classifier_context_include_assistant_turns?: unknown;
@@ -185,6 +192,7 @@ export interface BuildComplexityRouterConfigParams {
capabilityClassifierConfig?: CapabilitySettings;
llmV2Config?: FuseSettings;
classifierLlmConfig: ClassifierLLMConfigWire | undefined;
+ jevClassifierConfig?: JevClassifierConfig;
classifierContextWindowSize: number | undefined;
classifierContextBudgetChars: number | undefined;
classifierContextIncludeAssistantTurns: boolean | undefined;
@@ -251,6 +259,7 @@ export interface ComplexityRouterConfigPayload {
capability_classifier_config?: CapabilitySettings;
llm_v2_config?: FuseSettings;
classifier_llm_config?: ClassifierLLMConfig;
+ jev_classifier_config?: JevClassifierConfig;
classifier_context_window_size?: number;
classifier_context_budget_chars?: number;
classifier_context_per_turn_chars?: number;
@@ -356,11 +365,16 @@ export const getKeywordTierRulesError = (
return `Keyword rule(s) ${orphaned.join(", ")} route to a tier this router no longer has`;
};
-// An edited tier set forces the LLM classifier, so the model requirement follows the EFFECTIVE type.
-// Both forms' submit gates and their submit handlers read this one answer so they cannot drift.
export const getClassifierModelError = (
- config: Pick,
+ config: Pick<
+ ComplexityRouterConfigValue,
+ "custom_tier_set" | "classifier_type" | "classifier_llm_config" | "jev_classifier_config"
+ >,
): string | null => {
+ if (effectiveClassifierType(config) === "jev") {
+ const parsed = jevClassifierConfigSchema.safeParse(config.jev_classifier_config ?? {});
+ return parsed.success ? null : "Enter a JEV model, a positive whole-number timeout and a positive cooldown";
+ }
if (!usesLlmClassifier(effectiveClassifierType(config)) || config.classifier_llm_config?.model) return null;
return config.custom_tier_set
? "Please select a classifier model: an edited tier set routes with the LLM classifier"
@@ -395,6 +409,7 @@ export const getSemanticConfigError = ({
};
interface CustomTierWireFieldInputs {
+ classifierType?: ClassifierType;
classifierLlmConfig: ClassifierLLMConfigWire | undefined;
planModeMinTierId: string | undefined;
classificationPrompt: string | undefined;
@@ -403,7 +418,13 @@ interface CustomTierWireFieldInputs {
export const customTierWireFields = (
customTierSet: CustomTierSet,
- { classifierLlmConfig, planModeMinTierId, classificationPrompt, classificationExamples }: CustomTierWireFieldInputs,
+ {
+ classifierType,
+ classifierLlmConfig,
+ planModeMinTierId,
+ classificationPrompt,
+ classificationExamples,
+ }: CustomTierWireFieldInputs,
): Partial => {
const rows = customTierSet.tiers;
const fallback = tierRowById(rows, customTierSet.fallback_tier_id);
@@ -412,27 +433,30 @@ export const customTierWireFields = (
tiers: Object.fromEntries(rows.map((row) => [activeTierName(row), row.models])),
tier_definitions: tierDefinitionsFromRows(rows),
...(fallback && { fallback_tier: activeTierName(fallback) }),
- classifier_type: "llm",
+ classifier_type: classifierType === "jev" ? "jev" : "llm",
// Rebuilt from the fields an edited tier set allows. The backend rejects system_prompt and
// classification_rubric beside tier_definitions, and both live inside this object rather than at
// the top level the omit list covers. The opening instructions ride classification_prompt below.
- ...(classifierLlmConfig && {
- classifier_llm_config: {
- model: classifierLlmConfig.model,
- timeout_ms: classifierLlmConfig.timeout_ms,
- ...(classifierLlmConfig.circuit_breaker_enabled !== undefined && {
- circuit_breaker_enabled: classifierLlmConfig.circuit_breaker_enabled,
- }),
- ...(classifierLlmConfig.circuit_breaker_cooldown_seconds !== undefined && {
- circuit_breaker_cooldown_seconds: classifierLlmConfig.circuit_breaker_cooldown_seconds,
- }),
- ...(classifierLlmConfig.reasoning_effort && { reasoning_effort: classifierLlmConfig.reasoning_effort }),
- ...(classifierLlmConfig.vision && { vision: classifierLlmConfig.vision }),
- },
- }),
+ ...(classifierType !== "jev" &&
+ classifierLlmConfig && {
+ classifier_llm_config: {
+ model: classifierLlmConfig.model,
+ timeout_ms: classifierLlmConfig.timeout_ms,
+ ...(classifierLlmConfig.circuit_breaker_enabled !== undefined && {
+ circuit_breaker_enabled: classifierLlmConfig.circuit_breaker_enabled,
+ }),
+ ...(classifierLlmConfig.circuit_breaker_cooldown_seconds !== undefined && {
+ circuit_breaker_cooldown_seconds: classifierLlmConfig.circuit_breaker_cooldown_seconds,
+ }),
+ ...(classifierLlmConfig.reasoning_effort && { reasoning_effort: classifierLlmConfig.reasoning_effort }),
+ ...(classifierLlmConfig.vision && { vision: classifierLlmConfig.vision }),
+ },
+ }),
session_affinity: false,
- ...(classificationPrompt?.trim() && { classification_prompt: classificationPrompt.trim() }),
- ...(classificationExamples?.trim() && { classification_examples: classificationExamples.trim() }),
+ ...(classifierType !== "jev" &&
+ classificationPrompt?.trim() && { classification_prompt: classificationPrompt.trim() }),
+ ...(classifierType !== "jev" &&
+ classificationExamples?.trim() && { classification_examples: classificationExamples.trim() }),
...(floor && { plan_mode_min_tier: activeTierName(floor) }),
};
};
@@ -521,7 +545,7 @@ const classifierWireFields = (
| "classifierContextIncludeAssistantTurns"
>,
): Partial => {
- const supportsFallback = usesLlmClassifier(effectiveType) && !isForecastClassifier(effectiveType);
+ const supportsFallback = usesClassifierContext(effectiveType) && !isForecastClassifier(effectiveType);
return {
...(usesLlmClassifier(effectiveType) &&
classifierLlmConfig && {
@@ -534,15 +558,15 @@ const classifierWireFields = (
heuristicFirstMaxTier?.trim() && { heuristic_first_max_tier: heuristicFirstMaxTier }),
...(effectiveType === "hybrid" &&
hybridBoundaryMargin !== undefined && { hybrid_boundary_margin: hybridBoundaryMargin }),
- ...(usesLlmClassifier(effectiveType) &&
+ ...(usesClassifierContext(effectiveType) &&
classifierContextWindowSize !== undefined && {
classifier_context_window_size: classifierContextWindowSize,
}),
- ...(usesLlmClassifier(effectiveType) &&
+ ...(usesClassifierContext(effectiveType) &&
classifierContextBudgetChars !== undefined && {
classifier_context_budget_chars: classifierContextBudgetChars,
}),
- ...(usesLlmClassifier(effectiveType) &&
+ ...(usesClassifierContext(effectiveType) &&
classifierContextIncludeAssistantTurns !== undefined && {
classifier_context_include_assistant_turns: classifierContextIncludeAssistantTurns,
}),
@@ -560,6 +584,7 @@ export const buildComplexityRouterConfig = ({
capabilityClassifierConfig,
llmV2Config,
classifierLlmConfig,
+ jevClassifierConfig,
classifierContextWindowSize,
classifierContextBudgetChars,
classifierContextIncludeAssistantTurns,
@@ -625,9 +650,7 @@ export const buildComplexityRouterConfig = ({
classifierContextBudgetChars,
classifierContextIncludeAssistantTurns,
};
- // An edited tier set forces the LLM classifier, so llm-only inputs must survive a classifier_type
- // the form never rewrote. The UI gates the same controls on this, not on the raw value.
- const effectiveType: ClassifierType = customTierSet ? "llm" : classifierType;
+ const effectiveType = effectiveClassifierType({ custom_tier_set: customTierSet, classifier_type: classifierType });
const forecast = isForecastClassifier(effectiveType);
const supportsOpeningPrompt = !customTierSet && !forecast && usesLlmClassifier(effectiveType);
@@ -640,6 +663,7 @@ export const buildComplexityRouterConfig = ({
...(planModeMinTier?.trim() && { plan_mode_min_tier: planModeMinTier }),
...(cleanedTierLabels && { tier_labels: cleanedTierLabels }),
classifier_type: classifierType,
+ ...(effectiveType === "jev" && { jev_classifier_config: normalizeJevClassifierConfig(jevClassifierConfig) }),
...classifierWireFields(effectiveType, classifierInputs),
...(effectiveType === "capability" &&
capabilityClassifierConfig && { capability_classifier_config: capabilityClassifierConfig }),
@@ -700,6 +724,7 @@ export const buildComplexityRouterConfig = ({
Object.entries(payload).filter(([key]) => !CUSTOM_TIER_STRIPPED_KEYS.includes(key)),
) as ComplexityRouterConfigPayload;
const customTierInputs: CustomTierWireFieldInputs = {
+ classifierType: effectiveType,
classifierLlmConfig,
planModeMinTierId: planModeMinTier,
classificationPrompt,
diff --git a/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts b/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts
index e3fe00d2bc8..4a0b29ecee3 100644
--- a/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
-import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
+import { effectiveClassifierType, type ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { transitionClassifierType } from "./classifier_type_transition";
+import { applyTierSetAction } from "./tier_set_actions";
const standard: ComplexityRouterConfigValue = {
classifier_type: "llm",
@@ -13,6 +14,44 @@ const standard: ComplexityRouterConfigValue = {
};
describe("transitionClassifierType", () => {
+ it("switches between LLM and JEV without losing shared routing settings or leaking opposite config", () => {
+ const initial = {
+ ...standard,
+ classification_prompt: "LLM only",
+ classification_examples: "LLM examples",
+ enable_non_reasoning_tier: true,
+ tiers: { ...standard.tiers, NON_REASONING: ["fast"] },
+ plan_mode_min_tier: "NON_REASONING",
+ adaptive: true,
+ };
+ const jev = transitionClassifierType(initial, "jev");
+ expect(jev).toMatchObject({
+ classifier_type: "jev",
+ jev_classifier_config: { model: "jev-latest", timeout_ms: 3000 },
+ classifier_context_window_size: 8,
+ classifier_context_budget_chars: 16000,
+ classifier_context_include_assistant_turns: true,
+ classifier_fallback: "default_model",
+ adaptive: true,
+ enable_non_reasoning_tier: true,
+ plan_mode_min_tier: "NON_REASONING",
+ tiers: initial.tiers,
+ });
+ expect(jev.classifier_llm_config).toBeUndefined();
+ expect(jev.classification_prompt).toBeUndefined();
+ expect(jev.classification_examples).toBeUndefined();
+ const custom = applyTierSetAction(jev, [], { kind: "patch", id: "SIMPLE", patch: { name: "QUICK" } }).value;
+ expect(effectiveClassifierType(custom)).toBe("jev");
+ const restored = applyTierSetAction(custom, [], { kind: "restore" }).value;
+ expect(effectiveClassifierType(restored)).toBe("jev");
+ expect(restored.jev_classifier_config).toEqual(jev.jev_classifier_config);
+ const llm = transitionClassifierType(custom, "llm");
+ expect(llm.jev_classifier_config).toBeUndefined();
+ expect(llm.classifier_llm_config).toMatchObject({ model: "" });
+ expect(llm.custom_tier_set).toEqual(custom.custom_tier_set);
+ expect(llm.classifier_context_window_size).toBe(8);
+ });
+
it.each(["heuristic_first", "hybrid"] as const)("keeps existing LLM settings when switching to %s", (target) => {
const result = transitionClassifierType(standard, target);
const expectedSettings = {
diff --git a/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.ts b/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.ts
index df87e2854e3..ba758eac471 100644
--- a/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.ts
+++ b/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.ts
@@ -8,7 +8,9 @@ import {
DEFAULT_HYBRID_BOUNDARY_MARGIN,
NEW_CLASSIFIER_CLASSIFICATION_RUBRIC,
usesLlmClassifier,
+ usesClassifierContext,
} from "./ComplexityRouterConfig";
+import { defaultJevClassifierConfig } from "./jev_classifier_config";
import { isForecastClassifier, prepareForecastClassifier } from "./forecast_classifier_config";
import { nonReasoningTierFields } from "./nonReasoningTierFields";
@@ -22,22 +24,29 @@ export const transitionClassifierType = (
const judgeConfig = value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS };
const nextValue: ComplexityRouterConfigValue = {
...value,
+ jev_classifier_config:
+ classifierType === "jev" ? value.jev_classifier_config ?? defaultJevClassifierConfig() : undefined,
+ classification_prompt: classifierType === "jev" ? undefined : value.classification_prompt,
+ classification_examples: classifierType === "jev" ? undefined : value.classification_examples,
classifier_llm_config: usesLlmClassifier(classifierType)
? {
...judgeConfig,
...(startsLlmRubric && { classification_rubric: NEW_CLASSIFIER_CLASSIFICATION_RUBRIC }),
}
: undefined,
- classifier_context_window_size: usesLlmClassifier(classifierType)
+ classifier_context_window_size: usesClassifierContext(classifierType)
? value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE
: undefined,
- classifier_context_budget_chars: usesLlmClassifier(classifierType)
+ classifier_context_budget_chars: usesClassifierContext(classifierType)
? value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS
: undefined,
- classifier_context_include_assistant_turns: usesLlmClassifier(classifierType)
+ classifier_context_per_turn_chars: usesClassifierContext(classifierType)
+ ? value.classifier_context_per_turn_chars
+ : undefined,
+ classifier_context_include_assistant_turns: usesClassifierContext(classifierType)
? value.classifier_context_include_assistant_turns
: undefined,
- classifier_fallback: usesLlmClassifier(classifierType) ? value.classifier_fallback : undefined,
+ classifier_fallback: usesClassifierContext(classifierType) ? value.classifier_fallback : undefined,
heuristic_first_max_tier:
classifierType === "heuristic_first"
? value.heuristic_first_max_tier ?? DEFAULT_HEURISTIC_FIRST_MAX_TIER
diff --git a/ui/litellm-dashboard/src/components/add_model/classifier_types.ts b/ui/litellm-dashboard/src/components/add_model/classifier_types.ts
new file mode 100644
index 00000000000..ec88166ed2e
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/add_model/classifier_types.ts
@@ -0,0 +1,15 @@
+export type ClassifierType =
+ | "heuristic"
+ | "heuristic_v2"
+ | "llm"
+ | "jev"
+ | "heuristic_first"
+ | "hybrid"
+ | "capability"
+ | "llm_v2";
+
+export const usesLlmClassifier = (classifierType: ClassifierType): boolean =>
+ (["llm", "heuristic_first", "hybrid", "capability", "llm_v2"] as const).some((type) => type === classifierType);
+
+export const usesClassifierContext = (classifierType: ClassifierType): boolean =>
+ classifierType === "jev" || usesLlmClassifier(classifierType);
diff --git a/ui/litellm-dashboard/src/components/add_model/jev_classifier_config.ts b/ui/litellm-dashboard/src/components/add_model/jev_classifier_config.ts
new file mode 100644
index 00000000000..a1481c9c2e8
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/add_model/jev_classifier_config.ts
@@ -0,0 +1,28 @@
+import { z } from "zod";
+
+export const jevClassifierConfigSchema = z.object({
+ model: z.string().trim().min(1).default("jev-latest"),
+ timeout_ms: z.number().int().positive().default(3000),
+ instructions: z
+ .string()
+ .nullish()
+ .transform((value) => value ?? undefined),
+ circuit_breaker_enabled: z.boolean().optional(),
+ circuit_breaker_cooldown_seconds: z.number().finite().positive().optional(),
+});
+
+export type JevClassifierConfig = z.infer;
+
+export const defaultJevClassifierConfig = (): JevClassifierConfig => jevClassifierConfigSchema.parse({});
+
+export const normalizeJevClassifierConfig = (
+ config: JevClassifierConfig = defaultJevClassifierConfig(),
+): JevClassifierConfig => ({
+ model: config.model.trim(),
+ timeout_ms: config.timeout_ms,
+ ...(config.instructions?.trim() && { instructions: config.instructions.trim() }),
+ ...(config.circuit_breaker_enabled !== undefined && { circuit_breaker_enabled: config.circuit_breaker_enabled }),
+ ...(config.circuit_breaker_cooldown_seconds !== undefined && {
+ circuit_breaker_cooldown_seconds: config.circuit_breaker_cooldown_seconds,
+ }),
+});
diff --git a/ui/litellm-dashboard/src/components/add_model/nonReasoningTierFields.ts b/ui/litellm-dashboard/src/components/add_model/nonReasoningTierFields.ts
index 92a665a199c..d278518000c 100644
--- a/ui/litellm-dashboard/src/components/add_model/nonReasoningTierFields.ts
+++ b/ui/litellm-dashboard/src/components/add_model/nonReasoningTierFields.ts
@@ -12,7 +12,7 @@ export const nonReasoningTierFields = (
classifierType: ClassifierType,
value: ComplexityRouterConfigValue,
): Pick => {
- if (classifierType === "llm") {
+ if (classifierType === "llm" || classifierType === "jev") {
return {
enable_non_reasoning_tier: value.enable_non_reasoning_tier,
tiers: value.tiers,
diff --git a/ui/litellm-dashboard/src/components/add_model/tier_rows.ts b/ui/litellm-dashboard/src/components/add_model/tier_rows.ts
index b4c6b2cb81e..dff051e5674 100644
--- a/ui/litellm-dashboard/src/components/add_model/tier_rows.ts
+++ b/ui/litellm-dashboard/src/components/add_model/tier_rows.ts
@@ -145,7 +145,7 @@ export const CUSTOM_TIER_RESTRICTIONS = {
heuristicClassifier: {
omit: ["heuristic_first_max_tier", "hybrid_boundary_margin"],
reason:
- "The heuristic scorer only produces the built-in tiers, so an edited set needs the LLM classifier. " +
+ "The heuristic scorer only produces the built-in tiers, so an edited set needs the LLM or JEV classifier. " +
"Heuristic first and hybrid are out for the same reason: their local scorer decides the traffic it is sure of",
},
heuristicScoring: {
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
index 4ae6efbb12d..2a3804b0307 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
+import { transitionClassifierType } from "../add_model/classifier_type_transition";
+import { effectiveClassifierType } from "../add_model/ComplexityRouterConfig";
import {
MANAGED_COMPLEXITY_ROUTER_KEYS,
@@ -46,6 +48,62 @@ const hydratedState: KeywordMatchingState = {
};
describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
+ it("hydrates nullable JEV instructions without resetting the server configuration", () => {
+ const stored = {
+ classifier_type: "jev" as const,
+ jev_classifier_config: {
+ model: "jev-configured",
+ timeout_ms: 6100,
+ instructions: null,
+ circuit_breaker_enabled: false,
+ },
+ tiers: FORM_VALUE.tiers,
+ };
+ const saved = buildUpdatedComplexityRouterConfig(stored, hydrateComplexityRouterConfig(stored, undefined));
+ expect(saved.jev_classifier_config).toEqual({
+ model: "jev-configured",
+ timeout_ms: 6100,
+ circuit_breaker_enabled: false,
+ });
+ });
+ it.each([false, true])("round trips JEV settings and preserves unmanaged fields, custom: %s", (custom) => {
+ const stored = {
+ ...(custom ? storedCustomConfig() : STORED),
+ classifier_llm_config: { model: "stale-judge", timeout_ms: 3000 },
+ classifier_type: "jev" as const,
+ jev_classifier_config: {
+ model: "jev-test",
+ timeout_ms: 4100,
+ instructions: "Judge the request",
+ circuit_breaker_enabled: false,
+ circuit_breaker_cooldown_seconds: 10.5,
+ },
+ classifier_context_window_size: 7,
+ classifier_context_budget_chars: 9000,
+ classifier_context_include_assistant_turns: true,
+ some_future_backend_key: { nested: true },
+ };
+ const hydrated = hydrateComplexityRouterConfig(stored, undefined);
+ expect(effectiveClassifierType(hydrated)).toBe("jev");
+ expect(hydrated.classifier_llm_config).toBeUndefined();
+ expect(hydrated.jev_classifier_config).toEqual(stored.jev_classifier_config);
+ const saved = buildUpdatedComplexityRouterConfig(stored, hydrated);
+ expect(saved).toMatchObject({
+ classifier_type: "jev",
+ jev_classifier_config: stored.jev_classifier_config,
+ classifier_context_window_size: 7,
+ classifier_context_budget_chars: 9000,
+ classifier_context_include_assistant_turns: true,
+ some_future_backend_key: { nested: true },
+ });
+ expect(saved).not.toHaveProperty("classifier_llm_config");
+ const reloaded = hydrateComplexityRouterConfig(saved, undefined);
+ expect(reloaded.jev_classifier_config).toEqual(hydrated.jev_classifier_config);
+ expect(effectiveClassifierType(reloaded)).toBe("jev");
+ const llm = buildUpdatedComplexityRouterConfig(saved, transitionClassifierType(reloaded, "llm"));
+ expect(llm).not.toHaveProperty("jev_classifier_config");
+ });
+
it.each(["capability", "llm_v2", "heuristic"] as const)(
"handles enabled stored overrides when editing %s with or without keyword form state",
(classifier_type) => {
@@ -700,7 +758,12 @@ describe("managed keys survive an untouched open-and-save", () => {
// tier_definitions and fallback_tier cannot sit beside heuristic_first, which this fixture uses,
// and hybrid_boundary_margin belongs to the sibling hybrid type, so no single stored config can
// hold every managed key. Each gets its own round trip below.
- const KEYS_ANOTHER_CLASSIFIER_TYPE_OWNS = new Set(["tier_definitions", "fallback_tier", "hybrid_boundary_margin"]);
+ const KEYS_ANOTHER_CLASSIFIER_TYPE_OWNS = new Set([
+ "tier_definitions",
+ "fallback_tier",
+ "hybrid_boundary_margin",
+ "jev_classifier_config",
+ ]);
// The stall keys are rejected beside the session pinning and user-turn classification this
// fixture sets, so they get their own round trip below rather than widening this one.
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
index e25c7f07dd7..63ad5deb21c 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
@@ -1,4 +1,5 @@
import AutoRouterClassifierTabs from "../add_model/AutoRouterClassifierTabs";
+import { defaultJevClassifierConfig, jevClassifierConfigSchema } from "../add_model/jev_classifier_config";
import type { StoredComplexityRouterConfig } from "../add_model/build_complexity_router_config";
export type { StoredComplexityRouterConfig } from "../add_model/build_complexity_router_config";
import {
@@ -129,7 +130,12 @@ export const hydrateComplexityRouterConfig = (
classifier_type: parsedConfig.classifier_type || "heuristic",
capability_classifier_config: capabilitySettingsSchema.safeParse(parsedConfig.capability_classifier_config).data,
llm_v2_config: fuseSettingsSchema.safeParse(parsedConfig.llm_v2_config).data,
- classifier_llm_config: parsedConfig.classifier_llm_config,
+ classifier_llm_config: parsedConfig.classifier_type === "jev" ? undefined : parsedConfig.classifier_llm_config,
+ jev_classifier_config:
+ parsedConfig.classifier_type === "jev"
+ ? jevClassifierConfigSchema.safeParse(parsedConfig.jev_classifier_config ?? {}).data ??
+ defaultJevClassifierConfig()
+ : undefined,
classifier_context_window_size:
typeof parsedConfig.classifier_context_window_size === "number"
? parsedConfig.classifier_context_window_size
@@ -219,6 +225,7 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"capability_classifier_config",
"llm_v2_config",
"classifier_llm_config",
+ "jev_classifier_config",
"classifier_context_window_size",
"classifier_context_budget_chars",
"classifier_context_include_assistant_turns",
@@ -329,6 +336,7 @@ export const buildUpdatedComplexityRouterConfig = (
classificationMode: value.classification_mode,
tierLabels: value.tier_labels,
classifierType: value.classifier_type,
+ jevClassifierConfig: value.jev_classifier_config,
capabilityClassifierConfig: value.capability_classifier_config,
llmV2Config: value.llm_v2_config,
classifierLlmConfig: value.classifier_llm_config,
diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx
index 77c9d700c69..4e5ba81f2a4 100644
--- a/ui/litellm-dashboard/src/components/model_info_view.tsx
+++ b/ui/litellm-dashboard/src/components/model_info_view.tsx
@@ -15,6 +15,7 @@ import { copyToClipboard as utilCopyToClipboard } from "../utils/dataUtils";
import { stripMaskedSecrets } from "../utils/maskedSecretUtils";
import { truncateString } from "../utils/textUtils";
import AutoRouterConnectionTest from "./add_model/auto_router_connection_test";
+import { buildSavedJevConnectionTestRequest } from "./add_model/build_auto_router_routing_test_request";
import { AutoRouterTestTarget, buildComplexityRouterTestTargets } from "./add_model/build_auto_router_test_targets";
import {
hasAutoRouterEditor,
@@ -846,6 +847,12 @@ export default function ModelInfoView({
key={autoRouterTestId}
accessToken={accessToken}
targets={autoRouterTestTargets}
+ jevRequest={buildSavedJevConnectionTestRequest(
+ (localModelData ?? modelData)?.litellm_params?.complexity_router_config,
+ (localModelData ?? modelData)?.litellm_params?.complexity_router_default_model,
+ (localModelData ?? modelData)?.model_name,
+ (localModelData ?? modelData)?.model_info?.team_id,
+ )}
/>
)}
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
index 80b4a72649d..2358e1baf9a 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -2326,7 +2326,7 @@ export const testModelGroupConnection = async (
export interface AutoRouterRoutingTestRequest {
prompt: string;
- complexity_router_config: ComplexityRouterConfigPayload;
+ complexity_router_config: ComplexityRouterConfigPayload | Record;
default_model?: string;
router_name?: string;
team_id?: string;
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RoutingDecisionCard.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RoutingDecisionCard.test.tsx
index fd1777f802c..474b2e116b7 100644
--- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RoutingDecisionCard.test.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RoutingDecisionCard.test.tsx
@@ -103,7 +103,7 @@ describe("RoutingDecisionCard", () => {
}}
/>,
);
- expect(screen.getByText("Default model, LLM classifier failed")).toBeInTheDocument();
+ expect(screen.getByText("Default model, classifier failed")).toBeInTheDocument();
expect(screen.queryByText("Tier")).not.toBeInTheDocument();
});
@@ -120,7 +120,7 @@ describe("RoutingDecisionCard", () => {
}}
/>,
);
- expect(screen.getByText("Fallback tier, LLM classifier failed")).toBeInTheDocument();
+ expect(screen.getByText("Fallback tier, classifier failed")).toBeInTheDocument();
expect(screen.getByText("SECURITY_REVIEW")).toBeInTheDocument();
});
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RoutingDecisionCard.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RoutingDecisionCard.tsx
index cf2c71e64c6..7bbf18e16ed 100644
--- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RoutingDecisionCard.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RoutingDecisionCard.tsx
@@ -24,6 +24,9 @@ export interface RoutingDecision {
matched_keyword?: string;
escalation_keyword?: string;
classifier_model?: string;
+ classifier_confidence?: number;
+ classifier_probabilities?: Record;
+ classifier_cost?: number;
escalated?: boolean;
tier_boundaries?: RoutingDecisionTierBoundaries;
reasoning_override_min_score?: number;
@@ -97,8 +100,8 @@ const CONSTANT_CAUSE_LABELS: Record = {
quality_tier: "Quality tier mapping",
bandit: "Adaptive bandit",
default_fallback: "Default model, no route matched",
- classifier_fallback: "Fallback tier, LLM classifier failed",
- default_model_fallback: "Default model, LLM classifier failed",
+ classifier_fallback: "Fallback tier, classifier failed",
+ default_model_fallback: "Default model, classifier failed",
};
function describeCause(decision: RoutingDecision): string {
@@ -118,6 +121,8 @@ function describeCause(decision: RoutingDecision): string {
return describeReasoningOverride(tierLabel, overrideFloor);
case "llm_classifier":
return classifierModel ? `LLM classifier (${classifierModel})` : "LLM classifier";
+ case "jev_classifier":
+ return "JEV classifier";
case "literal_keyword_match":
case "keyword":
return matchedKeyword ? `Keyword match: "${matchedKeyword}"` : "Keyword match";
@@ -208,6 +213,20 @@ export function RoutingDecisionCard({
{requestType && {requestType}
}
{describeCause(decision)}
+ {decision.classifier_model && {decision.classifier_model}
}
+ {decision.classifier_confidence != null && (
+ {(decision.classifier_confidence * 100).toFixed(1)}%
+ )}
+ {decision.classifier_probabilities && (
+
+ {Object.entries(decision.classifier_probabilities).map(([name, probability]) => (
+
+ {name}: {(probability * 100).toFixed(1)}%
+
+ ))}
+
+ )}
+ {decision.classifier_cost != null && ${decision.classifier_cost.toFixed(8)}
}
{score !== undefined && (
diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
index fed11454c23..442dd974368 100644
--- a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
+++ b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
@@ -680,6 +680,32 @@ describe("autorouter_presets", () => {
});
describe("buildPresetPrefill", () => {
+ it("preserves JEV settings and drops inactive classifier settings when prefilling", () => {
+ const config = {
+ tiers: { SIMPLE: ["fast"], MEDIUM: [], COMPLEX: [], REASONING: [] },
+ classifier_type: "jev" as const,
+ classification_mode: "every_request" as const,
+ session_affinity: false,
+ deployment_affinity: true,
+ modality_routing: false,
+ modality_pin_override: false,
+ jev_classifier_config: { model: "jev-test", timeout_ms: 4000, circuit_breaker_enabled: false },
+ classifier_llm_config: { model: "stale-judge", timeout_ms: 6000 },
+ classifier_context_window_size: 6,
+ };
+ const prefill = buildPresetPrefill(config, groupsOnly(["fast"]));
+ expect(prefill.complexityRouterConfig).toMatchObject({
+ classifier_type: "jev",
+ jev_classifier_config: config.jev_classifier_config,
+ classifier_context_window_size: 6,
+ classifier_llm_config: undefined,
+ });
+ const llmConfig = { ...config, classifier_type: "llm" as const };
+ const llmPrefill = buildPresetPrefill(llmConfig, groupsOnly(["fast"]));
+ expect(llmPrefill.complexityRouterConfig.jev_classifier_config).toBeUndefined();
+ expect(llmPrefill.complexityRouterConfig.classifier_llm_config).toEqual(config.classifier_llm_config);
+ });
+
it("prefills a real bundled preset's tiers into the config", () => {
const preset = getPresetByKey("anthropic_family")!;
const prefill = buildPresetPrefill(
diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.ts
index 02096cada41..728c1e53574 100644
--- a/ui/litellm-dashboard/src/lib/autorouter_presets.ts
+++ b/ui/litellm-dashboard/src/lib/autorouter_presets.ts
@@ -284,10 +284,11 @@ export const buildPresetPrefill = (
tier_model_params: resolveParamKeys(hydrateTierModelParams(config.tiers, config.tier_model_configs)),
tier_labels: hydrateTierLabels(config.tier_labels),
classifier_type: config.classifier_type,
- classifier_llm_config: config.classifier_llm_config && {
- ...config.classifier_llm_config,
- model: resolve(config.classifier_llm_config.model),
- },
+ jev_classifier_config: config.classifier_type === "jev" ? config.jev_classifier_config : undefined,
+ classifier_llm_config:
+ config.classifier_type !== "jev" && config.classifier_llm_config
+ ? { ...config.classifier_llm_config, model: resolve(config.classifier_llm_config.model) }
+ : undefined,
classifier_context_window_size: config.classifier_context_window_size,
classifier_context_budget_chars: config.classifier_context_budget_chars,
classifier_context_per_turn_chars: config.classifier_context_per_turn_chars,
From 7c493ff3b9746fd6e2cef9fe42cb53b6c51aa556 Mon Sep 17 00:00:00 2001
From: Moe Khalil
Date: Fri, 18 Sep 2026 21:52:24 +0000
Subject: [PATCH 037/317] test(auto-router): reconcile JEV integration checks
Co-authored-by: Moe Khalil
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../test_auto_router_endpoints.py | 134 +++++++++---------
.../JevConnectionTest.integration.test.tsx | 12 +-
...d_auto_router_routing_test_request.test.ts | 15 +-
.../build_complexity_router_config.test.ts | 15 +-
.../classifier_type_transition.test.ts | 5 +-
.../add_model/jev_classifier_config.ts | 6 +-
...d_updated_complexity_router_config.test.ts | 5 +-
.../src/lib/autorouter_presets.test.ts | 5 +-
8 files changed, 104 insertions(+), 93 deletions(-)
diff --git a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
index 6cea2a946e4..5c65c2f9ba4 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
@@ -7,24 +7,24 @@ from pathlib import Path
from typing import Final
import httpx
+import litellm.llms.custom_httpx.http_handler as http_handler
+import litellm.router_strategy.complexity_router.complexity_router as complexity_module
import pytest
import respx
from fastapi import HTTPException, Request
from pydantic import ValidationError
-from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
+from litellm.proxy import proxy_server
from litellm.proxy._types import (
LitellmUserRoles,
ProxyErrorTypes,
ProxyException,
UserAPIKeyAuth,
)
-from litellm.proxy import proxy_server
from litellm.proxy.management_endpoints.auto_router_endpoints import (
preview_auto_router_routing,
)
from litellm.router import Router
-from litellm.router_strategy.complexity_router import complexity_router as complexity_module
from litellm.types.management_endpoints.auto_router_endpoints import (
AutoRouterBenchmarksResponse,
AutoRouterRoutingTestRequest,
@@ -429,70 +429,6 @@ async def test_a_key_over_its_budget_cannot_run_a_classifier_config(monkeypatch:
assert calls == []
-@pytest.mark.asyncio
-@pytest.mark.parametrize("denial", ["key", "team", "budget", None])
-async def test_jev_test_routing_authorizes_paid_evaluation_before_contacting_typesafe(
- monkeypatch: pytest.MonkeyPatch, denial: str | None
-) -> None:
- router: Final = RecordingRouter("SIMPLE")
- monkeypatch.setattr(proxy_server, "llm_router", router)
- monkeypatch.setenv("TYPESAFE_API_KEY", "test")
- monkeypatch.setenv("TYPESAFE_API_BASE", "https://typesafe.test")
- models: Final = ["cheap-model", "typesafe/jev-latest"]
- actor: Final = UserAPIKeyAuth(
- user_role=LitellmUserRoles.PROXY_ADMIN,
- api_key="sk-jev-test",
- user_id="admin",
- models=["cheap-model"] if denial == "key" else models,
- team_id="jev-test-team" if denial == "team" else None,
- team_models=["cheap-model"] if denial == "team" else models,
- max_budget=1,
- spend=1 if denial == "budget" else 0,
- )
- with respx.mock(assert_all_called=False) as http:
- handler: Final = AsyncHTTPHandler()
- handler.client = httpx.AsyncClient(transport=httpx.MockTransport(http.async_handler))
-
- def http_client(_provider: object) -> AsyncHTTPHandler:
- return handler
-
- monkeypatch.setattr(complexity_module, "get_async_httpx_client", http_client)
- evaluation: Final = http.post("https://typesafe.test/v1/systemone").mock(
- return_value=httpx.Response(
- 200,
- json={
- "answers": {
- "tier": {"type": "choice", "choice": "SIMPLE", "confidence": 1, "probabilities": {"SIMPLE": 1}}
- }
- },
- )
- )
- call: Final = preview_auto_router_routing(
- http_request=ROUTING_HTTP_REQUEST,
- data=_request("small deterministic ask", classifier_type="jev", jev_classifier_config={}),
- user_api_key_dict=actor,
- )
- if denial is not None:
- with pytest.raises(ProxyException) as exc:
- await call
- assert (
- exc.value.type
- == {
- "key": ProxyErrorTypes.key_model_access_denied,
- "team": ProxyErrorTypes.team_model_access_denied,
- "budget": ProxyErrorTypes.budget_exceeded,
- }[denial]
- )
- assert evaluation.call_count == 0
- else:
- response: Final = await call
- assert response.routing_decision["cause"] == "jev_classifier"
- assert response.routed_model == "cheap-model"
- assert evaluation.call_count == 1
- assert router.recorded_calls == []
- await handler.client.aclose()
-
-
@pytest.mark.asyncio
async def test_a_heuristic_config_does_not_need_a_budget(monkeypatch: pytest.MonkeyPatch):
import litellm.proxy.proxy_server as proxy_server
@@ -2352,6 +2288,70 @@ async def test_list_shadow_eval_jobs_collapses_legs_into_jobs_newest_first(monke
assert group_reads == []
+@pytest.mark.asyncio
+@pytest.mark.parametrize("denial", ["key", "team", "budget", None])
+async def test_jev_test_routing_authorizes_paid_evaluation_before_contacting_typesafe(
+ monkeypatch: pytest.MonkeyPatch, denial: str | None
+) -> None:
+ router: Final = RecordingRouter("SIMPLE")
+ monkeypatch.setattr(proxy_server, "llm_router", router)
+ monkeypatch.setenv("TYPESAFE_API_KEY", "test")
+ monkeypatch.setenv("TYPESAFE_API_BASE", "https://typesafe.test")
+ models: Final = ["cheap-model", "typesafe/jev-latest"]
+ actor: Final = UserAPIKeyAuth(
+ user_role=LitellmUserRoles.PROXY_ADMIN,
+ api_key="sk-jev-test",
+ user_id="admin",
+ models=["cheap-model"] if denial == "key" else models,
+ team_id="jev-test-team" if denial == "team" else None,
+ team_models=["cheap-model"] if denial == "team" else models,
+ max_budget=1,
+ spend=1 if denial == "budget" else 0,
+ )
+ with respx.mock(assert_all_called=False) as http:
+ handler: Final = http_handler.AsyncHTTPHandler()
+ handler.client = httpx.AsyncClient(transport=httpx.MockTransport(http.async_handler))
+
+ def http_client(_provider: object) -> http_handler.AsyncHTTPHandler:
+ return handler
+
+ monkeypatch.setattr(complexity_module, "get_async_httpx_client", http_client)
+ evaluation: Final = http.post("https://typesafe.test/v1/systemone").mock(
+ return_value=httpx.Response(
+ 200,
+ json={
+ "answers": {
+ "tier": {"type": "choice", "choice": "SIMPLE", "confidence": 1, "probabilities": {"SIMPLE": 1}}
+ }
+ },
+ )
+ )
+ call: Final = preview_auto_router_routing(
+ http_request=ROUTING_HTTP_REQUEST,
+ data=_request("small deterministic ask", classifier_type="jev", jev_classifier_config={}),
+ user_api_key_dict=actor,
+ )
+ if denial is not None:
+ with pytest.raises(ProxyException) as exc:
+ await call
+ assert (
+ exc.value.type
+ == {
+ "key": ProxyErrorTypes.key_model_access_denied,
+ "team": ProxyErrorTypes.team_model_access_denied,
+ "budget": ProxyErrorTypes.budget_exceeded,
+ }[denial]
+ )
+ assert evaluation.call_count == 0
+ else:
+ response: Final = await call
+ assert response.routing_decision["cause"] == "jev_classifier"
+ assert response.routed_model == "cheap-model"
+ assert evaluation.call_count == 1
+ assert router.recorded_calls == []
+ await handler.client.aclose()
+
+
@pytest.mark.asyncio
async def test_list_shadow_eval_jobs_filters_to_jobs_containing_the_key(monkeypatch: pytest.MonkeyPatch):
"""The filter matches a key anywhere in a job's key set and still returns the whole
diff --git a/ui/litellm-dashboard/src/components/add_model/JevConnectionTest.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/JevConnectionTest.integration.test.tsx
index 8f0ad88eb65..2a00e8bb45e 100644
--- a/ui/litellm-dashboard/src/components/add_model/JevConnectionTest.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/JevConnectionTest.integration.test.tsx
@@ -7,14 +7,14 @@ import {
buildSavedJevConnectionTestRequest,
JEV_CONNECTION_TEST_PROMPT,
} from "./build_auto_router_routing_test_request";
-import { buildComplexityRouterConfig } from "./build_complexity_router_config";
+import { buildComplexityRouterConfig, type BuildComplexityRouterConfigParams } from "./build_complexity_router_config";
vi.mock(
"@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults",
async () => await import("../../../tests/mocks/complexityScorerDefaults"),
);
-const config = buildComplexityRouterConfig({
+const configParams: BuildComplexityRouterConfigParams = {
classifierType: "jev",
jevClassifierConfig: { model: "jev-latest", timeout_ms: 3000 },
tiers: { SIMPLE: ["fast"], MEDIUM: ["mid"], COMPLEX: ["strong"], REASONING: ["reasoner"] },
@@ -43,7 +43,8 @@ const config = buildComplexityRouterConfig({
tierDistancePenalty: 0.5,
adaptiveEligible: "all",
returnRawModelName: false,
-});
+};
+const config = buildComplexityRouterConfig(configParams);
const request = buildSavedJevConnectionTestRequest(JSON.stringify(config), "fast", "my-router");
const targets = buildAutoRouterTestTargets({
tiers: Object.entries(config.tiers),
@@ -92,12 +93,13 @@ describe("JEV network probes", () => {
}),
);
const routingCall = fetchMock.mock.calls.find(([url]) => String(url).endsWith("/auto_router/test_routing"));
- expect(JSON.parse(String(routingCall?.[1]?.body))).toEqual({
+ const expectedRequest = {
prompt: JEV_CONNECTION_TEST_PROMPT,
complexity_router_config: config,
default_model: "fast",
router_name: "my-router",
- });
+ };
+ expect(JSON.parse(String(routingCall?.[1]?.body))).toEqual(expectedRequest);
expect(fetchMock).toHaveBeenCalledTimes(5);
expect(screen.getAllByTestId("test-status-success")).toHaveLength(4);
expect(screen.getByRole("status", { name: "JEV connection" })).toHaveTextContent(
diff --git a/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.test.ts b/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.test.ts
index 2aa02e40b5f..fba4ca47e00 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.test.ts
@@ -29,6 +29,13 @@ describe("buildAutoRouterRoutingTestRequest", () => {
fallback_tier: "DEEP",
classifier_context_window_size: 4,
};
+ const expectedRequest = {
+ prompt: JEV_CONNECTION_TEST_PROMPT,
+ complexity_router_config: config,
+ default_model: "strong",
+ router_name: "saved-router",
+ team_id: "team-1",
+ };
expect(
buildSavedJevConnectionTestRequest(
format === "json" ? JSON.stringify(config) : config,
@@ -36,13 +43,7 @@ describe("buildAutoRouterRoutingTestRequest", () => {
"saved-router",
"team-1",
),
- ).toEqual({
- prompt: JEV_CONNECTION_TEST_PROMPT,
- complexity_router_config: config,
- default_model: "strong",
- router_name: "saved-router",
- team_id: "team-1",
- });
+ ).toEqual(expectedRequest);
});
it.each([undefined, null, "not json", "[]", {}, { classifier_type: "llm", tiers: {} }, { classifier_type: "jev" }])(
"does not build a JEV probe for invalid or other classifier configurations: %j",
diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
index e03ec22b79f..88a0cebd506 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
@@ -76,7 +76,7 @@ describe("buildComplexityRouterConfig", () => {
});
it.each([false, true])("serializes JEV with shared context and no LLM config, custom tiers: %s", (custom) => {
- const config = buildComplexityRouterConfig({
+ const params: BuildComplexityRouterConfigParams = {
...baseParams,
classifierType: "jev",
jevClassifierConfig: {
@@ -102,15 +102,17 @@ describe("buildComplexityRouterConfig", () => {
fallback_tier_id: "quick",
},
}),
- });
+ };
+ const config = buildComplexityRouterConfig(params);
expect(config.classifier_type).toBe("jev");
- expect(config.jev_classifier_config).toEqual({
+ const expectedJevConfig = {
model: "jev-test",
timeout_ms: 4500,
instructions: "Choose the configured tier",
circuit_breaker_enabled: false,
circuit_breaker_cooldown_seconds: 12.5,
- });
+ };
+ expect(config.jev_classifier_config).toEqual(expectedJevConfig);
expect(config.classifier_context_window_size).toBe(4);
expect(config.classifier_context_budget_chars).toBe(2000);
expect(config.classifier_context_include_assistant_turns).toBe(true);
@@ -133,12 +135,13 @@ describe("buildComplexityRouterConfig", () => {
jevClassifierConfig: { model: "jev-latest", timeout_ms: 3000, instructions: " " },
});
expect(jev.jev_classifier_config).toEqual({ model: "jev-latest", timeout_ms: 3000 });
- const llm = buildComplexityRouterConfig({
+ const llmParams: BuildComplexityRouterConfigParams = {
...baseParams,
classifierType: "llm",
classifierLlmConfig: { model: "judge", timeout_ms: 1000 },
jevClassifierConfig: jev.jev_classifier_config,
- });
+ };
+ const llm = buildComplexityRouterConfig(llmParams);
expect(llm).not.toHaveProperty("jev_classifier_config");
});
diff --git a/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts b/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts
index 4a0b29ecee3..a26b39c2980 100644
--- a/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/classifier_type_transition.test.ts
@@ -25,7 +25,7 @@ describe("transitionClassifierType", () => {
adaptive: true,
};
const jev = transitionClassifierType(initial, "jev");
- expect(jev).toMatchObject({
+ const expectedJevConfig = {
classifier_type: "jev",
jev_classifier_config: { model: "jev-latest", timeout_ms: 3000 },
classifier_context_window_size: 8,
@@ -36,7 +36,8 @@ describe("transitionClassifierType", () => {
enable_non_reasoning_tier: true,
plan_mode_min_tier: "NON_REASONING",
tiers: initial.tiers,
- });
+ };
+ expect(jev).toMatchObject(expectedJevConfig);
expect(jev.classifier_llm_config).toBeUndefined();
expect(jev.classification_prompt).toBeUndefined();
expect(jev.classification_examples).toBeUndefined();
diff --git a/ui/litellm-dashboard/src/components/add_model/jev_classifier_config.ts b/ui/litellm-dashboard/src/components/add_model/jev_classifier_config.ts
index a1481c9c2e8..478c763351c 100644
--- a/ui/litellm-dashboard/src/components/add_model/jev_classifier_config.ts
+++ b/ui/litellm-dashboard/src/components/add_model/jev_classifier_config.ts
@@ -1,6 +1,6 @@
import { z } from "zod";
-export const jevClassifierConfigSchema = z.object({
+const jevClassifierConfigFields = {
model: z.string().trim().min(1).default("jev-latest"),
timeout_ms: z.number().int().positive().default(3000),
instructions: z
@@ -9,7 +9,9 @@ export const jevClassifierConfigSchema = z.object({
.transform((value) => value ?? undefined),
circuit_breaker_enabled: z.boolean().optional(),
circuit_breaker_cooldown_seconds: z.number().finite().positive().optional(),
-});
+};
+
+export const jevClassifierConfigSchema = z.object(jevClassifierConfigFields);
export type JevClassifierConfig = z.infer;
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
index 2a3804b0307..02387dcf759 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
@@ -88,14 +88,15 @@ describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
expect(hydrated.classifier_llm_config).toBeUndefined();
expect(hydrated.jev_classifier_config).toEqual(stored.jev_classifier_config);
const saved = buildUpdatedComplexityRouterConfig(stored, hydrated);
- expect(saved).toMatchObject({
+ const expectedSavedConfig = {
classifier_type: "jev",
jev_classifier_config: stored.jev_classifier_config,
classifier_context_window_size: 7,
classifier_context_budget_chars: 9000,
classifier_context_include_assistant_turns: true,
some_future_backend_key: { nested: true },
- });
+ };
+ expect(saved).toMatchObject(expectedSavedConfig);
expect(saved).not.toHaveProperty("classifier_llm_config");
const reloaded = hydrateComplexityRouterConfig(saved, undefined);
expect(reloaded.jev_classifier_config).toEqual(hydrated.jev_classifier_config);
diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
index 442dd974368..d9e83ab850f 100644
--- a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
+++ b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts
@@ -694,12 +694,13 @@ describe("autorouter_presets", () => {
classifier_context_window_size: 6,
};
const prefill = buildPresetPrefill(config, groupsOnly(["fast"]));
- expect(prefill.complexityRouterConfig).toMatchObject({
+ const expectedJevConfig = {
classifier_type: "jev",
jev_classifier_config: config.jev_classifier_config,
classifier_context_window_size: 6,
classifier_llm_config: undefined,
- });
+ };
+ expect(prefill.complexityRouterConfig).toMatchObject(expectedJevConfig);
const llmConfig = { ...config, classifier_type: "llm" as const };
const llmPrefill = buildPresetPrefill(llmConfig, groupsOnly(["fast"]));
expect(llmPrefill.complexityRouterConfig.jev_classifier_config).toBeUndefined();
From b9e5bb3abb0f2f0ddc06dcaf9edb63563cac2a2a Mon Sep 17 00:00:00 2001
From: Moe Khalil
Date: Fri, 18 Sep 2026 22:03:37 +0000
Subject: [PATCH 038/317] test(proxy): allow JEV dependency in budget fixtures
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../proxy/management_endpoints/test_auto_router_endpoints.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
index 36130137c64..a5c93c41a84 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
@@ -459,7 +459,7 @@ async def test_jev_test_routing_enforces_key_budget_before_provider_invocation(
user_role=LitellmUserRoles.PROXY_ADMIN,
api_key="sk-jev-budget-test",
user_id="admin",
- models=["cheap-model"],
+ models=["cheap-model", "typesafe/jev-test"],
max_budget=max_budget,
spend=spend,
)
From 8e5f43f45897fc72612aac53a690fa573ce029cd Mon Sep 17 00:00:00 2001
From: Moe Khalil
Date: Fri, 18 Sep 2026 22:09:27 +0000
Subject: [PATCH 039/317] fix(auto-router): preserve JEV accounting and context
bounds
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../complexity_router/jev_classifier.py | 2 +-
.../complexity_router/test_jev_classifier.py | 32 +++++++++++++++++
.../add_model/add_auto_router_tab.test.tsx | 36 ++++++++++++++++++-
.../add_model/add_auto_router_tab.tsx | 1 +
.../build_complexity_router_config.test.ts | 6 ++--
.../build_complexity_router_config.ts | 10 ++++++
...d_updated_complexity_router_config.test.ts | 15 ++++++++
.../edit_auto_router_modal.tsx | 5 +++
8 files changed, 103 insertions(+), 4 deletions(-)
diff --git a/litellm/router_strategy/complexity_router/jev_classifier.py b/litellm/router_strategy/complexity_router/jev_classifier.py
index ce6ffbbc3bc..11591b02461 100644
--- a/litellm/router_strategy/complexity_router/jev_classifier.py
+++ b/litellm/router_strategy/complexity_router/jev_classifier.py
@@ -100,8 +100,8 @@ class HttpJevClassifierClient:
), # pyright: ignore[reportArgumentType] # HTTP headers are not mutated by AsyncHTTPHandler
timeout=timeout_s,
)
- self._log_response(request, response, request_kwargs, start_time)
response.raise_for_status()
+ self._log_response(request, response, request_kwargs, start_time)
return TypeAdapter(JevSystemOneResponse).validate_python(response.json())
@staticmethod
diff --git a/tests/test_litellm/router_strategy/complexity_router/test_jev_classifier.py b/tests/test_litellm/router_strategy/complexity_router/test_jev_classifier.py
index 80e945ca2f2..d51690d8818 100644
--- a/tests/test_litellm/router_strategy/complexity_router/test_jev_classifier.py
+++ b/tests/test_litellm/router_strategy/complexity_router/test_jev_classifier.py
@@ -3,6 +3,7 @@ import json
from collections.abc import Mapping
from datetime import datetime
from typing import Final
+from unittest.mock import create_autospec
import httpx
import pytest
@@ -39,6 +40,37 @@ class _UsageRecorder(CustomLogger):
self.calls = (*self.calls, kwargs)
+@pytest.mark.asyncio
+@pytest.mark.parametrize("status_code", [400, 429, 500, 503])
+async def test_jev_http_errors_do_not_dispatch_successful_usage(
+ monkeypatch: pytest.MonkeyPatch, status_code: int
+) -> None:
+ recorder: Final = _UsageRecorder()
+ monkeypatch.setattr(litellm, "_async_success_callback", [recorder])
+ handler: Final = create_autospec(AsyncHTTPHandler, instance=True)
+ handler.post.return_value = httpx.Response(
+ status_code,
+ request=httpx.Request("POST", "https://typesafe.test/v1/systemone"),
+ json={
+ "model": "jev-accounting",
+ "usage": {"input_tokens": 3, "output_tokens": 2},
+ "answers": {"tier": _answer().model_dump()},
+ },
+ )
+ provider: Final = HttpJevClassifierClient("test", "https://typesafe.test", handler)
+ request: Final = build_jev_request(
+ "choose a tier", None, "jev-accounting", DEFAULT_JEV_INSTRUCTIONS, {"SIMPLE": "cheap"}
+ )
+
+ with pytest.raises(httpx.HTTPStatusError) as error:
+ await provider.evaluate(request, timeout_s=3)
+ await GLOBAL_LOGGING_WORKER.flush()
+
+ assert error.value.response.status_code == status_code
+ handler.post.assert_awaited_once()
+ assert recorder.calls == ()
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize("answer", ["SIMPLE", "UNAVAILABLE", "malformed"])
@pytest.mark.parametrize("private", [False, True])
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
index 48903d585ff..66621981ef5 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
@@ -8,7 +8,7 @@ import {
chooseSelectOption,
} from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
-import { vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import AddAutoRouterTab from "./add_auto_router_tab";
import { toast } from "@/lib/toast";
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
@@ -1535,6 +1535,40 @@ describe("getSubmitBlockedReason", () => {
describe("preset catalog fetch states", () => {
afterEach(() => vi.mocked(useAutoRouterPresets).mockReturnValue(LOADED_PRESETS_QUERY));
+ it("preserves a JEV preset's per-turn bound in the create request", async () => {
+ vi.clearAllMocks();
+ testQueryClient.clear();
+ vi.mocked(handleAddAutoRouterSubmit).mockReset();
+ mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
+ vi.mocked(useAutoRouterPresets).mockReturnValue({
+ ...LOADED_PRESETS_QUERY,
+ data: [
+ {
+ ...ANTHROPIC_PRESET,
+ key: "bounded_jev",
+ label: "Bounded JEV",
+ complexity_router_config: {
+ ...ANTHROPIC_PRESET.complexity_router_config,
+ classifier_type: "jev",
+ jev_classifier_config: { model: "jev-test", timeout_ms: 3000 },
+ classifier_context_per_turn_chars: 450,
+ },
+ },
+ ],
+ });
+ renderWithProviders( );
+ await waitForPresetEnabled("Bounded JEV");
+ await selectTemplate("Bounded JEV");
+ fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "bounded-router" } });
+ fireEvent.click(screen.getByRole("button", { name: "Add Auto Router" }));
+
+ await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalledOnce());
+ expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls[0][0].complexity_router_config).toMatchObject({
+ classifier_type: "jev",
+ classifier_context_per_turn_chars: 450,
+ });
+ });
+
it("keeps showing cached presets without the error banner when only a refetch fails", () => {
vi.mocked(useAutoRouterPresets).mockReturnValue({
...LOADED_PRESETS_QUERY,
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
index 8a4f6e4eac9..c8252408f6b 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
@@ -415,6 +415,7 @@ const AddAutoRouterTab: React.FC = ({
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
classifierContextWindowSize: complexityRouterConfig.classifier_context_window_size,
classifierContextBudgetChars: complexityRouterConfig.classifier_context_budget_chars,
+ classifierContextPerTurnChars: complexityRouterConfig.classifier_context_per_turn_chars,
classifierContextIncludeAssistantTurns: complexityRouterConfig.classifier_context_include_assistant_turns,
classifierFallback: complexityRouterConfig.classifier_fallback,
sessionAffinity: complexityRouterConfig.session_affinity ?? DEFAULT_SESSION_AFFINITY,
diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
index 88a0cebd506..9918bc5d2ac 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
@@ -91,6 +91,7 @@ describe("buildComplexityRouterConfig", () => {
classificationExamples: "stale examples",
classifierContextWindowSize: 4,
classifierContextBudgetChars: 2000,
+ classifierContextPerTurnChars: 450,
classifierContextIncludeAssistantTurns: true,
classifierFallback: "default_model",
...(custom && {
@@ -115,6 +116,7 @@ describe("buildComplexityRouterConfig", () => {
expect(config.jev_classifier_config).toEqual(expectedJevConfig);
expect(config.classifier_context_window_size).toBe(4);
expect(config.classifier_context_budget_chars).toBe(2000);
+ expect(config.classifier_context_per_turn_chars).toBe(450);
expect(config.classifier_context_include_assistant_turns).toBe(true);
expect(config).not.toHaveProperty("classifier_llm_config");
expect(config).not.toHaveProperty("classification_prompt");
@@ -876,13 +878,13 @@ describe("buildComplexityRouterConfig scorer knobs", () => {
"%s with fallback %s only emits custom dimensions when its scorer decides",
(classifierType, classifierFallback, emits) => {
const dimension = { name: "d", weight: 0.4, keywords: ["orbitmesh"] };
- const params = {
+ const uncheckedParams: unknown = {
...baseParams,
classifierType,
classifierFallback,
customDimensions: [{ id: "row", ...dimension }],
};
- const payload = buildComplexityRouterConfig(params);
+ const payload = buildComplexityRouterConfig(uncheckedParams as BuildComplexityRouterConfigParams);
if (emits) expect(payload.custom_dimensions).toEqual([dimension]);
else expect(payload).not.toHaveProperty("custom_dimensions");
},
diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
index 0b844b8ddd5..d21c5a80812 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
@@ -156,6 +156,7 @@ export interface StoredComplexityRouterConfig {
jev_classifier_config?: unknown;
classifier_context_window_size?: unknown;
classifier_context_budget_chars?: unknown;
+ classifier_context_per_turn_chars?: unknown;
classifier_context_include_assistant_turns?: unknown;
classifier_fallback?: unknown;
classification_mode?: unknown;
@@ -195,6 +196,7 @@ export interface BuildComplexityRouterConfigParams {
jevClassifierConfig?: JevClassifierConfig;
classifierContextWindowSize: number | undefined;
classifierContextBudgetChars: number | undefined;
+ classifierContextPerTurnChars?: number;
classifierContextIncludeAssistantTurns: boolean | undefined;
classifierFallback: ClassifierFallback | undefined;
classificationPrompt: string | undefined;
@@ -533,6 +535,7 @@ const classifierWireFields = (
hybridBoundaryMargin,
classifierContextWindowSize,
classifierContextBudgetChars,
+ classifierContextPerTurnChars,
classifierContextIncludeAssistantTurns,
}: Pick<
BuildComplexityRouterConfigParams,
@@ -542,6 +545,7 @@ const classifierWireFields = (
| "hybridBoundaryMargin"
| "classifierContextWindowSize"
| "classifierContextBudgetChars"
+ | "classifierContextPerTurnChars"
| "classifierContextIncludeAssistantTurns"
>,
): Partial => {
@@ -566,6 +570,10 @@ const classifierWireFields = (
classifierContextBudgetChars !== undefined && {
classifier_context_budget_chars: classifierContextBudgetChars,
}),
+ ...(usesClassifierContext(effectiveType) &&
+ classifierContextPerTurnChars !== undefined && {
+ classifier_context_per_turn_chars: classifierContextPerTurnChars,
+ }),
...(usesClassifierContext(effectiveType) &&
classifierContextIncludeAssistantTurns !== undefined && {
classifier_context_include_assistant_turns: classifierContextIncludeAssistantTurns,
@@ -587,6 +595,7 @@ export const buildComplexityRouterConfig = ({
jevClassifierConfig,
classifierContextWindowSize,
classifierContextBudgetChars,
+ classifierContextPerTurnChars,
classifierContextIncludeAssistantTurns,
classifierFallback,
classificationPrompt,
@@ -648,6 +657,7 @@ export const buildComplexityRouterConfig = ({
hybridBoundaryMargin,
classifierContextWindowSize,
classifierContextBudgetChars,
+ classifierContextPerTurnChars,
classifierContextIncludeAssistantTurns,
};
const effectiveType = effectiveClassifierType({ custom_tier_set: customTierSet, classifier_type: classifierType });
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
index 02387dcf759..6a522b9ad4c 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
@@ -80,6 +80,7 @@ describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
},
classifier_context_window_size: 7,
classifier_context_budget_chars: 9000,
+ classifier_context_per_turn_chars: 450,
classifier_context_include_assistant_turns: true,
some_future_backend_key: { nested: true },
};
@@ -87,12 +88,14 @@ describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
expect(effectiveClassifierType(hydrated)).toBe("jev");
expect(hydrated.classifier_llm_config).toBeUndefined();
expect(hydrated.jev_classifier_config).toEqual(stored.jev_classifier_config);
+ expect(hydrated.classifier_context_per_turn_chars).toBe(450);
const saved = buildUpdatedComplexityRouterConfig(stored, hydrated);
const expectedSavedConfig = {
classifier_type: "jev",
jev_classifier_config: stored.jev_classifier_config,
classifier_context_window_size: 7,
classifier_context_budget_chars: 9000,
+ classifier_context_per_turn_chars: 450,
classifier_context_include_assistant_turns: true,
some_future_backend_key: { nested: true },
};
@@ -100,6 +103,7 @@ describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
expect(saved).not.toHaveProperty("classifier_llm_config");
const reloaded = hydrateComplexityRouterConfig(saved, undefined);
expect(reloaded.jev_classifier_config).toEqual(hydrated.jev_classifier_config);
+ expect(reloaded.classifier_context_per_turn_chars).toBe(450);
expect(effectiveClassifierType(reloaded)).toBe("jev");
const llm = buildUpdatedComplexityRouterConfig(saved, transitionClassifierType(reloaded, "llm"));
expect(llm).not.toHaveProperty("jev_classifier_config");
@@ -287,6 +291,17 @@ describe("capability classifier configuration", () => {
});
describe("buildUpdatedComplexityRouterConfig classifier context window", () => {
+ it.each(["llm", "jev"] as const)("saves the form's per-turn bound over the stored %s bound", (classifier_type) => {
+ const formValue = {
+ ...hydrateComplexityRouterConfig({ ...STORED_LLM, classifier_type }, undefined),
+ classifier_context_per_turn_chars: 600,
+ };
+ const saved = buildUpdatedComplexityRouterConfig(STORED_LLM, formValue);
+
+ expect(saved.classifier_context_per_turn_chars).toBe(600);
+ expect(hydrateComplexityRouterConfig(saved, undefined).classifier_context_per_turn_chars).toBe(600);
+ });
+
it("round-trips an untouched edit without changing the classifier context values", () => {
const formValue = {
tiers: STORED_LLM.tiers,
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
index 63ad5deb21c..56a851fba8c 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
@@ -144,6 +144,10 @@ export const hydrateComplexityRouterConfig = (
typeof parsedConfig.classifier_context_budget_chars === "number"
? parsedConfig.classifier_context_budget_chars
: undefined,
+ classifier_context_per_turn_chars:
+ typeof parsedConfig.classifier_context_per_turn_chars === "number"
+ ? parsedConfig.classifier_context_per_turn_chars
+ : undefined,
classifier_context_include_assistant_turns:
typeof parsedConfig.classifier_context_include_assistant_turns === "boolean"
? parsedConfig.classifier_context_include_assistant_turns
@@ -342,6 +346,7 @@ export const buildUpdatedComplexityRouterConfig = (
classifierLlmConfig: value.classifier_llm_config,
classifierContextWindowSize: value.classifier_context_window_size,
classifierContextBudgetChars: value.classifier_context_budget_chars,
+ classifierContextPerTurnChars: value.classifier_context_per_turn_chars,
classifierContextIncludeAssistantTurns: value.classifier_context_include_assistant_turns,
classifierFallback: value.classifier_fallback,
sessionAffinity: value.session_affinity ?? DEFAULT_SESSION_AFFINITY,
From e0b2c511445783f059a00a0a08c1d068356a4cc5 Mon Sep 17 00:00:00 2001
From: Moe Khalil
Date: Fri, 18 Sep 2026 23:41:20 +0000
Subject: [PATCH 040/317] fix(auto-router): validate JEV usage and clear stale
context
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../complexity_router/jev_classifier.py | 13 ++++----
.../complexity_router/test_jev_classifier.py | 31 +++++++++++++++++++
...d_updated_complexity_router_config.test.ts | 22 +++++++++++++
.../edit_auto_router_modal.tsx | 4 +++
4 files changed, 64 insertions(+), 6 deletions(-)
diff --git a/litellm/router_strategy/complexity_router/jev_classifier.py b/litellm/router_strategy/complexity_router/jev_classifier.py
index 11591b02461..de23824a5f6 100644
--- a/litellm/router_strategy/complexity_router/jev_classifier.py
+++ b/litellm/router_strategy/complexity_router/jev_classifier.py
@@ -55,8 +55,8 @@ class JevChoiceAnswer(BaseModel):
class JevUsage(BaseModel):
model_config = ConfigDict(frozen=True)
- input_tokens: int = 0
- output_tokens: int = 0
+ input_tokens: int = Field(default=0, ge=0, strict=True)
+ output_tokens: int = Field(default=0, ge=0, strict=True)
class JevSystemOneResponse(BaseModel):
@@ -111,6 +111,11 @@ class HttpJevClassifierClient:
request_kwargs: Mapping[str, object] | None,
start_time: datetime,
) -> None:
+ try:
+ body: Final = TypeAdapter(dict[str, object]).validate_json(response.content)
+ _ = TypeAdapter(JevUsage | None).validate_python(body.get("usage"))
+ except ValidationError:
+ return
end_time: Final = datetime.now(timezone.utc)
parent: Final = request_kwargs or MappingProxyType({})
parent_metadata: Final = {
@@ -144,10 +149,6 @@ class HttpJevClassifierClient:
optional_params={},
litellm_params=params,
)
- try:
- body: Final = TypeAdapter(dict[str, object]).validate_json(response.content)
- except ValidationError:
- return
normalized: Final = TypeSafePassthroughLoggingHandler.typesafe_passthrough_handler(
httpx_response=response,
response_body=body,
diff --git a/tests/test_litellm/router_strategy/complexity_router/test_jev_classifier.py b/tests/test_litellm/router_strategy/complexity_router/test_jev_classifier.py
index d51690d8818..dae037ff47c 100644
--- a/tests/test_litellm/router_strategy/complexity_router/test_jev_classifier.py
+++ b/tests/test_litellm/router_strategy/complexity_router/test_jev_classifier.py
@@ -71,6 +71,37 @@ async def test_jev_http_errors_do_not_dispatch_successful_usage(
assert recorder.calls == ()
+@pytest.mark.asyncio
+@pytest.mark.parametrize("field", ["input_tokens", "output_tokens"])
+@pytest.mark.parametrize("tokens", [-1, True, 1.5, "3"])
+async def test_jev_invalid_usage_never_reaches_spend_callbacks(
+ monkeypatch: pytest.MonkeyPatch, field: str, tokens: object
+) -> None:
+ recorder: Final = _UsageRecorder()
+ monkeypatch.setattr(litellm, "_async_success_callback", [recorder])
+ handler: Final = create_autospec(AsyncHTTPHandler, instance=True)
+ handler.post.return_value = httpx.Response(
+ 200,
+ request=httpx.Request("POST", "https://typesafe.test/v1/systemone"),
+ json={
+ "model": "jev-accounting",
+ "usage": {"input_tokens": 3, "output_tokens": 2, field: tokens},
+ "answers": {"tier": _answer().model_dump()},
+ },
+ )
+ provider: Final = HttpJevClassifierClient("test", "https://typesafe.test", handler)
+ request: Final = build_jev_request(
+ "choose a tier", None, "jev-accounting", DEFAULT_JEV_INSTRUCTIONS, {"SIMPLE": "cheap"}
+ )
+
+ with pytest.raises(ValueError, match=field):
+ await provider.evaluate(request, timeout_s=3)
+ await GLOBAL_LOGGING_WORKER.flush()
+
+ handler.post.assert_awaited_once()
+ assert recorder.calls == ()
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize("answer", ["SIMPLE", "UNAVAILABLE", "malformed"])
@pytest.mark.parametrize("private", [False, True])
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
index 6a522b9ad4c..e5e2c61933c 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
@@ -291,6 +291,28 @@ describe("capability classifier configuration", () => {
});
describe("buildUpdatedComplexityRouterConfig classifier context window", () => {
+ it.each(["llm", "jev"] as const)(
+ "drops the stored %s per-turn bound when switching to heuristic",
+ (classifier_type) => {
+ const stored = { ...STORED_LLM, classifier_type };
+ const saved = buildUpdatedComplexityRouterConfig(stored, {
+ ...hydrateComplexityRouterConfig(stored, undefined),
+ classifier_type: "heuristic",
+ });
+
+ expect(saved).not.toHaveProperty("classifier_context_per_turn_chars");
+ },
+ );
+
+ it("does not resurrect an explicitly cleared per-turn bound", () => {
+ const saved = buildUpdatedComplexityRouterConfig(STORED_LLM, {
+ ...hydrateComplexityRouterConfig(STORED_LLM, undefined),
+ classifier_context_per_turn_chars: undefined,
+ });
+
+ expect(saved).not.toHaveProperty("classifier_context_per_turn_chars");
+ });
+
it.each(["llm", "jev"] as const)("saves the form's per-turn bound over the stored %s bound", (classifier_type) => {
const formValue = {
...hydrateComplexityRouterConfig({ ...STORED_LLM, classifier_type }, undefined),
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
index 56a851fba8c..10fa6fcb6be 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
@@ -1,4 +1,5 @@
import AutoRouterClassifierTabs from "../add_model/AutoRouterClassifierTabs";
+import { usesClassifierContext } from "../add_model/classifier_types";
import { defaultJevClassifierConfig, jevClassifierConfigSchema } from "../add_model/jev_classifier_config";
import type { StoredComplexityRouterConfig } from "../add_model/build_complexity_router_config";
export type { StoredComplexityRouterConfig } from "../add_model/build_complexity_router_config";
@@ -317,6 +318,9 @@ export const buildUpdatedComplexityRouterConfig = (
keywordMatching?: KeywordMatchingState,
): Record => {
const isManaged = (key: string): boolean => {
+ if (key === "classifier_context_per_turn_chars") {
+ return !usesClassifierContext(effectiveClassifierType(value)) || Object.prototype.hasOwnProperty.call(value, key);
+ }
if (MANAGED_COMPLEXITY_ROUTER_KEYS.has(key)) return true;
if (key === "escalation_keywords" && isForecastClassifier(effectiveClassifierType(value))) return true;
if (keywordMatching !== undefined && KEYWORD_MATCHING_KEYS.has(key)) return true;
From 4036b769a952ace55b802d317e335881a921d3b4 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 00:22:15 +0000
Subject: [PATCH 041/317] fix(proxy): count unprefixed release bullets and
coalesce concurrent latest release fetches
Unprefixed release bullets now count as other_updates, concurrent cache misses share one upstream GitHub request through an injected asyncio.Lock, and the dashboard upgrade banner is announced as status rather than alert
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../latest_release_endpoints.py | 55 +++++++-----
.../test_latest_release_endpoints.py | 89 ++++++++++++++++---
.../src/components/UpgradeBanner.test.tsx | 32 +++++--
.../src/components/UpgradeBanner.tsx | 2 +-
4 files changed, 139 insertions(+), 39 deletions(-)
diff --git a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py
index 4a01728dfe1..61124e9f27e 100644
--- a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py
+++ b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py
@@ -1,3 +1,4 @@
+import asyncio
import re
from collections import Counter
from collections.abc import Awaitable, Mapping
@@ -21,7 +22,8 @@ LATEST_RELEASE_CACHE_TTL_SECONDS: Final = 60 * 60
LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS: Final = 5 * 60
LATEST_RELEASE_CACHE_KEY: Final = "latest_release_info"
-_RELEASE_BULLET_PATTERN: Final = re.compile(r"^\*\s+([A-Za-z]+)(\([^)]*\))?!?:\s")
+_RELEASE_BULLET_PATTERN: Final = re.compile(r"^\*\s+(?:([A-Za-z]+)(?:\([^)]*\))?!?:\s)?\S")
+_NEW_CONTRIBUTOR_PATTERN: Final = re.compile(r"^\*\s+@\S+ made their first contribution\b")
_Bucket = Literal["new_features", "bug_fixes", "other_updates"]
_PREFIX_BUCKETS: Final[Mapping[str, _Bucket]] = MappingProxyType({"feat": "new_features", "fix": "bug_fixes"})
@@ -51,6 +53,7 @@ class _AsyncGetClient(Protocol):
_latest_release_cache: Final = InMemoryCache(max_size_in_memory=1, default_ttl=LATEST_RELEASE_CACHE_TTL_SECONDS)
+_latest_release_fetch_lock: Final = asyncio.Lock()
def _default_client() -> _AsyncGetClient:
@@ -64,16 +67,23 @@ def _default_cache() -> InMemoryCache:
return _latest_release_cache
+def _default_fetch_lock() -> asyncio.Lock:
+ return _latest_release_fetch_lock
+
+
+def _bucket_for(line: str) -> _Bucket | None:
+ if _NEW_CONTRIBUTOR_PATTERN.match(line) is not None:
+ return None
+ match: Final = _RELEASE_BULLET_PATTERN.match(line)
+ if match is None:
+ return None
+ prefix: Final = match.group(1)
+ return "other_updates" if prefix is None else _PREFIX_BUCKETS.get(prefix.lower(), "other_updates")
+
+
def count_release_bullets(body: str) -> Counter[_Bucket]:
- """
- Bucket a release body's ``* type(scope): title by @user in `` bullets by conventional-commit type.
- Lines without that shape (headings, "New Contributors" entries) are skipped, not counted as other.
- """
- return Counter(
- _PREFIX_BUCKETS.get(match.group(1).lower(), "other_updates")
- for line in body.splitlines()
- if (match := _RELEASE_BULLET_PATTERN.match(line)) is not None
- )
+ """Bucket release-note bullets by conventional-commit type or ``other_updates``."""
+ return Counter(bucket for line in body.splitlines() if (bucket := _bucket_for(line)) is not None)
def parse_latest_release(response: httpx.Response) -> LatestReleaseInfo | LatestReleaseUnavailable:
@@ -102,19 +112,23 @@ async def fetch_latest_release(client: _AsyncGetClient) -> LatestReleaseInfo | L
async def get_latest_release_info(
- client: _AsyncGetClient, cache: InMemoryCache
+ client: _AsyncGetClient, cache: InMemoryCache, fetch_lock: asyncio.Lock
) -> LatestReleaseInfo | LatestReleaseUnavailable:
cached: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY)
if isinstance(cached, (LatestReleaseInfo, LatestReleaseUnavailable)):
return cached
- result: Final = await fetch_latest_release(client)
- ttl: Final = (
- LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS
- if isinstance(result, LatestReleaseUnavailable)
- else LATEST_RELEASE_CACHE_TTL_SECONDS
- )
- cache.set_cache(LATEST_RELEASE_CACHE_KEY, result, ttl=ttl)
- return result
+ async with fetch_lock:
+ cached_after_lock: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY)
+ if isinstance(cached_after_lock, (LatestReleaseInfo, LatestReleaseUnavailable)):
+ return cached_after_lock
+ result: Final = await fetch_latest_release(client)
+ ttl: Final = (
+ LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS
+ if isinstance(result, LatestReleaseUnavailable)
+ else LATEST_RELEASE_CACHE_TTL_SECONDS
+ )
+ cache.set_cache(LATEST_RELEASE_CACHE_KEY, result, ttl=ttl)
+ return result
@router.get(
@@ -126,12 +140,13 @@ async def get_latest_release_info(
async def latest_release_info(
client: Annotated[_AsyncGetClient, Depends(_default_client)],
cache: Annotated[InMemoryCache, Depends(_default_cache)],
+ fetch_lock: Annotated[asyncio.Lock, Depends(_default_fetch_lock)],
) -> LatestReleaseInfo | None:
"""
Latest stable LiteLLM GitHub release with its PR count split into new features, bug fixes and other updates.
Returns null when GitHub can't be reached so the dashboard upgrade banner simply doesn't render.
"""
- result: Final = await get_latest_release_info(client=client, cache=cache)
+ result: Final = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
if isinstance(result, LatestReleaseUnavailable):
verbose_proxy_logger.warning("LiteLLM: latest release info unavailable: %s", result.reason)
return None
diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py
index 0bbcdfe317d..c966b8b7135 100644
--- a/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py
+++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py
@@ -1,3 +1,4 @@
+import asyncio
import json
import time
from typing import Final
@@ -19,6 +20,7 @@ from litellm.proxy.ui_crud_endpoints.latest_release_endpoints import (
LatestReleaseUnavailable,
_default_cache,
_default_client,
+ _default_fetch_lock,
count_release_bullets,
get_latest_release_info,
)
@@ -48,7 +50,7 @@ EXPECTED_INFO: Final = {
"version": "1.102.0",
"new_features": 2,
"bug_fixes": 2,
- "other_updates": 2,
+ "other_updates": 4,
"release_url": SAMPLE_RELEASE["html_url"],
}
@@ -81,6 +83,7 @@ def _override_dependencies(client: _RecordingClient, cache: InMemoryCache, role:
app.dependency_overrides[user_api_key_auth] = auth
app.dependency_overrides[_default_client] = lambda: client
app.dependency_overrides[_default_cache] = lambda: cache
+ app.dependency_overrides[_default_fetch_lock] = lambda: asyncio.Lock()
@pytest.fixture
@@ -89,6 +92,7 @@ def http_client():
app.dependency_overrides.pop(user_api_key_auth, None)
app.dependency_overrides.pop(_default_client, None)
app.dependency_overrides.pop(_default_cache, None)
+ app.dependency_overrides.pop(_default_fetch_lock, None)
class TestCountReleaseBullets:
@@ -96,11 +100,21 @@ class TestCountReleaseBullets:
counts = count_release_bullets(SAMPLE_BODY)
assert counts["new_features"] == 2
assert counts["bug_fixes"] == 2
- assert counts["other_updates"] == 2
+ assert counts["other_updates"] == 4
+
+ def test_unprefixed_bullets_count_as_other_updates(self):
+ counts = count_release_bullets("* Litellm dev 09 08 2026 by @f in https://x/pull/7\n")
+ assert (counts["new_features"], counts["bug_fixes"], counts["other_updates"]) == (0, 0, 1)
def test_ignores_non_bullet_lines_and_contributor_entries(self):
assert (
- sum(count_release_bullets("## What's Changed\n\n* @x made their first contribution in url\n").values()) == 0
+ sum(
+ count_release_bullets(
+ "## What's Changed\n\n* @x made their first contribution in url\n"
+ "\n**Full Changelog**: https://github.com/BerriAI/litellm/compare/v1...v2\n"
+ ).values()
+ )
+ == 0
)
def test_empty_body_yields_zero_counts(self):
@@ -112,7 +126,7 @@ class TestGetLatestReleaseInfo:
@pytest.mark.asyncio
async def test_fetches_and_parses_github_release(self):
client = _RecordingClient([_github_response()])
- result = await get_latest_release_info(client=client, cache=_fresh_cache())
+ result = await get_latest_release_info(client=client, cache=_fresh_cache(), fetch_lock=asyncio.Lock())
assert isinstance(result, LatestReleaseInfo)
assert result.model_dump() == EXPECTED_INFO
assert client.calls == [(LATEST_RELEASE_URL, 5)]
@@ -121,15 +135,18 @@ class TestGetLatestReleaseInfo:
async def test_second_call_within_ttl_does_not_refetch(self):
client = _RecordingClient([_github_response()])
cache = _fresh_cache()
- first = await get_latest_release_info(client=client, cache=cache)
- second = await get_latest_release_info(client=client, cache=cache)
+ fetch_lock = asyncio.Lock()
+ first = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
+ second = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
assert first == second
assert len(client.calls) == 1
@pytest.mark.asyncio
async def test_success_is_cached_for_the_full_ttl(self):
cache = _fresh_cache()
- await get_latest_release_info(client=_RecordingClient([_github_response()]), cache=cache)
+ await get_latest_release_info(
+ client=_RecordingClient([_github_response()]), cache=cache, fetch_lock=asyncio.Lock()
+ )
remaining = await cache.async_get_ttl(LATEST_RELEASE_CACHE_KEY) - time.time()
assert LATEST_RELEASE_CACHE_TTL_SECONDS - 5 < remaining <= LATEST_RELEASE_CACHE_TTL_SECONDS
@@ -137,8 +154,9 @@ class TestGetLatestReleaseInfo:
async def test_failure_is_cached_briefly_so_github_is_not_hammered(self):
client = _RecordingClient([httpx.ConnectError("boom")])
cache = _fresh_cache()
- first = await get_latest_release_info(client=client, cache=cache)
- second = await get_latest_release_info(client=client, cache=cache)
+ fetch_lock = asyncio.Lock()
+ first = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
+ second = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
assert isinstance(first, LatestReleaseUnavailable)
assert first == second
assert len(client.calls) == 1
@@ -159,9 +177,60 @@ class TestGetLatestReleaseInfo:
ids=["rate_limited", "server_error", "missing_fields", "not_json"],
)
async def test_bad_github_responses_are_unavailable(self, response: httpx.Response):
- result = await get_latest_release_info(client=_RecordingClient([response]), cache=_fresh_cache())
+ result = await get_latest_release_info(
+ client=_RecordingClient([response]), cache=_fresh_cache(), fetch_lock=asyncio.Lock()
+ )
assert isinstance(result, LatestReleaseUnavailable)
+ @pytest.mark.asyncio
+ async def test_concurrent_misses_share_one_fetch(self):
+ event = asyncio.Event()
+
+ class _BlockingClient(_RecordingClient):
+ async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response:
+ self.calls.append((url, timeout))
+ await event.wait()
+ return _github_response()
+
+ client = _BlockingClient([])
+ cache = _fresh_cache()
+ fetch_lock = asyncio.Lock()
+ tasks = [
+ asyncio.create_task(get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock))
+ for _ in range(5)
+ ]
+ await asyncio.sleep(0)
+ await asyncio.sleep(0)
+ event.set()
+ results = await asyncio.gather(*tasks)
+ expected: Final = LatestReleaseInfo.model_validate(EXPECTED_INFO)
+ assert results == [expected] * 5
+ assert len(client.calls) == 1
+
+ @pytest.mark.asyncio
+ async def test_failure_under_lock_is_also_coalesced(self):
+ event = asyncio.Event()
+
+ class _FailingBlockingClient(_RecordingClient):
+ async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response:
+ self.calls.append((url, timeout))
+ await event.wait()
+ raise httpx.ConnectError("boom")
+
+ client = _FailingBlockingClient([])
+ cache = _fresh_cache()
+ fetch_lock = asyncio.Lock()
+ tasks = [
+ asyncio.create_task(get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock))
+ for _ in range(5)
+ ]
+ await asyncio.sleep(0)
+ await asyncio.sleep(0)
+ event.set()
+ results = await asyncio.gather(*tasks)
+ assert all(isinstance(result, LatestReleaseUnavailable) for result in results)
+ assert len(client.calls) == 1
+
class TestLatestReleaseInfoEndpoint:
def test_returns_release_stats_for_authenticated_user(self, http_client):
diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx
index 6aa384600c8..831030fee80 100644
--- a/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx
+++ b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx
@@ -58,7 +58,7 @@ describe("UpgradeBannerView", () => {
it("shows the latest version, the stat line, and the current version when behind", () => {
render( );
- const alert = screen.getByRole("alert");
+ const alert = screen.getByRole("status");
expect(alert).toHaveTextContent("The latest version is v1.103.0: 12 new features, 30 fixes, and 8 other updates");
expect(alert).toHaveTextContent("Your current version is v1.102.0");
expect(screen.getByRole("link", { name: "v1.103.0" })).toHaveAttribute("href", RELEASE.release_url);
@@ -67,7 +67,7 @@ describe("UpgradeBannerView", () => {
it("dismissing hides the banner and keeps it hidden on remount for the same release", () => {
const { unmount } = render( );
fireEvent.click(screen.getByRole("button", { name: "Close" }));
- expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
unmount();
const { container } = render( );
@@ -80,7 +80,7 @@ describe("UpgradeBannerView", () => {
unmount();
render( );
- expect(screen.getByRole("alert")).toHaveTextContent("The latest version is v1.104.0");
+ expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0");
});
});
@@ -89,18 +89,34 @@ describe("UpgradeBanner", () => {
localStorage.clear();
});
+ afterEach(() => {
+ localStorage.clear();
+ });
+
it("feeds both hooks the access token and renders from their data", () => {
- vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { litellm_version: "1.102.0" } } as any);
- vi.mocked(useLatestReleaseInfo).mockReturnValue({ data: RELEASE } as any);
+ const healthReadinessResult = {
+ data: { litellm_version: "1.102.0" },
+ } as Partial> as ReturnType;
+ const latestReleaseResult = { data: RELEASE } as Partial> as ReturnType<
+ typeof useLatestReleaseInfo
+ >;
+ vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult);
+ vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult);
render( );
expect(useHealthReadinessDetails).toHaveBeenCalledWith("token");
expect(useLatestReleaseInfo).toHaveBeenCalledWith("token");
- expect(screen.getByRole("alert")).toHaveTextContent("The latest version is v1.103.0");
+ expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.103.0");
});
it("renders nothing when the release endpoint returns null", () => {
- vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { litellm_version: "1.102.0" } } as any);
- vi.mocked(useLatestReleaseInfo).mockReturnValue({ data: null } as any);
+ const healthReadinessResult = {
+ data: { litellm_version: "1.102.0" },
+ } as Partial> as ReturnType;
+ const latestReleaseResult = { data: null } as Partial> as ReturnType<
+ typeof useLatestReleaseInfo
+ >;
+ vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult);
+ vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult);
const { container } = render( );
expect(container).toBeEmptyDOMElement();
});
diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx
index cc57223eaff..d990c5112a4 100644
--- a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx
+++ b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx
@@ -51,7 +51,7 @@ export const UpgradeBannerView: React.FC = ({ currentVer
};
return (
-
+
The latest version is{" "}
From b32d1112a6c9af25f0b5a66eae7ba33d8e201c74 Mon Sep 17 00:00:00 2001
From: ryan
Date: Sat, 19 Sep 2026 00:31:55 +0000
Subject: [PATCH 042/317] feat(team): show whether a member follows the team
default budget and allow resetting to it
Adds budget_source (team_default, custom, none) to each membership in /team/info and a
POST /team/{team_id}/member/{user_id}/reset_budget route that relinks a member to the team's
shared team_member_budget row without touching their spend. The Admin UI team members table
shows a Team default or Custom badge next to each member's budget and offers a
Use team default action on customized members
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/proxy/_types.py | 19 +-
.../management_endpoints/team_endpoints.py | 112 ++++++++-
.../test_team_endpoints.py | 214 ++++++++++++++++++
.../hooks/teams/useResetTeamMemberBudget.ts | 16 ++
.../src/components/team/TeamInfo.tsx | 7 +-
.../components/team/TeamMemberTab.test.tsx | 150 ++++++++++++
.../src/components/team/TeamMemberTab.tsx | 103 ++++++++-
ui/litellm-dashboard/src/lib/http/schema.d.ts | 71 ++++++
8 files changed, 679 insertions(+), 13 deletions(-)
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useResetTeamMemberBudget.ts
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 76a51627d0c..70a93676d4e 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -4,7 +4,7 @@ import os
from collections.abc import Callable, Mapping
from datetime import datetime
from types import MappingProxyType
-from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, NamedTuple
+from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, NamedTuple, TypeAlias
import httpx
from pydantic import (
@@ -4588,11 +4588,26 @@ class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable):
caller_edit_access: TeamEditAccess = Field(default_factory=TeamEditNone)
+TeamMemberBudgetSource: TypeAlias = Literal["team_default", "custom", "none"]
+
+
+class TeamInfoMembership(LiteLLM_TeamMembership):
+ budget_source: TeamMemberBudgetSource
+
+
class TeamInfoResponseObject(TypedDict):
team_id: str
team_info: TeamInfoResponseObjectTeamTable
keys: list
- team_memberships: list[LiteLLM_TeamMembership]
+ team_memberships: ReadOnly[tuple[TeamInfoMembership, ...]]
+
+
+class TeamMemberResetBudgetResponse(BaseModel):
+ team_id: str
+ user_id: str
+ budget_id: str | None
+ previous_budget_id: str | None
+ budget_source: TeamMemberBudgetSource
class TeamListResponseObject(LiteLLM_TeamTable):
diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py
index 28c12173ea7..b8d95167045 100644
--- a/litellm/proxy/management_endpoints/team_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_endpoints.py
@@ -80,11 +80,14 @@ from litellm.proxy._types import (
TeamEditNone,
TeamEditUnrestricted,
TeamInfoMember,
+ TeamInfoMembership,
TeamInfoResponseObject,
TeamInfoResponseObjectTeamTable,
TeamListResponseObject,
TeamMemberAddRequest,
+ TeamMemberBudgetSource,
TeamMemberDeleteRequest,
+ TeamMemberResetBudgetResponse,
TeamMemberUpdateRequest,
TeamMemberUpdateResponse,
TeamModelAddRequest,
@@ -3954,6 +3957,99 @@ async def reset_team_member_spend_fn(
}
+class _TeamMetadataView(BaseModel):
+ metadata: Mapping[str, object] | None = None
+
+
+def _team_default_budget_id(team: LiteLLM_TeamTable) -> str | None:
+ view: Final = _TeamMetadataView.model_validate(team, from_attributes=True)
+ raw: Final = view.metadata.get("team_member_budget_id") if view.metadata is not None else None
+ return raw if isinstance(raw, str) else None
+
+
+async def _existing_team_default_budget_id(team: LiteLLM_TeamTable, prisma_client: PrismaClient) -> str | None:
+ budget_id: Final = _team_default_budget_id(team)
+ if budget_id is None:
+ return None
+ row: Final = await _budget_db(prisma_client).find_unique(
+ where={"budget_id": budget_id}, # mutable-ok: prisma client requires a plain dict where= argument
+ )
+ return budget_id if row is not None else None
+
+
+def _member_budget_source(budget_id: str | None, team_default_budget_id: str | None) -> TeamMemberBudgetSource:
+ if budget_id is not None and budget_id != team_default_budget_id:
+ return "custom"
+ return "team_default" if team_default_budget_id is not None else "none"
+
+
+@router.post(
+ "/team/{team_id}/member/{user_id}/reset_budget",
+ tags=["team management"], # mutable-ok: FastAPI's `tags` param is typed as list[str], not Sequence
+ dependencies=(Depends(user_api_key_auth),),
+ response_model=TeamMemberResetBudgetResponse,
+)
+@management_endpoint_wrapper
+async def reset_team_member_budget_fn(
+ team_id: str,
+ user_id: str,
+ user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
+) -> TeamMemberResetBudgetResponse:
+ """
+ Put a team member back on the team's shared default member budget (`team_member_budget`).
+
+ Drops the member's own budget row link so team-wide changes made through /team/update
+ reach them again. Leaves the member with no budget when the team has no default. Spend is untouched.
+ """
+ from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
+
+ if prisma_client is None:
+ _raise_reset_spend_error(status.HTTP_500_INTERNAL_SERVER_ERROR, "DB not connected. prisma_client is None")
+
+ team_obj: Final = await get_team_object(
+ team_id=team_id,
+ prisma_client=prisma_client,
+ user_api_key_cache=user_api_key_cache,
+ parent_otel_span=None,
+ proxy_logging_obj=proxy_logging_obj,
+ check_db_only=True,
+ )
+ await _verify_team_access(team_obj=team_obj, user_api_key_dict=user_api_key_dict)
+
+ membership_where: Final = { # mutable-ok: prisma client requires a plain dict where= argument
+ "user_id_team_id": {"user_id": user_id, "team_id": team_id} # mutable-ok: same prisma where= argument
+ }
+ membership_row: Final = await _team_membership_db(prisma_client).find_unique(where=membership_where)
+ if membership_row is None:
+ _raise_reset_spend_error(status.HTTP_404_NOT_FOUND, f"User {user_id} is not a member of team {team_id}.")
+
+ team_default_budget_id: Final = await _existing_team_default_budget_id(team_obj, prisma_client)
+ budget_link: Final = (
+ {
+ "connect": {"budget_id": team_default_budget_id}
+ } # mutable-ok: prisma client requires a plain dict data= argument
+ if team_default_budget_id is not None
+ else {"disconnect": True} # mutable-ok: same prisma data= argument
+ )
+ await _team_membership_db(prisma_client).update(
+ where=membership_where,
+ data={"litellm_budget_table": budget_link}, # mutable-ok: prisma client requires a plain dict data= argument
+ )
+ await invalidate_team_member_spend_state(
+ user_id=user_id,
+ team_id=team_id,
+ user_api_key_cache=user_api_key_cache,
+ )
+
+ return TeamMemberResetBudgetResponse(
+ team_id=team_id,
+ user_id=user_id,
+ budget_id=team_default_budget_id,
+ previous_budget_id=membership_row.budget_id,
+ budget_source=_member_budget_source(team_default_budget_id, team_default_budget_id),
+ )
+
+
def _create_results_from_response(
members: list[Member],
response: TeamAddMemberResponse,
@@ -4722,9 +4818,7 @@ async def team_info(
_team_info = TeamInfoResponseObjectTeamTable()
## GET TEAM BUDGET (if exists) ##
- team_member_budget_id: Final = (
- _team_info.metadata.get("team_member_budget_id") if _team_info.metadata is not None else None
- )
+ team_member_budget_id: Final = _team_default_budget_id(_team_info)
if team_member_budget_id is not None:
_team_info = await _add_team_member_budget_table(
team_member_budget_id=team_member_budget_id,
@@ -4757,7 +4851,17 @@ async def team_info(
team_id=team_id,
team_info=hydrated_team_info,
keys=keys,
- team_memberships=returned_tm,
+ team_memberships=tuple(
+ TeamInfoMembership.model_validate(
+ MappingProxyType(
+ {
+ **tm.model_dump(),
+ "budget_source": _member_budget_source(tm.budget_id, team_member_budget_id),
+ }
+ )
+ )
+ for tm in returned_tm
+ ),
)
return response_object
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 690b5ae80b6..d55b9f79b5f 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
@@ -46,6 +46,7 @@ from litellm.proxy.management_endpoints.team_endpoints import (
_verify_team_access,
delete_team,
list_available_teams,
+ reset_team_member_budget_fn,
reset_team_member_spend_fn,
router,
team_member_add_duplication_check,
@@ -14432,6 +14433,219 @@ async def test_reset_team_member_spend_fn_proxy_admin_can_reset_own_spend(monkey
assert response["spend"] == 0.0
+def _reset_budget_admin() -> UserAPIKeyAuth:
+ return UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id="admin-user")
+
+
+def _team_with_default_budget(team_id: str, budget_id: str) -> LiteLLM_TeamTable:
+ return LiteLLM_TeamTable(team_id=team_id, metadata={"team_member_budget_id": budget_id})
+
+
+@pytest.mark.asyncio
+async def test_reset_team_member_budget_fn_relinks_custom_member_to_team_default(monkeypatch):
+ """An admin undoing a per-member budget must put the membership back on the team's shared
+ default row (a connect, not a copy) so later /team/update changes reach the member again,
+ and must drop the cached membership so the old cap stops being enforced. The shared row and
+ the member's tracked spend are never written."""
+ from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
+
+ mock_prisma_client = MagicMock()
+ real_cache = UserApiKeyCache()
+ await real_cache.async_set_cache(key="team-1_member-1", value="stale-membership")
+ await real_cache.async_set_cache(key="team_membership:member-1:team-1", value="stale-membership")
+
+ membership_row = LiteLLM_TeamMembership(user_id="member-1", team_id="team-1", spend=10.0, budget_id="custom-b1")
+ mock_prisma_client.db.litellm_teammembership.find_unique = AsyncMock(return_value=membership_row)
+ mock_prisma_client.db.litellm_teammembership.update = AsyncMock(return_value=membership_row)
+ mock_prisma_client.db.litellm_budgettable.find_unique = AsyncMock(
+ return_value=LiteLLM_BudgetTable(budget_id="team-default-b", max_budget=100.0)
+ )
+ mock_prisma_client.db.litellm_budgettable.update = AsyncMock()
+
+ monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
+ monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", real_cache)
+ monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock())
+
+ with patch( # test-quality-ok: no live DB here; matches this file's established convention for endpoint-logic unit tests
+ "litellm.proxy.management_endpoints.team_endpoints.get_team_object",
+ AsyncMock(return_value=_team_with_default_budget("team-1", "team-default-b")),
+ ):
+ response = await reset_team_member_budget_fn(
+ team_id="team-1", user_id="member-1", user_api_key_dict=_reset_budget_admin()
+ )
+
+ assert response.budget_id == "team-default-b"
+ assert response.previous_budget_id == "custom-b1"
+ assert response.budget_source == "team_default"
+ mock_prisma_client.db.litellm_teammembership.update.assert_awaited_once_with(
+ where={"user_id_team_id": {"user_id": "member-1", "team_id": "team-1"}},
+ data={"litellm_budget_table": {"connect": {"budget_id": "team-default-b"}}},
+ )
+ mock_prisma_client.db.litellm_budgettable.update.assert_not_awaited()
+ assert await real_cache.async_get_cache(key="team-1_member-1") is None
+ assert await real_cache.async_get_cache(key="team_membership:member-1:team-1") is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "team_obj, default_row",
+ [
+ (LiteLLM_TeamTable(team_id="team-1"), None),
+ (_team_with_default_budget("team-1", "gone-b"), None),
+ ],
+ ids=["no_default_configured", "configured_default_row_missing"],
+)
+async def test_reset_team_member_budget_fn_detaches_member_when_team_has_no_usable_default(
+ monkeypatch, team_obj, default_row
+):
+ """With no shared default to link to, reset leaves the member exactly where a freshly added
+ member would be: no budget row at all, reported as budget_source='none', rather than
+ connecting to a budget_id that does not exist or leaving the custom cap in place."""
+ mock_prisma_client = MagicMock()
+ membership_row = LiteLLM_TeamMembership(user_id="member-1", team_id="team-1", budget_id="custom-b1")
+ mock_prisma_client.db.litellm_teammembership.find_unique = AsyncMock(return_value=membership_row)
+ mock_prisma_client.db.litellm_teammembership.update = AsyncMock(return_value=membership_row)
+ mock_prisma_client.db.litellm_budgettable.find_unique = AsyncMock(return_value=default_row)
+ monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
+ monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", MagicMock())
+ monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock())
+
+ with patch( # test-quality-ok: no live DB here; matches this file's established convention for endpoint-logic unit tests
+ "litellm.proxy.management_endpoints.team_endpoints.get_team_object",
+ AsyncMock(return_value=team_obj),
+ ):
+ response = await reset_team_member_budget_fn(
+ team_id="team-1", user_id="member-1", user_api_key_dict=_reset_budget_admin()
+ )
+
+ assert response.budget_id is None
+ assert response.previous_budget_id == "custom-b1"
+ assert response.budget_source == "none"
+ mock_prisma_client.db.litellm_teammembership.update.assert_awaited_once_with(
+ where={"user_id_team_id": {"user_id": "member-1", "team_id": "team-1"}},
+ data={"litellm_budget_table": {"disconnect": True}},
+ )
+
+
+@pytest.mark.asyncio
+async def test_reset_team_member_budget_fn_membership_not_found(monkeypatch):
+ mock_prisma_client = MagicMock()
+ mock_prisma_client.db.litellm_teammembership.find_unique = AsyncMock(return_value=None)
+ mock_prisma_client.db.litellm_teammembership.update = AsyncMock()
+ monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
+ monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", MagicMock())
+ monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock())
+
+ with patch( # test-quality-ok: no live DB here; matches this file's established convention for endpoint-logic unit tests
+ "litellm.proxy.management_endpoints.team_endpoints.get_team_object",
+ AsyncMock(return_value=_team_with_default_budget("team-1", "team-default-b")),
+ ):
+ with pytest.raises(HTTPException) as exc:
+ await reset_team_member_budget_fn(
+ team_id="team-1", user_id="ghost-user", user_api_key_dict=_reset_budget_admin()
+ )
+ assert exc.value.status_code == 404
+ mock_prisma_client.db.litellm_teammembership.update.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_reset_team_member_budget_fn_forbidden_for_non_admin(monkeypatch):
+ mock_prisma_client = MagicMock()
+ mock_prisma_client.db.litellm_teammembership.update = AsyncMock()
+ monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
+ monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", MagicMock())
+ monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock())
+
+ with patch( # test-quality-ok: no live DB here; matches this file's established convention for endpoint-logic unit tests
+ "litellm.proxy.management_endpoints.team_endpoints.get_team_object",
+ AsyncMock(return_value=LiteLLM_TeamTable(team_id="team-1", members_with_roles=[])),
+ ):
+ with pytest.raises(HTTPException) as exc:
+ await reset_team_member_budget_fn(
+ team_id="team-1",
+ user_id="member-1",
+ user_api_key_dict=UserAPIKeyAuth(
+ user_role=LitellmUserRoles.INTERNAL_USER, api_key="sk-user", user_id="plain-user"
+ ),
+ )
+ assert exc.value.status_code == 403
+ mock_prisma_client.db.litellm_teammembership.update.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_team_info_reports_whether_each_member_follows_the_team_default_budget():
+ """/team/info must tell the caller which members still follow the team's shared member budget
+ and which carry their own row, since budget_id alone only means something to a reader who
+ also knows the team's team_member_budget_id."""
+ from fastapi import Request
+
+ from litellm.proxy.management_endpoints import team_endpoints
+
+ team_row = _team_with_default_budget("team-1", "team-default-b")
+ memberships = [
+ LiteLLM_TeamMembership(user_id="inherits", team_id="team-1", budget_id="team-default-b"),
+ LiteLLM_TeamMembership(user_id="customized", team_id="team-1", budget_id="own-b"),
+ LiteLLM_TeamMembership(user_id="unlinked", team_id="team-1", budget_id=None),
+ ]
+
+ mock_prisma = MagicMock()
+ mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
+ mock_prisma.db.litellm_budgettable.find_unique = AsyncMock(
+ return_value=LiteLLM_BudgetTable(budget_id="team-default-b", max_budget=100.0)
+ )
+ mock_prisma.get_data = AsyncMock(return_value=[])
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
+ patch.object(team_endpoints, "get_all_team_memberships", AsyncMock(return_value=memberships)),
+ ):
+ response = await team_endpoints.team_info(
+ http_request=MagicMock(spec=Request),
+ team_id="team-1",
+ user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
+ )
+
+ assert {tm.user_id: tm.budget_source for tm in response["team_memberships"]} == {
+ "inherits": "team_default",
+ "customized": "custom",
+ "unlinked": "team_default",
+ }
+
+
+@pytest.mark.asyncio
+async def test_team_info_reports_no_budget_source_when_team_has_no_default():
+ """A team that never set team_member_budget has nothing for members to inherit, so an
+ unlinked member is 'none' rather than 'team_default', while a member with their own row is
+ still 'custom'."""
+ from fastapi import Request
+
+ from litellm.proxy.management_endpoints import team_endpoints
+
+ memberships = [
+ LiteLLM_TeamMembership(user_id="customized", team_id="team-1", budget_id="own-b"),
+ LiteLLM_TeamMembership(user_id="unlinked", team_id="team-1", budget_id=None),
+ ]
+
+ mock_prisma = MagicMock()
+ mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=LiteLLM_TeamTable(team_id="team-1"))
+ mock_prisma.get_data = AsyncMock(return_value=[])
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
+ patch.object(team_endpoints, "get_all_team_memberships", AsyncMock(return_value=memberships)),
+ ):
+ response = await team_endpoints.team_info(
+ http_request=MagicMock(spec=Request),
+ team_id="team-1",
+ user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
+ )
+
+ assert {tm.user_id: tm.budget_source for tm in response["team_memberships"]} == {
+ "customized": "custom",
+ "unlinked": "none",
+ }
+
+
@pytest.mark.asyncio
async def test_team_member_update_invalidates_team_member_spend_state_when_budget_patch_applied(monkeypatch):
"""Raising a stuck member's max_budget_in_team via the documented /team/member_update
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useResetTeamMemberBudget.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useResetTeamMemberBudget.ts
new file mode 100644
index 00000000000..e7cf95440a5
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useResetTeamMemberBudget.ts
@@ -0,0 +1,16 @@
+import { useMutation } from "@tanstack/react-query";
+import { fetchClient } from "@/lib/http/api";
+
+export interface ResetTeamMemberBudgetParams {
+ teamId: string;
+ userId: string;
+}
+
+export const resetTeamMemberBudget = async ({ teamId, userId }: ResetTeamMemberBudgetParams): Promise => {
+ await fetchClient.POST("/team/{team_id}/member/{user_id}/reset_budget", {
+ params: { path: { team_id: teamId, user_id: userId } },
+ });
+};
+
+export const useResetTeamMemberBudget = () =>
+ useMutation({ mutationFn: resetTeamMemberBudget });
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
index 3b2c344c2f3..22cc99b32c8 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
@@ -1,4 +1,5 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import type { components } from "@/lib/http/schema";
import useCan from "@/app/(dashboard)/hooks/useCan";
import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useQueryClient } from "@tanstack/react-query";
@@ -247,10 +248,13 @@ export const retainedMcpToolPermissions = (
export const mcpUnresolvableSaveError = (reason: string): string =>
`Cannot save MCP tool permissions because ${reason}. Retry once the page has finished loading`;
+export type TeamMemberBudgetSource = components["schemas"]["TeamMemberResetBudgetResponse"]["budget_source"];
+
export interface TeamMembership {
user_id: string;
team_id: string;
- budget_id: string;
+ budget_id: string | null;
+ budget_source: TeamMemberBudgetSource;
spend: number;
total_spend: number | null;
litellm_budget_table: {
@@ -1361,6 +1365,7 @@ const TeamInfoView: React.FC = ({
canEditTeam={canEditTeam}
handleMemberDelete={handleMemberDelete}
onMemberSpendReset={refreshTeamData}
+ onMemberBudgetReset={refreshTeamData}
setSelectedEditMember={setSelectedEditMember}
setIsEditMemberModalVisible={setIsEditMemberModalVisible}
setIsAddMemberModalVisible={setIsAddMemberModalVisible}
diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx
index 52cba1e6330..8652ffa7de2 100644
--- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx
@@ -30,6 +30,7 @@ const mockSetSelectedEditMember = vi.fn();
const mockSetIsEditMemberModalVisible = vi.fn();
const mockSetIsAddMemberModalVisible = vi.fn();
const mockOnMemberSpendReset = vi.fn();
+const mockOnMemberBudgetReset = vi.fn();
const budgetResetIso = new Date(2026, 6, 15, 12, 0, 0).toISOString();
@@ -74,6 +75,7 @@ const createMockTeamData = (overrides: Partial = {}): TeamData => ({
user_id: "user1@test.com",
team_id: "team-123",
budget_id: "budget1",
+ budget_source: "custom",
spend: 100.5,
total_spend: 1538.2608,
litellm_budget_table: {
@@ -126,6 +128,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -142,6 +145,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -161,6 +165,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -180,6 +185,7 @@ describe("TeamMembersComponent", () => {
canEditTeam: false,
handleMemberDelete: mockHandleMemberDelete,
onMemberSpendReset: mockOnMemberSpendReset,
+ onMemberBudgetReset: mockOnMemberBudgetReset,
setSelectedEditMember: mockSetSelectedEditMember,
setIsEditMemberModalVisible: mockSetIsEditMemberModalVisible,
setIsAddMemberModalVisible: mockSetIsAddMemberModalVisible,
@@ -204,6 +210,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={true}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -231,6 +238,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -258,6 +266,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -274,6 +283,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -293,6 +303,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -309,6 +320,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -326,6 +338,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -346,6 +359,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={true}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -381,6 +395,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={true}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -435,6 +450,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={true}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -466,6 +482,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={true}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -486,6 +503,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={true}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -503,6 +521,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -521,6 +540,7 @@ describe("TeamMembersComponent", () => {
canEditTeam={true}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
+ onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
@@ -603,4 +623,134 @@ describe("TeamMembersComponent", () => {
expect(screen.getByTestId("reset-member-spend")).toBeVisible();
});
});
+
+ describe("budget source", () => {
+ const teamDataWithDefault = () => {
+ const base = createMockTeamData();
+ return createMockTeamData({
+ team_info: {
+ ...base.team_info,
+ team_member_budget_table: { max_budget: 25, budget_duration: null, tpm_limit: null, rpm_limit: null },
+ },
+ team_memberships: [
+ base.team_memberships[0],
+ {
+ user_id: "user2@test.com",
+ team_id: "team-123",
+ budget_id: "team-default-budget",
+ budget_source: "team_default",
+ spend: 0,
+ total_spend: null,
+ litellm_budget_table: {
+ budget_id: "team-default-budget",
+ soft_budget: null,
+ max_budget: 25,
+ max_parallel_requests: null,
+ tpm_limit: null,
+ rpm_limit: null,
+ model_max_budget: null,
+ budget_duration: null,
+ budget_reset_at: null,
+ },
+ },
+ ],
+ });
+ };
+
+ const renderTab = (teamData: TeamData, canEditTeam = true) =>
+ renderWithProviders(
+ ,
+ );
+
+ it("labels each member's budget as Custom or Team default and shows the team amount for inherited members", () => {
+ renderTab(teamDataWithDefault());
+
+ const customRow = screen.getByRole("row", { name: /user1@test\.com/ });
+ const inheritedRow = screen.getByRole("row", { name: /user2@test\.com/ });
+ expect(within(customRow).getByTestId("member-budget-source")).toHaveTextContent("Custom");
+ expect(customRow).toHaveTextContent("$1,000.00");
+ expect(within(inheritedRow).getByTestId("member-budget-source")).toHaveTextContent("Team default");
+ expect(inheritedRow).toHaveTextContent("$25.00");
+ });
+
+ it("shows no source label for a member with neither a custom nor a team budget", () => {
+ renderTab(createMockTeamData({ team_memberships: [] }));
+
+ expect(screen.queryByTestId("member-budget-source")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("reset-member-budget")).not.toBeInTheDocument();
+ });
+
+ it("only offers Use team default on customized members, and only to editors", () => {
+ const { unmount } = renderTab(teamDataWithDefault());
+
+ expect(
+ within(screen.getByRole("row", { name: /user1@test\.com/ })).getByTestId("reset-member-budget"),
+ ).toBeVisible();
+ expect(
+ within(screen.getByRole("row", { name: /user2@test\.com/ })).queryByTestId("reset-member-budget"),
+ ).not.toBeInTheDocument();
+
+ unmount();
+ renderTab(teamDataWithDefault(), false);
+ expect(screen.queryByTestId("reset-member-budget")).not.toBeInTheDocument();
+ });
+
+ it("puts the member back on the team default after confirming, then refreshes the team", async () => {
+ const user = userEvent.setup();
+ POST.mockResolvedValue({ data: {} });
+ renderTab(teamDataWithDefault());
+
+ await user.click(screen.getByTestId("reset-member-budget"));
+
+ const dialog = await screen.findByRole("dialog", { name: "Reset Team Member Budget" });
+ expect(dialog).toHaveTextContent("user1@test.com");
+ expect(dialog).toHaveTextContent("team default of $25.00");
+ expect(dialog).toHaveTextContent("Custom budget: $1,000.00");
+ expect(POST).not.toHaveBeenCalled();
+
+ await user.click(within(dialog).getByRole("button", { name: "Use team default" }));
+
+ await waitFor(() => expect(mockOnMemberBudgetReset).toHaveBeenCalledTimes(1));
+ expect(POST).toHaveBeenCalledExactlyOnceWith("/team/{team_id}/member/{user_id}/reset_budget", {
+ params: { path: { team_id: "team-123", user_id: "user1@test.com" } },
+ });
+ expect(mockOnMemberSpendReset).not.toHaveBeenCalled();
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ it("keeps the dialog open and does not refresh the team when the reset fails", async () => {
+ const user = userEvent.setup();
+ POST.mockRejectedValue(new Error("Team admin cannot reset budgets"));
+ renderTab(teamDataWithDefault());
+
+ await user.click(screen.getByTestId("reset-member-budget"));
+ const dialog = await screen.findByRole("dialog", { name: "Reset Team Member Budget" });
+ await user.click(within(dialog).getByRole("button", { name: "Use team default" }));
+
+ await waitFor(() => expect(POST).toHaveBeenCalledTimes(1));
+ expect(mockOnMemberBudgetReset).not.toHaveBeenCalled();
+ expect(screen.getByRole("dialog", { name: "Reset Team Member Budget" })).toBeInTheDocument();
+ });
+
+ it("does not call the API when the dialog is cancelled", async () => {
+ const user = userEvent.setup();
+ renderTab(teamDataWithDefault());
+
+ await user.click(screen.getByTestId("reset-member-budget"));
+ const dialog = await screen.findByRole("dialog", { name: "Reset Team Member Budget" });
+ await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
+
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+ expect(POST).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx
index a869c1ad624..660416504fe 100644
--- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx
@@ -1,6 +1,8 @@
+import { useResetTeamMemberBudget } from "@/app/(dashboard)/hooks/teams/useResetTeamMemberBudget";
import { useResetTeamMemberSpend } from "@/app/(dashboard)/hooks/teams/useResetTeamMemberSpend";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { SimpleTooltip } from "@/components/ui/tooltip";
@@ -13,7 +15,15 @@ import { formatNumberWithCommas } from "@/utils/dataUtils";
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
import { CircleHelp } from "lucide-react";
import { useState, type ComponentProps } from "react";
-import { TeamData, TeamMembership } from "./TeamInfo";
+import { TeamData, TeamMemberBudgetSource, TeamMembership } from "./TeamInfo";
+
+const BUDGET_SOURCE_LABELS: Record, string> = {
+ team_default: "Team default",
+ custom: "Custom",
+};
+
+const formatBudget = (value: number | null): string =>
+ value === null ? "Unlimited" : `$${formatNumberWithCommas(value, 2)}`;
export const seedMemberBudgetFields = (
record: Member,
@@ -37,6 +47,7 @@ interface TeamMemberTabProps {
setIsEditMemberModalVisible: (visible: boolean) => void;
setIsAddMemberModalVisible: (visible: boolean) => void;
onMemberSpendReset: () => void;
+ onMemberBudgetReset: () => void;
}
export default function TeamMemberTab({
@@ -47,9 +58,13 @@ export default function TeamMemberTab({
setIsEditMemberModalVisible,
setIsAddMemberModalVisible,
onMemberSpendReset,
+ onMemberBudgetReset,
}: TeamMemberTabProps) {
const [memberToResetSpend, setMemberToResetSpend] = useState(null);
+ const [memberToResetBudget, setMemberToResetBudget] = useState(null);
const { mutate: resetMemberSpend, isPending: isResettingSpend } = useResetTeamMemberSpend();
+ const { mutate: resetMemberBudget, isPending: isResettingBudget } = useResetTeamMemberBudget();
+ const teamDefaultBudget = teamData.team_info.team_member_budget_table?.max_budget ?? null;
const formatNumber = (value: number | null): string => {
if (value === null || value === undefined) return "0";
@@ -82,10 +97,19 @@ export default function TeamMemberTab({
return membership?.total_spend ?? 0;
};
+ const getUserBudgetSource = (userId: string | null): TeamMemberBudgetSource => {
+ if (!userId) return "none";
+ const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
+ return membership?.budget_source ?? "none";
+ };
+
const getUserBudget = (userId: string | null): number | null => {
if (!userId) return null;
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
- return membership?.litellm_budget_table?.max_budget ?? null;
+ return (
+ membership?.litellm_budget_table?.max_budget ??
+ (membership?.budget_source === "team_default" ? teamDefaultBudget : null)
+ );
};
// Helper function to get rate limits for a user
@@ -182,12 +206,40 @@ export default function TeamMemberTab({
render: (record: Member) => ,
},
{
- title: "Team Member Budget (USD)",
+ title: (
+
+ Team Member Budget (USD)
+
+
+
+
+ ),
key: "budget",
sortValue: (record: Member) => getUserBudget(record.user_id),
- render: (record: Member) => (
-
- ),
+ render: (record: Member) => {
+ const source = getUserBudgetSource(record.user_id);
+ return (
+
+
+ {source !== "none" && (
+
+ {BUDGET_SOURCE_LABELS[source]}
+
+ )}
+ {source === "custom" && canEditTeam && (
+ setMemberToResetBudget(record)}
+ >
+ Use team default
+
+ )}
+
+ );
+ },
},
{
title: "Budget Reset",
@@ -224,6 +276,21 @@ export default function TeamMemberTab({
);
};
+ const handleResetBudget = () => {
+ if (!memberToResetBudget?.user_id) return;
+ resetMemberBudget(
+ { teamId: teamData.team_id, userId: memberToResetBudget.user_id },
+ {
+ onSuccess: () => {
+ toast.success("Team member budget reset to the team default");
+ setMemberToResetBudget(null);
+ onMemberBudgetReset();
+ },
+ onError: (error) => toast.fromError(parseErrorMessage(error)),
+ },
+ );
+ };
+
return (
<>
+ !open && setMemberToResetBudget(null)}>
+
+
+ Reset Team Member Budget
+
+
+ Remove the custom budget for{" "}
+ {memberToResetBudget?.user_email || memberToResetBudget?.user_id} and put them back on the
+ team default of {formatBudget(teamDefaultBudget)} ?
+
+
+ Custom budget: {formatBudget(getUserBudget(memberToResetBudget?.user_id ?? null))} . Their
+ spend is kept. Future changes to the team's member budget will apply to them again.
+
+
+ setMemberToResetBudget(null)}>
+ Cancel
+
+
+ Use team default
+
+
+
+
>
);
}
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 136b8f26784..aa6776d12bd 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -16272,6 +16272,29 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/team/{team_id}/member/{user_id}/reset_budget": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Reset Team Member Budget Fn
+ * @description Put a team member back on the team's shared default member budget (`team_member_budget`).
+ *
+ * Drops the member's own budget row link so team-wide changes made through /team/update
+ * reach them again. Leaves the member with no budget when the team has no default. Spend is untouched.
+ */
+ post: operations["reset_team_member_budget_fn_team__team_id__member__user_id__reset_budget_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/team/{team_id}/member/{user_id}/reset_spend": {
parameters: {
query?: never;
@@ -38547,6 +38570,22 @@ export interface components {
/** User Id */
user_id?: string | null;
};
+ /** TeamMemberResetBudgetResponse */
+ TeamMemberResetBudgetResponse: {
+ /** Budget Id */
+ budget_id: string | null;
+ /**
+ * Budget Source
+ * @enum {string}
+ */
+ budget_source: "team_default" | "custom" | "none";
+ /** Previous Budget Id */
+ previous_budget_id: string | null;
+ /** Team Id */
+ team_id: string;
+ /** User Id */
+ user_id: string;
+ };
/** TeamMemberUpdateRequest */
TeamMemberUpdateRequest: {
/**
@@ -61730,6 +61769,38 @@ export interface operations {
};
};
};
+ reset_team_member_budget_fn_team__team_id__member__user_id__reset_budget_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ team_id: string;
+ user_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TeamMemberResetBudgetResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
reset_team_member_spend_fn_team__team_id__member__user_id__reset_spend_post: {
parameters: {
query?: never;
From 26bcc537a5d07044423d75aa75550bfafc56a982 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 00:44:10 +0000
Subject: [PATCH 043/317] fix(proxy): allow latest release info and reset
banner dismissal
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/proxy/_types.py | 1 +
.../proxy/auth/test_route_checks.py | 27 +++++++++++++++++++
.../src/components/UpgradeBanner.test.tsx | 8 ++++++
.../src/components/UpgradeBanner.tsx | 6 ++---
4 files changed, 39 insertions(+), 3 deletions(-)
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 76a51627d0c..66a00e14f15 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -633,6 +633,7 @@ class LiteLLMRoutes(enum.Enum):
"/v1/models",
"/sso/get/ui_settings",
"/get/user_banner",
+ "/get/latest_release_info",
]
# NOTE: ROUTES ONLY FOR MASTER KEY - only the Master Key should be able to Reset Spend
diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py
index 72c59223549..df5ac4ac135 100644
--- a/tests/test_litellm/proxy/auth/test_route_checks.py
+++ b/tests/test_litellm/proxy/auth/test_route_checks.py
@@ -120,6 +120,33 @@ def test_user_banner_read_open_to_non_admin_roles(role):
)
+@pytest.mark.parametrize(
+ "role",
+ [
+ LitellmUserRoles.INTERNAL_USER.value,
+ LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
+ ],
+)
+def test_latest_release_info_read_open_to_non_admin_roles(role):
+ user_obj = LiteLLM_UserTable(
+ user_id="test_user",
+ user_email="test@example.com",
+ user_role=role,
+ )
+ valid_token = UserAPIKeyAuth(user_id="test_user", user_role=role)
+ request = MagicMock(spec=Request)
+ request.query_params = {}
+
+ RouteChecks.non_proxy_admin_allowed_routes_check(
+ user_obj=user_obj,
+ _user_role=role,
+ route="/get/latest_release_info",
+ request=request,
+ valid_token=valid_token,
+ request_data={},
+ )
+
+
def test_user_banner_update_rejected_for_non_admin():
"""Publishing the banner stays admin-only at the route layer."""
user_obj = LiteLLM_UserTable(
diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx
index 831030fee80..4466597394c 100644
--- a/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx
+++ b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx
@@ -82,6 +82,14 @@ describe("UpgradeBannerView", () => {
render( );
expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0");
});
+
+ it("shows a newer release after the current one was dismissed without remounting", () => {
+ const { rerender } = render( );
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ rerender( );
+ expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0");
+ });
});
describe("UpgradeBanner", () => {
diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx
index d990c5112a4..101a6e3410d 100644
--- a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx
+++ b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx
@@ -34,20 +34,20 @@ export const describeRelease = ({ new_features, bug_fixes, other_updates }: Late
].join(", ");
export const UpgradeBannerView: React.FC = ({ currentVersion, latestRelease }) => {
- const [locallyDismissed, setLocallyDismissed] = useState(false);
+ const [dismissedVersion, setDismissedVersion] = useState(null);
if (!currentVersion || !latestRelease || !isNewerVersion(currentVersion, latestRelease.version)) {
return null;
}
const dismissKey = `${DISMISS_KEY_PREFIX}${latestRelease.version}`;
- if (locallyDismissed || getLocalStorageItem(dismissKey) === "true") {
+ if (dismissedVersion === latestRelease.version || getLocalStorageItem(dismissKey) === "true") {
return null;
}
const handleClose = () => {
setLocalStorageItem(dismissKey, "true");
- setLocallyDismissed(true);
+ setDismissedVersion(latestRelease.version);
};
return (
From d3a364d74f8bee7f6133d51e68c3036cde7f8136 Mon Sep 17 00:00:00 2001
From: ryan
Date: Sat, 19 Sep 2026 00:49:58 +0000
Subject: [PATCH 044/317] fix(team): report no budget source when the team
default row was deleted
Derive budget_source from the budget row /team/info actually loaded, so a
metadata id whose row was removed via /budget/delete reads as none instead
of team_default. Share the /team/info test scaffolding so the added patch
calls stay within the TQ008 budget, and allowlist the imperative
reset_budget route in the provider endpoint audit
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../management_endpoints/team_endpoints.py | 5 +-
.../endpointaudit/coverage_allowlist.txt | 1 +
.../test_team_endpoints.py | 112 ++++++++++--------
3 files changed, 70 insertions(+), 48 deletions(-)
diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py
index b8d95167045..a441b3834ed 100644
--- a/litellm/proxy/management_endpoints/team_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_endpoints.py
@@ -4825,6 +4825,9 @@ async def team_info(
prisma_client=prisma_client,
team_info_response_object=_team_info,
)
+ active_default_budget_id: Final = (
+ team_member_budget_id if _team_info.team_member_budget_table is not None else None
+ )
# Resolve resources inherited from access groups
resolved_team_info: Final = await _resolve_team_access_group_resources(_team_info)
@@ -4856,7 +4859,7 @@ async def team_info(
MappingProxyType(
{
**tm.model_dump(),
- "budget_source": _member_budget_source(tm.budget_id, team_member_budget_id),
+ "budget_source": _member_budget_source(tm.budget_id, active_default_budget_id),
}
)
)
diff --git a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt
index 6bc8947e89f..4ea64b152f1 100644
--- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt
+++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt
@@ -81,6 +81,7 @@ POST /prompts/test
POST /search_tools/test_connection
POST /team/bulk_member_add
POST /team/{team_id}/member/{user_id}/reset_spend
+POST /team/{team_id}/member/{user_id}/reset_budget
POST /team/key/bulk_update
POST /team/permissions_bulk_update
POST /team/{team_id}/disable_logging
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 d55b9f79b5f..484c054fa54 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
@@ -14572,40 +14572,52 @@ async def test_reset_team_member_budget_fn_forbidden_for_non_admin(monkeypatch):
mock_prisma_client.db.litellm_teammembership.update.assert_not_awaited()
+async def _team_info_budget_sources(
+ team_row: LiteLLM_TeamTable,
+ memberships: list[LiteLLM_TeamMembership],
+ default_budget_row: LiteLLM_BudgetTable | None,
+) -> dict[str, str]:
+ from fastapi import Request
+
+ from litellm.proxy.management_endpoints import team_endpoints
+
+ mock_prisma = MagicMock()
+ mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
+ mock_prisma.db.litellm_budgettable.find_unique = AsyncMock(return_value=default_budget_row)
+ mock_prisma.get_data = AsyncMock(return_value=[])
+
+ with (
+ patch( # test-quality-ok: no live DB here; matches this file's established convention for endpoint-logic unit tests
+ "litellm.proxy.proxy_server.prisma_client", mock_prisma
+ ),
+ patch.object( # test-quality-ok: membership lookup is a module-level DB query with no injection point
+ team_endpoints, "get_all_team_memberships", AsyncMock(return_value=memberships)
+ ),
+ ):
+ response = await team_endpoints.team_info(
+ http_request=MagicMock(spec=Request),
+ team_id=team_row.team_id,
+ user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
+ )
+ return {tm.user_id: tm.budget_source for tm in response["team_memberships"]}
+
+
@pytest.mark.asyncio
async def test_team_info_reports_whether_each_member_follows_the_team_default_budget():
"""/team/info must tell the caller which members still follow the team's shared member budget
and which carry their own row, since budget_id alone only means something to a reader who
also knows the team's team_member_budget_id."""
- from fastapi import Request
-
- from litellm.proxy.management_endpoints import team_endpoints
-
- team_row = _team_with_default_budget("team-1", "team-default-b")
- memberships = [
- LiteLLM_TeamMembership(user_id="inherits", team_id="team-1", budget_id="team-default-b"),
- LiteLLM_TeamMembership(user_id="customized", team_id="team-1", budget_id="own-b"),
- LiteLLM_TeamMembership(user_id="unlinked", team_id="team-1", budget_id=None),
- ]
-
- mock_prisma = MagicMock()
- mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
- mock_prisma.db.litellm_budgettable.find_unique = AsyncMock(
- return_value=LiteLLM_BudgetTable(budget_id="team-default-b", max_budget=100.0)
+ sources = await _team_info_budget_sources(
+ team_row=_team_with_default_budget("team-1", "team-default-b"),
+ memberships=[
+ LiteLLM_TeamMembership(user_id="inherits", team_id="team-1", budget_id="team-default-b"),
+ LiteLLM_TeamMembership(user_id="customized", team_id="team-1", budget_id="own-b"),
+ LiteLLM_TeamMembership(user_id="unlinked", team_id="team-1", budget_id=None),
+ ],
+ default_budget_row=LiteLLM_BudgetTable(budget_id="team-default-b", max_budget=100.0),
)
- mock_prisma.get_data = AsyncMock(return_value=[])
- with (
- patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
- patch.object(team_endpoints, "get_all_team_memberships", AsyncMock(return_value=memberships)),
- ):
- response = await team_endpoints.team_info(
- http_request=MagicMock(spec=Request),
- team_id="team-1",
- user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
- )
-
- assert {tm.user_id: tm.budget_source for tm in response["team_memberships"]} == {
+ assert sources == {
"inherits": "team_default",
"customized": "custom",
"unlinked": "team_default",
@@ -14617,30 +14629,36 @@ async def test_team_info_reports_no_budget_source_when_team_has_no_default():
"""A team that never set team_member_budget has nothing for members to inherit, so an
unlinked member is 'none' rather than 'team_default', while a member with their own row is
still 'custom'."""
- from fastapi import Request
+ sources = await _team_info_budget_sources(
+ team_row=LiteLLM_TeamTable(team_id="team-1"),
+ memberships=[
+ LiteLLM_TeamMembership(user_id="customized", team_id="team-1", budget_id="own-b"),
+ LiteLLM_TeamMembership(user_id="unlinked", team_id="team-1", budget_id=None),
+ ],
+ default_budget_row=None,
+ )
- from litellm.proxy.management_endpoints import team_endpoints
+ assert sources == {
+ "customized": "custom",
+ "unlinked": "none",
+ }
- memberships = [
- LiteLLM_TeamMembership(user_id="customized", team_id="team-1", budget_id="own-b"),
- LiteLLM_TeamMembership(user_id="unlinked", team_id="team-1", budget_id=None),
- ]
- mock_prisma = MagicMock()
- mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=LiteLLM_TeamTable(team_id="team-1"))
- mock_prisma.get_data = AsyncMock(return_value=[])
+@pytest.mark.asyncio
+async def test_team_info_reports_no_budget_source_when_team_default_row_was_deleted():
+ """If the budget row named by team_member_budget_id was removed via /budget/delete, nothing is
+ enforced for unlinked members any more, so /team/info must not keep advertising a team default
+ that no longer exists."""
+ sources = await _team_info_budget_sources(
+ team_row=_team_with_default_budget("team-1", "deleted-b"),
+ memberships=[
+ LiteLLM_TeamMembership(user_id="customized", team_id="team-1", budget_id="own-b"),
+ LiteLLM_TeamMembership(user_id="unlinked", team_id="team-1", budget_id=None),
+ ],
+ default_budget_row=None,
+ )
- with (
- patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
- patch.object(team_endpoints, "get_all_team_memberships", AsyncMock(return_value=memberships)),
- ):
- response = await team_endpoints.team_info(
- http_request=MagicMock(spec=Request),
- team_id="team-1",
- user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
- )
-
- assert {tm.user_id: tm.budget_source for tm in response["team_memberships"]} == {
+ assert sources == {
"customized": "custom",
"unlinked": "none",
}
From 52a71ff68188b0d6781204144472c613c1c8240b Mon Sep 17 00:00:00 2001
From: ryan
Date: Sat, 19 Sep 2026 00:58:39 +0000
Subject: [PATCH 045/317] fix(team): let team admins reach the member
reset_budget route and cover it in the behavior suite
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/proxy/_types.py | 1 +
.../test_team_member_reset_budget.py | 201 ++++++++++++++++++
2 files changed, 202 insertions(+)
create mode 100644 tests/proxy_behavior/management/test_team_member_reset_budget.py
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index a624234cf5f..c21113f8c29 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -865,6 +865,7 @@ class LiteLLMRoutes(enum.Enum):
"/management/v1/teams/{team_id}/members/bulk_update",
"/team/member_update",
"/team/{team_id}/member/{user_id}/reset_spend",
+ "/team/{team_id}/member/{user_id}/reset_budget",
"/team/permissions_list",
"/team/permissions_update",
"/team/daily/activity",
diff --git a/tests/proxy_behavior/management/test_team_member_reset_budget.py b/tests/proxy_behavior/management/test_team_member_reset_budget.py
new file mode 100644
index 00000000000..42f327c33ef
--- /dev/null
+++ b/tests/proxy_behavior/management/test_team_member_reset_budget.py
@@ -0,0 +1,201 @@
+import uuid
+
+import pytest
+
+from .actors import Actor
+from .conftest import create_scratch_team
+
+pytestmark = pytest.mark.asyncio(loop_scope="session")
+
+_SEED_SPEND = 5.0
+_TEAM_DEFAULT_MAX_BUDGET = 100.0
+_CUSTOM_MAX_BUDGET = 50.0
+
+_MATRIX = [
+ ("alpha/proxy_admin", Actor.PROXY_ADMIN, "alpha", 200),
+ ("alpha/org_admin", Actor.ORG_ADMIN, "alpha", 200),
+ ("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 200),
+ ("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 403),
+ ("alpha/owner", Actor.OWNER, "alpha", 403),
+ ("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 403),
+ ("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 403),
+ ("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 403),
+ ("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 403),
+ ("beta/proxy_admin", Actor.PROXY_ADMIN, "beta", 200),
+ ("beta/org_admin", Actor.ORG_ADMIN, "beta", 403),
+ ("beta/team_admin", Actor.TEAM_ADMIN, "beta", 403),
+ ("beta/org_b_admin", Actor.ORG_B_ADMIN, "beta", 200),
+]
+
+
+async def _seed_budget(prisma, budget_id: str, max_budget: float) -> str:
+ await prisma.db.litellm_budgettable.create(
+ data={
+ "budget_id": budget_id,
+ "max_budget": max_budget,
+ "created_by": "phase4-scratch",
+ "updated_by": "phase4-scratch",
+ }
+ )
+ return budget_id
+
+
+async def _seed_team_with_default_budget(prisma, world, shape: str, team_id: str, scratch) -> str:
+ default_budget_id = await _seed_budget(prisma, scratch.tag("team-default-budget"), _TEAM_DEFAULT_MAX_BUDGET)
+ metadata = {"team_member_budget_id": default_budget_id}
+ if shape == "alpha":
+ await create_scratch_team(
+ prisma,
+ team_id,
+ organization_id=world.org_a_id,
+ admin_user_ids=[world.keys[Actor.TEAM_ADMIN].user_id],
+ metadata=metadata,
+ )
+ elif shape == "beta":
+ await create_scratch_team(prisma, team_id, organization_id=world.org_b_id, metadata=metadata)
+ else: # pragma: no cover - guard
+ pytest.fail(f"unknown shape={shape}")
+ return default_budget_id
+
+
+async def _seed_custom_member(prisma, team_id: str, member_id: str, scratch) -> str:
+ custom_budget_id = await _seed_budget(prisma, scratch.tag("custom-budget"), _CUSTOM_MAX_BUDGET)
+ await prisma.db.litellm_teammembership.create(
+ data={
+ "user_id": member_id,
+ "team_id": team_id,
+ "spend": _SEED_SPEND,
+ "litellm_budget_table": {"connect": {"budget_id": custom_budget_id}},
+ }
+ )
+ return custom_budget_id
+
+
+async def _membership(prisma, team_id: str, member_id: str):
+ row = await prisma.db.litellm_teammembership.find_unique(
+ where={"user_id_team_id": {"user_id": member_id, "team_id": team_id}}
+ )
+ assert row is not None
+ return row
+
+
+@pytest.mark.parametrize(
+ "actor,shape,expected_status",
+ [(a, sh, s) for (_id, a, sh, s) in _MATRIX],
+ ids=[s[0] for s in _MATRIX],
+)
+async def test_team_member_reset_budget_authz_matrix(
+ actor: Actor,
+ shape: str,
+ expected_status: int,
+ proxy_client,
+ prisma,
+ scratch,
+ world,
+):
+ member_id = scratch.tag("member")
+ default_budget_id = await _seed_team_with_default_budget(prisma, world, shape, scratch.prefix, scratch)
+ custom_budget_id = await _seed_custom_member(prisma, scratch.prefix, member_id, scratch)
+ caller = world.keys[actor]
+
+ resp = await proxy_client.post(
+ f"/team/{scratch.prefix}/member/{member_id}/reset_budget",
+ headers={"Authorization": f"Bearer {caller.cleartext}"},
+ )
+ assert resp.status_code == expected_status, f"{actor.value} {shape}: {resp.status_code} {resp.text}"
+
+ row = await _membership(prisma, scratch.prefix, member_id)
+ assert row.spend == _SEED_SPEND, "reset_budget must never touch spend"
+ if expected_status == 200:
+ assert row.budget_id == default_budget_id
+ body = resp.json()
+ assert body["budget_id"] == default_budget_id
+ assert body["previous_budget_id"] == custom_budget_id
+ assert body["budget_source"] == "team_default"
+ else:
+ assert row.budget_id == custom_budget_id, "denied but budget relinked"
+
+
+async def test_team_member_reset_budget_leaves_shared_default_row_untouched(proxy_client, prisma, scratch, world):
+ """Relinking must point the member at the shared row, not copy or edit it, so a later
+ /team/update to team_member_budget reaches this member again."""
+ member_id = scratch.tag("member")
+ default_budget_id = await _seed_team_with_default_budget(prisma, world, "alpha", scratch.prefix, scratch)
+ await _seed_custom_member(prisma, scratch.prefix, member_id, scratch)
+
+ resp = await proxy_client.post(
+ f"/team/{scratch.prefix}/member/{member_id}/reset_budget",
+ headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
+ )
+ assert resp.status_code == 200, resp.text
+
+ default_row = await prisma.db.litellm_budgettable.find_unique(where={"budget_id": default_budget_id})
+ assert default_row is not None and default_row.max_budget == _TEAM_DEFAULT_MAX_BUDGET
+
+ info = await proxy_client.get(
+ f"/team/info?team_id={scratch.prefix}",
+ headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
+ )
+ assert info.status_code == 200, info.text
+ memberships = {tm["user_id"]: tm for tm in info.json()["team_memberships"]}
+ assert memberships[member_id]["budget_source"] == "team_default"
+ assert memberships[member_id]["litellm_budget_table"]["max_budget"] == _TEAM_DEFAULT_MAX_BUDGET
+
+
+async def test_team_member_reset_budget_without_team_default_detaches_member(proxy_client, prisma, scratch, world):
+ member_id = scratch.tag("member")
+ await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id)
+ await _seed_custom_member(prisma, scratch.prefix, member_id, scratch)
+
+ resp = await proxy_client.post(
+ f"/team/{scratch.prefix}/member/{member_id}/reset_budget",
+ headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
+ )
+ assert resp.status_code == 200, resp.text
+ assert resp.json()["budget_id"] is None
+ assert resp.json()["budget_source"] == "none"
+
+ row = await _membership(prisma, scratch.prefix, member_id)
+ assert row.budget_id is None
+ assert row.spend == _SEED_SPEND
+
+
+async def test_team_member_reset_budget_with_deleted_team_default_detaches_member(proxy_client, prisma, scratch, world):
+ """metadata.team_member_budget_id can outlive its budget row; a stale id must not be
+ relinked to (the FK would fail) and must read as no budget, not as the team default."""
+ member_id = scratch.tag("member")
+ await create_scratch_team(
+ prisma,
+ scratch.prefix,
+ organization_id=world.org_a_id,
+ metadata={"team_member_budget_id": scratch.tag("deleted-budget")},
+ )
+ await _seed_custom_member(prisma, scratch.prefix, member_id, scratch)
+
+ resp = await proxy_client.post(
+ f"/team/{scratch.prefix}/member/{member_id}/reset_budget",
+ headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
+ )
+ assert resp.status_code == 200, resp.text
+ assert resp.json()["budget_id"] is None
+ assert resp.json()["budget_source"] == "none"
+
+ row = await _membership(prisma, scratch.prefix, member_id)
+ assert row.budget_id is None
+
+
+async def test_team_member_reset_budget_missing_team_is_404(proxy_client, world):
+ resp = await proxy_client.post(
+ f"/team/behavior-pin-no-such-team/member/{uuid.uuid4().hex}/reset_budget",
+ headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
+ )
+ assert resp.status_code == 404, resp.text
+
+
+async def test_team_member_reset_budget_missing_membership_is_404(proxy_client, prisma, scratch, world):
+ await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id)
+ resp = await proxy_client.post(
+ f"/team/{scratch.prefix}/member/{uuid.uuid4().hex}/reset_budget",
+ headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
+ )
+ assert resp.status_code == 404, resp.text
From 8bd9d356dcc10632a8efdc1a2229646e97cb301f Mon Sep 17 00:00:00 2001
From: ryan
Date: Sat, 19 Sep 2026 01:00:31 +0000
Subject: [PATCH 046/317] test(team): drop docstrings that restate the budget
source and reset assertions
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../management/test_team_member_reset_budget.py | 4 ----
.../management_endpoints/test_team_endpoints.py | 16 ----------------
2 files changed, 20 deletions(-)
diff --git a/tests/proxy_behavior/management/test_team_member_reset_budget.py b/tests/proxy_behavior/management/test_team_member_reset_budget.py
index 42f327c33ef..1e55b8b6b15 100644
--- a/tests/proxy_behavior/management/test_team_member_reset_budget.py
+++ b/tests/proxy_behavior/management/test_team_member_reset_budget.py
@@ -117,8 +117,6 @@ async def test_team_member_reset_budget_authz_matrix(
async def test_team_member_reset_budget_leaves_shared_default_row_untouched(proxy_client, prisma, scratch, world):
- """Relinking must point the member at the shared row, not copy or edit it, so a later
- /team/update to team_member_budget reaches this member again."""
member_id = scratch.tag("member")
default_budget_id = await _seed_team_with_default_budget(prisma, world, "alpha", scratch.prefix, scratch)
await _seed_custom_member(prisma, scratch.prefix, member_id, scratch)
@@ -161,8 +159,6 @@ async def test_team_member_reset_budget_without_team_default_detaches_member(pro
async def test_team_member_reset_budget_with_deleted_team_default_detaches_member(proxy_client, prisma, scratch, world):
- """metadata.team_member_budget_id can outlive its budget row; a stale id must not be
- relinked to (the FK would fail) and must read as no budget, not as the team default."""
member_id = scratch.tag("member")
await create_scratch_team(
prisma,
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 484c054fa54..1f19163933a 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
@@ -14443,10 +14443,6 @@ def _team_with_default_budget(team_id: str, budget_id: str) -> LiteLLM_TeamTable
@pytest.mark.asyncio
async def test_reset_team_member_budget_fn_relinks_custom_member_to_team_default(monkeypatch):
- """An admin undoing a per-member budget must put the membership back on the team's shared
- default row (a connect, not a copy) so later /team/update changes reach the member again,
- and must drop the cached membership so the old cap stops being enforced. The shared row and
- the member's tracked spend are never written."""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
mock_prisma_client = MagicMock()
@@ -14498,9 +14494,6 @@ async def test_reset_team_member_budget_fn_relinks_custom_member_to_team_default
async def test_reset_team_member_budget_fn_detaches_member_when_team_has_no_usable_default(
monkeypatch, team_obj, default_row
):
- """With no shared default to link to, reset leaves the member exactly where a freshly added
- member would be: no budget row at all, reported as budget_source='none', rather than
- connecting to a budget_id that does not exist or leaving the custom cap in place."""
mock_prisma_client = MagicMock()
membership_row = LiteLLM_TeamMembership(user_id="member-1", team_id="team-1", budget_id="custom-b1")
mock_prisma_client.db.litellm_teammembership.find_unique = AsyncMock(return_value=membership_row)
@@ -14604,9 +14597,6 @@ async def _team_info_budget_sources(
@pytest.mark.asyncio
async def test_team_info_reports_whether_each_member_follows_the_team_default_budget():
- """/team/info must tell the caller which members still follow the team's shared member budget
- and which carry their own row, since budget_id alone only means something to a reader who
- also knows the team's team_member_budget_id."""
sources = await _team_info_budget_sources(
team_row=_team_with_default_budget("team-1", "team-default-b"),
memberships=[
@@ -14626,9 +14616,6 @@ async def test_team_info_reports_whether_each_member_follows_the_team_default_bu
@pytest.mark.asyncio
async def test_team_info_reports_no_budget_source_when_team_has_no_default():
- """A team that never set team_member_budget has nothing for members to inherit, so an
- unlinked member is 'none' rather than 'team_default', while a member with their own row is
- still 'custom'."""
sources = await _team_info_budget_sources(
team_row=LiteLLM_TeamTable(team_id="team-1"),
memberships=[
@@ -14646,9 +14633,6 @@ async def test_team_info_reports_no_budget_source_when_team_has_no_default():
@pytest.mark.asyncio
async def test_team_info_reports_no_budget_source_when_team_default_row_was_deleted():
- """If the budget row named by team_member_budget_id was removed via /budget/delete, nothing is
- enforced for unlinked members any more, so /team/info must not keep advertising a team default
- that no longer exists."""
sources = await _team_info_budget_sources(
team_row=_team_with_default_budget("team-1", "deleted-b"),
memberships=[
From ec59078ad99d14e9c4b596f89b83c88d607586b8 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 04:17:38 -0700
Subject: [PATCH 047/317] fix: apply configured cache_control_injection_points
beside client cache_control marks
Configured injection points were dropped whenever the request already
carried a client-set cache_control anywhere, so an operator's rolling
tail checkpoint silently never landed once a caller marked its own
system prompt. Only the automatic defaults stand down now. Configured
points skip a target the client already marked and stay under the
provider's 4-block cap, counting the client's marks on messages, system,
tools and the root cache_control first. The chat path carries the tool
count as a stamp on the points because the prompt-management hook never
receives tools.
Fixes #40675
---
.../anthropic_cache_control_hook.py | 212 ++++++++--------
.../anthropic_cache_control_hook.py | 4 +-
.../test_anthropic_cache_control_hook.py | 234 +++++++++++++-----
3 files changed, 270 insertions(+), 180 deletions(-)
diff --git a/litellm/integrations/anthropic_cache_control_hook.py b/litellm/integrations/anthropic_cache_control_hook.py
index 4f9b18713d0..b06372baa78 100644
--- a/litellm/integrations/anthropic_cache_control_hook.py
+++ b/litellm/integrations/anthropic_cache_control_hook.py
@@ -121,6 +121,12 @@ def _carries_cache_breakpoint(block: object) -> bool:
return isinstance(block, dict) and any(block.get(key) is not None for key in CACHE_BREAKPOINT_KEYS)
+def _tool_carries_cache_breakpoint(tool: object) -> bool:
+ return _carries_cache_breakpoint(tool) or (
+ isinstance(tool, dict) and _carries_cache_breakpoint(tool.get("function"))
+ )
+
+
def _accepts_prompt_cache_breakpoint(block: object) -> bool:
return isinstance(block, dict) and block.get("type") in OPENAI_PROMPT_CACHE_BREAKPOINT_BLOCK_TYPES
@@ -131,6 +137,8 @@ def _accepts_prompt_cache_breakpoint(block: object) -> bool:
# rather than spending them on a list that is still missing some of their targets.
CARRY_UNMATCHED_MESSAGE_POINTS: Final = "_litellm_carry_unmatched_cache_control_points"
+EXTERNAL_BREAKPOINTS_STAMP: Final = "_litellm_external_breakpoints"
+
class AnthropicCacheControlHook(CustomPromptManagement):
@staticmethod
@@ -205,10 +213,6 @@ class AnthropicCacheControlHook(CustomPromptManagement):
else:
remaining_points.append(point)
- # Non-message points (currently Bedrock tool_config) are handled in the
- # provider transform, where each tool_config point appends at most one
- # cachePoint to the tools. That block also counts toward Anthropic's
- # limit, so reserve a slot for it here to leave room.
stamped_dialect: Final = injection_points[0].get("_litellm_openai_dialect")
openai_dialect: Final = (
stamped_dialect
@@ -233,8 +237,11 @@ class AnthropicCacheControlHook(CustomPromptManagement):
if carry_unmatched
else tuple(message_points)
)
- reserved_blocks: Final = (
- 1 if not openai_dialect and any(p.get("location") == "tool_config" for p in remaining_points) else 0
+ stamped_external: Final = injection_points[0].get(EXTERNAL_BREAKPOINTS_STAMP)
+ reserved_blocks: Final = AnthropicCacheControlHook._blocks_reserved_outside_messages(
+ remaining_points,
+ stamped_external if isinstance(stamped_external, int) else 0,
+ openai_dialect,
)
breakpoints_before: Final = AnthropicCacheControlHook.count_request_cache_breakpoints(processed_messages)
processed_messages = self._apply_message_injections(
@@ -251,14 +258,12 @@ class AnthropicCacheControlHook(CustomPromptManagement):
# Points this pass did not place: non-message ones for the provider transform, and
# the deferred role-targeted ones. Deferring is what reaches the Responses API's
- # `instructions`, which is only a system message once the bridge builds one. The
- # judged stamp is what makes it safe: the next pass must not re-judge points
- # against messages this pass already marked (see `_should_stand_down`).
+ # `instructions`, which is only a system message once the bridge builds one. A later
+ # pass re-applies them safely: a target that already carries a mark is skipped and
+ # the census counts every mark on the wire, litellm's own included.
carried_points: Final[Sequence[CacheControlInjectionPoint]] = (*remaining_points, *carried_message_points)
if carried_points:
- non_default_params["cache_control_injection_points"] = AnthropicCacheControlHook._stamped_as_judged(
- carried_points
- )
+ non_default_params["cache_control_injection_points"] = list(carried_points)
return model, processed_messages, non_default_params
@@ -293,6 +298,34 @@ class AnthropicCacheControlHook(CustomPromptManagement):
)
return system_blocks + sum(AnthropicCacheControlHook._count_cache_control_blocks(msg) for msg in messages)
+ @staticmethod
+ def count_external_cache_breakpoints(tools: Iterable[object] | None, cache_control: object = None) -> int:
+ """Client breakpoints outside messages and system that the provider cap still counts.
+
+ A tool carries its mark at the top level (Anthropic shape) or under ``function``
+ (OpenAI shape); the Anthropic chat transform forwards both. A top-level
+ ``cache_control`` is Anthropic's automatic caching, which places one breakpoint
+ of its own on top of the explicit ones.
+ """
+ automatic_blocks: Final = 1 if cache_control is not None else 0
+ tool_blocks: Final = sum(1 for tool in tools if _tool_carries_cache_breakpoint(tool)) if tools else 0
+ return automatic_blocks + tool_blocks
+
+ @staticmethod
+ def _blocks_reserved_outside_messages(
+ remaining_points: Sequence[CacheControlInjectionPoint], external_breakpoints: int, openai_dialect: bool
+ ) -> int:
+ """Slots of the provider cap that the message census cannot see.
+
+ The client's breakpoints on tools and its automatic top-level ``cache_control``
+ are already on the wire, and a ``tool_config`` point becomes one more cachePoint
+ in the Bedrock converse transform. OpenAI's cap counts only its own block markers.
+ """
+ if openai_dialect:
+ return 0
+ tool_config_blocks: Final = 1 if any(p.get("location") == "tool_config" for p in remaining_points) else 0
+ return external_breakpoints + tool_config_blocks
+
@staticmethod
def _apply_message_injections(
points: Sequence[CacheControlMessageInjectionPoint],
@@ -473,11 +506,16 @@ class AnthropicCacheControlHook(CustomPromptManagement):
def apply_to_anthropic_messages_request(
messages: list[dict],
system: str | list | None,
- injection_points: list[CacheControlInjectionPoint],
+ injection_points: Sequence[CacheControlInjectionPoint],
openai_dialect: bool = False,
+ external_breakpoints: int = 0,
) -> tuple[list[dict], str | list | None, list[CacheControlInjectionPoint]]:
"""Apply cache control injection for the Anthropic-native v1/messages endpoint.
+ ``external_breakpoints`` is the client's breakpoint count outside ``messages`` and
+ ``system`` (see ``count_external_cache_breakpoints``); it shrinks the budget so
+ the request never exceeds the provider cap.
+
Returns (messages, system, remaining_non_message_points).
"""
if not injection_points:
@@ -500,8 +538,8 @@ class AnthropicCacheControlHook(CustomPromptManagement):
else:
remaining_points.append(point)
- reserved_blocks: Final = (
- 1 if not openai_dialect and any(p.get("location") == "tool_config" for p in remaining_points) else 0
+ reserved_blocks: Final = AnthropicCacheControlHook._blocks_reserved_outside_messages(
+ remaining_points, external_breakpoints, openai_dialect
)
max_blocks: Final = MAX_CACHE_CONTROL_BLOCKS - reserved_blocks
@@ -556,30 +594,26 @@ class AnthropicCacheControlHook(CustomPromptManagement):
return ChatCompletionCachedContent(type="ephemeral")
@staticmethod
- def _stamped_as_judged(points: Sequence[CacheControlInjectionPoint]) -> Sequence[Mapping[str, object]]:
- """Mark written-back points as having passed the client cache_control judgment.
-
- Builds copies because config-owned point dicts are shared across
- requests; mutating them would leak the stamp into future requests.
- """
- return AnthropicCacheControlHook._stamped(points, "_litellm_judged", True)
-
- @staticmethod
- def _judged_configured_points(
+ def _stamped_for_prompt_hook(
points: Sequence[CacheControlInjectionPoint],
- messages: list[AllMessageValues],
- tools: list[object] | None,
- cache_control: object,
+ external_breakpoints: int,
model: str,
custom_llm_provider: str | None,
api_base: object,
prompt_cache_options: object,
- ) -> Sequence[Mapping[str, object]] | None:
- if AnthropicCacheControlHook._should_stand_down(points, messages, None, tools, cache_control):
- return None
- return AnthropicCacheControlHook._stamped_with_dialect(
+ ) -> Sequence[Mapping[str, object]]:
+ """Carry onto the points what the prompt-management hook never receives.
+
+ The hook sees neither the tools nor the request kwargs, so the target dialect
+ and the client's breakpoint count outside the message list ride on the points.
+ Builds copies because config-owned point dicts are shared across requests.
+ """
+ with_dialect: Final = AnthropicCacheControlHook._stamped_with_dialect(
points, model, custom_llm_provider, api_base, prompt_cache_options
)
+ if external_breakpoints == 0:
+ return with_dialect
+ return AnthropicCacheControlHook._stamped(with_dialect, EXTERNAL_BREAKPOINTS_STAMP, external_breakpoints)
@staticmethod
def _stamped_with_dialect(
@@ -600,32 +634,9 @@ class AnthropicCacheControlHook(CustomPromptManagement):
)
@staticmethod
- def _stamped(
- points: Sequence[CacheControlInjectionPoint], key: str, value: object
- ) -> Sequence[Mapping[str, object]]:
+ def _stamped(points: Sequence[Mapping[str, object]], key: str, value: object) -> Sequence[Mapping[str, object]]:
return [{**point, key: value} for point in points]
- @staticmethod
- def _should_stand_down(
- points: Sequence[CacheControlInjectionPoint],
- messages: list[AllMessageValues],
- system: str | list | None,
- tools: list | None,
- cache_control: object = None,
- ) -> bool:
- """Whether configured injection points must yield to client-set cache_control.
-
- Points that a prior pass over this request already judged and wrote
- back carry the internal judged stamp; any re-entry (acompletion
- re-entering completion, the async-to-sync /v1/messages dispatch,
- interceptor sub-calls reusing the request kwargs) must not re-judge
- them, because by then the messages carry litellm's own injected marks
- and the judgment would misread those as client breakpoints.
- """
- if all(point.get("_litellm_judged") for point in points):
- return False
- return AnthropicCacheControlHook._request_has_cache_control(messages, system, tools, cache_control)
-
@staticmethod
def _request_has_cache_control(
messages: list[AllMessageValues],
@@ -635,28 +646,15 @@ class AnthropicCacheControlHook(CustomPromptManagement):
) -> bool:
"""Return True if the request already carries any client-supplied cache_control.
- When the client (e.g. Claude Code) already marks its own breakpoints we
- stand down entirely rather than add more, per the auto-caching contract.
- Tools count: they are a breakpoint the client can mark, they count toward
- the provider's four-block limit, and caching only the tool definitions is
- a common pattern, so injecting alongside them can exceed the cap. Tools
- carry the mark either at the top level (Anthropic shape) or nested under
- ``function`` (OpenAI shape); the Anthropic chat transform accepts both.
+ Only the automatic defaults stand down on it: a client that marks its own
+ breakpoints (Claude Code does) has a caching strategy the defaults would
+ clash with. Configured injection points are an explicit instruction and are
+ applied alongside the client's marks, bounded by the provider cap.
"""
- if cache_control is not None:
- return True
- if AnthropicCacheControlHook.count_request_cache_breakpoints(messages, system) > 0:
- return True
- if tools is not None:
- return any(
- isinstance(tool, dict)
- and (
- tool.get("cache_control") is not None
- or (isinstance(tool.get("function"), dict) and tool["function"].get("cache_control") is not None)
- )
- for tool in tools
- )
- return False
+ return (
+ AnthropicCacheControlHook.count_request_cache_breakpoints(messages, system)
+ + AnthropicCacheControlHook.count_external_cache_breakpoints(tools, cache_control)
+ ) > 0
@staticmethod
def get_default_injection_points(
@@ -779,31 +777,25 @@ class AnthropicCacheControlHook(CustomPromptManagement):
) -> None:
"""For /chat/completions: resolve the injection points the request should carry.
- Configured injection points win over the automatic defaults, but stand
- down entirely when the client already marked its own cache_control
- breakpoints (messages or tools): injecting alongside them clashes with
- the client's caching strategy and can exceed the provider's four-block
- limit. The judgment happens once per request; points a prior pass
- wrote back carry the judged stamp and are never re-judged (see
- ``_should_stand_down``). Seeding the param lets the existing
- prompt-management gate and the AnthropicCacheControlHook run
- unchanged.
+ Configured injection points win over the automatic defaults and are applied
+ even when the client marked its own cache_control elsewhere in the request;
+ the provider's four-block cap bounds them, counting the client's marks on
+ messages, tools and the top-level ``cache_control``. Only the defaults stand
+ down on client marks. Seeding the param lets the existing prompt-management
+ gate and the AnthropicCacheControlHook run unchanged.
"""
- if non_default_params.get("cache_control_injection_points"):
- judged: Final = AnthropicCacheControlHook._judged_configured_points(
- non_default_params["cache_control_injection_points"],
- messages,
- tools,
- non_default_params.get("cache_control"),
+ configured: Final = non_default_params.get("cache_control_injection_points")
+ if configured:
+ non_default_params["cache_control_injection_points"] = AnthropicCacheControlHook._stamped_for_prompt_hook(
+ configured,
+ AnthropicCacheControlHook.count_external_cache_breakpoints(
+ tools, non_default_params.get("cache_control")
+ ),
model,
custom_llm_provider,
api_base,
non_default_params.get("prompt_cache_options"),
)
- if judged is None:
- non_default_params.pop("cache_control_injection_points")
- else:
- non_default_params["cache_control_injection_points"] = judged
return
points: Final = AnthropicCacheControlHook.get_default_injection_points(
messages=messages,
@@ -904,15 +896,14 @@ class AnthropicCacheControlHook(CustomPromptManagement):
) -> tuple[list[dict], str | list | None]:
"""Extract cache_control_injection_points from kwargs and apply if present.
- Configured points stand down entirely when the client already marked
- its own cache_control breakpoints anywhere in the request. The
- judgment happens once per request; points a prior pass wrote back
- carry the judged stamp and are never re-judged (see
- ``_should_stand_down``). When none are configured but
+ Configured points are applied even when the client marked its own
+ cache_control elsewhere in the request, bounded by the provider cap,
+ which counts the client's marks on messages, system, tools and the
+ top-level ``cache_control``. When none are configured but
``litellm.enable_anthropic_prompt_caching`` or the per-request
``enable_prompt_caching`` kwarg (stamped from key metadata) is on,
- synthesize default breakpoints for the native /v1/messages path. Pops
- both keys from kwargs;
+ synthesize default breakpoints for the native /v1/messages path; those
+ defaults alone stand down on client marks. Pops both keys from kwargs;
if remaining (non-message) points exist they are written back so
downstream transforms can handle them.
"""
@@ -924,13 +915,8 @@ class AnthropicCacheControlHook(CustomPromptManagement):
configured: Final = cast( # cast-ok: kwargs is untyped; this key only holds the documented injection-point list
list[CacheControlInjectionPoint] | None, kwargs.pop("cache_control_injection_points", None)
)
- if configured and AnthropicCacheControlHook._should_stand_down(
- configured, typed_messages, system, tools, cache_control
- ):
- return messages, system
- injection_points: list[CacheControlInjectionPoint] = configured or []
- if not injection_points and model is not None:
- injection_points = AnthropicCacheControlHook.get_default_injection_points(
+ injection_points: Final[Sequence[CacheControlInjectionPoint]] = configured or (
+ AnthropicCacheControlHook.get_default_injection_points(
messages=typed_messages,
system=system,
tools=tools,
@@ -940,6 +926,9 @@ class AnthropicCacheControlHook(CustomPromptManagement):
cache_control=cache_control,
request_kwargs=kwargs,
)
+ if model is not None
+ else ()
+ )
if not injection_points:
return messages, system
@@ -952,6 +941,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
system=system,
injection_points=injection_points,
openai_dialect=openai_dialect,
+ external_breakpoints=AnthropicCacheControlHook.count_external_cache_breakpoints(tools, cache_control),
)
breakpoints_added: Final = (
AnthropicCacheControlHook.count_request_cache_breakpoints(messages, system) - breakpoints_before
@@ -960,7 +950,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
if openai_dialect and breakpoints_added > 0:
kwargs.setdefault("prompt_cache_options", PromptCacheOptions(mode="explicit"))
if remaining:
- kwargs["cache_control_injection_points"] = AnthropicCacheControlHook._stamped_as_judged(remaining)
+ kwargs["cache_control_injection_points"] = remaining
return messages, system
@property
diff --git a/litellm/types/integrations/anthropic_cache_control_hook.py b/litellm/types/integrations/anthropic_cache_control_hook.py
index ef414f22c3b..20e7885a2bf 100644
--- a/litellm/types/integrations/anthropic_cache_control_hook.py
+++ b/litellm/types/integrations/anthropic_cache_control_hook.py
@@ -17,8 +17,8 @@ class CacheControlMessageInjectionPoint(TypedDict):
role: Literal["user", "system", "assistant"] | None # Optional: target by role (user, system, assistant)
index: int | str | None # Optional: target by specific index
control: ChatCompletionCachedContent | None
- _litellm_judged: NotRequired[bool] # Internal: written back by litellm once the client cache_control judgment ran
_litellm_openai_dialect: NotRequired[ReadOnly[bool]]
+ _litellm_external_breakpoints: NotRequired[ReadOnly[int]]
class CacheControlToolConfigInjectionPoint(TypedDict):
@@ -26,8 +26,8 @@ class CacheControlToolConfigInjectionPoint(TypedDict):
location: Literal["tool_config"]
control: ChatCompletionCachedContent | None
- _litellm_judged: NotRequired[bool] # Internal: written back by litellm once the client cache_control judgment ran
_litellm_openai_dialect: NotRequired[ReadOnly[bool]]
+ _litellm_external_breakpoints: NotRequired[ReadOnly[int]]
CacheControlInjectionPoint = CacheControlMessageInjectionPoint | CacheControlToolConfigInjectionPoint
diff --git a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
index 92b1185e542..3424cc5fed6 100644
--- a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
+++ b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
@@ -1276,11 +1276,7 @@ def test_cache_control_hook_reserves_slot_for_tool_config_point():
)
assert _count_cache_control(processed) == 3
- # The tool_config point is passed through for the provider transform,
- # stamped so re-entries never re-judge it against litellm's own marks.
- assert non_default_params["cache_control_injection_points"] == [
- {"location": "tool_config", "_litellm_judged": True}
- ]
+ assert non_default_params["cache_control_injection_points"] == [{"location": "tool_config"}]
@pytest.mark.asyncio
@@ -2085,13 +2081,17 @@ class TestPerKeyEnablePromptCaching:
assert result_msgs == messages
-class TestConfiguredInjectionPointsStandDown:
- """Configured cache_control_injection_points must stand down entirely when the
- client already set its own cache_control anywhere in the request (LIT-4582);
- injecting alongside client breakpoints clashes with the client's caching
- strategy and can push the request past Anthropic's four-block limit."""
+class TestConfiguredInjectionPointsSurviveClientMarks:
+ """Configured cache_control_injection_points are an explicit instruction, so they
+ apply alongside the client's own cache_control marks (LIT-7586, #40675) instead of
+ standing down on them. What bounds them is Anthropic's four-block cap, which has to
+ count the client's marks on messages, system, tools and the root ``cache_control``
+ (LIT-4582: a client-marked tool the cap could not see produced "Found 5" 400s).
+ Only the automatic defaults stand down on client marks."""
CONFIGURED = [{"location": "message", "role": "system"}]
+ TAIL_POINT = [{"location": "message", "index": -1}]
+ EPHEMERAL = {"type": "ephemeral"}
CLEAN_MESSAGES: List[AllMessageValues] = [
{"role": "system", "content": "sys"},
@@ -2105,6 +2105,23 @@ class TestConfiguredInjectionPointsStandDown:
V1_MESSAGES = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
+ MARKED_TOOL_TOP_LEVEL = {
+ "type": "function",
+ "function": {"name": "t", "parameters": {}},
+ "cache_control": {"type": "ephemeral"},
+ }
+ MARKED_TOOL_NESTED = {"type": "function", "function": {"name": "t", "parameters": {}, "cache_control": {"type": "ephemeral"}}}
+ UNMARKED_TOOL = {"type": "function", "function": {"name": "t", "parameters": {}}}
+ MARKED_V1_TOOL = {"name": "t", "input_schema": {}, "cache_control": {"type": "ephemeral"}}
+ UNMARKED_V1_TOOL = {"name": "t", "input_schema": {}}
+
+ @staticmethod
+ def _marked_user_turns(count):
+ return [
+ {"role": "user", "content": [{"type": "text", "text": f"turn {i}", "cache_control": {"type": "ephemeral"}}]}
+ for i in range(count)
+ ]
+
def _seed(self, params, messages, tools=None):
AnthropicCacheControlHook.maybe_seed_default_injection_points(
non_default_params=params,
@@ -2114,6 +2131,17 @@ class TestConfiguredInjectionPointsStandDown:
tools=tools,
)
+ def _chat(self, params, messages):
+ _, processed, _ = AnthropicCacheControlHook().get_chat_completion_prompt(
+ model="claude-sonnet-4-5",
+ messages=messages,
+ non_default_params=params,
+ prompt_id=None,
+ prompt_variables=None,
+ dynamic_callback_params={},
+ )
+ return processed
+
def _inject(self, messages, kwargs, system="sys", tools=None):
return AnthropicCacheControlHook.maybe_inject_cache_control(
messages,
@@ -2124,23 +2152,64 @@ class TestConfiguredInjectionPointsStandDown:
tools=tools,
)
- def test_configured_points_dropped_when_messages_carry_cache_control(self):
+ def test_chat_tail_point_applies_when_client_marked_the_system_block(self):
+ """The issue's shape: the client caches its system prompt, the deployment is
+ configured to cache the trailing turn, and both marks must reach the provider."""
+ messages: List[AllMessageValues] = [
+ {"role": "system", "content": [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]},
+ {"role": "user", "content": "history"},
+ {"role": "assistant", "content": "reply"},
+ {"role": "user", "content": "question"},
+ ]
+ params = {"cache_control_injection_points": copy.deepcopy(self.TAIL_POINT)}
+ self._seed(params, messages)
+ processed = self._chat(params, messages)
+ assert processed[0] == messages[0]
+ assert processed[-1] == {"role": "user", "content": "question", "cache_control": self.EPHEMERAL}
+ assert _count_cache_control(processed) == 2
+
+ def test_chat_configured_points_apply_when_messages_carry_cache_control(self):
params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)}
self._seed(params, copy.deepcopy(self.MARKED_MESSAGES))
- assert "cache_control_injection_points" not in params
+ processed = self._chat(params, copy.deepcopy(self.MARKED_MESSAGES))
+ assert processed[0] == {"role": "system", "content": "sys", "cache_control": self.EPHEMERAL}
+ assert processed[1] == self.MARKED_MESSAGES[1]
@pytest.mark.parametrize(
- "tool",
- [
- {"type": "function", "function": {"name": "t", "parameters": {}}, "cache_control": {"type": "ephemeral"}},
- {"type": "function", "function": {"name": "t", "parameters": {}, "cache_control": {"type": "ephemeral"}}},
- ],
- ids=["top_level", "nested_in_function"],
+ "tool", [MARKED_TOOL_TOP_LEVEL, MARKED_TOOL_NESTED], ids=["top_level", "nested_in_function"]
)
- def test_configured_points_dropped_when_tools_carry_cache_control(self, tool):
+ def test_chat_configured_points_apply_when_tools_carry_cache_control(self, tool):
params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)}
self._seed(params, copy.deepcopy(self.CLEAN_MESSAGES), tools=[tool])
- assert "cache_control_injection_points" not in params
+ processed = self._chat(params, copy.deepcopy(self.CLEAN_MESSAGES))
+ assert processed[0] == {"role": "system", "content": "sys", "cache_control": self.EPHEMERAL}
+
+ @pytest.mark.parametrize(
+ "tool,injected",
+ [(MARKED_TOOL_TOP_LEVEL, 0), (MARKED_TOOL_NESTED, 0), (UNMARKED_TOOL, 1)],
+ ids=["marked_top_level", "marked_nested_in_function", "unmarked"],
+ )
+ def test_chat_cap_counts_client_marked_tools(self, tool, injected):
+ """LIT-4582 regression: the prompt-management hook never sees the tools, so the
+ seeding pass has to carry the client's tool marks into the cap or a configured
+ point lands as a fifth block."""
+ messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(3)]
+ params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)}
+ self._seed(params, copy.deepcopy(messages), tools=[tool])
+ processed = self._chat(params, copy.deepcopy(messages))
+ assert _count_cache_control(processed) == 3 + injected
+
+ @pytest.mark.parametrize("marked_turns,injected", [(2, 1), (3, 0)])
+ def test_chat_root_cache_control_reserves_a_slot(self, marked_turns, injected):
+ """Anthropic's automatic caching (a top-level ``cache_control``) places one
+ breakpoint of its own, so it counts toward the cap like a client mark."""
+ messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(marked_turns)]
+ root_cache_control = {"type": "ephemeral"}
+ params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), "cache_control": root_cache_control}
+ self._seed(params, copy.deepcopy(messages))
+ processed = self._chat(params, copy.deepcopy(messages))
+ assert _count_cache_control(processed) == marked_turns + injected
+ assert params["cache_control"] is root_cache_control
def test_configured_points_kept_when_request_is_unmarked(self):
configured = copy.deepcopy(self.CONFIGURED)
@@ -2148,60 +2217,68 @@ class TestConfiguredInjectionPointsStandDown:
self._seed(params, copy.deepcopy(self.CLEAN_MESSAGES))
assert params["cache_control_injection_points"] is configured
- def test_judged_remainder_survives_reentry_despite_injected_marks(self):
- """acompletion() re-enters completion() after injection ran, with only the
- stamped non-message points written back; the re-entry must not misread
- litellm's own marks as client ones and drop that remainder."""
- remainder = [{"location": "tool_config", "_litellm_judged": True}]
- params = {"cache_control_injection_points": remainder}
- self._seed(params, copy.deepcopy(self.MARKED_MESSAGES))
- assert params["cache_control_injection_points"] is remainder
+ def test_chat_reentry_over_injected_messages_adds_no_duplicate_marks(self):
+ """acompletion() re-enters completion() and interceptor sub-calls reuse the
+ request kwargs, so the same configured points meet messages that already carry
+ litellm's own marks; the second pass must leave them as they are."""
+ points = [{"location": "message", "role": "system"}, {"location": "tool_config"}]
+ first_params = {"cache_control_injection_points": copy.deepcopy(points)}
+ self._seed(first_params, copy.deepcopy(self.MARKED_MESSAGES))
+ first = self._chat(first_params, copy.deepcopy(self.MARKED_MESSAGES))
+ assert _count_cache_control(first) == 2
+ assert first_params["cache_control_injection_points"] == [{"location": "tool_config"}]
- def test_v1_messages_stand_down_when_content_block_marked(self):
+ second_params = {"cache_control_injection_points": copy.deepcopy(points)}
+ self._seed(second_params, copy.deepcopy(first))
+ second = self._chat(second_params, copy.deepcopy(first))
+ assert second == first
+ assert second_params["cache_control_injection_points"] == [{"location": "tool_config"}]
+
+ def test_v1_messages_configured_point_applies_when_content_block_marked(self):
messages = [
{"role": "user", "content": [{"type": "text", "text": "hi", "cache_control": {"type": "ephemeral"}}]}
]
kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)}
result_msgs, result_sys = self._inject(copy.deepcopy(messages), kwargs)
assert result_msgs == messages
- assert result_sys == "sys"
+ assert result_sys == [{"type": "text", "text": "sys", "cache_control": self.EPHEMERAL}]
assert "cache_control_injection_points" not in kwargs
- def test_v1_messages_stand_down_when_system_block_marked(self):
- """A configured point targeting a message must not fire when the client
- marked the system prompt; the old behavior injected into the message
- because only the exact targeted position was guarded."""
+ def test_v1_messages_tail_point_applies_when_system_block_marked(self):
system = [{"type": "text", "text": "s", "cache_control": {"type": "ephemeral"}}]
- kwargs = {"cache_control_injection_points": [{"location": "message", "role": "user"}]}
+ kwargs = {"cache_control_injection_points": copy.deepcopy(self.TAIL_POINT)}
result_msgs, result_sys = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs, system=system)
- assert result_msgs == self.V1_MESSAGES
+ assert result_msgs == [{"role": "user", "content": [{"type": "text", "text": "hi", "cache_control": self.EPHEMERAL}]}]
assert result_sys == system
- assert "cache_control_injection_points" not in kwargs
- def test_v1_messages_stand_down_when_tools_marked(self):
- tools = [{"name": "t", "input_schema": {}, "cache_control": {"type": "ephemeral"}}]
+ def test_v1_messages_configured_point_applies_when_tools_marked(self):
kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)}
- result_msgs, result_sys = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs, tools=tools)
+ result_msgs, result_sys = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs, tools=[self.MARKED_V1_TOOL])
assert result_msgs == self.V1_MESSAGES
- assert result_sys == "sys"
- assert "cache_control_injection_points" not in kwargs
+ assert result_sys == [{"type": "text", "text": "sys", "cache_control": self.EPHEMERAL}]
+
+ @pytest.mark.parametrize(
+ "tool,expected_system",
+ [
+ (MARKED_V1_TOOL, "sys"),
+ (UNMARKED_V1_TOOL, [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]),
+ ],
+ ids=["marked", "unmarked"],
+ )
+ def test_v1_messages_cap_counts_client_marked_tools(self, tool, expected_system):
+ kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)}
+ _, result_sys = self._inject(self._marked_user_turns(3), kwargs, tools=[tool])
+ assert result_sys == expected_system
def test_v1_messages_configured_points_apply_when_unmarked(self):
kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)}
_, result_sys = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs)
assert result_sys == [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]
- @pytest.mark.parametrize(
- "configured",
- [None, CONFIGURED],
- ids=["automatic_defaults", "configured_points"],
- )
- def test_v1_messages_stands_down_for_root_cache_control(self, monkeypatch, configured):
+ def test_v1_messages_automatic_defaults_stand_down_for_root_cache_control(self, monkeypatch):
monkeypatch.setattr(litellm, "enable_anthropic_prompt_caching", True)
root_cache_control = {"type": "ephemeral"}
kwargs = {"cache_control": root_cache_control, "litellm_metadata": {}}
- if configured is not None:
- kwargs["cache_control_injection_points"] = copy.deepcopy(configured)
result_messages, result_system = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs)
@@ -2210,17 +2287,33 @@ class TestConfiguredInjectionPointsStandDown:
assert kwargs["cache_control"] is root_cache_control
assert "litellm_gateway_injected_cache" not in kwargs["litellm_metadata"]
+ @pytest.mark.parametrize(
+ "marked_turns,expected_system",
+ [(2, [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]), (3, "sys")],
+ )
+ def test_v1_messages_configured_points_apply_with_root_cache_control_reserving_a_slot(
+ self, marked_turns, expected_system
+ ):
+ root_cache_control = {"type": "ephemeral"}
+ kwargs = {
+ "cache_control": root_cache_control,
+ "cache_control_injection_points": copy.deepcopy(self.CONFIGURED),
+ }
+ _, result_system = self._inject(self._marked_user_turns(marked_turns), kwargs)
+ assert result_system == expected_system
+ assert kwargs["cache_control"] is root_cache_control
+
def test_v1_messages_reentry_flow_preserves_tool_config_remainder(self):
"""The advisor interceptor re-enters anthropic_messages() with the outer
request's kwargs and post-injection messages. The first pass applies the
- message point and writes back a stamped tool_config remainder; the
- re-entry must keep that remainder even though the messages and system
- now carry litellm's own marks."""
+ message point and writes back the tool_config remainder; the re-entry must
+ keep that remainder and add no mark even though the messages and system
+ now carry litellm's own."""
points = [{"location": "message", "role": "system"}, {"location": "tool_config"}]
kwargs = {"cache_control_injection_points": copy.deepcopy(points)}
msgs1, sys1 = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs)
assert sys1[0]["cache_control"] == {"type": "ephemeral"}
- expected_remainder = [{"location": "tool_config", "_litellm_judged": True}]
+ expected_remainder = [{"location": "tool_config"}]
assert kwargs["cache_control_injection_points"] == expected_remainder
msgs2, sys2 = self._inject(msgs1, kwargs, system=sys1)
@@ -2459,22 +2552,22 @@ class TestOpenAIPromptCacheBreakpoint:
assert system == [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]
assert kwargs == {}
- def test_v1_messages_client_content_breakpoint_makes_configured_points_stand_down(self):
+ def test_v1_messages_configured_points_apply_beside_client_content_breakpoint(self):
messages = [{"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]}]
kwargs = {"cache_control_injection_points": copy.deepcopy(self.SYSTEM_POINT)}
result, system = self._inject(messages, "sys", kwargs)
assert result == messages
- assert system == "sys"
- assert kwargs == {}
+ assert system == [{"type": "text", "text": "sys", "prompt_cache_breakpoint": self.EXPLICIT}]
+ assert kwargs == {"prompt_cache_options": self.EXPLICIT}
- def test_v1_messages_client_system_breakpoint_makes_configured_points_stand_down(self):
+ def test_v1_messages_tail_point_applies_beside_client_system_breakpoint(self):
system = [{"type": "text", "text": "sys", "prompt_cache_breakpoint": self.EXPLICIT}]
messages = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
kwargs = {"cache_control_injection_points": [{"location": "message", "index": -1}]}
result, result_system = self._inject(messages, system, kwargs)
- assert result == messages
+ assert result == [{"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]}]
assert result_system == system
- assert kwargs == {}
+ assert kwargs == {"prompt_cache_options": self.EXPLICIT}
def test_chat_system_string_wrapped_with_block_breakpoint(self):
params = {"cache_control_injection_points": copy.deepcopy(self.SYSTEM_POINT)}
@@ -2538,18 +2631,25 @@ class TestOpenAIPromptCacheBreakpoint:
assert processed[0] == {"role": "system", "content": "sys", "cache_control": {"type": "ephemeral"}}
assert params == {}
- def test_chat_client_breakpoint_makes_seeded_points_stand_down(self):
+ def test_chat_seeded_points_apply_beside_client_breakpoint(self):
params = {"cache_control_injection_points": copy.deepcopy(self.SYSTEM_POINT)}
+ messages = [
+ {"role": "system", "content": "sys"},
+ {"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]},
+ ]
AnthropicCacheControlHook.maybe_seed_default_injection_points(
non_default_params=params,
- messages=[
- {"role": "system", "content": "sys"},
- {"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]},
- ],
+ messages=messages,
model="openai/gpt-5.6",
custom_llm_provider="openai",
)
- assert params == {}
+ assert params["cache_control_injection_points"] == [
+ {"location": "message", "role": "system", "_litellm_openai_dialect": True}
+ ]
+ _, processed, _ = self._chat(messages, params)
+ assert processed[0]["content"] == [{"type": "text", "text": "sys", "prompt_cache_breakpoint": self.EXPLICIT}]
+ assert processed[1] == messages[1]
+ assert params["prompt_cache_options"] == self.EXPLICIT
def test_cap_counts_client_breakpoints_of_both_kinds(self):
messages = [
@@ -3143,7 +3243,7 @@ class TestRecordGatewayInjection:
assert kwargs["litellm_metadata"][self.KEY] == self.DEPLOYMENT
def test_configured_points_skipping_a_marked_target_record_nothing(self):
- """Configured injection stands down on client breakpoints, so no marker lands."""
+ """A configured point whose target the client already marked places nothing, so no marker lands."""
kwargs: dict = {
"litellm_metadata": {},
"cache_control_injection_points": [{"location": "message", "role": "system", "index": None}],
From 171b33abfedf8e6ccded1bef7e5f9ce60081ad32 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 04:53:15 -0700
Subject: [PATCH 048/317] fix: leave tool-search tool marks out of the
chat-path cache breakpoint census
---
.../anthropic_cache_control_hook.py | 16 +++++++++---
litellm/types/llms/anthropic.py | 4 +++
.../test_anthropic_cache_control_hook.py | 25 ++++++++++++++++++-
3 files changed, 40 insertions(+), 5 deletions(-)
diff --git a/litellm/integrations/anthropic_cache_control_hook.py b/litellm/integrations/anthropic_cache_control_hook.py
index b06372baa78..6f90acade10 100644
--- a/litellm/integrations/anthropic_cache_control_hook.py
+++ b/litellm/integrations/anthropic_cache_control_hook.py
@@ -33,6 +33,7 @@ from litellm.types.integrations.anthropic_cache_control_hook import (
CacheControlMessageInjectionPoint,
)
from litellm.types.llms.anthropic import (
+ ANTHROPIC_TOOL_SEARCH_TOOL_TYPES,
AllAnthropicToolsValues,
AnthropicSystemMessageContent,
)
@@ -127,6 +128,10 @@ def _tool_carries_cache_breakpoint(tool: object) -> bool:
)
+def _chat_transform_drops_tool_cache_control(tool: object) -> bool:
+ return isinstance(tool, dict) and tool.get("type") in ANTHROPIC_TOOL_SEARCH_TOOL_TYPES
+
+
def _accepts_prompt_cache_breakpoint(block: object) -> bool:
return isinstance(block, dict) and block.get("type") in OPENAI_PROMPT_CACHE_BREAKPOINT_BLOCK_TYPES
@@ -303,9 +308,9 @@ class AnthropicCacheControlHook(CustomPromptManagement):
"""Client breakpoints outside messages and system that the provider cap still counts.
A tool carries its mark at the top level (Anthropic shape) or under ``function``
- (OpenAI shape); the Anthropic chat transform forwards both. A top-level
- ``cache_control`` is Anthropic's automatic caching, which places one breakpoint
- of its own on top of the explicit ones.
+ (OpenAI shape). A top-level ``cache_control`` is Anthropic's automatic caching,
+ which places one breakpoint of its own on top of the explicit ones. Callers
+ pass only the tools whose mark reaches the provider on their path.
"""
automatic_blocks: Final = 1 if cache_control is not None else 0
tool_blocks: Final = sum(1 for tool in tools if _tool_carries_cache_breakpoint(tool)) if tools else 0
@@ -786,10 +791,13 @@ class AnthropicCacheControlHook(CustomPromptManagement):
"""
configured: Final = non_default_params.get("cache_control_injection_points")
if configured:
+ tools_keeping_marks: Final = tuple(
+ tool for tool in tools or () if not _chat_transform_drops_tool_cache_control(tool)
+ )
non_default_params["cache_control_injection_points"] = AnthropicCacheControlHook._stamped_for_prompt_hook(
configured,
AnthropicCacheControlHook.count_external_cache_breakpoints(
- tools, non_default_params.get("cache_control")
+ tools_keeping_marks, non_default_params.get("cache_control")
),
model,
custom_llm_provider,
diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py
index bcd24695f25..43a7b0e0e9c 100644
--- a/litellm/types/llms/anthropic.py
+++ b/litellm/types/llms/anthropic.py
@@ -753,6 +753,10 @@ class ANTHROPIC_BETA_HEADER_VALUES(str, Enum):
# Tool search beta header constant (for Anthropic direct API and Microsoft Foundry)
ANTHROPIC_TOOL_SEARCH_BETA_HEADER: Final = "advanced-tool-use-2025-11-20"
+ANTHROPIC_TOOL_SEARCH_TOOL_TYPES: Final = frozenset(
+ {"tool_search_tool_regex_20251119", "tool_search_tool_bm25_20251119"}
+)
+
# Effort beta header constant
ANTHROPIC_EFFORT_BETA_HEADER: Final = "effort-2025-11-24"
diff --git a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
index 3424cc5fed6..1dfd9cf619b 100644
--- a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
+++ b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
@@ -2114,6 +2114,16 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
UNMARKED_TOOL = {"type": "function", "function": {"name": "t", "parameters": {}}}
MARKED_V1_TOOL = {"name": "t", "input_schema": {}, "cache_control": {"type": "ephemeral"}}
UNMARKED_V1_TOOL = {"name": "t", "input_schema": {}}
+ MARKED_TOOL_SEARCH_REGEX = {
+ "type": "tool_search_tool_regex_20251119",
+ "name": "tool_search",
+ "cache_control": {"type": "ephemeral"},
+ }
+ MARKED_TOOL_SEARCH_BM25 = {
+ "type": "tool_search_tool_bm25_20251119",
+ "name": "tool_search",
+ "cache_control": {"type": "ephemeral"},
+ }
@staticmethod
def _marked_user_turns(count):
@@ -2199,6 +2209,17 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
processed = self._chat(params, copy.deepcopy(messages))
assert _count_cache_control(processed) == 3 + injected
+ @pytest.mark.parametrize("tool", [MARKED_TOOL_SEARCH_REGEX, MARKED_TOOL_SEARCH_BM25], ids=["regex", "bm25"])
+ def test_chat_cap_ignores_marked_tool_search_tools(self, tool):
+ """The chat transform strips cache_control from tool-search tools before the
+ request leaves, so a client mark there never reaches the provider's cap and
+ must not cost the configured point its fourth slot."""
+ messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(3)]
+ params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)}
+ self._seed(params, copy.deepcopy(messages), tools=[tool])
+ processed = self._chat(params, copy.deepcopy(messages))
+ assert _count_cache_control(processed) == 4
+
@pytest.mark.parametrize("marked_turns,injected", [(2, 1), (3, 0)])
def test_chat_root_cache_control_reserves_a_slot(self, marked_turns, injected):
"""Anthropic's automatic caching (a top-level ``cache_control``) places one
@@ -2261,9 +2282,11 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
"tool,expected_system",
[
(MARKED_V1_TOOL, "sys"),
+ (MARKED_TOOL_SEARCH_REGEX, "sys"),
+ (MARKED_TOOL_SEARCH_BM25, "sys"),
(UNMARKED_V1_TOOL, [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]),
],
- ids=["marked", "unmarked"],
+ ids=["marked", "marked_tool_search_regex", "marked_tool_search_bm25", "unmarked"],
)
def test_v1_messages_cap_counts_client_marked_tools(self, tool, expected_system):
kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)}
From 752092592482299d6785970ccde6c289815082b3 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 06:28:16 -0700
Subject: [PATCH 049/317] fix: forward a tool_config point only while the cap
has a slot left
---
.../anthropic_cache_control_hook.py | 67 +++++++----
.../test_anthropic_cache_control_hook.py | 112 ++++++++++++++++--
2 files changed, 142 insertions(+), 37 deletions(-)
diff --git a/litellm/integrations/anthropic_cache_control_hook.py b/litellm/integrations/anthropic_cache_control_hook.py
index 6f90acade10..cff7d23935c 100644
--- a/litellm/integrations/anthropic_cache_control_hook.py
+++ b/litellm/integrations/anthropic_cache_control_hook.py
@@ -209,14 +209,12 @@ class AnthropicCacheControlHook(CustomPromptManagement):
# Create a deep copy of messages to avoid modifying the original list
processed_messages = copy.deepcopy(messages)
- # Separate message-level and non-message-level injection points
- message_points: Final[list[CacheControlMessageInjectionPoint]] = []
- remaining_points: Final[list[CacheControlInjectionPoint]] = []
- for point in injection_points:
- if point.get("location") == "message":
- message_points.append(cast(CacheControlMessageInjectionPoint, point))
- else:
- remaining_points.append(point)
+ message_points: Final = tuple(
+ cast(CacheControlMessageInjectionPoint, point)
+ for point in injection_points
+ if point.get("location") == "message"
+ )
+ remaining_points: Final = tuple(point for point in injection_points if point.get("location") != "message")
stamped_dialect: Final = injection_points[0].get("_litellm_openai_dialect")
openai_dialect: Final = (
@@ -243,10 +241,9 @@ class AnthropicCacheControlHook(CustomPromptManagement):
else tuple(message_points)
)
stamped_external: Final = injection_points[0].get(EXTERNAL_BREAKPOINTS_STAMP)
+ external_breakpoints: Final = stamped_external if isinstance(stamped_external, int) else 0
reserved_blocks: Final = AnthropicCacheControlHook._blocks_reserved_outside_messages(
- remaining_points,
- stamped_external if isinstance(stamped_external, int) else 0,
- openai_dialect,
+ remaining_points, external_breakpoints, openai_dialect
)
breakpoints_before: Final = AnthropicCacheControlHook.count_request_cache_breakpoints(processed_messages)
processed_messages = self._apply_message_injections(
@@ -266,7 +263,14 @@ class AnthropicCacheControlHook(CustomPromptManagement):
# `instructions`, which is only a system message once the bridge builds one. A later
# pass re-applies them safely: a target that already carries a mark is skipped and
# the census counts every mark on the wire, litellm's own included.
- carried_points: Final[Sequence[CacheControlInjectionPoint]] = (*remaining_points, *carried_message_points)
+ carried_points: Final[Sequence[CacheControlInjectionPoint]] = (
+ *AnthropicCacheControlHook._points_with_a_slot_left(
+ remaining_points,
+ AnthropicCacheControlHook.count_request_cache_breakpoints(processed_messages) + external_breakpoints,
+ openai_dialect,
+ ),
+ *carried_message_points,
+ )
if carried_points:
non_default_params["cache_control_injection_points"] = list(carried_points)
@@ -331,6 +335,16 @@ class AnthropicCacheControlHook(CustomPromptManagement):
tool_config_blocks: Final = 1 if any(p.get("location") == "tool_config" for p in remaining_points) else 0
return external_breakpoints + tool_config_blocks
+ @staticmethod
+ def _points_with_a_slot_left(
+ remaining_points: Sequence[CacheControlInjectionPoint], breakpoints_on_wire: int, openai_dialect: bool
+ ) -> tuple[CacheControlInjectionPoint, ...]:
+ """A ``tool_config`` point becomes a cachePoint the Bedrock converse transform never
+ counts against the cap, so it is forwarded only while the wire still has a slot."""
+ if openai_dialect or breakpoints_on_wire < MAX_CACHE_CONTROL_BLOCKS:
+ return tuple(remaining_points)
+ return tuple(point for point in remaining_points if point.get("location") != "tool_config")
+
@staticmethod
def _apply_message_injections(
points: Sequence[CacheControlMessageInjectionPoint],
@@ -529,19 +543,14 @@ class AnthropicCacheControlHook(CustomPromptManagement):
processed_messages: list[dict] = copy.deepcopy(messages)
processed_system = copy.deepcopy(system) if system is not None else None
- message_points: Final[list[CacheControlMessageInjectionPoint]] = []
- system_points: Final[list[CacheControlMessageInjectionPoint]] = []
- remaining_points: Final[list[CacheControlInjectionPoint]] = []
-
- for point in injection_points:
- if point.get("location") == "message":
- msg_point = cast(CacheControlMessageInjectionPoint, point)
- if msg_point.get("role") == "system":
- system_points.append(msg_point)
- else:
- message_points.append(msg_point)
- else:
- remaining_points.append(point)
+ role_points: Final = tuple(
+ cast(CacheControlMessageInjectionPoint, point)
+ for point in injection_points
+ if point.get("location") == "message"
+ )
+ system_points: Final = tuple(point for point in role_points if point.get("role") == "system")
+ message_points: Final = tuple(point for point in role_points if point.get("role") != "system")
+ remaining_points: Final = tuple(point for point in injection_points if point.get("location") != "message")
reserved_blocks: Final = AnthropicCacheControlHook._blocks_reserved_outside_messages(
remaining_points, external_breakpoints, openai_dialect
@@ -581,8 +590,14 @@ class AnthropicCacheControlHook(CustomPromptManagement):
max_blocks=max_blocks - system_blocks,
openai_dialect=openai_dialect,
)
+ forwarded_points: Final = AnthropicCacheControlHook._points_with_a_slot_left(
+ remaining_points,
+ AnthropicCacheControlHook.count_request_cache_breakpoints(processed_messages, processed_system)
+ + external_breakpoints,
+ openai_dialect,
+ )
- return processed_messages, processed_system, remaining_points
+ return processed_messages, processed_system, list(forwarded_points)
@staticmethod
def _default_control() -> ChatCompletionCachedContent:
diff --git a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
index 1dfd9cf619b..2723526ae6b 100644
--- a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
+++ b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
@@ -1335,17 +1335,7 @@ async def test_cache_control_hook_bedrock_payload_caps_with_tool_config_point(mo
)
request_body = json.loads(mock_post.call_args.kwargs["data"])
-
- cache_points = sum(
- 1 for block in request_body.get("system", []) if isinstance(block, dict) and "cachePoint" in block
- )
- for msg in request_body.get("messages", []):
- content = msg.get("content", [])
- if isinstance(content, list):
- cache_points += sum(1 for block in content if isinstance(block, dict) and "cachePoint" in block)
- for tool in request_body.get("toolConfig", {}).get("tools", []):
- if isinstance(tool, dict) and "cachePoint" in tool:
- cache_points += 1
+ cache_points = _count_converse_cache_points(request_body)
assert cache_points <= 4, (
f"Bedrock payload exceeded Anthropic's 4 cache_control block limit "
@@ -1353,6 +1343,89 @@ async def test_cache_control_hook_bedrock_payload_caps_with_tool_config_point(mo
)
+def _count_converse_cache_points(request_body: dict) -> int:
+ system_points = sum(
+ 1 for block in request_body.get("system", []) if isinstance(block, dict) and "cachePoint" in block
+ )
+ message_points = sum(
+ 1
+ for msg in request_body.get("messages", [])
+ if isinstance(msg.get("content"), list)
+ for block in msg["content"]
+ if isinstance(block, dict) and "cachePoint" in block
+ )
+ tool_points = sum(
+ 1
+ for tool in request_body.get("toolConfig", {}).get("tools", [])
+ if isinstance(tool, dict) and "cachePoint" in tool
+ )
+ return system_points + message_points + tool_points
+
+
+@pytest.mark.asyncio
+async def test_cache_control_hook_bedrock_tool_config_point_stands_down_when_client_marks_fill_the_cap(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ """The client's own four marks fill the cap, so the configured tool_config point must
+ not land as a fifth cachePoint in the converse payload."""
+ with patch.dict(
+ os.environ,
+ {
+ "AWS_ACCESS_KEY_ID": "fake_access_key_id",
+ "AWS_SECRET_ACCESS_KEY": "fake_secret_access_key",
+ "AWS_REGION_NAME": "us-east-1",
+ },
+ ):
+ monkeypatch.setattr(litellm, "callbacks", [AnthropicCacheControlHook()])
+
+ mock_response = MagicMock()
+ mock_response.json.return_value = {
+ "output": {"message": {"role": "assistant", "content": "ok"}},
+ "stopReason": "end_turn",
+ "usage": {"inputTokens": 100, "outputTokens": 4, "totalTokens": 104},
+ }
+ mock_response.status_code = 200
+
+ client = AsyncHTTPHandler()
+ with patch.object(client, "post", return_value=mock_response) as mock_post:
+ marked = {"type": "ephemeral"}
+ messages = [
+ {"role": "system", "content": [{"type": "text", "text": "sys", "cache_control": marked}]},
+ *(
+ {"role": "user", "content": [{"type": "text", "text": f"turn {i}", "cache_control": marked}]}
+ for i in range(3)
+ ),
+ {"role": "user", "content": "What is the weather?"},
+ ]
+
+ await litellm.acompletion(
+ model="bedrock/us.anthropic.claude-opus-4-6-v1:0",
+ messages=messages,
+ max_tokens=32,
+ tools=[
+ {
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "description": "Get weather for a location",
+ "parameters": {
+ "type": "object",
+ "properties": {"location": {"type": "string"}},
+ "required": ["location"],
+ },
+ },
+ }
+ ],
+ cache_control_injection_points=[{"location": "tool_config"}],
+ client=client,
+ )
+
+ request_body = json.loads(mock_post.call_args.kwargs["data"])
+
+ assert _count_converse_cache_points(request_body) == 4
+ assert not any("cachePoint" in tool for tool in request_body["toolConfig"]["tools"])
+
+
class TestApplyToAnthropicMessagesRequest:
"""Tests for apply_to_anthropic_messages_request (v1/messages cache control)."""
@@ -2091,6 +2164,7 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
CONFIGURED = [{"location": "message", "role": "system"}]
TAIL_POINT = [{"location": "message", "index": -1}]
+ TOOL_CONFIG_POINT = [{"location": "tool_config"}]
EPHEMERAL = {"type": "ephemeral"}
CLEAN_MESSAGES: List[AllMessageValues] = [
@@ -2220,6 +2294,22 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
processed = self._chat(params, copy.deepcopy(messages))
assert _count_cache_control(processed) == 4
+ @pytest.mark.parametrize("marked_turns,forwarded", [(3, ["tool_config"]), (4, [])], ids=["slot_left", "cap_full"])
+ def test_chat_forwards_tool_config_point_only_while_a_slot_is_left(self, marked_turns, forwarded):
+ """A forwarded tool_config point becomes a Bedrock cachePoint unconditionally, so
+ it stands down once the client's own marks fill the cap."""
+ messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(marked_turns)]
+ params = {"cache_control_injection_points": copy.deepcopy(self.TOOL_CONFIG_POINT)}
+ self._seed(params, copy.deepcopy(messages), tools=[self.UNMARKED_TOOL])
+ self._chat(params, copy.deepcopy(messages))
+ assert [p["location"] for p in params.get("cache_control_injection_points", [])] == forwarded
+
+ @pytest.mark.parametrize("marked_turns,forwarded", [(3, ["tool_config"]), (4, [])], ids=["slot_left", "cap_full"])
+ def test_v1_messages_forwards_tool_config_point_only_while_a_slot_is_left(self, marked_turns, forwarded):
+ kwargs = {"cache_control_injection_points": copy.deepcopy(self.TOOL_CONFIG_POINT)}
+ self._inject(self._marked_user_turns(marked_turns), kwargs, tools=[self.UNMARKED_V1_TOOL])
+ assert [p["location"] for p in kwargs.get("cache_control_injection_points", [])] == forwarded
+
@pytest.mark.parametrize("marked_turns,injected", [(2, 1), (3, 0)])
def test_chat_root_cache_control_reserves_a_slot(self, marked_turns, injected):
"""Anthropic's automatic caching (a top-level ``cache_control``) places one
From 5f54f87d9887c13b75c42ffca5142ef2b7bcf2f1 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 16:40:10 +0000
Subject: [PATCH 050/317] feat(fal_ai): add Seedance video generation via fal
queue API
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/llms/fal_ai/videos/__init__.py | 3 +
litellm/llms/fal_ai/videos/transformation.py | 512 ++++++++++++++++++
...odel_prices_and_context_window_backup.json | 121 +++++
litellm/utils.py | 4 +
model_prices_and_context_window.json | 121 +++++
.../test_fal_ai_video_transformation.py | 231 ++++++++
6 files changed, 992 insertions(+)
create mode 100644 litellm/llms/fal_ai/videos/__init__.py
create mode 100644 litellm/llms/fal_ai/videos/transformation.py
create mode 100644 tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py
diff --git a/litellm/llms/fal_ai/videos/__init__.py b/litellm/llms/fal_ai/videos/__init__.py
new file mode 100644
index 00000000000..c7e8f76c75b
--- /dev/null
+++ b/litellm/llms/fal_ai/videos/__init__.py
@@ -0,0 +1,3 @@
+from litellm.llms.fal_ai.videos.transformation import FalAIVideoConfig
+
+__all__ = ("FalAIVideoConfig",)
diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py
new file mode 100644
index 00000000000..f8ebf828d68
--- /dev/null
+++ b/litellm/llms/fal_ai/videos/transformation.py
@@ -0,0 +1,512 @@
+import math
+import time
+from collections.abc import Mapping
+from types import MappingProxyType
+from typing import Final
+
+import httpx
+from httpx._types import FileContent, RequestFiles
+from pydantic import TypeAdapter
+
+from litellm.litellm_core_utils.url_utils import encode_url_path_segment
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.llms.base_llm.videos.transformation import BaseVideoConfig
+from litellm.llms.custom_httpx.http_handler import (
+ AsyncHTTPHandler,
+ HTTPHandler,
+ _get_httpx_client, # pyright: ignore[reportPrivateUsage, reportUnknownVariableType] # shared HTTP factory is private
+ get_async_httpx_client, # pyright: ignore[reportUnknownVariableType] # shared HTTP factory lacks typed params
+)
+from litellm.secret_managers.main import get_secret_str
+from litellm.types.router import GenericLiteLLMParams
+from litellm.types.utils import LlmProviders
+from litellm.types.videos.main import (
+ CharacterObject,
+ VideoCreateOptionalRequestParams,
+ VideoObject,
+)
+from litellm.types.videos.utils import (
+ decode_video_id_with_provider,
+ encode_video_id_with_provider,
+)
+
+
+class FalAIVideoError(BaseLLMException):
+ pass
+
+
+_ALLOWED_ASPECT_RATIOS: Final[frozenset[str]] = frozenset({"auto", "16:9", "9:16", "1:1", "4:3", "3:4", "21:9"})
+_ALLOWED_RESOLUTIONS: Final[frozenset[str]] = frozenset({"480p", "720p", "1080p", "4k"})
+_RESOLUTION_TIERS: Final[tuple[tuple[int, str], ...]] = (
+ (480, "480p"),
+ (720, "720p"),
+ (1080, "1080p"),
+)
+_FAL_AI_PROVIDER: Final[str] = LlmProviders.FAL_AI.value
+
+
+def _queue_request_base_path(model: str) -> str:
+ segments: Final[tuple[str, ...]] = tuple(model.split("/"))
+ segment_count: Final[int] = 3 if segments and segments[0] in frozenset(("workflows", "comfy")) else 2
+ return "/".join(segments[:segment_count])
+
+
+def _duration_value(value: object) -> str | None:
+ if isinstance(value, str) and value == "auto":
+ return value
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
+ return None
+ try:
+ return str(int(float(value)))
+ except (TypeError, ValueError):
+ return None
+
+
+def _resolution_for_height(height: int) -> str:
+ return next((resolution for threshold, resolution in _RESOLUTION_TIERS if height <= threshold), "4k")
+
+
+def _size_params(size: object) -> Mapping[str, str]:
+ if not isinstance(size, str):
+ return MappingProxyType({})
+ if size in _ALLOWED_RESOLUTIONS:
+ return MappingProxyType({"resolution": size})
+ if size.count("x") != 1:
+ return MappingProxyType({})
+ width_text, height_text = size.split("x")
+ if not (width_text.isdigit() and height_text.isdigit()):
+ return MappingProxyType({})
+ width: Final[int] = int(width_text)
+ height: Final[int] = int(height_text)
+ if width <= 0 or height <= 0:
+ return MappingProxyType({})
+ reduced_gcd: Final[int] = math.gcd(width, height)
+ aspect_ratio: Final[str] = f"{width // reduced_gcd}:{height // reduced_gcd}"
+ resolution: Final[str] = _resolution_for_height(height)
+ if aspect_ratio in _ALLOWED_ASPECT_RATIOS:
+ return MappingProxyType({"resolution": resolution, "aspect_ratio": aspect_ratio})
+ return MappingProxyType({"resolution": resolution})
+
+
+def _numeric_duration(value: object) -> float | None:
+ duration: Final[str | None] = _duration_value(value)
+ if duration is None or duration == "auto":
+ return None
+ return float(duration)
+
+
+def _response_data(raw_response: httpx.Response) -> Mapping[str, object]:
+ return TypeAdapter(Mapping[str, object]).validate_python(raw_response.json())
+
+
+def _response_string(response_data: Mapping[str, object], key: str, default: str = "") -> str:
+ value: Final[object] = response_data.get(key)
+ return value if isinstance(value, str) else default
+
+
+class FalAIVideoConfig(BaseVideoConfig):
+ def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: API contract requires a list
+ return [ # mutable-ok: API contract requires a list
+ "model",
+ "prompt",
+ "input_reference",
+ "seconds",
+ "size",
+ "user",
+ "extra_headers",
+ ]
+
+ def map_openai_params(
+ self,
+ video_create_optional_params: VideoCreateOptionalRequestParams,
+ model: str,
+ drop_params: bool,
+ ) -> dict[str, object]: # mutable-ok: BaseVideoConfig requires a mutable mapping
+ supported_params: Final[frozenset[str]] = frozenset(self.get_supported_openai_params(model))
+ input_reference: Final[object] = video_create_optional_params.get("input_reference")
+ input_reference_params: Final[Mapping[str, str]] = (
+ MappingProxyType({})
+ if "input_reference" not in video_create_optional_params
+ else (
+ MappingProxyType({"image_url": input_reference})
+ if isinstance(input_reference, str)
+ else self._invalid_input_reference()
+ )
+ )
+ duration_params: Final[Mapping[str, str]] = (
+ MappingProxyType({})
+ if "seconds" not in video_create_optional_params
+ else self._duration_params(video_create_optional_params["seconds"])
+ )
+ size_params: Final[Mapping[str, str]] = (
+ self._size_params(video_create_optional_params["size"])
+ if "size" in video_create_optional_params
+ else MappingProxyType({})
+ )
+ user_params: Final[Mapping[str, str]] = (
+ MappingProxyType({"end_user_id": user})
+ if isinstance(user := video_create_optional_params.get("user"), str)
+ else MappingProxyType({})
+ )
+ return dict( # mutable-ok: BaseVideoConfig requires a mutable mapping
+ MappingProxyType(
+ {
+ **input_reference_params,
+ **duration_params,
+ **size_params,
+ **user_params,
+ **{ # mutable-ok: dynamic passthrough fields require a mapping
+ key: value for key, value in video_create_optional_params.items() if key not in supported_params
+ },
+ }
+ )
+ ) # mutable-ok: BaseVideoConfig requires a mutable mapping
+
+ @staticmethod
+ def _invalid_input_reference() -> Mapping[str, str]:
+ raise ValueError("fal.ai needs a public image URL for input_reference")
+
+ @staticmethod
+ def _duration_params(seconds: object) -> Mapping[str, str]:
+ duration: Final[str | None] = _duration_value(seconds)
+ if duration is None:
+ raise ValueError("fal.ai seconds must be a numeric value")
+ return MappingProxyType({"duration": duration})
+
+ @staticmethod
+ def _size_params(size: object) -> Mapping[str, str]:
+ return _size_params(size)
+
+ def validate_environment(
+ self,
+ headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ model: str,
+ api_key: str | None = None,
+ litellm_params: GenericLiteLLMParams | None = None,
+ ) -> dict[str, str]: # mutable-ok: BaseVideoConfig requires mutable headers
+ final_api_key: Final[str | None] = (
+ api_key
+ or (litellm_params.api_key if litellm_params is not None else None)
+ or get_secret_str("FAL_AI_API_KEY")
+ or get_secret_str("FAL_KEY")
+ )
+ if not final_api_key:
+ raise ValueError("fal.ai API key is required")
+ return dict( # mutable-ok: BaseVideoConfig requires mutable headers
+ MappingProxyType(
+ {
+ **headers,
+ "Authorization": f"Key {final_api_key}",
+ "Content-Type": "application/json",
+ }
+ )
+ ) # mutable-ok: BaseVideoConfig requires mutable headers
+
+ def get_complete_url(
+ self,
+ model: str,
+ api_base: str | None,
+ litellm_params: dict[str, object], # mutable-ok: BaseVideoConfig requires mutable parameters
+ ) -> str:
+ return (api_base or get_secret_str("FAL_AI_QUEUE_API_BASE") or "https://queue.fal.run").rstrip("/")
+
+ def transform_video_create_request(
+ self,
+ model: str,
+ prompt: str,
+ api_base: str,
+ video_create_optional_request_params: dict[ # mutable-ok: BaseVideoConfig requires mutable parameters
+ str, object
+ ], # mutable-ok: BaseVideoConfig requires mutable parameters
+ litellm_params: GenericLiteLLMParams,
+ headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ ) -> tuple[dict[str, object], RequestFiles, str]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ request_data: Final[dict[str, object]] = dict( # mutable-ok: HTTP JSON payload requires mutable data
+ MappingProxyType(
+ {
+ "prompt": prompt,
+ **{ # mutable-ok: dynamic request fields require a mapping
+ key: value for key, value in video_create_optional_request_params.items() if key != "model"
+ },
+ }
+ )
+ )
+ return request_data, [], f"{api_base.rstrip('/')}/{model}" # mutable-ok: HTTP files payload requires a list
+
+ def transform_video_create_response(
+ self,
+ model: str,
+ raw_response: httpx.Response,
+ logging_obj: object,
+ custom_llm_provider: str | None = None,
+ request_data: Mapping[str, object] | None = None,
+ ) -> VideoObject:
+ response_data: Final[Mapping[str, object]] = _response_data(raw_response)
+ request_params: Final[Mapping[str, object]] = request_data or MappingProxyType({})
+ request_id: Final[str] = _response_string(response_data, "request_id")
+ provider: Final[str] = custom_llm_provider or _FAL_AI_PROVIDER
+ duration: Final[float | None] = _numeric_duration(request_params.get("duration"))
+ resolution: Final[object] = request_params.get("resolution")
+ seconds: Final[str | None] = _duration_value(request_params["duration"]) if duration is not None else None
+ size: Final[str | None] = resolution if isinstance(resolution, str) else None
+ usage: Final[dict[str, object]] = dict( # mutable-ok: VideoObject requires a mutable usage mapping
+ MappingProxyType(
+ {
+ key: value
+ for key, value in (
+ ("duration_seconds", duration),
+ ("video_resolution", resolution if isinstance(resolution, str) else "720p"),
+ )
+ if value is not None
+ }
+ )
+ ) # mutable-ok: VideoObject requires a mutable usage mapping
+ video_object: Final[VideoObject] = VideoObject(
+ id=encode_video_id_with_provider(request_id, provider, model),
+ object="video",
+ status="queued",
+ created_at=int(time.time()),
+ model=model,
+ seconds=seconds,
+ size=size,
+ )
+ video_object.usage = usage
+ return video_object
+
+ def transform_video_status_retrieve_request(
+ self,
+ video_id: str,
+ api_base: str,
+ litellm_params: GenericLiteLLMParams,
+ headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ ) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ request_id, model_id = self._decode_video_id(video_id)
+ encoded_request_id: Final[str] = encode_url_path_segment(request_id, field_name="video_id")
+ return (
+ f"{api_base.rstrip('/')}/{_queue_request_base_path(model_id)}/requests/{encoded_request_id}/status",
+ {}, # mutable-ok: BaseVideoConfig requires a mutable mapping
+ )
+
+ def transform_video_status_retrieve_response(
+ self,
+ raw_response: httpx.Response,
+ logging_obj: object,
+ custom_llm_provider: str | None = None,
+ ) -> VideoObject:
+ response_data: Final[Mapping[str, object]] = _response_data(raw_response)
+ raw_status: Final[str] = _response_string(response_data, "status", "IN_QUEUE")
+ status: Final[str] = MappingProxyType(
+ {
+ "IN_QUEUE": "queued",
+ "IN_PROGRESS": "in_progress",
+ "COMPLETED": "completed",
+ }
+ ).get(raw_status, "queued")
+ error_value: Final[object] = response_data.get("error")
+ error: Final[str | None] = error_value if isinstance(error_value, str) else None
+ provider: Final[str] = custom_llm_provider or _FAL_AI_PROVIDER
+ return VideoObject(
+ id=encode_video_id_with_provider(_response_string(response_data, "request_id"), provider),
+ object="video",
+ status="failed" if error else status,
+ created_at=0,
+ error=(
+ {"code": "fal_error", "message": error} if error else None # mutable-ok: VideoObject requires a dict
+ ), # mutable-ok: VideoObject requires a dict
+ )
+
+ @staticmethod
+ def _decode_video_id(video_id: str) -> tuple[str, str]:
+ decoded: Final = decode_video_id_with_provider(video_id)
+ request_id: Final[str] = decoded.get("video_id", video_id)
+ model_id: Final[str | None] = decoded.get("model_id")
+ if not model_id:
+ raise ValueError("fal.ai video ids must be created through litellm with a model")
+ return request_id, model_id
+
+ def transform_video_content_request(
+ self,
+ video_id: str,
+ api_base: str,
+ litellm_params: GenericLiteLLMParams,
+ headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ variant: str | None = None,
+ ) -> tuple[str, dict[str, str]]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ request_id, model_id = self._decode_video_id(video_id)
+ encoded_request_id: Final[str] = encode_url_path_segment(request_id, field_name="video_id")
+ return (
+ f"{api_base.rstrip('/')}/{_queue_request_base_path(model_id)}/requests/{encoded_request_id}",
+ {}, # mutable-ok: BaseVideoConfig requires a mutable mapping
+ )
+
+ @staticmethod
+ def _extract_video_url(response_data: Mapping[str, object]) -> str:
+ raw_video_data: Final[object] = response_data.get("video")
+ video_data: Final[Mapping[str, object] | None] = (
+ TypeAdapter(Mapping[str, object]).validate_python(raw_video_data)
+ if isinstance(raw_video_data, Mapping)
+ else None
+ )
+ if video_data is not None:
+ video_url: Final[object] = video_data.get("url")
+ if isinstance(video_url, str) and video_url:
+ return video_url
+ error_message: Final[str | None] = next(
+ (value for key in ("error", "detail") if isinstance(value := response_data.get(key), str)),
+ None,
+ )
+ if error_message:
+ raise ValueError(f"fal.ai video result did not include a video URL: {error_message}")
+ raise ValueError("fal.ai video result did not include a video URL")
+
+ def transform_video_content_response(self, raw_response: httpx.Response, logging_obj: object) -> bytes:
+ video_url: Final[str] = self._extract_video_url(_response_data(raw_response))
+ httpx_client: Final[HTTPHandler] = _get_httpx_client()
+ video_response: Final[httpx.Response] = httpx_client.get( # pyright: ignore[reportUnknownMemberType] # HTTP handler stubs are untyped
+ video_url
+ )
+ video_response.raise_for_status()
+ return video_response.content
+
+ async def async_transform_video_content_response(self, raw_response: httpx.Response, logging_obj: object) -> bytes:
+ video_url: Final[str] = self._extract_video_url(_response_data(raw_response))
+ async_httpx_client: Final[AsyncHTTPHandler] = get_async_httpx_client(llm_provider=LlmProviders.FAL_AI)
+ video_response: Final[httpx.Response] = await async_httpx_client.get( # pyright: ignore[reportUnknownMemberType] # HTTP handler stubs are untyped
+ video_url
+ )
+ video_response.raise_for_status()
+ return video_response.content
+
+ def transform_video_remix_request(
+ self,
+ video_id: str,
+ prompt: str,
+ api_base: str,
+ litellm_params: GenericLiteLLMParams,
+ headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ extra_body: Mapping[str, object] | None = None,
+ ) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ raise NotImplementedError("video remix is not supported for fal.ai")
+
+ def transform_video_remix_response(
+ self,
+ raw_response: httpx.Response,
+ logging_obj: object,
+ custom_llm_provider: str | None = None,
+ ) -> VideoObject:
+ raise NotImplementedError("video remix is not supported for fal.ai")
+
+ def transform_video_list_request(
+ self,
+ api_base: str,
+ litellm_params: GenericLiteLLMParams,
+ headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ after: str | None = None,
+ limit: int | None = None,
+ order: str | None = None,
+ extra_query: Mapping[str, object] | None = None,
+ ) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ raise NotImplementedError("video listing is not supported for fal.ai")
+
+ def transform_video_list_response(
+ self,
+ raw_response: httpx.Response,
+ logging_obj: object,
+ custom_llm_provider: str | None = None,
+ ) -> dict[str, str]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ raise NotImplementedError("video listing is not supported for fal.ai")
+
+ def transform_video_delete_request(
+ self,
+ video_id: str,
+ api_base: str,
+ litellm_params: GenericLiteLLMParams,
+ headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ ) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ raise NotImplementedError("video delete is not supported for fal.ai")
+
+ def transform_video_delete_response(self, raw_response: httpx.Response, logging_obj: object) -> VideoObject:
+ raise NotImplementedError("video delete is not supported for fal.ai")
+
+ def transform_video_create_character_request(
+ self,
+ name: str,
+ video: object,
+ api_base: str,
+ litellm_params: GenericLiteLLMParams,
+ headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ ) -> tuple[str, list[object]]: # mutable-ok: BaseVideoConfig requires mutable lists
+ raise NotImplementedError("video character creation is not supported for fal.ai")
+
+ def transform_video_create_character_response(
+ self,
+ raw_response: httpx.Response,
+ logging_obj: object,
+ ) -> CharacterObject:
+ raise NotImplementedError("video character creation is not supported for fal.ai")
+
+ def transform_video_get_character_request(
+ self,
+ character_id: str,
+ api_base: str,
+ litellm_params: GenericLiteLLMParams,
+ headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ ) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ raise NotImplementedError("video character retrieval is not supported for fal.ai")
+
+ def transform_video_get_character_response(
+ self,
+ raw_response: httpx.Response,
+ logging_obj: object,
+ ) -> CharacterObject:
+ raise NotImplementedError("video character retrieval is not supported for fal.ai")
+
+ def transform_video_edit_request(
+ self,
+ prompt: str,
+ video_id: str,
+ api_base: str,
+ litellm_params: GenericLiteLLMParams,
+ headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ video_file: FileContent | None = None,
+ extra_body: Mapping[str, object] | None = None,
+ prefetched_source_data: Mapping[str, object] | None = None,
+ ) -> tuple[str, Mapping[str, object], RequestFiles | None]:
+ raise NotImplementedError("video edit is not supported for fal.ai")
+
+ def transform_video_edit_response(
+ self,
+ raw_response: httpx.Response,
+ logging_obj: object,
+ custom_llm_provider: str | None = None,
+ request_data: Mapping[str, object] | None = None,
+ ) -> VideoObject:
+ raise NotImplementedError("video edit is not supported for fal.ai")
+
+ def transform_video_extension_request(
+ self,
+ prompt: str,
+ video_id: str,
+ seconds: str,
+ api_base: str,
+ litellm_params: GenericLiteLLMParams,
+ headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ extra_body: Mapping[str, object] | None = None,
+ ) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ raise NotImplementedError("video extension is not supported for fal.ai")
+
+ def transform_video_extension_response(
+ self,
+ raw_response: httpx.Response,
+ logging_obj: object,
+ custom_llm_provider: str | None = None,
+ ) -> VideoObject:
+ raise NotImplementedError("video extension is not supported for fal.ai")
+
+ def get_error_class(
+ self,
+ error_message: str,
+ status_code: int,
+ headers: dict[str, str] | httpx.Headers, # mutable-ok: BaseLLMException requires mutable headers
+ ) -> BaseLLMException:
+ return FalAIVideoError(status_code=status_code, message=error_message, headers=headers)
diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json
index 4dbf0337894..324eb6b2d66 100644
--- a/litellm/model_prices_and_context_window_backup.json
+++ b/litellm/model_prices_and_context_window_backup.json
@@ -22807,6 +22807,127 @@
"/v1/images/generations"
]
},
+ "fal_ai/bytedance/seedance-2.5/text-to-video": {
+ "litellm_provider": "fal_ai",
+ "mode": "video_generation",
+ "output_cost_per_second": 0.473,
+ "output_cost_per_second_480p": 0.2205,
+ "output_cost_per_second_720p": 0.473,
+ "source": "https://fal.ai/models/bytedance/seedance-2.5/text-to-video",
+ "supported_endpoints": [
+ "/v1/videos"
+ ],
+ "supported_modalities": [
+ "text"
+ ],
+ "supported_output_modalities": [
+ "video"
+ ]
+ },
+ "fal_ai/bytedance/seedance-2.5/image-to-video": {
+ "litellm_provider": "fal_ai",
+ "mode": "video_generation",
+ "output_cost_per_second": 0.473,
+ "output_cost_per_second_480p": 0.2205,
+ "output_cost_per_second_720p": 0.473,
+ "source": "https://fal.ai/models/bytedance/seedance-2.5/image-to-video",
+ "supported_endpoints": [
+ "/v1/videos"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "video"
+ ]
+ },
+ "fal_ai/bytedance/seedance-2.5/reference-to-video": {
+ "litellm_provider": "fal_ai",
+ "mode": "video_generation",
+ "output_cost_per_second": 0.473,
+ "output_cost_per_second_480p": 0.2205,
+ "output_cost_per_second_720p": 0.473,
+ "source": "https://fal.ai/models/bytedance/seedance-2.5/reference-to-video",
+ "supported_endpoints": [
+ "/v1/videos"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "video"
+ ]
+ },
+ "fal_ai/bytedance/seedance-2.0/text-to-video": {
+ "litellm_provider": "fal_ai",
+ "mode": "video_generation",
+ "output_cost_per_second": 0.3034,
+ "output_cost_per_second_480p": 0.1346,
+ "output_cost_per_second_720p": 0.3034,
+ "output_cost_per_second_1080p": 0.682,
+ "output_cost_per_second_4k": 1.5552,
+ "source": "https://fal.ai/models/bytedance/seedance-2.0/text-to-video",
+ "metadata": {
+ "comment": "fal bills $0.014 per 1k tokens (480p/720p/1080p) and $0.008 per 1k tokens (4k) with tokens = h*w*seconds*24/1024; 480p and 4k rates derived from that formula at 854x480 and 3840x2160"
+ },
+ "supported_endpoints": [
+ "/v1/videos"
+ ],
+ "supported_modalities": [
+ "text"
+ ],
+ "supported_output_modalities": [
+ "video"
+ ]
+ },
+ "fal_ai/bytedance/seedance-2.0/image-to-video": {
+ "litellm_provider": "fal_ai",
+ "mode": "video_generation",
+ "output_cost_per_second": 0.3034,
+ "output_cost_per_second_480p": 0.1346,
+ "output_cost_per_second_720p": 0.3034,
+ "output_cost_per_second_1080p": 0.682,
+ "output_cost_per_second_4k": 1.5552,
+ "source": "https://fal.ai/models/bytedance/seedance-2.0/image-to-video",
+ "metadata": {
+ "comment": "fal bills $0.014 per 1k tokens (480p/720p/1080p) and $0.008 per 1k tokens (4k) with tokens = h*w*seconds*24/1024; 480p and 4k rates derived from that formula at 854x480 and 3840x2160"
+ },
+ "supported_endpoints": [
+ "/v1/videos"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "video"
+ ]
+ },
+ "fal_ai/bytedance/seedance-2.0/reference-to-video": {
+ "litellm_provider": "fal_ai",
+ "mode": "video_generation",
+ "output_cost_per_second": 0.3034,
+ "output_cost_per_second_480p": 0.1346,
+ "output_cost_per_second_720p": 0.3034,
+ "output_cost_per_second_1080p": 0.682,
+ "output_cost_per_second_4k": 1.5552,
+ "source": "https://fal.ai/models/bytedance/seedance-2.0/reference-to-video",
+ "metadata": {
+ "comment": "fal bills $0.014 per 1k tokens (480p/720p/1080p) and $0.008 per 1k tokens (4k) with tokens = h*w*seconds*24/1024; 480p and 4k rates derived from that formula at 854x480 and 3840x2160"
+ },
+ "supported_endpoints": [
+ "/v1/videos"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "video"
+ ]
+ },
"fal_ai/fal-ai/ideogram/v3": {
"litellm_provider": "fal_ai",
"mode": "image_generation",
diff --git a/litellm/utils.py b/litellm/utils.py
index 48d13bc16af..3991cecdac6 100644
--- a/litellm/utils.py
+++ b/litellm/utils.py
@@ -9403,6 +9403,10 @@ class ProviderConfigManager:
from litellm.llms.runwayml.videos.transformation import RunwayMLVideoConfig
return RunwayMLVideoConfig()
+ elif LlmProviders.FAL_AI == provider:
+ from litellm.llms.fal_ai.videos.transformation import FalAIVideoConfig
+
+ return FalAIVideoConfig()
elif LlmProviders.HOSTED_VLLM == provider:
from litellm.llms.hosted_vllm.videos import get_hosted_vllm_video_config
diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json
index 4dbf0337894..324eb6b2d66 100644
--- a/model_prices_and_context_window.json
+++ b/model_prices_and_context_window.json
@@ -22807,6 +22807,127 @@
"/v1/images/generations"
]
},
+ "fal_ai/bytedance/seedance-2.5/text-to-video": {
+ "litellm_provider": "fal_ai",
+ "mode": "video_generation",
+ "output_cost_per_second": 0.473,
+ "output_cost_per_second_480p": 0.2205,
+ "output_cost_per_second_720p": 0.473,
+ "source": "https://fal.ai/models/bytedance/seedance-2.5/text-to-video",
+ "supported_endpoints": [
+ "/v1/videos"
+ ],
+ "supported_modalities": [
+ "text"
+ ],
+ "supported_output_modalities": [
+ "video"
+ ]
+ },
+ "fal_ai/bytedance/seedance-2.5/image-to-video": {
+ "litellm_provider": "fal_ai",
+ "mode": "video_generation",
+ "output_cost_per_second": 0.473,
+ "output_cost_per_second_480p": 0.2205,
+ "output_cost_per_second_720p": 0.473,
+ "source": "https://fal.ai/models/bytedance/seedance-2.5/image-to-video",
+ "supported_endpoints": [
+ "/v1/videos"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "video"
+ ]
+ },
+ "fal_ai/bytedance/seedance-2.5/reference-to-video": {
+ "litellm_provider": "fal_ai",
+ "mode": "video_generation",
+ "output_cost_per_second": 0.473,
+ "output_cost_per_second_480p": 0.2205,
+ "output_cost_per_second_720p": 0.473,
+ "source": "https://fal.ai/models/bytedance/seedance-2.5/reference-to-video",
+ "supported_endpoints": [
+ "/v1/videos"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "video"
+ ]
+ },
+ "fal_ai/bytedance/seedance-2.0/text-to-video": {
+ "litellm_provider": "fal_ai",
+ "mode": "video_generation",
+ "output_cost_per_second": 0.3034,
+ "output_cost_per_second_480p": 0.1346,
+ "output_cost_per_second_720p": 0.3034,
+ "output_cost_per_second_1080p": 0.682,
+ "output_cost_per_second_4k": 1.5552,
+ "source": "https://fal.ai/models/bytedance/seedance-2.0/text-to-video",
+ "metadata": {
+ "comment": "fal bills $0.014 per 1k tokens (480p/720p/1080p) and $0.008 per 1k tokens (4k) with tokens = h*w*seconds*24/1024; 480p and 4k rates derived from that formula at 854x480 and 3840x2160"
+ },
+ "supported_endpoints": [
+ "/v1/videos"
+ ],
+ "supported_modalities": [
+ "text"
+ ],
+ "supported_output_modalities": [
+ "video"
+ ]
+ },
+ "fal_ai/bytedance/seedance-2.0/image-to-video": {
+ "litellm_provider": "fal_ai",
+ "mode": "video_generation",
+ "output_cost_per_second": 0.3034,
+ "output_cost_per_second_480p": 0.1346,
+ "output_cost_per_second_720p": 0.3034,
+ "output_cost_per_second_1080p": 0.682,
+ "output_cost_per_second_4k": 1.5552,
+ "source": "https://fal.ai/models/bytedance/seedance-2.0/image-to-video",
+ "metadata": {
+ "comment": "fal bills $0.014 per 1k tokens (480p/720p/1080p) and $0.008 per 1k tokens (4k) with tokens = h*w*seconds*24/1024; 480p and 4k rates derived from that formula at 854x480 and 3840x2160"
+ },
+ "supported_endpoints": [
+ "/v1/videos"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "video"
+ ]
+ },
+ "fal_ai/bytedance/seedance-2.0/reference-to-video": {
+ "litellm_provider": "fal_ai",
+ "mode": "video_generation",
+ "output_cost_per_second": 0.3034,
+ "output_cost_per_second_480p": 0.1346,
+ "output_cost_per_second_720p": 0.3034,
+ "output_cost_per_second_1080p": 0.682,
+ "output_cost_per_second_4k": 1.5552,
+ "source": "https://fal.ai/models/bytedance/seedance-2.0/reference-to-video",
+ "metadata": {
+ "comment": "fal bills $0.014 per 1k tokens (480p/720p/1080p) and $0.008 per 1k tokens (4k) with tokens = h*w*seconds*24/1024; 480p and 4k rates derived from that formula at 854x480 and 3840x2160"
+ },
+ "supported_endpoints": [
+ "/v1/videos"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "video"
+ ]
+ },
"fal_ai/fal-ai/ideogram/v3": {
"litellm_provider": "fal_ai",
"mode": "image_generation",
diff --git a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py
new file mode 100644
index 00000000000..d4b058ddcf6
--- /dev/null
+++ b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py
@@ -0,0 +1,231 @@
+from unittest.mock import Mock
+
+import httpx
+import pytest
+
+import litellm
+import litellm.llms.fal_ai.videos.transformation as fal_video_module
+from litellm.cost_calculator import default_video_cost_calculator
+from litellm.llms.fal_ai.videos.transformation import (
+ FalAIVideoConfig,
+ FalAIVideoError,
+ _queue_request_base_path,
+)
+from litellm.types.router import GenericLiteLLMParams
+from litellm.types.utils import LlmProviders
+from litellm.types.videos.utils import decode_video_id_with_provider
+from litellm.utils import ProviderConfigManager
+
+MODEL = "bytedance/seedance-2.5/text-to-video"
+
+
+class TestFalAIVideoTransformation:
+ def setup_method(self):
+ self.config = FalAIVideoConfig()
+ self.logging_obj = Mock()
+
+ def test_map_openai_params(self):
+ mapped = self.config.map_openai_params(
+ {
+ "seconds": "5",
+ "size": "1280x720",
+ "input_reference": "https://example.com/image.png",
+ "user": "user-123",
+ "generate_audio": False,
+ },
+ MODEL,
+ False,
+ )
+
+ assert mapped == {
+ "duration": "5",
+ "resolution": "720p",
+ "aspect_ratio": "16:9",
+ "image_url": "https://example.com/image.png",
+ "end_user_id": "user-123",
+ "generate_audio": False,
+ }
+
+ assert self.config.map_openai_params({"size": "1080x1080"}, MODEL, False) == {
+ "resolution": "1080p",
+ "aspect_ratio": "1:1",
+ }
+ assert self.config.map_openai_params({"size": "720p"}, MODEL, False) == {"resolution": "720p"}
+
+ def test_map_openai_params_rejects_non_url_input_reference(self):
+ with pytest.raises(ValueError, match="public image URL"):
+ self.config.map_openai_params({"input_reference": b"image"}, MODEL, False)
+
+ def test_transform_video_create_request(self):
+ body, files, url = self.config.transform_video_create_request(
+ model=MODEL,
+ prompt="A quiet ocean at sunrise",
+ api_base="https://queue.fal.run",
+ video_create_optional_request_params={
+ "duration": "5",
+ "resolution": "480p",
+ "aspect_ratio": "16:9",
+ "generate_audio": False,
+ "model": MODEL,
+ },
+ litellm_params=GenericLiteLLMParams(),
+ headers={},
+ )
+
+ assert url == f"https://queue.fal.run/{MODEL}"
+ assert files == []
+ assert body == {
+ "prompt": "A quiet ocean at sunrise",
+ "duration": "5",
+ "resolution": "480p",
+ "aspect_ratio": "16:9",
+ "generate_audio": False,
+ }
+ assert "model" not in body
+
+ def test_transform_video_create_response_encodes_model_and_usage(self):
+ response = Mock(spec=httpx.Response)
+ response.json.return_value = {"request_id": "abc"}
+
+ video = self.config.transform_video_create_response(
+ model=MODEL,
+ raw_response=response,
+ logging_obj=self.logging_obj,
+ custom_llm_provider="fal_ai",
+ request_data={"duration": "5", "resolution": "480p"},
+ )
+
+ decoded = decode_video_id_with_provider(video.id)
+ assert decoded["custom_llm_provider"] == "fal_ai"
+ assert decoded["model_id"] == MODEL
+ assert decoded["video_id"] == "abc"
+ assert video.status == "queued"
+ assert video.usage == {"duration_seconds": 5.0, "video_resolution": "480p"}
+
+ auto_video = self.config.transform_video_create_response(
+ model=MODEL,
+ raw_response=response,
+ logging_obj=self.logging_obj,
+ custom_llm_provider="fal_ai",
+ request_data={"duration": "auto"},
+ )
+ assert auto_video.usage == {"video_resolution": "720p"}
+ assert auto_video.seconds is None
+ assert auto_video.size is None
+
+ def test_status_request_uses_queue_base_path(self):
+ response = Mock(spec=httpx.Response)
+ response.json.return_value = {"request_id": "abc"}
+ video = self.config.transform_video_create_response(
+ model=MODEL,
+ raw_response=response,
+ logging_obj=self.logging_obj,
+ custom_llm_provider="fal_ai",
+ request_data={},
+ )
+
+ url, params = self.config.transform_video_status_retrieve_request(
+ video_id=video.id,
+ api_base="https://queue.fal.run",
+ litellm_params=GenericLiteLLMParams(),
+ headers={},
+ )
+ assert url == "https://queue.fal.run/bytedance/seedance-2.5/requests/abc/status"
+ assert params == {}
+ assert _queue_request_base_path("workflows/owner/app/x") == "workflows/owner/app"
+ assert _queue_request_base_path("comfy/owner/app/x") == "comfy/owner/app"
+
+ def test_status_request_rejects_unencoded_video_id(self):
+ with pytest.raises(ValueError, match="must be created through litellm"):
+ self.config.transform_video_status_retrieve_request(
+ video_id="abc",
+ api_base="https://queue.fal.run",
+ litellm_params=GenericLiteLLMParams(),
+ headers={},
+ )
+
+ @pytest.mark.parametrize(
+ ("response_data", "expected_status"),
+ [
+ ({"request_id": "abc", "status": "IN_QUEUE"}, "queued"),
+ ({"request_id": "abc", "status": "IN_PROGRESS"}, "in_progress"),
+ ({"request_id": "abc", "status": "COMPLETED"}, "completed"),
+ ],
+ )
+ def test_status_response_mapping(self, response_data, expected_status):
+ response = Mock(spec=httpx.Response)
+ response.json.return_value = response_data
+
+ video = self.config.transform_video_status_retrieve_response(
+ raw_response=response,
+ logging_obj=self.logging_obj,
+ custom_llm_provider="fal_ai",
+ )
+
+ assert video.status == expected_status
+ assert video.created_at == 0
+
+ def test_status_response_error(self):
+ response = Mock(spec=httpx.Response)
+ response.json.return_value = {
+ "request_id": "abc",
+ "status": "COMPLETED",
+ "error": "generation failed",
+ }
+
+ video = self.config.transform_video_status_retrieve_response(
+ raw_response=response,
+ logging_obj=self.logging_obj,
+ custom_llm_provider="fal_ai",
+ )
+
+ assert video.status == "failed"
+ assert video.error == {"code": "fal_error", "message": "generation failed"}
+
+ def test_content_response_downloads_video_url(self, monkeypatch):
+ content_response = httpx.Response(
+ 200,
+ content=b"video-bytes",
+ request=httpx.Request("GET", "https://cdn.example.com/video.mp4"),
+ )
+
+ class FakeHTTPClient:
+ def get(self, url):
+ assert url == "https://cdn.example.com/video.mp4"
+ return content_response
+
+ monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: FakeHTTPClient())
+ response = Mock(spec=httpx.Response)
+ response.json.return_value = {"video": {"url": "https://cdn.example.com/video.mp4"}}
+
+ assert self.config.transform_video_content_response(response, self.logging_obj) == b"video-bytes"
+
+ def test_content_response_rejects_missing_video(self):
+ response = Mock(spec=httpx.Response)
+ response.json.return_value = {"error": "generation failed"}
+
+ with pytest.raises(ValueError, match="generation failed"):
+ self.config.transform_video_content_response(response, self.logging_obj)
+
+ def test_provider_config_and_error_class(self):
+ provider_config = ProviderConfigManager.get_provider_video_config(
+ model=MODEL,
+ provider=LlmProviders.FAL_AI,
+ )
+ assert isinstance(provider_config, FalAIVideoConfig)
+ assert isinstance(self.config.get_error_class("bad key", 401, {}), FalAIVideoError)
+
+ def test_video_cost_uses_tiered_rows(self):
+ rows = {
+ model: row
+ for model, row in litellm.model_cost.items()
+ if row.get("litellm_provider") == "fal_ai" and row.get("mode") == "video_generation"
+ }
+ assert rows
+ for model, row in rows.items():
+ assert default_video_cost_calculator(model, 5, "fal_ai", video_resolution="480p") == (
+ 5 * row["output_cost_per_second_480p"]
+ )
+ assert default_video_cost_calculator(model, 5, "fal_ai", video_resolution="720p") == (
+ 5 * row["output_cost_per_second"]
+ )
From 0f4ce95492cde57b1f2eb29a1ea119dda0b13e24 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 16:45:53 +0000
Subject: [PATCH 051/317] refactor(fal_ai): simplify video config mappings
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/llms/fal_ai/videos/transformation.py | 178 +++++++++----------
1 file changed, 80 insertions(+), 98 deletions(-)
diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py
index f8ebf828d68..f0142ddc3de 100644
--- a/litellm/llms/fal_ai/videos/transformation.py
+++ b/litellm/llms/fal_ai/videos/transformation.py
@@ -2,7 +2,7 @@ import math
import time
from collections.abc import Mapping
from types import MappingProxyType
-from typing import Final
+from typing import Final, TypeAlias
import httpx
from httpx._types import FileContent, RequestFiles
@@ -42,12 +42,25 @@ _RESOLUTION_TIERS: Final[tuple[tuple[int, str], ...]] = (
(720, "720p"),
(1080, "1080p"),
)
+_QUEUE_NAMESPACES: Final[frozenset[str]] = frozenset(("workflows", "comfy"))
+_STATUS_MAP: Final[Mapping[str, str]] = MappingProxyType(
+ {
+ "IN_QUEUE": "queued",
+ "IN_PROGRESS": "in_progress",
+ "COMPLETED": "completed",
+ }
+)
_FAL_AI_PROVIDER: Final[str] = LlmProviders.FAL_AI.value
+_SupportedParams: TypeAlias = list[str]
+_VideoParams: TypeAlias = dict[str, object]
+_VideoHeaders: TypeAlias = dict[str, str]
+_VideoStringParams: TypeAlias = dict[str, str]
+_VideoFiles: TypeAlias = list[object]
def _queue_request_base_path(model: str) -> str:
segments: Final[tuple[str, ...]] = tuple(model.split("/"))
- segment_count: Final[int] = 3 if segments and segments[0] in frozenset(("workflows", "comfy")) else 2
+ segment_count: Final[int] = 3 if segments and segments[0] in _QUEUE_NAMESPACES else 2
return "/".join(segments[:segment_count])
@@ -105,8 +118,8 @@ def _response_string(response_data: Mapping[str, object], key: str, default: str
class FalAIVideoConfig(BaseVideoConfig):
- def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: API contract requires a list
- return [ # mutable-ok: API contract requires a list
+ def get_supported_openai_params(self, model: str) -> _SupportedParams:
+ supported_params: Final[_SupportedParams] = [ # mutable-ok: BaseVideoConfig requires a list
"model",
"prompt",
"input_reference",
@@ -115,23 +128,22 @@ class FalAIVideoConfig(BaseVideoConfig):
"user",
"extra_headers",
]
+ return supported_params
def map_openai_params(
self,
video_create_optional_params: VideoCreateOptionalRequestParams,
model: str,
drop_params: bool,
- ) -> dict[str, object]: # mutable-ok: BaseVideoConfig requires a mutable mapping
+ ) -> _VideoParams:
supported_params: Final[frozenset[str]] = frozenset(self.get_supported_openai_params(model))
input_reference: Final[object] = video_create_optional_params.get("input_reference")
+ if "input_reference" in video_create_optional_params and not isinstance(input_reference, str):
+ raise ValueError("fal.ai needs a public image URL for input_reference")
input_reference_params: Final[Mapping[str, str]] = (
MappingProxyType({})
- if "input_reference" not in video_create_optional_params
- else (
- MappingProxyType({"image_url": input_reference})
- if isinstance(input_reference, str)
- else self._invalid_input_reference()
- )
+ if not isinstance(input_reference, str)
+ else MappingProxyType({"image_url": input_reference})
)
duration_params: Final[Mapping[str, str]] = (
MappingProxyType({})
@@ -139,7 +151,7 @@ class FalAIVideoConfig(BaseVideoConfig):
else self._duration_params(video_create_optional_params["seconds"])
)
size_params: Final[Mapping[str, str]] = (
- self._size_params(video_create_optional_params["size"])
+ _size_params(video_create_optional_params["size"])
if "size" in video_create_optional_params
else MappingProxyType({})
)
@@ -148,23 +160,16 @@ class FalAIVideoConfig(BaseVideoConfig):
if isinstance(user := video_create_optional_params.get("user"), str)
else MappingProxyType({})
)
- return dict( # mutable-ok: BaseVideoConfig requires a mutable mapping
- MappingProxyType(
- {
- **input_reference_params,
- **duration_params,
- **size_params,
- **user_params,
- **{ # mutable-ok: dynamic passthrough fields require a mapping
- key: value for key, value in video_create_optional_params.items() if key not in supported_params
- },
- }
- )
- ) # mutable-ok: BaseVideoConfig requires a mutable mapping
-
- @staticmethod
- def _invalid_input_reference() -> Mapping[str, str]:
- raise ValueError("fal.ai needs a public image URL for input_reference")
+ mapped_params: Final[_VideoParams] = {
+ **input_reference_params,
+ **duration_params,
+ **size_params,
+ **user_params,
+ **{ # mutable-ok: BaseVideoConfig requires a mutable parameter mapping
+ key: value for key, value in video_create_optional_params.items() if key not in supported_params
+ },
+ }
+ return mapped_params
@staticmethod
def _duration_params(seconds: object) -> Mapping[str, str]:
@@ -173,17 +178,13 @@ class FalAIVideoConfig(BaseVideoConfig):
raise ValueError("fal.ai seconds must be a numeric value")
return MappingProxyType({"duration": duration})
- @staticmethod
- def _size_params(size: object) -> Mapping[str, str]:
- return _size_params(size)
-
def validate_environment(
self,
- headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ headers: _VideoHeaders,
model: str,
api_key: str | None = None,
litellm_params: GenericLiteLLMParams | None = None,
- ) -> dict[str, str]: # mutable-ok: BaseVideoConfig requires mutable headers
+ ) -> _VideoHeaders:
final_api_key: Final[str | None] = (
api_key
or (litellm_params.api_key if litellm_params is not None else None)
@@ -192,21 +193,18 @@ class FalAIVideoConfig(BaseVideoConfig):
)
if not final_api_key:
raise ValueError("fal.ai API key is required")
- return dict( # mutable-ok: BaseVideoConfig requires mutable headers
- MappingProxyType(
- {
- **headers,
- "Authorization": f"Key {final_api_key}",
- "Content-Type": "application/json",
- }
- )
- ) # mutable-ok: BaseVideoConfig requires mutable headers
+ validated_headers: Final[_VideoHeaders] = {
+ **headers,
+ "Authorization": f"Key {final_api_key}",
+ "Content-Type": "application/json",
+ }
+ return validated_headers
def get_complete_url(
self,
model: str,
api_base: str | None,
- litellm_params: dict[str, object], # mutable-ok: BaseVideoConfig requires mutable parameters
+ litellm_params: _VideoParams,
) -> str:
return (api_base or get_secret_str("FAL_AI_QUEUE_API_BASE") or "https://queue.fal.run").rstrip("/")
@@ -215,22 +213,16 @@ class FalAIVideoConfig(BaseVideoConfig):
model: str,
prompt: str,
api_base: str,
- video_create_optional_request_params: dict[ # mutable-ok: BaseVideoConfig requires mutable parameters
- str, object
- ], # mutable-ok: BaseVideoConfig requires mutable parameters
+ video_create_optional_request_params: _VideoParams,
litellm_params: GenericLiteLLMParams,
- headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
- ) -> tuple[dict[str, object], RequestFiles, str]: # mutable-ok: BaseVideoConfig requires mutable mappings
- request_data: Final[dict[str, object]] = dict( # mutable-ok: HTTP JSON payload requires mutable data
- MappingProxyType(
- {
- "prompt": prompt,
- **{ # mutable-ok: dynamic request fields require a mapping
- key: value for key, value in video_create_optional_request_params.items() if key != "model"
- },
- }
- )
- )
+ headers: _VideoHeaders,
+ ) -> tuple[_VideoParams, RequestFiles, str]:
+ request_data: Final[_VideoParams] = {
+ "prompt": prompt,
+ **{ # mutable-ok: HTTP JSON payload requires a mutable mapping
+ key: value for key, value in video_create_optional_request_params.items() if key != "model"
+ },
+ }
return request_data, [], f"{api_base.rstrip('/')}/{model}" # mutable-ok: HTTP files payload requires a list
def transform_video_create_response(
@@ -249,18 +241,14 @@ class FalAIVideoConfig(BaseVideoConfig):
resolution: Final[object] = request_params.get("resolution")
seconds: Final[str | None] = _duration_value(request_params["duration"]) if duration is not None else None
size: Final[str | None] = resolution if isinstance(resolution, str) else None
- usage: Final[dict[str, object]] = dict( # mutable-ok: VideoObject requires a mutable usage mapping
- MappingProxyType(
- {
- key: value
- for key, value in (
- ("duration_seconds", duration),
- ("video_resolution", resolution if isinstance(resolution, str) else "720p"),
- )
- if value is not None
- }
+ usage: Final[_VideoParams] = { # mutable-ok: VideoObject requires a mutable usage mapping
+ key: value
+ for key, value in (
+ ("duration_seconds", duration),
+ ("video_resolution", resolution if isinstance(resolution, str) else "720p"),
)
- ) # mutable-ok: VideoObject requires a mutable usage mapping
+ if value is not None
+ }
video_object: Final[VideoObject] = VideoObject(
id=encode_video_id_with_provider(request_id, provider, model),
object="video",
@@ -278,8 +266,8 @@ class FalAIVideoConfig(BaseVideoConfig):
video_id: str,
api_base: str,
litellm_params: GenericLiteLLMParams,
- headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
- ) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ headers: _VideoHeaders,
+ ) -> tuple[str, _VideoParams]:
request_id, model_id = self._decode_video_id(video_id)
encoded_request_id: Final[str] = encode_url_path_segment(request_id, field_name="video_id")
return (
@@ -295,13 +283,7 @@ class FalAIVideoConfig(BaseVideoConfig):
) -> VideoObject:
response_data: Final[Mapping[str, object]] = _response_data(raw_response)
raw_status: Final[str] = _response_string(response_data, "status", "IN_QUEUE")
- status: Final[str] = MappingProxyType(
- {
- "IN_QUEUE": "queued",
- "IN_PROGRESS": "in_progress",
- "COMPLETED": "completed",
- }
- ).get(raw_status, "queued")
+ status: Final[str] = _STATUS_MAP.get(raw_status, "queued")
error_value: Final[object] = response_data.get("error")
error: Final[str | None] = error_value if isinstance(error_value, str) else None
provider: Final[str] = custom_llm_provider or _FAL_AI_PROVIDER
@@ -312,7 +294,7 @@ class FalAIVideoConfig(BaseVideoConfig):
created_at=0,
error=(
{"code": "fal_error", "message": error} if error else None # mutable-ok: VideoObject requires a dict
- ), # mutable-ok: VideoObject requires a dict
+ ),
)
@staticmethod
@@ -329,9 +311,9 @@ class FalAIVideoConfig(BaseVideoConfig):
video_id: str,
api_base: str,
litellm_params: GenericLiteLLMParams,
- headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ headers: _VideoHeaders,
variant: str | None = None,
- ) -> tuple[str, dict[str, str]]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ ) -> tuple[str, _VideoStringParams]:
request_id, model_id = self._decode_video_id(video_id)
encoded_request_id: Final[str] = encode_url_path_segment(request_id, field_name="video_id")
return (
@@ -383,9 +365,9 @@ class FalAIVideoConfig(BaseVideoConfig):
prompt: str,
api_base: str,
litellm_params: GenericLiteLLMParams,
- headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ headers: _VideoHeaders,
extra_body: Mapping[str, object] | None = None,
- ) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ ) -> tuple[str, _VideoParams]:
raise NotImplementedError("video remix is not supported for fal.ai")
def transform_video_remix_response(
@@ -400,12 +382,12 @@ class FalAIVideoConfig(BaseVideoConfig):
self,
api_base: str,
litellm_params: GenericLiteLLMParams,
- headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ headers: _VideoHeaders,
after: str | None = None,
limit: int | None = None,
order: str | None = None,
extra_query: Mapping[str, object] | None = None,
- ) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ ) -> tuple[str, _VideoParams]:
raise NotImplementedError("video listing is not supported for fal.ai")
def transform_video_list_response(
@@ -413,7 +395,7 @@ class FalAIVideoConfig(BaseVideoConfig):
raw_response: httpx.Response,
logging_obj: object,
custom_llm_provider: str | None = None,
- ) -> dict[str, str]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ ) -> _VideoStringParams:
raise NotImplementedError("video listing is not supported for fal.ai")
def transform_video_delete_request(
@@ -421,8 +403,8 @@ class FalAIVideoConfig(BaseVideoConfig):
video_id: str,
api_base: str,
litellm_params: GenericLiteLLMParams,
- headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
- ) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ headers: _VideoHeaders,
+ ) -> tuple[str, _VideoParams]:
raise NotImplementedError("video delete is not supported for fal.ai")
def transform_video_delete_response(self, raw_response: httpx.Response, logging_obj: object) -> VideoObject:
@@ -434,8 +416,8 @@ class FalAIVideoConfig(BaseVideoConfig):
video: object,
api_base: str,
litellm_params: GenericLiteLLMParams,
- headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
- ) -> tuple[str, list[object]]: # mutable-ok: BaseVideoConfig requires mutable lists
+ headers: _VideoHeaders,
+ ) -> tuple[str, _VideoFiles]:
raise NotImplementedError("video character creation is not supported for fal.ai")
def transform_video_create_character_response(
@@ -450,8 +432,8 @@ class FalAIVideoConfig(BaseVideoConfig):
character_id: str,
api_base: str,
litellm_params: GenericLiteLLMParams,
- headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
- ) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ headers: _VideoHeaders,
+ ) -> tuple[str, _VideoParams]:
raise NotImplementedError("video character retrieval is not supported for fal.ai")
def transform_video_get_character_response(
@@ -467,7 +449,7 @@ class FalAIVideoConfig(BaseVideoConfig):
video_id: str,
api_base: str,
litellm_params: GenericLiteLLMParams,
- headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ headers: _VideoHeaders,
video_file: FileContent | None = None,
extra_body: Mapping[str, object] | None = None,
prefetched_source_data: Mapping[str, object] | None = None,
@@ -490,9 +472,9 @@ class FalAIVideoConfig(BaseVideoConfig):
seconds: str,
api_base: str,
litellm_params: GenericLiteLLMParams,
- headers: dict[str, str], # mutable-ok: BaseVideoConfig requires mutable headers
+ headers: _VideoHeaders,
extra_body: Mapping[str, object] | None = None,
- ) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig requires mutable mappings
+ ) -> tuple[str, _VideoParams]:
raise NotImplementedError("video extension is not supported for fal.ai")
def transform_video_extension_response(
@@ -507,6 +489,6 @@ class FalAIVideoConfig(BaseVideoConfig):
self,
error_message: str,
status_code: int,
- headers: dict[str, str] | httpx.Headers, # mutable-ok: BaseLLMException requires mutable headers
+ headers: _VideoHeaders | httpx.Headers,
) -> BaseLLMException:
return FalAIVideoError(status_code=status_code, message=error_message, headers=headers)
From 141548dcf3ec7588e86c2e304fca33be6c4cdc7e Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 16:54:11 +0000
Subject: [PATCH 052/317] fix(fal_ai): keep status ids pollable and size
resolution by the short side
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/llms/fal_ai/videos/transformation.py | 17 +++++++--
.../test_fal_ai_video_transformation.py | 37 +++++++++++++++++++
2 files changed, 50 insertions(+), 4 deletions(-)
diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py
index f0142ddc3de..4ca1f918c90 100644
--- a/litellm/llms/fal_ai/videos/transformation.py
+++ b/litellm/llms/fal_ai/videos/transformation.py
@@ -75,8 +75,16 @@ def _duration_value(value: object) -> str | None:
return None
-def _resolution_for_height(height: int) -> str:
- return next((resolution for threshold, resolution in _RESOLUTION_TIERS if height <= threshold), "4k")
+def _resolution_for_short_side(short_side: int) -> str:
+ return next((resolution for threshold, resolution in _RESOLUTION_TIERS if short_side <= threshold), "4k")
+
+
+def _model_path_from_queue_url(url: object) -> str | None:
+ if not isinstance(url, str) or not url:
+ return None
+ path: Final[str] = httpx.URL(url).path.strip("/")
+ model_path, separator, _ = path.partition("/requests/")
+ return model_path if separator and model_path else None
def _size_params(size: object) -> Mapping[str, str]:
@@ -95,7 +103,7 @@ def _size_params(size: object) -> Mapping[str, str]:
return MappingProxyType({})
reduced_gcd: Final[int] = math.gcd(width, height)
aspect_ratio: Final[str] = f"{width // reduced_gcd}:{height // reduced_gcd}"
- resolution: Final[str] = _resolution_for_height(height)
+ resolution: Final[str] = _resolution_for_short_side(min(width, height))
if aspect_ratio in _ALLOWED_ASPECT_RATIOS:
return MappingProxyType({"resolution": resolution, "aspect_ratio": aspect_ratio})
return MappingProxyType({"resolution": resolution})
@@ -287,8 +295,9 @@ class FalAIVideoConfig(BaseVideoConfig):
error_value: Final[object] = response_data.get("error")
error: Final[str | None] = error_value if isinstance(error_value, str) else None
provider: Final[str] = custom_llm_provider or _FAL_AI_PROVIDER
+ model_path: Final[str | None] = _model_path_from_queue_url(response_data.get("response_url"))
return VideoObject(
- id=encode_video_id_with_provider(_response_string(response_data, "request_id"), provider),
+ id=encode_video_id_with_provider(_response_string(response_data, "request_id"), provider, model_path),
object="video",
status="failed" if error else status,
created_at=0,
diff --git a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py
index d4b058ddcf6..f367fa331d5 100644
--- a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py
+++ b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py
@@ -51,6 +51,14 @@ class TestFalAIVideoTransformation:
"aspect_ratio": "1:1",
}
assert self.config.map_openai_params({"size": "720p"}, MODEL, False) == {"resolution": "720p"}
+ assert self.config.map_openai_params({"size": "720x1280"}, MODEL, False) == {
+ "resolution": "720p",
+ "aspect_ratio": "9:16",
+ }
+ assert self.config.map_openai_params({"size": "1080x1920"}, MODEL, False) == {
+ "resolution": "1080p",
+ "aspect_ratio": "9:16",
+ }
def test_map_openai_params_rejects_non_url_input_reference(self):
with pytest.raises(ValueError, match="public image URL"):
@@ -165,6 +173,35 @@ class TestFalAIVideoTransformation:
assert video.status == expected_status
assert video.created_at == 0
+ def test_status_response_id_stays_pollable(self):
+ response = Mock(spec=httpx.Response)
+ response.json.return_value = {
+ "request_id": "abc",
+ "status": "IN_PROGRESS",
+ "response_url": "https://queue.fal.run/bytedance/seedance-2.5/requests/abc",
+ }
+
+ video = self.config.transform_video_status_retrieve_response(
+ raw_response=response,
+ logging_obj=self.logging_obj,
+ custom_llm_provider="fal_ai",
+ )
+
+ status_url, _ = self.config.transform_video_status_retrieve_request(
+ video_id=video.id,
+ api_base="https://queue.fal.run",
+ litellm_params=GenericLiteLLMParams(),
+ headers={},
+ )
+ content_url, _ = self.config.transform_video_content_request(
+ video_id=video.id,
+ api_base="https://queue.fal.run",
+ litellm_params=GenericLiteLLMParams(),
+ headers={},
+ )
+ assert status_url == "https://queue.fal.run/bytedance/seedance-2.5/requests/abc/status"
+ assert content_url == "https://queue.fal.run/bytedance/seedance-2.5/requests/abc"
+
def test_status_response_error(self):
response = Mock(spec=httpx.Response)
response.json.return_value = {
From c359ef763eae44523cace811bcb1999992250bde Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 17:01:09 +0000
Subject: [PATCH 053/317] fix(fal_ai): keep model in polled video ids and pick
resolution from the short side
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/llms/fal_ai/videos/transformation.py | 28 ++++++--
.../test_fal_ai_video_transformation.py | 64 +++++++++++--------
2 files changed, 58 insertions(+), 34 deletions(-)
diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py
index 4ca1f918c90..766316a5b18 100644
--- a/litellm/llms/fal_ai/videos/transformation.py
+++ b/litellm/llms/fal_ai/videos/transformation.py
@@ -79,12 +79,22 @@ def _resolution_for_short_side(short_side: int) -> str:
return next((resolution for threshold, resolution in _RESOLUTION_TIERS if short_side <= threshold), "4k")
-def _model_path_from_queue_url(url: object) -> str | None:
- if not isinstance(url, str) or not url:
+def _model_path_from_request_url(raw_response: httpx.Response) -> str | None:
+ segments: Final[tuple[str, ...]] = tuple(segment for segment in raw_response.request.url.path.split("/") if segment)
+ if "requests" not in segments:
return None
- path: Final[str] = httpx.URL(url).path.strip("/")
- model_path, separator, _ = path.partition("/requests/")
- return model_path if separator and model_path else None
+ model_segments: Final[tuple[str, ...]] = segments[: segments.index("requests")]
+ segment_count: Final[int] = 3 if len(model_segments) >= 3 and model_segments[-3] in _QUEUE_NAMESPACES else 2
+ return "/".join(model_segments[-segment_count:]) if len(model_segments) >= segment_count else None
+
+
+def _request_id_from_request_url(raw_response: httpx.Response) -> str | None:
+ segments: Final[tuple[str, ...]] = tuple(segment for segment in raw_response.request.url.path.split("/") if segment)
+ if "requests" not in segments:
+ return None
+ request_index: Final[int] = segments.index("requests")
+ request_id_index: Final[int] = request_index + 1
+ return segments[request_id_index] if len(segments) > request_id_index else None
def _size_params(size: object) -> Mapping[str, str]:
@@ -295,12 +305,16 @@ class FalAIVideoConfig(BaseVideoConfig):
error_value: Final[object] = response_data.get("error")
error: Final[str | None] = error_value if isinstance(error_value, str) else None
provider: Final[str] = custom_llm_provider or _FAL_AI_PROVIDER
- model_path: Final[str | None] = _model_path_from_queue_url(response_data.get("response_url"))
+ model_path: Final[str | None] = _model_path_from_request_url(raw_response)
+ request_id: Final[str] = _response_string(response_data, "request_id") or (
+ _request_id_from_request_url(raw_response) or ""
+ )
return VideoObject(
- id=encode_video_id_with_provider(_response_string(response_data, "request_id"), provider, model_path),
+ id=encode_video_id_with_provider(request_id, provider, model_path),
object="video",
status="failed" if error else status,
created_at=0,
+ model=model_path,
error=(
{"code": "fal_error", "message": error} if error else None # mutable-ok: VideoObject requires a dict
),
diff --git a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py
index f367fa331d5..8e0c68e30bb 100644
--- a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py
+++ b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py
@@ -161,8 +161,8 @@ class TestFalAIVideoTransformation:
],
)
def test_status_response_mapping(self, response_data, expected_status):
- response = Mock(spec=httpx.Response)
- response.json.return_value = response_data
+ status_url = "https://queue.fal.run/bytedance/seedance-2.5/requests/abc/status"
+ response = httpx.Response(200, json=response_data, request=httpx.Request("GET", status_url))
video = self.config.transform_video_status_retrieve_response(
raw_response=response,
@@ -172,43 +172,32 @@ class TestFalAIVideoTransformation:
assert video.status == expected_status
assert video.created_at == 0
+ decoded = decode_video_id_with_provider(video.id)
+ assert decoded["model_id"] == "bytedance/seedance-2.5"
+ assert decoded["video_id"] == "abc"
- def test_status_response_id_stays_pollable(self):
- response = Mock(spec=httpx.Response)
- response.json.return_value = {
- "request_id": "abc",
- "status": "IN_PROGRESS",
- "response_url": "https://queue.fal.run/bytedance/seedance-2.5/requests/abc",
- }
-
- video = self.config.transform_video_status_retrieve_response(
- raw_response=response,
- logging_obj=self.logging_obj,
- custom_llm_provider="fal_ai",
- )
-
- status_url, _ = self.config.transform_video_status_retrieve_request(
+ poll_url, _ = self.config.transform_video_status_retrieve_request(
video_id=video.id,
api_base="https://queue.fal.run",
litellm_params=GenericLiteLLMParams(),
headers={},
)
- content_url, _ = self.config.transform_video_content_request(
- video_id=video.id,
- api_base="https://queue.fal.run",
- litellm_params=GenericLiteLLMParams(),
- headers={},
- )
- assert status_url == "https://queue.fal.run/bytedance/seedance-2.5/requests/abc/status"
- assert content_url == "https://queue.fal.run/bytedance/seedance-2.5/requests/abc"
+ assert poll_url == status_url
def test_status_response_error(self):
- response = Mock(spec=httpx.Response)
- response.json.return_value = {
+ response_data = {
"request_id": "abc",
"status": "COMPLETED",
"error": "generation failed",
}
+ response = httpx.Response(
+ 200,
+ json=response_data,
+ request=httpx.Request(
+ "GET",
+ "https://queue.fal.run/bytedance/seedance-2.5/requests/abc/status",
+ ),
+ )
video = self.config.transform_video_status_retrieve_response(
raw_response=response,
@@ -219,6 +208,27 @@ class TestFalAIVideoTransformation:
assert video.status == "failed"
assert video.error == {"code": "fal_error", "message": "generation failed"}
+ def test_status_response_uses_namespaced_request_url(self):
+ response = httpx.Response(
+ 200,
+ json={"status": "IN_PROGRESS"},
+ request=httpx.Request(
+ "GET",
+ "https://example.com/proxy/workflows/owner/app/requests/xyz/status",
+ ),
+ )
+
+ video = self.config.transform_video_status_retrieve_response(
+ raw_response=response,
+ logging_obj=self.logging_obj,
+ custom_llm_provider="fal_ai",
+ )
+
+ decoded = decode_video_id_with_provider(video.id)
+ assert decoded["model_id"] == "workflows/owner/app"
+ assert decoded["video_id"] == "xyz"
+ assert video.model == "workflows/owner/app"
+
def test_content_response_downloads_video_url(self, monkeypatch):
content_response = httpx.Response(
200,
From aa5f0858f75b3e074264e0266f87b70a6cb70391 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 17:13:41 +0000
Subject: [PATCH 054/317] test(pricing): allow video endpoint and rates
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/test_litellm/test_utils.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py
index b40c10de428..d694c08510a 100644
--- a/tests/test_litellm/test_utils.py
+++ b/tests/test_litellm/test_utils.py
@@ -940,6 +940,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"/v1/audio/transcriptions",
"/v1/audio/speech",
"/v1/ocr",
+ "/v1/videos",
"/vertex_ai/live",
"/v1/listen",
"/v1beta/interactions",
@@ -1069,6 +1070,9 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
# Add any model IDs that should be exempt from the cost validation
# Example: "expensive-model-id",
"runwayml/seedance2", # 4K output is 150 credits/second = $1.50/second
+ "fal_ai/bytedance/seedance-2.0/text-to-video",
+ "fal_ai/bytedance/seedance-2.0/image-to-video",
+ "fal_ai/bytedance/seedance-2.0/reference-to-video",
]
is_valid, violations = validate_model_cost_values(actual_json, exceptions)
From e0b455e94e83dfedad0364aedc6b4cdfcb59acb0 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 17:27:44 +0000
Subject: [PATCH 055/317] fix(fal_ai): read only the documented FAL_AI_API_KEY
env var
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/llms/fal_ai/videos/transformation.py | 5 ++---
.../test_fal_ai_video_transformation.py | 20 +++++++++++++++++++
2 files changed, 22 insertions(+), 3 deletions(-)
diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py
index 766316a5b18..98528c82f6b 100644
--- a/litellm/llms/fal_ai/videos/transformation.py
+++ b/litellm/llms/fal_ai/videos/transformation.py
@@ -207,10 +207,9 @@ class FalAIVideoConfig(BaseVideoConfig):
api_key
or (litellm_params.api_key if litellm_params is not None else None)
or get_secret_str("FAL_AI_API_KEY")
- or get_secret_str("FAL_KEY")
)
if not final_api_key:
- raise ValueError("fal.ai API key is required")
+ raise ValueError("FAL_AI_API_KEY is not set")
validated_headers: Final[_VideoHeaders] = {
**headers,
"Authorization": f"Key {final_api_key}",
@@ -224,7 +223,7 @@ class FalAIVideoConfig(BaseVideoConfig):
api_base: str | None,
litellm_params: _VideoParams,
) -> str:
- return (api_base or get_secret_str("FAL_AI_QUEUE_API_BASE") or "https://queue.fal.run").rstrip("/")
+ return (api_base or "https://queue.fal.run").rstrip("/")
def transform_video_create_request(
self,
diff --git a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py
index 8e0c68e30bb..5e2e4532265 100644
--- a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py
+++ b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py
@@ -91,6 +91,26 @@ class TestFalAIVideoTransformation:
}
assert "model" not in body
+ def test_get_complete_url_respects_api_base_override(self):
+ url = self.config.get_complete_url(
+ model=MODEL,
+ api_base="https://proxy.internal/",
+ litellm_params={},
+ )
+
+ assert url == "https://proxy.internal"
+
+ def test_validate_environment_requires_fal_ai_api_key(self, monkeypatch):
+ monkeypatch.setattr(fal_video_module, "get_secret_str", lambda _: None)
+
+ with pytest.raises(ValueError, match="FAL_AI_API_KEY is not set"):
+ self.config.validate_environment(
+ headers={},
+ model=MODEL,
+ api_key=None,
+ litellm_params=GenericLiteLLMParams(),
+ )
+
def test_transform_video_create_response_encodes_model_and_usage(self):
response = Mock(spec=httpx.Response)
response.json.return_value = {"request_id": "abc"}
From 52aa20d138aaab58f751cbcd2b5c376d441232d2 Mon Sep 17 00:00:00 2001
From: Moe Khalil
Date: Sat, 19 Sep 2026 17:49:55 +0000
Subject: [PATCH 056/317] refactor(auto-router): freeze JEV logging input
mappings
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../complexity_router/jev_classifier.py | 16 +++++++++-------
1 file changed, 9 insertions(+), 7 deletions(-)
diff --git a/litellm/router_strategy/complexity_router/jev_classifier.py b/litellm/router_strategy/complexity_router/jev_classifier.py
index de23824a5f6..acaf19a5aba 100644
--- a/litellm/router_strategy/complexity_router/jev_classifier.py
+++ b/litellm/router_strategy/complexity_router/jev_classifier.py
@@ -118,12 +118,14 @@ class HttpJevClassifierClient:
return
end_time: Final = datetime.now(timezone.utc)
parent: Final = request_kwargs or MappingProxyType({})
- parent_metadata: Final = {
- key: value
- for field in ("metadata", "litellm_metadata")
- if isinstance(metadata := parent.get(field), Mapping)
- for key, value in TypeAdapter(Mapping[str, object]).validate_python(metadata).items()
- }
+ parent_metadata: Final = MappingProxyType(
+ {
+ key: value
+ for field in ("metadata", "litellm_metadata")
+ if isinstance(metadata := parent.get(field), Mapping)
+ for key, value in TypeAdapter(Mapping[str, object]).validate_python(metadata).items()
+ }
+ )
params: Final = {
"metadata": {
**forwarded_internal_call_metadata(parent_metadata, AUTOROUTER_CLASSIFIER_CALL_ORIGIN),
@@ -158,7 +160,7 @@ class HttpJevClassifierClient:
start_time=start_time,
end_time=end_time,
cache_hit=False,
- request_body={"model": request.model},
+ request_body=MappingProxyType({"model": request.model}),
litellm_params=params,
)
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(
From 3b0d32ec6f14979687d4bb76f51db5f9427a131a Mon Sep 17 00:00:00 2001
From: Moe Khalil
Date: Sat, 19 Sep 2026 18:11:50 +0000
Subject: [PATCH 057/317] fix(proxy): reject throttled exhausted budgets in JEV
previews
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../auto_router_endpoints.py | 8 +++
.../test_auto_router_endpoints.py | 49 +++++++++++++++++++
2 files changed, 57 insertions(+)
diff --git a/litellm/proxy/management_endpoints/auto_router_endpoints.py b/litellm/proxy/management_endpoints/auto_router_endpoints.py
index f79425d2e97..c5a10cf5c80 100644
--- a/litellm/proxy/management_endpoints/auto_router_endpoints.py
+++ b/litellm/proxy/management_endpoints/auto_router_endpoints.py
@@ -345,6 +345,14 @@ async def _authorize_models_this_test_can_call(
code=status.HTTP_400_BAD_REQUEST,
) from e
+ if config.classifier_type == "jev" and user_api_key_dict.budget_throttle_pct is not None:
+ raise ProxyException(
+ message="Budget has been exceeded! JEV Test Routing requires available budget.",
+ type=ProxyErrorTypes.budget_exceeded,
+ param=None,
+ code=status.HTTP_400_BAD_REQUEST,
+ )
+
@router.post(
"/auto_router/validate_complexity_router_config",
diff --git a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
index a5c93c41a84..eb9076a9d2d 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
@@ -12,6 +12,7 @@ import pytest
from fastapi import HTTPException, Request
from pydantic import ValidationError
+import litellm
from litellm.proxy import proxy_server
from litellm.proxy._types import (
LitellmUserRoles,
@@ -488,6 +489,54 @@ async def test_jev_test_routing_enforces_key_budget_before_provider_invocation(
client.evaluate.assert_awaited_once()
+@pytest.mark.parametrize(
+ "max_budget, spend, denied",
+ ((0.0, 0.0, True), (1.0, 2.0, True), (1.0, 0.5, False), (None, 2.0, False)),
+)
+@pytest.mark.asyncio
+async def test_jev_test_routing_hard_blocks_exhausted_throttle_enabled_keys(
+ monkeypatch: pytest.MonkeyPatch, max_budget: float | None, spend: float, denied: bool
+) -> None:
+ client: Final = AsyncMock(spec=JevClassifierClient)
+ client.evaluate.return_value = JevSystemOneResponse(
+ model="jev-test",
+ answers={
+ "tier": JevChoiceAnswer(type="choice", choice="SIMPLE", probabilities={"SIMPLE": 1.0}, confidence=1.0)
+ },
+ )
+ monkeypatch.setattr(litellm, "budget_exceeded_throttle_percentage", 0.1)
+ monkeypatch.setattr(proxy_server, "llm_router", _router())
+ monkeypatch.setattr(auto_router_endpoints, "ComplexityRouter", partial(ComplexityRouter, jev_client=client))
+ actor: Final = UserAPIKeyAuth(
+ user_role=LitellmUserRoles.PROXY_ADMIN,
+ api_key="sk-jev-throttle-test",
+ user_id="admin",
+ models=["cheap-model", "typesafe/jev-test"],
+ max_budget=max_budget,
+ spend=spend,
+ rpm_limit=100,
+ metadata={"throttle_on_budget_exceeded": True},
+ )
+ request: Final = _request(
+ "what is 2+2",
+ classifier_type="jev",
+ jev_classifier_config={"model": "jev-test"},
+ )
+ if denied:
+ with pytest.raises(ProxyException) as exc_info:
+ await preview_auto_router_routing(http_request=ROUTING_HTTP_REQUEST, data=request, user_api_key_dict=actor)
+ assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
+ assert exc_info.value.code == "400"
+ client.evaluate.assert_not_called()
+ return
+
+ response: Final = await preview_auto_router_routing(
+ http_request=ROUTING_HTTP_REQUEST, data=request, user_api_key_dict=actor
+ )
+ assert response.routing_decision["cause"] == "jev_classifier"
+ client.evaluate.assert_awaited_once()
+
+
@pytest.mark.parametrize("max_budget, spend", ((0.0, 0.0), (1.0, 2.0)))
@pytest.mark.asyncio
async def test_a_heuristic_config_does_not_need_a_budget(
From 85a6a8e2063ef0932dc40b19c97eb0120a0c4235 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 18:22:58 +0000
Subject: [PATCH 058/317] test(e2e): cover fal Seedance video create, poll and
download
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../llm_nonconversational.yaml | 1 +
tests/e2e/coverage_registry/schema.py | 2 +
.../LLM_TRANSLATION_COVERAGE_MATRIX.md | 2 +
tests/e2e/llm_translation/endpoints_client.py | 36 +++++++++-
.../test_video_generation_e2e.py | 69 +++++++++++++++++++
5 files changed, 109 insertions(+), 1 deletion(-)
create mode 100644 tests/e2e/llm_translation/test_video_generation_e2e.py
diff --git a/tests/e2e/coverage_registry/llm_nonconversational.yaml b/tests/e2e/coverage_registry/llm_nonconversational.yaml
index 50f9b9808b2..6970567b6f0 100644
--- a/tests/e2e/coverage_registry/llm_nonconversational.yaml
+++ b/tests/e2e/coverage_registry/llm_nonconversational.yaml
@@ -80,6 +80,7 @@
- {id: llm.images_generations.vertex.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: images_generations, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "vertex_ai/image_generation/image_generation_handler.py", rationale: "Vertex Imagen"}
- {id: llm.images_generations.bedrock.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: images_generations, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "bedrock/image_generation/image_handler.py", rationale: "Bedrock Titan Image"}
- {id: llm.images_generations.black_forest_labs.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: images_generations, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "black_forest_labs/image_generation/handler.py", rationale: "BFL Flux via OpenAI-compat"}
+- {id: llm.videos.fal_ai.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: videos, route: fal_ai, capability: basic, streaming: nonstream, assertions: [works], source: "test_video_generation_e2e.py", rationale: "fal queue video create, poll, content download"}
- {id: llm.audio_speech.openai.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: audio_speech, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_audio_speech_e2e.py:22", rationale: "OpenAI TTS binary audio"}
- {id: llm.audio_speech.openai.basic.stream.works, module: llm, tier: P1, subject_endpoint: audio_speech, route: openai, capability: basic, streaming: stream, assertions: [works], source: "proxy_server.py:9043", rationale: "TTS streaming chunk generator"}
- {id: llm.audio_speech.openai.input_validation.nonstream.works, module: llm, tier: P1, subject_endpoint: audio_speech, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "vendor strategy §9.6 / LIT-4778", rationale: "TTS missing input/model, invalid voice, empty input rejected"}
diff --git a/tests/e2e/coverage_registry/schema.py b/tests/e2e/coverage_registry/schema.py
index fa6dad90126..3ae17432863 100644
--- a/tests/e2e/coverage_registry/schema.py
+++ b/tests/e2e/coverage_registry/schema.py
@@ -44,6 +44,7 @@ LlmEndpoint = Literal[
"vector_stores",
"ocr",
"bedrock_native",
+ "videos",
]
LlmRoute = Literal[
@@ -53,6 +54,7 @@ LlmRoute = Literal[
"bedrock_converse",
"bedrock_invoke",
"cohere",
+ "fal_ai",
"gemini",
"hosted_vllm",
"openai",
diff --git a/tests/e2e/llm_translation/LLM_TRANSLATION_COVERAGE_MATRIX.md b/tests/e2e/llm_translation/LLM_TRANSLATION_COVERAGE_MATRIX.md
index 44d6e79122e..178af054f2b 100644
--- a/tests/e2e/llm_translation/LLM_TRANSLATION_COVERAGE_MATRIX.md
+++ b/tests/e2e/llm_translation/LLM_TRANSLATION_COVERAGE_MATRIX.md
@@ -48,6 +48,7 @@ most likely to silently break and the one a mock can't prove works.
|----------|---------------|-----------|------------|-------------|--------|
| Chat | live (spend suite) | live (spend suite) | gap | live | partial |
| Embeddings | live (spend suite) | n/a | n/a | live | covered |
+| Video | live (fal.ai Seedance) | n/a | n/a | - | partial |
| Responses / image / audio / rerank / realtime | - | - | - | - | gap |
## This suite's files
@@ -61,6 +62,7 @@ most likely to silently break and the one a mock can't prove works.
| `test_anthropic_passthrough_streaming_logs_cost` | anthropic native, stream, cost |
| `test_anthropic_passthrough_tool_call_logs_cost` | anthropic native, tool call, cost |
| `test_vertex_passthrough_via_managed_model_logs_cost` | vertex_ai native, non-stream, cost |
+| `test_fal_seedance_video_completes_and_downloads` | fal.ai Seedance video create, poll, and content download |
Vertex keeps the credential on the proxy like gemini/anthropic, but the deployment is
added at runtime instead of declared in the gateway config: the test POSTs `/model/new`
diff --git a/tests/e2e/llm_translation/endpoints_client.py b/tests/e2e/llm_translation/endpoints_client.py
index 4d2c73e7078..165a83e76c0 100644
--- a/tests/e2e/llm_translation/endpoints_client.py
+++ b/tests/e2e/llm_translation/endpoints_client.py
@@ -13,7 +13,7 @@ from dataclasses import dataclass
from typing import Literal
from e2e_config import SLOW_PROVIDER_TIMEOUT_SECONDS
-from e2e_http import BinaryStream, Result, StreamingResponse
+from e2e_http import BinaryStream, NoBody, Result, StreamingResponse
from models import CacheControl, ChatMessage, LiteLLMParamsBody, RichMessage, TextBlock
from proxy_client import ProxyClient
from pydantic import BaseModel
@@ -26,6 +26,8 @@ __all__ = [
"TextBlock",
"TranscriptionForm",
"TranscriptionResult",
+ "VideoObject",
+ "VideoRequest",
]
@@ -127,6 +129,13 @@ class ImageRequest(BaseModel):
size: str = "1024x1024"
+class VideoRequest(BaseModel):
+ model: str
+ prompt: str
+ seconds: str = "4"
+ size: str = "1280x720"
+
+
class ImageEditForm(BaseModel):
model: str
prompt: str
@@ -267,6 +276,12 @@ class ImagesResult(BaseModel):
data: list[ImageItem] = []
+class VideoObject(BaseModel):
+ id: str
+ status: str
+ model: str | None = None
+
+
class TranscriptionResult(BaseModel):
text: str = ""
@@ -440,6 +455,25 @@ class EndpointsClient:
"/v1/images/generations", key, ImageRequest(model=model, prompt=prompt)
)
+ def videos(self, key: str, model: str, prompt: str) -> StreamingResponse:
+ return self._send(
+ "/v1/videos", key, VideoRequest(model=model, prompt=prompt)
+ )
+
+ def video_status(self, key: str, video_id: str) -> Result[VideoObject]:
+ return self.proxy.transport.get(
+ f"/v1/videos/{video_id}",
+ headers=self.proxy.transport.bearer(key),
+ params=NoBody(),
+ response_type=VideoObject,
+ )
+
+ def video_content(self, key: str, video_id: str) -> StreamingResponse:
+ return self.proxy.transport.download(
+ f"/v1/videos/{video_id}/content",
+ headers=self.proxy.transport.bearer(key),
+ )
+
def image_edit(
self, key: str, model: str, prompt: str, image: bytes, *, filename: str = "image.png"
) -> Result[ImagesResult]:
diff --git a/tests/e2e/llm_translation/test_video_generation_e2e.py b/tests/e2e/llm_translation/test_video_generation_e2e.py
new file mode 100644
index 00000000000..b65529aa260
--- /dev/null
+++ b/tests/e2e/llm_translation/test_video_generation_e2e.py
@@ -0,0 +1,69 @@
+"""Live e2e: POST /v1/videos creates a video and serves its content.
+
+Registers a fal.ai Seedance deployment at runtime, polls the queued video until it
+completes, and asserts the generated content is returned as binary data.
+"""
+
+from __future__ import annotations
+
+import time
+from typing import Final
+
+import pytest
+from e2e_config import unique_marker
+from e2e_http import require_successful_call, unwrap
+from endpoints_client import EndpointsClient, VideoObject
+from lifecycle import ResourceManager
+from models import LiteLLMParamsBody
+
+pytestmark = pytest.mark.e2e
+
+_POLL_INTERVAL_SECONDS: Final[float] = 5.0
+_POLL_TIMEOUT_SECONDS: Final[float] = 600.0
+
+
+def _wait_for_completion(
+ endpoints_client: EndpointsClient, key: str, created: VideoObject
+) -> VideoObject:
+ deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
+ while time.monotonic() < deadline:
+ status = unwrap(endpoints_client.video_status(key, created.id))
+ assert status.id == created.id
+ if status.status == "completed":
+ return status
+ if status.status == "failed":
+ pytest.fail(f"fal.ai video generation failed: {status}")
+ time.sleep(_POLL_INTERVAL_SECONDS)
+ pytest.fail(f"fal.ai video {created.id!r} did not complete within {_POLL_TIMEOUT_SECONDS}s")
+
+
+class TestVideoGeneration:
+ @pytest.mark.covers("llm.videos.fal_ai.basic.nonstream.works")
+ def test_fal_seedance_video_completes_and_downloads(
+ self, endpoints_client: EndpointsClient, resources: ResourceManager
+ ) -> None:
+ model = f"e2e-fal-video-{unique_marker()}"
+ model_id = endpoints_client.create_model(
+ model,
+ LiteLLMParamsBody(
+ model="fal_ai/bytedance/seedance-2.5/text-to-video",
+ api_key="os.environ/FAL_AI_API_KEY",
+ ),
+ )
+ resources.defer(lambda: endpoints_client.delete_model(model_id))
+ key = resources.key()
+
+ result = endpoints_client.videos(
+ key, model, "a red fox running through snow at dawn"
+ )
+ require_successful_call(result)
+ created = VideoObject.model_validate_json(result.body)
+ assert created.id
+ assert created.model
+
+ _wait_for_completion(endpoints_client, key, created)
+
+ content = endpoints_client.video_content(key, created.id)
+ require_successful_call(content)
+ assert len(content.body) > 0
+ assert not (content.content_type or "").startswith("application/json")
From d268c8b58ae61c9fe5280a1915a0f17a85e5f1a8 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 18:34:09 +0000
Subject: [PATCH 059/317] feat(azure_ai): add MAI-Image-2.5-Pro image
generation pricing
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
...odel_prices_and_context_window_backup.json | 13 ++++++++
model_prices_and_context_window.json | 13 ++++++++
.../test_mai_image_generation.py | 32 +++++++++++++++++++
3 files changed, 58 insertions(+)
diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json
index 48dded6a323..fff4c2b7e1b 100644
--- a/litellm/model_prices_and_context_window_backup.json
+++ b/litellm/model_prices_and_context_window_backup.json
@@ -11159,6 +11159,19 @@
],
"deprecation_date": "2026-10-01"
},
+ "azure_ai/MAI-Image-2.5-Pro": {
+ "input_cost_per_image_token": 8e-06,
+ "input_cost_per_token": 5e-06,
+ "litellm_provider": "azure_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1085,
+ "output_cost_per_image_token": 0.000106,
+ "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/introducing-mai-image-2-5-pro-and-mai-voice-2-flash-in-microsoft-foundry/4539446",
+ "supported_endpoints": [
+ "/v1/images/generations",
+ "/v1/images/edits"
+ ]
+ },
"azure_ai/MAI-Image-2e": {
"deprecation_date": "2026-08-15",
"input_cost_per_token": 5e-06,
diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json
index 48dded6a323..fff4c2b7e1b 100644
--- a/model_prices_and_context_window.json
+++ b/model_prices_and_context_window.json
@@ -11159,6 +11159,19 @@
],
"deprecation_date": "2026-10-01"
},
+ "azure_ai/MAI-Image-2.5-Pro": {
+ "input_cost_per_image_token": 8e-06,
+ "input_cost_per_token": 5e-06,
+ "litellm_provider": "azure_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1085,
+ "output_cost_per_image_token": 0.000106,
+ "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/introducing-mai-image-2-5-pro-and-mai-voice-2-flash-in-microsoft-foundry/4539446",
+ "supported_endpoints": [
+ "/v1/images/generations",
+ "/v1/images/edits"
+ ]
+ },
"azure_ai/MAI-Image-2e": {
"deprecation_date": "2026-08-15",
"input_cost_per_token": 5e-06,
diff --git a/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py b/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py
index 55656b97c57..27e78d35c69 100644
--- a/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py
+++ b/tests/test_litellm/llms/azure_ai/image_generation/test_mai_image_generation.py
@@ -453,6 +453,38 @@ class TestAzureMAIImageGeneration:
)
assert round(cost, 10) == round(expected_cost, 10)
+ def test_mai_image_pro_edit_cost_splits_text_and_image_input(self, monkeypatch):
+ monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
+ litellm.model_cost = litellm.get_model_cost_map(url="")
+ model = "azure_ai/MAI-Image-2.5-Pro"
+ model_info = litellm.get_model_info(model=model, custom_llm_provider="azure_ai")
+ text_tokens = 37
+ image_tokens = 1024
+ output_image_tokens = 1024
+
+ image_response = ImageResponse(
+ data=[ImageObject(b64_json="img1")],
+ usage=ImageUsage(
+ input_tokens=text_tokens + image_tokens,
+ input_tokens_details=ImageUsageInputTokensDetails(
+ text_tokens=text_tokens,
+ image_tokens=image_tokens,
+ ),
+ output_tokens=output_image_tokens,
+ total_tokens=text_tokens + image_tokens + output_image_tokens,
+ ),
+ )
+
+ cost = azure_ai_image_cost_calculator(model=model, image_response=image_response)
+
+ expected_cost = (
+ text_tokens * model_info["input_cost_per_token"]
+ + image_tokens * model_info["input_cost_per_image_token"]
+ + output_image_tokens * model_info["output_cost_per_image_token"]
+ )
+ assert round(cost, 10) == round(expected_cost, 10)
+ assert model_info["input_cost_per_image_token"] != model_info["input_cost_per_token"]
+
def test_mai_image_cost_calculator_falls_back_to_flat_image_pricing(self, monkeypatch):
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
litellm.model_cost = litellm.get_model_cost_map(url="")
From c958f9db7e0a024f7fbbb28168c47ac9e65c4853 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 18:44:51 +0000
Subject: [PATCH 060/317] test: extend cost tracking integration harness
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/integration/_support/upstream.py | 20 +--
tests/integration/contracts.json | 9 ++
.../integration/cost_calculation/conftest.py | 34 ++++-
.../cost_calculation/cost_tracking_case.py | 74 +++++++++-
.../cost_calculation/cost_tracking_cases.json | 135 +++++++++++++++++-
.../cost_calculation/test_cost_tracking.py | 54 ++++++-
6 files changed, 304 insertions(+), 22 deletions(-)
diff --git a/tests/integration/_support/upstream.py b/tests/integration/_support/upstream.py
index 1ad02b6a3f2..3c4198c8133 100644
--- a/tests/integration/_support/upstream.py
+++ b/tests/integration/_support/upstream.py
@@ -1,25 +1,19 @@
from __future__ import annotations
import argparse
+import json
+import os
+import struct
+import zlib
from collections import deque
from collections.abc import Mapping
-import json
from dataclasses import dataclass, field
-import os
from pathlib import Path
from queue import SimpleQueue
-import struct
from typing import Final, cast
-import zlib
import httpx
import uvicorn
-from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
-from starlette.applications import Starlette
-from starlette.requests import Request
-from starlette.responses import JSONResponse, Response
-from starlette.routing import Route
-
from _fake_openai_endpoint_server import chat_completions, completions, embeddings, health, moderations
from integration.cost_calculation.cost_tracking_case import (
EventStreamResponse,
@@ -27,6 +21,11 @@ from integration.cost_calculation.cost_tracking_case import (
SseResponse,
StoredResponse,
)
+from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
+from starlette.applications import Starlette
+from starlette.requests import Request
+from starlette.responses import JSONResponse, Response
+from starlette.routing import Route
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
CASES_FILE: Final = Path(__file__).resolve().parents[1] / "cost_calculation" / "cost_tracking_cases.json"
@@ -210,6 +209,7 @@ class Provider:
"$REQUEST_ID", scenario_id
).encode(),
media_type=response.content_type,
+ status_code=response.status,
)
case SseResponse():
stream_body: Final = ("\n\n".join(response.frames) + "\n\n").replace(
diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json
index fe7b6dfe7ac..1b0c9e31d4e 100644
--- a/tests/integration/contracts.json
+++ b/tests/integration/contracts.json
@@ -382,6 +382,15 @@
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[azure-gpt-5.6-stream_full_usage]": [
"quota_management.spend_tracking.scripted_wire.logs_cost"
],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-responses_native_json]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-upstream_500_zero_spend]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-upstream_429_zero_spend]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[claude-haiku-4-5-input_text]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
diff --git a/tests/integration/cost_calculation/conftest.py b/tests/integration/cost_calculation/conftest.py
index f1b8901d626..192f2bf5461 100644
--- a/tests/integration/cost_calculation/conftest.py
+++ b/tests/integration/cost_calculation/conftest.py
@@ -9,12 +9,11 @@ from typing import Final
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
-from pydantic import BaseModel, ConfigDict
-
from integration._support.client import JSON_OBJECT, Scenario, eventually, object_value, string_value
from integration._support.database import read_rows
from integration._support.upstream import delete_scenario, register_scenario
from integration.cost_calculation.cost_tracking_case import CostTrackingTestCase
+from pydantic import BaseModel, ConfigDict
class CostBreakdown(BaseModel):
@@ -50,6 +49,15 @@ class CostRow(BaseModel):
return self.metadata.cost_breakdown
+class FailureRow(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+
+ spend: float
+ status: str
+ prompt_tokens: int | None = None
+ completion_tokens: int | None = None
+
+
def approx_equal(actual: float, expected: float) -> bool:
return abs(actual - expected) <= max(1e-9, abs(expected) * 1e-2)
@@ -92,6 +100,28 @@ def poll_cost_row(key: str) -> CostRow:
return result
+def poll_failure_row(key: str) -> FailureRow:
+ digest: Final = sha256(key.encode()).hexdigest()
+
+ def read() -> FailureRow | None:
+ rows: Final = read_rows(
+ 'SELECT spend, status, prompt_tokens, completion_tokens FROM "LiteLLM_SpendLogs" WHERE api_key=%s',
+ (digest,),
+ )
+ return next(
+ (
+ parsed
+ for row in rows
+ if (parsed := FailureRow.model_validate(row)).status == "failure"
+ ),
+ None,
+ )
+
+ result: Final = eventually(read, lambda row: row is not None, seconds=60)
+ assert result is not None
+ return result
+
+
@functools.cache
def _vertex_private_key_pem() -> str:
return rsa.generate_private_key(public_exponent=65537, key_size=2048).private_bytes(
diff --git a/tests/integration/cost_calculation/cost_tracking_case.py b/tests/integration/cost_calculation/cost_tracking_case.py
index 6af95f995ff..cb408f2c488 100644
--- a/tests/integration/cost_calculation/cost_tracking_case.py
+++ b/tests/integration/cost_calculation/cost_tracking_case.py
@@ -71,6 +71,7 @@ class JsonResponse(BaseModel):
content_type: Literal["application/json"]
body: dict[str, JsonValue]
+ status: int = 200
class SseResponse(BaseModel):
@@ -108,6 +109,10 @@ class ExactExpected(BaseModel):
output_cost: float
prompt_tokens: int
completion_tokens: int
+ cache_read_cost: float | None = None
+ cache_creation_cost: float | None = None
+ reasoning_cost: float | None = None
+ tool_usage_cost: float | None = None
class RecountRates(BaseModel):
@@ -123,7 +128,19 @@ class RecountExpected(BaseModel):
recount: RecountRates
-Expected: TypeAlias = ExactExpected | RecountExpected
+class FailureDetails(BaseModel):
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ status: int
+
+
+class FailureExpected(BaseModel):
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ failure: FailureDetails
+
+
+Expected: TypeAlias = ExactExpected | RecountExpected | FailureExpected
class CostTrackingTestCase(BaseModel):
@@ -132,6 +149,15 @@ class CostTrackingTestCase(BaseModel):
name: str
covers: str
model: str
+ endpoint: Literal[
+ "/v1/chat/completions",
+ "/v1/responses",
+ "/v1/messages",
+ "/v1/embeddings",
+ "/v1/rerank",
+ "/v1/completions",
+ "/v1/moderations",
+ ] = "/v1/chat/completions"
deployment: Deployment | None = None
request: dict[str, JsonValue]
response: StoredResponse
@@ -240,6 +266,44 @@ def data_errors() -> tuple[str, ...]:
or case.expected.recount.output_cost_per_token != (COST_MAP[case.model].output_cost_per_token or 0.0)
)
)
+ component_mismatches: Final = sorted(
+ case.name
+ for case in CASES
+ if isinstance(case.expected, ExactExpected)
+ and any(
+ component is not None
+ for component in (
+ case.expected.cache_read_cost,
+ case.expected.cache_creation_cost,
+ case.expected.reasoning_cost,
+ case.expected.tool_usage_cost,
+ )
+ )
+ and (
+ (case.expected.cache_read_cost or 0.0) + (case.expected.cache_creation_cost or 0.0)
+ > case.expected.input_cost
+ or (case.expected.reasoning_cost or 0.0) > case.expected.output_cost
+ or not _approx_equal(
+ case.expected.input_cost
+ + case.expected.output_cost
+ + (case.expected.tool_usage_cost or 0.0),
+ case.expected.spend,
+ )
+ )
+ )
+ failure_response_mismatches: Final = sorted(
+ case.name
+ for case in CASES
+ if (
+ isinstance(case.expected, FailureExpected)
+ and (not isinstance(case.response, JsonResponse) or case.response.status < 400)
+ )
+ or (
+ not isinstance(case.expected, FailureExpected)
+ and isinstance(case.response, JsonResponse)
+ and case.response.status != 200
+ )
+ )
return tuple(
message
for message in (
@@ -248,6 +312,14 @@ def data_errors() -> tuple[str, ...]:
f"duplicate case names: {duplicate_names}" if duplicate_names else None,
f"cost-map entries share input_cost_per_token: {shared_input_rates}" if shared_input_rates else None,
f"recount rates differ from cost-map rates: {recount_mismatches}" if recount_mismatches else None,
+ f"breakdown components are inconsistent: {component_mismatches}" if component_mismatches else None,
+ f"failure response statuses are inconsistent: {failure_response_mismatches}"
+ if failure_response_mismatches
+ else None,
)
if message is not None
)
+
+
+def _approx_equal(actual: float, expected: float) -> bool:
+ return abs(actual - expected) <= max(1e-9, abs(expected) * 1e-2)
diff --git a/tests/integration/cost_calculation/cost_tracking_cases.json b/tests/integration/cost_calculation/cost_tracking_cases.json
index 3627774816f..379250d3123 100644
--- a/tests/integration/cost_calculation/cost_tracking_cases.json
+++ b/tests/integration/cost_calculation/cost_tracking_cases.json
@@ -534,7 +534,8 @@
"input_cost": 0.00616704,
"output_cost": 0.00627,
"prompt_tokens": 12928,
- "completion_tokens": 380
+ "completion_tokens": 380,
+ "cache_read_cost": 0.00405504
}
},
{
@@ -605,7 +606,8 @@
"input_cost": 0.0397056,
"output_cost": 0.005775,
"prompt_tokens": 9728,
- "completion_tokens": 350
+ "completion_tokens": 350,
+ "cache_creation_cost": 0.038016
}
},
{
@@ -681,7 +683,8 @@
"input_cost": 0.0574464,
"output_cost": 0.005775,
"prompt_tokens": 9728,
- "completion_tokens": 350
+ "completion_tokens": 350,
+ "cache_creation_cost": 0.0557568
}
},
{
@@ -3417,7 +3420,8 @@
"input_cost": 0.002232,
"output_cost": 0.065484,
"prompt_tokens": 1240,
- "completion_tokens": 4040
+ "completion_tokens": 4040,
+ "reasoning_cost": 0.05742
}
},
{
@@ -3638,7 +3642,8 @@
"input_cost": 0.003312,
"output_cost": 0.0059328,
"prompt_tokens": 1840,
- "completion_tokens": 412
+ "completion_tokens": 412,
+ "tool_usage_cost": 0.0125
}
},
{
@@ -4494,7 +4499,8 @@
"input_cost": 0.0018688,
"output_cost": 0.0019,
"prompt_tokens": 12928,
- "completion_tokens": 380
+ "completion_tokens": 380,
+ "cache_read_cost": 0.0012288
}
},
{
@@ -16955,7 +16961,8 @@
"input_cost": 0.00276,
"output_cost": 0.004944,
"prompt_tokens": 1840,
- "completion_tokens": 412
+ "completion_tokens": 412,
+ "tool_usage_cost": 0.0025
}
},
{
@@ -21797,6 +21804,120 @@
"completion_tokens": 1592
}
},
+ {
+ "name": "gpt-5.6-responses_native_json",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gpt-5.6",
+ "endpoint": "/v1/responses",
+ "request": {
+ "model": "$MODEL",
+ "input": "responses native fixture",
+ "stream": false
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "object": "response",
+ "status": "completed",
+ "model": "gpt-5.6",
+ "output": [
+ {
+ "type": "message",
+ "id": "msg_$REQUEST_ID",
+ "status": "completed",
+ "role": "assistant",
+ "content": [
+ {
+ "type": "output_text",
+ "text": "scripted response",
+ "annotations": []
+ }
+ ]
+ }
+ ],
+ "usage": {
+ "input_tokens": 11,
+ "output_tokens": 7,
+ "total_tokens": 18,
+ "input_tokens_details": {
+ "cached_tokens": 0
+ },
+ "output_tokens_details": {
+ "reasoning_tokens": 0
+ }
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.00011725,
+ "input_cost": 1.925e-05,
+ "output_cost": 9.8e-05,
+ "prompt_tokens": 11,
+ "completion_tokens": 7
+ }
+ },
+ {
+ "name": "gpt-5.6-upstream_500_zero_spend",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gpt-5.6",
+ "request": {
+ "model": "$MODEL",
+ "messages": [
+ {
+ "role": "user",
+ "content": "scripted upstream failure 500"
+ }
+ ],
+ "stream": false
+ },
+ "response": {
+ "content_type": "application/json",
+ "status": 500,
+ "body": {
+ "error": {
+ "message": "scripted upstream failure",
+ "type": "server_error",
+ "code": "500"
+ }
+ }
+ },
+ "expected": {
+ "failure": {
+ "status": 500
+ }
+ }
+ },
+ {
+ "name": "gpt-5.6-upstream_429_zero_spend",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gpt-5.6",
+ "request": {
+ "model": "$MODEL",
+ "messages": [
+ {
+ "role": "user",
+ "content": "scripted upstream failure 429"
+ }
+ ],
+ "stream": false
+ },
+ "response": {
+ "content_type": "application/json",
+ "status": 429,
+ "body": {
+ "error": {
+ "message": "scripted upstream failure",
+ "type": "rate_limit_error",
+ "code": "429"
+ }
+ }
+ },
+ "expected": {
+ "failure": {
+ "status": 429
+ }
+ }
+ },
{
"name": "meta.llama4-maverick-17b-instruct-v1:0-input_text",
"covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
diff --git a/tests/integration/cost_calculation/test_cost_tracking.py b/tests/integration/cost_calculation/test_cost_tracking.py
index a8a56fbfbbd..c9dc7a5ffe3 100644
--- a/tests/integration/cost_calculation/test_cost_tracking.py
+++ b/tests/integration/cost_calculation/test_cost_tracking.py
@@ -6,18 +6,19 @@ from hashlib import sha256
from typing import Final, cast
import pytest
-
from integration._support.client import JSON_OBJECT, Gateway
from integration.cost_calculation.conftest import (
approx_equal,
assert_total_is_sum_of_components,
poll_cost_row,
+ poll_failure_row,
register_scenario_deployment,
)
from integration.cost_calculation.cost_tracking_case import (
CASES,
CostTrackingTestCase,
ExactExpected,
+ FailureExpected,
RecountExpected,
data_errors,
)
@@ -51,10 +52,22 @@ def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase)
model_name: Final = register_scenario_deployment(scenario, case, marker, key)
response: Final = gateway.request(
"POST",
- "/v1/chat/completions",
+ case.endpoint,
{**case.request, "model": model_name},
key=key,
)
+ if isinstance(case.expected, FailureExpected):
+ assert response.status_code == case.expected.failure.status, (
+ f"{case.name}: proxy returned {response.status_code}, expected {case.expected.failure.status}: "
+ f"{response.text[:400]}"
+ )
+ response_cost: Final = response.headers.get("x-litellm-response-cost")
+ assert response_cost is None or approx_equal(float(response_cost), 0.0), (
+ f"{case.name}: failure response cost was {response_cost}"
+ )
+ row: Final = poll_failure_row(key)
+ assert row.spend == 0, f"{case.name}: failure spend was {row.spend}"
+ return
assert response.is_success, f"{case.name}: proxy returned {response.status_code}: {response.text[:400]}"
if case.response.content_type == "text/event-stream":
_assert_stream_has_no_error(response.text)
@@ -92,6 +105,43 @@ def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase)
assert breakdown.output_cost is not None and approx_equal(breakdown.output_cost, expected.output_cost), (
f"{case.name}: output_cost {breakdown.output_cost} != expected {expected.output_cost}"
)
+ for field, header_name, expected_component in (
+ ("cache_read_cost", "x-litellm-response-cost-cache-read", expected.cache_read_cost),
+ ("cache_creation_cost", "x-litellm-response-cost-cache-creation", expected.cache_creation_cost),
+ ("reasoning_cost", "x-litellm-response-cost-reasoning", expected.reasoning_cost),
+ ("tool_usage_cost", "x-litellm-response-cost-tool-usage", expected.tool_usage_cost),
+ ):
+ if expected_component is None:
+ continue
+ actual_component: Final = getattr(breakdown, field)
+ assert actual_component is not None and approx_equal(actual_component, expected_component), (
+ f"{case.name}: {field} {actual_component} != expected {expected_component}"
+ )
+ if case.response.content_type == "application/json":
+ header: Final = response.headers.get(header_name)
+ assert header is not None and approx_equal(float(header), expected_component), (
+ f"{case.name}: {header_name} {header} != expected {expected_component}"
+ )
+ if case.response.content_type == "application/json" and any(
+ component is not None
+ for component in (
+ expected.cache_read_cost,
+ expected.cache_creation_cost,
+ expected.reasoning_cost,
+ expected.tool_usage_cost,
+ )
+ ):
+ input_header: Final = response.headers.get("x-litellm-response-cost-input")
+ output_header: Final = response.headers.get("x-litellm-response-cost-output")
+ expected_input_header: Final = expected.input_cost - (
+ expected.cache_read_cost or 0.0
+ ) - (expected.cache_creation_cost or 0.0)
+ assert input_header is not None and approx_equal(float(input_header), expected_input_header), (
+ f"{case.name}: x-litellm-response-cost-input {input_header} != expected {expected_input_header}"
+ )
+ assert output_header is not None and approx_equal(float(output_header), expected.output_cost), (
+ f"{case.name}: x-litellm-response-cost-output {output_header} != expected {expected.output_cost}"
+ )
assert row.prompt_tokens == expected.prompt_tokens, (
f"{case.name}: prompt_tokens {row.prompt_tokens} != expected {expected.prompt_tokens}"
)
From e42f3f1562c9b5a07ef69a1468269399beb4bc24 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 19:10:04 +0000
Subject: [PATCH 061/317] test: fix cost harness review issues
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/integration/_support/upstream.py | 19 +++++------
.../integration/cost_calculation/conftest.py | 3 +-
.../cost_calculation/cost_tracking_cases.json | 2 ++
.../cost_calculation/test_cost_tracking.py | 32 +++++++++++++++----
4 files changed, 40 insertions(+), 16 deletions(-)
diff --git a/tests/integration/_support/upstream.py b/tests/integration/_support/upstream.py
index 3c4198c8133..a289589b2dc 100644
--- a/tests/integration/_support/upstream.py
+++ b/tests/integration/_support/upstream.py
@@ -1,19 +1,25 @@
from __future__ import annotations
import argparse
-import json
-import os
-import struct
-import zlib
from collections import deque
from collections.abc import Mapping
+import json
from dataclasses import dataclass, field
+import os
from pathlib import Path
from queue import SimpleQueue
+import struct
from typing import Final, cast
+import zlib
import httpx
import uvicorn
+from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
+from starlette.applications import Starlette
+from starlette.requests import Request
+from starlette.responses import JSONResponse, Response
+from starlette.routing import Route
+
from _fake_openai_endpoint_server import chat_completions, completions, embeddings, health, moderations
from integration.cost_calculation.cost_tracking_case import (
EventStreamResponse,
@@ -21,11 +27,6 @@ from integration.cost_calculation.cost_tracking_case import (
SseResponse,
StoredResponse,
)
-from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
-from starlette.applications import Starlette
-from starlette.requests import Request
-from starlette.responses import JSONResponse, Response
-from starlette.routing import Route
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
CASES_FILE: Final = Path(__file__).resolve().parents[1] / "cost_calculation" / "cost_tracking_cases.json"
diff --git a/tests/integration/cost_calculation/conftest.py b/tests/integration/cost_calculation/conftest.py
index 192f2bf5461..166488e36a5 100644
--- a/tests/integration/cost_calculation/conftest.py
+++ b/tests/integration/cost_calculation/conftest.py
@@ -9,11 +9,12 @@ from typing import Final
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
+from pydantic import BaseModel, ConfigDict
+
from integration._support.client import JSON_OBJECT, Scenario, eventually, object_value, string_value
from integration._support.database import read_rows
from integration._support.upstream import delete_scenario, register_scenario
from integration.cost_calculation.cost_tracking_case import CostTrackingTestCase
-from pydantic import BaseModel, ConfigDict
class CostBreakdown(BaseModel):
diff --git a/tests/integration/cost_calculation/cost_tracking_cases.json b/tests/integration/cost_calculation/cost_tracking_cases.json
index 379250d3123..428124ac4a9 100644
--- a/tests/integration/cost_calculation/cost_tracking_cases.json
+++ b/tests/integration/cost_calculation/cost_tracking_cases.json
@@ -21817,8 +21817,10 @@
"response": {
"content_type": "application/json",
"body": {
+ "id": "resp_$REQUEST_ID",
"object": "response",
"status": "completed",
+ "created_at": 1700000000,
"model": "gpt-5.6",
"output": [
{
diff --git a/tests/integration/cost_calculation/test_cost_tracking.py b/tests/integration/cost_calculation/test_cost_tracking.py
index c9dc7a5ffe3..5b876974c63 100644
--- a/tests/integration/cost_calculation/test_cost_tracking.py
+++ b/tests/integration/cost_calculation/test_cost_tracking.py
@@ -6,6 +6,7 @@ from hashlib import sha256
from typing import Final, cast
import pytest
+
from integration._support.client import JSON_OBJECT, Gateway
from integration.cost_calculation.conftest import (
approx_equal,
@@ -105,15 +106,34 @@ def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase)
assert breakdown.output_cost is not None and approx_equal(breakdown.output_cost, expected.output_cost), (
f"{case.name}: output_cost {breakdown.output_cost} != expected {expected.output_cost}"
)
- for field, header_name, expected_component in (
- ("cache_read_cost", "x-litellm-response-cost-cache-read", expected.cache_read_cost),
- ("cache_creation_cost", "x-litellm-response-cost-cache-creation", expected.cache_creation_cost),
- ("reasoning_cost", "x-litellm-response-cost-reasoning", expected.reasoning_cost),
- ("tool_usage_cost", "x-litellm-response-cost-tool-usage", expected.tool_usage_cost),
+ for field, header_name, actual_component, expected_component in (
+ (
+ "cache_read_cost",
+ "x-litellm-response-cost-cache-read",
+ breakdown.cache_read_cost,
+ expected.cache_read_cost,
+ ),
+ (
+ "cache_creation_cost",
+ "x-litellm-response-cost-cache-creation",
+ breakdown.cache_creation_cost,
+ expected.cache_creation_cost,
+ ),
+ (
+ "reasoning_cost",
+ "x-litellm-response-cost-reasoning",
+ breakdown.reasoning_cost,
+ expected.reasoning_cost,
+ ),
+ (
+ "tool_usage_cost",
+ "x-litellm-response-cost-tool-usage",
+ breakdown.tool_usage_cost,
+ expected.tool_usage_cost,
+ ),
):
if expected_component is None:
continue
- actual_component: Final = getattr(breakdown, field)
assert actual_component is not None and approx_equal(actual_component, expected_component), (
f"{case.name}: {field} {actual_component} != expected {expected_component}"
)
From dccb1b56b47c62fdca4865c967d7901a0cd2a461 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 19:17:46 +0000
Subject: [PATCH 062/317] test(integration): reject out-of-range failure
statuses at import
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/integration/cost_calculation/cost_tracking_case.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/tests/integration/cost_calculation/cost_tracking_case.py b/tests/integration/cost_calculation/cost_tracking_case.py
index cb408f2c488..9737508e27c 100644
--- a/tests/integration/cost_calculation/cost_tracking_case.py
+++ b/tests/integration/cost_calculation/cost_tracking_case.py
@@ -296,7 +296,11 @@ def data_errors() -> tuple[str, ...]:
for case in CASES
if (
isinstance(case.expected, FailureExpected)
- and (not isinstance(case.response, JsonResponse) or case.response.status < 400)
+ and (
+ not isinstance(case.response, JsonResponse)
+ or not 400 <= case.response.status <= 599
+ or not 400 <= case.expected.failure.status <= 599
+ )
)
or (
not isinstance(case.expected, FailureExpected)
From 9bd648baf590ccf5ad3f81ccc44e29a57c3bc7b9 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 19:22:25 +0000
Subject: [PATCH 063/317] test(integration): price fireworks cached input at
the 50% default
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/integration/contracts.json | 2 +-
.../integration/cost_calculation/cost_tracking_cases.json | 7 ++++---
2 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json
index 1b0c9e31d4e..338ace07908 100644
--- a/tests/integration/contracts.json
+++ b/tests/integration/contracts.json
@@ -559,7 +559,7 @@
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[fireworks_ai-accounts-fireworks-models-deepseek-v4p1-flash-input_text]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[fireworks_ai-accounts-fireworks-models-deepseek-v4p1-flash-fallback_cache_read_at_input_rate]": [
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[fireworks_ai-accounts-fireworks-models-deepseek-v4p1-flash-fallback_cache_read_at_half_input_rate]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[fireworks_ai-accounts-fireworks-models-deepseek-v4p1-flash-stream]": [
diff --git a/tests/integration/cost_calculation/cost_tracking_cases.json b/tests/integration/cost_calculation/cost_tracking_cases.json
index 428124ac4a9..b0ad270f860 100644
--- a/tests/integration/cost_calculation/cost_tracking_cases.json
+++ b/tests/integration/cost_calculation/cost_tracking_cases.json
@@ -7958,7 +7958,7 @@
}
},
{
- "name": "fireworks_ai-accounts-fireworks-models-deepseek-v4p1-flash-fallback_cache_read_at_input_rate",
+ "name": "fireworks_ai-accounts-fireworks-models-deepseek-v4p1-flash-fallback_cache_read_at_half_input_rate",
"covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
"model": "fireworks_ai/accounts/fireworks/models/deepseek-v4p1-flash",
"request": {
@@ -8014,9 +8014,10 @@
}
},
"expected": {
- "spend": 0.0021672,
- "input_cost": 0.0019392,
+ "spend": 0.0012456,
+ "input_cost": 0.0010176,
"output_cost": 0.000228,
+ "cache_read_cost": 0.0009216,
"prompt_tokens": 12928,
"completion_tokens": 380
}
From c55e9a492441e435c7ff3fe57561a355c96fdfb8 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Sat, 19 Sep 2026 19:40:27 +0000
Subject: [PATCH 064/317] registry audit: fireworks/together/openrouter fixes,
absorb #28853 #27064
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
...odel_prices_and_context_window_backup.json | 87 +++++++++++++++++--
model_prices_and_context_window.json | 87 +++++++++++++++++--
model_prices_and_context_window.schema.json | 4 +
3 files changed, 166 insertions(+), 12 deletions(-)
diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json
index accabcf85d3..d3ff6e97c4a 100644
--- a/litellm/model_prices_and_context_window_backup.json
+++ b/litellm/model_prices_and_context_window_backup.json
@@ -3740,6 +3740,21 @@
"supports_vision": true,
"supports_web_search": true
},
+ "azure_ai/gpt-image-2": {
+ "cache_read_input_image_token_cost": 2e-06,
+ "cache_read_input_token_cost": 1.25e-06,
+ "input_cost_per_image_token": 8e-06,
+ "input_cost_per_token": 5e-06,
+ "litellm_provider": "azure_ai",
+ "mode": "image_generation",
+ "output_cost_per_image_token": 3e-05,
+ "source": "https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/",
+ "supported_endpoints": [
+ "/v1/images/generations",
+ "/v1/images/edits"
+ ],
+ "supports_vision": true
+ },
"azure_ai/codex-mini": {
"cache_read_input_token_cost": 3.75e-07,
"deprecation_date": "2026-11-15",
@@ -21891,6 +21906,7 @@
"supports_tool_choice": true
},
"deepseek/deepseek-coder": {
+ "cache_read_input_token_cost": 1.4e-08,
"input_cost_per_token": 1.4e-07,
"input_cost_per_token_cache_hit": 1.4e-08,
"litellm_provider": "deepseek",
@@ -21905,6 +21921,7 @@
"supports_tool_choice": true
},
"deepseek/deepseek-r1": {
+ "cache_read_input_token_cost": 1.4e-07,
"input_cost_per_token": 5.5e-07,
"input_cost_per_token_cache_hit": 1.4e-07,
"litellm_provider": "deepseek",
@@ -21960,6 +21977,7 @@
"supports_tool_choice": true
},
"deepseek/deepseek-v3.2": {
+ "cache_read_input_token_cost": 2.8e-08,
"input_cost_per_token": 2.8e-07,
"input_cost_per_token_cache_hit": 2.8e-08,
"litellm_provider": "deepseek",
@@ -23678,6 +23696,25 @@
"supports_tool_choice": true,
"supports_vision": false
},
+ "fireworks_ai/deepseek-v4-pro-0813": {
+ "cache_read_input_token_cost": 4.4e-08,
+ "cache_read_input_token_cost_priority": 5.5e-08,
+ "input_cost_per_token": 1.32e-06,
+ "input_cost_per_token_priority": 1.65e-06,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 131072,
+ "max_tokens": 131072,
+ "mode": "chat",
+ "output_cost_per_token": 3.96e-06,
+ "output_cost_per_token_priority": 4.95e-06,
+ "source": "https://api.fireworks.ai/v1/serverless/models",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
"fireworks_ai/accounts/fireworks/models/firefunction-v2": {
"input_cost_per_token": 9e-07,
"litellm_provider": "fireworks_ai",
@@ -24064,7 +24101,7 @@
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
- "supports_vision": true
+ "supports_vision": false
},
"fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct-hf": {
"input_cost_per_token": 1.2e-06,
@@ -24390,7 +24427,7 @@
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
- "supports_vision": true
+ "supports_vision": false
},
"fireworks_ai/qwen3p7-plus": {
"cache_read_input_token_cost": 8e-08,
@@ -41353,6 +41390,7 @@
"supports_web_search": false
},
"openrouter/deepseek/deepseek-v3.2-exp": {
+ "cache_read_input_token_cost": 2e-08,
"input_cost_per_token": 2.7e-07,
"input_cost_per_token_cache_hit": 2e-08,
"litellm_provider": "openrouter",
@@ -41374,6 +41412,7 @@
"supports_web_search": false
},
"openrouter/deepseek/deepseek-r1": {
+ "cache_read_input_token_cost": 1.4e-07,
"input_cost_per_token": 7e-07,
"input_cost_per_token_cache_hit": 1.4e-07,
"litellm_provider": "openrouter",
@@ -46169,8 +46208,8 @@
"together_ai/zai-org/GLM-4.6": {
"input_cost_per_token": 6e-07,
"litellm_provider": "together_ai",
- "max_input_tokens": 200000,
- "max_tokens": 200000,
+ "max_input_tokens": 202752,
+ "max_tokens": 202752,
"metadata": {
"successor": "together_ai/zai-org/GLM-5.2"
},
@@ -46186,8 +46225,8 @@
"deprecation_date": "2026-04-02",
"input_cost_per_token": 4.5e-07,
"litellm_provider": "together_ai",
- "max_input_tokens": 200000,
- "max_tokens": 200000,
+ "max_input_tokens": 202752,
+ "max_tokens": 202752,
"metadata": {
"successor": "together_ai/zai-org/GLM-5.2"
},
@@ -64093,6 +64132,25 @@
"supports_tool_choice": true,
"supports_vision": false
},
+ "fireworks_ai/glm-5p3": {
+ "cache_read_input_token_cost": 2.6e-07,
+ "cache_read_input_token_cost_priority": 3.25e-07,
+ "input_cost_per_token": 1.4e-06,
+ "input_cost_per_token_priority": 1.75e-06,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 4.4e-06,
+ "output_cost_per_token_priority": 5.5e-06,
+ "source": "https://api.fireworks.ai/v1/serverless/models",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
"fireworks_ai/accounts/fireworks/routers/glm-5p3-fast": {
"cache_read_input_token_cost": 3.9e-07,
"input_cost_per_token": 2.1e-06,
@@ -64140,6 +64198,23 @@
"supports_tool_choice": true,
"supports_vision": true
},
+ "fireworks_ai/glm-5p3-flash": {
+ "cache_read_input_token_cost": 3e-08,
+ "cache_read_input_token_cost_priority": 3.75e-08,
+ "input_cost_per_token": 1.5e-07,
+ "input_cost_per_token_priority": 1.875e-07,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 1048576,
+ "max_tokens": 1048576,
+ "mode": "chat",
+ "output_cost_per_token": 5e-07,
+ "output_cost_per_token_priority": 6.25e-07,
+ "source": "https://api.fireworks.ai/v1/serverless/models",
+ "supports_function_calling": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
"fireworks_ai/accounts/fireworks/models/inkling": {
"cache_read_input_token_cost": 1.7e-07,
"input_cost_per_token": 1e-06,
diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json
index accabcf85d3..d3ff6e97c4a 100644
--- a/model_prices_and_context_window.json
+++ b/model_prices_and_context_window.json
@@ -3740,6 +3740,21 @@
"supports_vision": true,
"supports_web_search": true
},
+ "azure_ai/gpt-image-2": {
+ "cache_read_input_image_token_cost": 2e-06,
+ "cache_read_input_token_cost": 1.25e-06,
+ "input_cost_per_image_token": 8e-06,
+ "input_cost_per_token": 5e-06,
+ "litellm_provider": "azure_ai",
+ "mode": "image_generation",
+ "output_cost_per_image_token": 3e-05,
+ "source": "https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/",
+ "supported_endpoints": [
+ "/v1/images/generations",
+ "/v1/images/edits"
+ ],
+ "supports_vision": true
+ },
"azure_ai/codex-mini": {
"cache_read_input_token_cost": 3.75e-07,
"deprecation_date": "2026-11-15",
@@ -21891,6 +21906,7 @@
"supports_tool_choice": true
},
"deepseek/deepseek-coder": {
+ "cache_read_input_token_cost": 1.4e-08,
"input_cost_per_token": 1.4e-07,
"input_cost_per_token_cache_hit": 1.4e-08,
"litellm_provider": "deepseek",
@@ -21905,6 +21921,7 @@
"supports_tool_choice": true
},
"deepseek/deepseek-r1": {
+ "cache_read_input_token_cost": 1.4e-07,
"input_cost_per_token": 5.5e-07,
"input_cost_per_token_cache_hit": 1.4e-07,
"litellm_provider": "deepseek",
@@ -21960,6 +21977,7 @@
"supports_tool_choice": true
},
"deepseek/deepseek-v3.2": {
+ "cache_read_input_token_cost": 2.8e-08,
"input_cost_per_token": 2.8e-07,
"input_cost_per_token_cache_hit": 2.8e-08,
"litellm_provider": "deepseek",
@@ -23678,6 +23696,25 @@
"supports_tool_choice": true,
"supports_vision": false
},
+ "fireworks_ai/deepseek-v4-pro-0813": {
+ "cache_read_input_token_cost": 4.4e-08,
+ "cache_read_input_token_cost_priority": 5.5e-08,
+ "input_cost_per_token": 1.32e-06,
+ "input_cost_per_token_priority": 1.65e-06,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 131072,
+ "max_tokens": 131072,
+ "mode": "chat",
+ "output_cost_per_token": 3.96e-06,
+ "output_cost_per_token_priority": 4.95e-06,
+ "source": "https://api.fireworks.ai/v1/serverless/models",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
"fireworks_ai/accounts/fireworks/models/firefunction-v2": {
"input_cost_per_token": 9e-07,
"litellm_provider": "fireworks_ai",
@@ -24064,7 +24101,7 @@
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
- "supports_vision": true
+ "supports_vision": false
},
"fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct-hf": {
"input_cost_per_token": 1.2e-06,
@@ -24390,7 +24427,7 @@
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
- "supports_vision": true
+ "supports_vision": false
},
"fireworks_ai/qwen3p7-plus": {
"cache_read_input_token_cost": 8e-08,
@@ -41353,6 +41390,7 @@
"supports_web_search": false
},
"openrouter/deepseek/deepseek-v3.2-exp": {
+ "cache_read_input_token_cost": 2e-08,
"input_cost_per_token": 2.7e-07,
"input_cost_per_token_cache_hit": 2e-08,
"litellm_provider": "openrouter",
@@ -41374,6 +41412,7 @@
"supports_web_search": false
},
"openrouter/deepseek/deepseek-r1": {
+ "cache_read_input_token_cost": 1.4e-07,
"input_cost_per_token": 7e-07,
"input_cost_per_token_cache_hit": 1.4e-07,
"litellm_provider": "openrouter",
@@ -46169,8 +46208,8 @@
"together_ai/zai-org/GLM-4.6": {
"input_cost_per_token": 6e-07,
"litellm_provider": "together_ai",
- "max_input_tokens": 200000,
- "max_tokens": 200000,
+ "max_input_tokens": 202752,
+ "max_tokens": 202752,
"metadata": {
"successor": "together_ai/zai-org/GLM-5.2"
},
@@ -46186,8 +46225,8 @@
"deprecation_date": "2026-04-02",
"input_cost_per_token": 4.5e-07,
"litellm_provider": "together_ai",
- "max_input_tokens": 200000,
- "max_tokens": 200000,
+ "max_input_tokens": 202752,
+ "max_tokens": 202752,
"metadata": {
"successor": "together_ai/zai-org/GLM-5.2"
},
@@ -64093,6 +64132,25 @@
"supports_tool_choice": true,
"supports_vision": false
},
+ "fireworks_ai/glm-5p3": {
+ "cache_read_input_token_cost": 2.6e-07,
+ "cache_read_input_token_cost_priority": 3.25e-07,
+ "input_cost_per_token": 1.4e-06,
+ "input_cost_per_token_priority": 1.75e-06,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 4.4e-06,
+ "output_cost_per_token_priority": 5.5e-06,
+ "source": "https://api.fireworks.ai/v1/serverless/models",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
"fireworks_ai/accounts/fireworks/routers/glm-5p3-fast": {
"cache_read_input_token_cost": 3.9e-07,
"input_cost_per_token": 2.1e-06,
@@ -64140,6 +64198,23 @@
"supports_tool_choice": true,
"supports_vision": true
},
+ "fireworks_ai/glm-5p3-flash": {
+ "cache_read_input_token_cost": 3e-08,
+ "cache_read_input_token_cost_priority": 3.75e-08,
+ "input_cost_per_token": 1.5e-07,
+ "input_cost_per_token_priority": 1.875e-07,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 1048576,
+ "max_tokens": 1048576,
+ "mode": "chat",
+ "output_cost_per_token": 5e-07,
+ "output_cost_per_token_priority": 6.25e-07,
+ "source": "https://api.fireworks.ai/v1/serverless/models",
+ "supports_function_calling": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
"fireworks_ai/accounts/fireworks/models/inkling": {
"cache_read_input_token_cost": 1.7e-07,
"input_cost_per_token": 1e-06,
diff --git a/model_prices_and_context_window.schema.json b/model_prices_and_context_window.schema.json
index 44b2569defd..aaf4d81bcc7 100644
--- a/model_prices_and_context_window.schema.json
+++ b/model_prices_and_context_window.schema.json
@@ -137,6 +137,10 @@
"type": "number",
"minimum": 0
},
+ "cache_read_input_image_token_cost": {
+ "type": "number",
+ "minimum": 0
+ },
"cache_read_input_token_cost": {
"type": "number",
"minimum": 0,
From afde938a673b45d532b3dab4d00b1e3f999e8db0 Mon Sep 17 00:00:00 2001
From: Moe Khalil
Date: Sat, 19 Sep 2026 19:46:46 +0000
Subject: [PATCH 065/317] docs(auto-router): disclose shared JEV context
defaults
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../router_strategy/complexity_router/config.py | 17 ++++++++---------
.../add_model/ClassificationMethodConfig.tsx | 6 +++---
ui/litellm-dashboard/src/lib/http/schema.d.ts | 8 ++++----
3 files changed, 15 insertions(+), 16 deletions(-)
diff --git a/litellm/router_strategy/complexity_router/config.py b/litellm/router_strategy/complexity_router/config.py
index ca50e21c082..a2dc551578c 100644
--- a/litellm/router_strategy/complexity_router/config.py
+++ b/litellm/router_strategy/complexity_router/config.py
@@ -1119,23 +1119,22 @@ class ComplexityRouterConfig(BaseModel):
ge=0,
description=(
"Number of prior user turns (tool output and harness reminders excluded) to include as context "
- "in the LLM classifier prompt, so a follow-up like 'now do the same for the streaming path' is "
+ "in the LLM or JEV classifier input, so a follow-up like 'now do the same for the streaming path' is "
"classified against what it refers to. Counts turns of both roles when "
"classifier_context_include_assistant_turns is enabled. These turns are sent to the classifier "
- "model, which may "
+ "model (the configured TypeSafe endpoint for JEV), which may "
"be a different deployment or provider than the routed completion model; that call carries "
"the current user ask and, except for Claude Code requests, the extracted system-role text in full. "
"Claude Code system text is omitted to avoid classifying harness instructions; the routed "
- "completion still receives it. Set to 0 to send neither prior turns nor "
- "any conversation context beyond the current ask. Only applies when "
- "classifier_type is 'llm'."
+ "completion still receives it. Set to 0 to omit prior turns and the conversation-depth summary; "
+ "the current ask and selected system text are still sent. Applies to LLM and JEV classification."
),
)
classifier_context_budget_chars: int = Field(
default=DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS,
ge=0,
description=(
- "Maximum characters of prior-turn text quoted to the LLM classifier, across the whole "
+ "Maximum characters of prior-turn text quoted to the LLM or JEV classifier, across the whole "
"context window, per classification call. Turns are taken newest first and quoted whole "
"while they fit, so a conversation small enough to quote entirely is never cut; once the "
"budget runs out the older turns are dropped whole and only the turn straddling the "
@@ -1143,7 +1142,7 @@ class ComplexityRouterConfig(BaseModel):
"Code requests, the extracted system-role text sit outside this budget and are sent in full, as does "
"the numbering each quoted turn carries. A budget under 120 leaves no room to quote a turn and "
"suppresses the block; set classifier_context_window_size to 0 to turn context off "
- "deliberately. Only applies when classifier_type is 'llm'."
+ "deliberately. Applies to LLM and JEV classification."
),
)
classifier_context_per_turn_chars: int | None = Field(
@@ -1154,7 +1153,7 @@ class ComplexityRouterConfig(BaseModel):
"classifier_context_budget_chars bounds the block. Unset by default, so one long turn may "
"spend the whole budget, which is usually what a follow-up needs; set it when no single "
"turn should dominate the context the classifier sees. A capped turn keeps its opening "
- "and its ending with the middle elided. Only applies when classifier_type is 'llm'."
+ "and its ending with the middle elided. Applies to LLM and JEV classification."
),
)
classifier_context_include_assistant_turns: bool = Field(
@@ -1169,7 +1168,7 @@ class ComplexityRouterConfig(BaseModel):
"routed completion model. Assistant replies spend classifier_context_budget_chars "
"alongside user turns, so raise it if the oldest turns stop being quoted once replies "
"join the window. Off by default because enabling it shifts tier decisions, and therefore "
- "spend, for an already-deployed router. Only applies when classifier_type is 'llm'."
+ "spend, for an already-deployed router. Applies to LLM and JEV classification."
),
)
diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx
index 322515e0ac5..3b3343154a3 100644
--- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx
@@ -666,9 +666,9 @@ const ClassificationMethodConfig: React.FC = ({
className="w-full"
/>
- Number of prior user turns (tool output and harness reminders excluded) sent to the classifier as context,
- so a referring follow-up like "now do the same for the streaming path" is classified against
- what it refers to. Set to 0 to send only the current message.
+ Number of prior user turns sent to the classifier provider, excluding tool output and harness reminders.
+ LLM and JEV default to 3 turns; JEV sends them to the configured TypeSafe endpoint. Set to 0 to omit
+ conversation history. The current message and selected system text are still sent.
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index d43adfe1ae4..69bb860b076 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -36407,24 +36407,24 @@ export interface components {
classification_prompt?: string | null;
/**
* Classifier Context Budget Chars
- * @description Maximum characters of prior-turn text quoted to the LLM classifier, across the whole context window, per classification call. Turns are taken newest first and quoted whole while they fit, so a conversation small enough to quote entirely is never cut; once the budget runs out the older turns are dropped whole and only the turn straddling the boundary is truncated, into whatever space is left. The current ask and, except for Claude Code requests, the extracted system-role text sit outside this budget and are sent in full, as does the numbering each quoted turn carries. A budget under 120 leaves no room to quote a turn and suppresses the block; set classifier_context_window_size to 0 to turn context off deliberately. Only applies when classifier_type is 'llm'.
+ * @description Maximum characters of prior-turn text quoted to the LLM or JEV classifier, across the whole context window, per classification call. Turns are taken newest first and quoted whole while they fit, so a conversation small enough to quote entirely is never cut; once the budget runs out the older turns are dropped whole and only the turn straddling the boundary is truncated, into whatever space is left. The current ask and, except for Claude Code requests, the extracted system-role text sit outside this budget and are sent in full, as does the numbering each quoted turn carries. A budget under 120 leaves no room to quote a turn and suppresses the block; set classifier_context_window_size to 0 to turn context off deliberately. Applies to LLM and JEV classification.
* @default 8000
*/
classifier_context_budget_chars: number;
/**
* Classifier Context Include Assistant Turns
- * @description Include assistant turns in the classifier context window, so difficulty stated by the model rather than by the user stays visible: a plan the assistant calls complex, which the user approves with 'yes', is classified on the work being approved instead of on the word 'yes'. When enabled, classifier_context_window_size counts the last N turns of the conversation across both roles rather than the last N user turns, and assistant text is sent to the classifier model, which may be a different deployment or provider than the routed completion model. Assistant replies spend classifier_context_budget_chars alongside user turns, so raise it if the oldest turns stop being quoted once replies join the window. Off by default because enabling it shifts tier decisions, and therefore spend, for an already-deployed router. Only applies when classifier_type is 'llm'.
+ * @description Include assistant turns in the classifier context window, so difficulty stated by the model rather than by the user stays visible: a plan the assistant calls complex, which the user approves with 'yes', is classified on the work being approved instead of on the word 'yes'. When enabled, classifier_context_window_size counts the last N turns of the conversation across both roles rather than the last N user turns, and assistant text is sent to the classifier model, which may be a different deployment or provider than the routed completion model. Assistant replies spend classifier_context_budget_chars alongside user turns, so raise it if the oldest turns stop being quoted once replies join the window. Off by default because enabling it shifts tier decisions, and therefore spend, for an already-deployed router. Applies to LLM and JEV classification.
* @default false
*/
classifier_context_include_assistant_turns: boolean;
/**
* Classifier Context Per Turn Chars
- * @description Optional cap on each individual prior turn's text, applied before classifier_context_budget_chars bounds the block. Unset by default, so one long turn may spend the whole budget, which is usually what a follow-up needs; set it when no single turn should dominate the context the classifier sees. A capped turn keeps its opening and its ending with the middle elided. Only applies when classifier_type is 'llm'.
+ * @description Optional cap on each individual prior turn's text, applied before classifier_context_budget_chars bounds the block. Unset by default, so one long turn may spend the whole budget, which is usually what a follow-up needs; set it when no single turn should dominate the context the classifier sees. A capped turn keeps its opening and its ending with the middle elided. Applies to LLM and JEV classification.
*/
classifier_context_per_turn_chars?: number | null;
/**
* Classifier Context Window Size
- * @description Number of prior user turns (tool output and harness reminders excluded) to include as context in the LLM classifier prompt, so a follow-up like 'now do the same for the streaming path' is classified against what it refers to. Counts turns of both roles when classifier_context_include_assistant_turns is enabled. These turns are sent to the classifier model, which may be a different deployment or provider than the routed completion model; that call carries the current user ask and, except for Claude Code requests, the extracted system-role text in full. Claude Code system text is omitted to avoid classifying harness instructions; the routed completion still receives it. Set to 0 to send neither prior turns nor any conversation context beyond the current ask. Only applies when classifier_type is 'llm'.
+ * @description Number of prior user turns (tool output and harness reminders excluded) to include as context in the LLM or JEV classifier input, so a follow-up like 'now do the same for the streaming path' is classified against what it refers to. Counts turns of both roles when classifier_context_include_assistant_turns is enabled. These turns are sent to the classifier model (the configured TypeSafe endpoint for JEV), which may be a different deployment or provider than the routed completion model; that call carries the current user ask and, except for Claude Code requests, the extracted system-role text in full. Claude Code system text is omitted to avoid classifying harness instructions; the routed completion still receives it. Set to 0 to omit prior turns and the conversation-depth summary; the current ask and selected system text are still sent. Applies to LLM and JEV classification.
* @default 3
*/
classifier_context_window_size: number;
From 8907a1d1fccd718d99a5b08f5e9b4750d9536f88 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 19:52:43 +0000
Subject: [PATCH 066/317] test: add native responses and messages cost cases
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/integration/contracts.json | 63 +
.../cost_calculation/cost_tracking_case.py | 2 +-
.../cost_calculation/cost_tracking_cases.json | 1079 +++++++++++++++++
.../cost_calculation/test_cost_tracking.py | 4 +-
4 files changed, 1146 insertions(+), 2 deletions(-)
diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json
index 1b0c9e31d4e..04af53a6080 100644
--- a/tests/integration/contracts.json
+++ b/tests/integration/contracts.json
@@ -1320,6 +1320,69 @@
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[us.anthropic.claude-opus-5-v1:0-stream_full_usage]": [
"quota_management.spend_tracking.scripted_wire.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-responses_cache_read]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-responses_reasoning]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-responses_stream]": [
+ "quota_management.spend_tracking.scripted_wire.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-responses_stream_cache_read]": [
+ "quota_management.spend_tracking.scripted_wire.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-responses_incomplete]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-responses_previous_response_id]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-responses_web_search_medium]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.3-codex-responses_file_search]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-responses_service_tier_flex]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-5.6-responses_service_tier_priority]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[claude-sonnet-5-messages_input_text]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[claude-sonnet-5-messages_cache_read]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[claude-sonnet-5-messages_cache_write_5m]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[claude-sonnet-5-messages_cache_write_1h]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[claude-sonnet-5-messages_web_search]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[claude-sonnet-5-messages_stream]": [
+ "quota_management.spend_tracking.scripted_wire.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[claude-sonnet-5-messages_stream_cache_read]": [
+ "quota_management.spend_tracking.scripted_wire.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[claude-sonnet-5-messages_tiered_input_above_200k]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[claude-haiku-4-5-messages_input_text]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[us.anthropic.claude-opus-5-v1:0-messages_input_text]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[anthropic.claude-sonnet-5-v1:0-messages_cache_read]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
]
},
"browser": {
diff --git a/tests/integration/cost_calculation/cost_tracking_case.py b/tests/integration/cost_calculation/cost_tracking_case.py
index cb408f2c488..830c2999fbf 100644
--- a/tests/integration/cost_calculation/cost_tracking_case.py
+++ b/tests/integration/cost_calculation/cost_tracking_case.py
@@ -172,7 +172,7 @@ class CostTrackingTestCase(BaseModel):
provider: Final = self.rates.litellm_provider
prefix: Final = (
"openai"
- if provider == "openai" and self.rates.mode == "chat"
+ if provider == "openai" and (self.rates.mode == "chat" or self.endpoint == "/v1/responses")
else "openai/responses"
if provider == "openai"
else _PROVIDER_PREFIXES.get(provider)
diff --git a/tests/integration/cost_calculation/cost_tracking_cases.json b/tests/integration/cost_calculation/cost_tracking_cases.json
index 428124ac4a9..cea9c22577b 100644
--- a/tests/integration/cost_calculation/cost_tracking_cases.json
+++ b/tests/integration/cost_calculation/cost_tracking_cases.json
@@ -25776,6 +25776,1085 @@
"prompt_tokens": 11056,
"completion_tokens": 412
}
+ },
+ {
+ "name": "gpt-5.6-responses_cache_read",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gpt-5.6",
+ "endpoint": "/v1/responses",
+ "request": {
+ "model": "$MODEL",
+ "input": "summarize this text"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "resp_$REQUEST_ID",
+ "object": "response",
+ "created_at": 1700000000,
+ "status": "completed",
+ "model": "gpt-5.6",
+ "output": [
+ {
+ "type": "message",
+ "id": "msg_$REQUEST_ID",
+ "status": "completed",
+ "role": "assistant",
+ "content": [
+ {
+ "type": "output_text",
+ "text": "scripted response",
+ "annotations": []
+ }
+ ]
+ }
+ ],
+ "usage": {
+ "input_tokens": 12928,
+ "output_tokens": 380,
+ "total_tokens": 13308,
+ "input_tokens_details": {
+ "cached_tokens": 12288
+ },
+ "output_tokens_details": {
+ "reasoning_tokens": 0
+ }
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.0085904,
+ "input_cost": 0.0032704,
+ "output_cost": 0.00532,
+ "prompt_tokens": 12928,
+ "completion_tokens": 380,
+ "cache_read_cost": 0.0021504
+ }
+ },
+ {
+ "name": "gpt-5.6-responses_reasoning",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gpt-5.6",
+ "endpoint": "/v1/responses",
+ "request": {
+ "model": "$MODEL",
+ "input": "reason about this text"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "resp_$REQUEST_ID",
+ "object": "response",
+ "created_at": 1700000000,
+ "status": "completed",
+ "model": "gpt-5.6",
+ "output": [
+ {
+ "type": "reasoning",
+ "id": "rs_$REQUEST_ID",
+ "status": "completed",
+ "summary": []
+ },
+ {
+ "type": "message",
+ "id": "msg_$REQUEST_ID",
+ "status": "completed",
+ "role": "assistant",
+ "content": [
+ {
+ "type": "output_text",
+ "text": "scripted response",
+ "annotations": []
+ }
+ ]
+ }
+ ],
+ "usage": {
+ "input_tokens": 1240,
+ "output_tokens": 4040,
+ "total_tokens": 5280,
+ "input_tokens_details": {
+ "cached_tokens": 0
+ },
+ "output_tokens_details": {
+ "reasoning_tokens": 3480
+ }
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.067716,
+ "input_cost": 0.00217,
+ "output_cost": 0.065484,
+ "prompt_tokens": 1240,
+ "completion_tokens": 4040,
+ "reasoning_cost": 0.05742
+ }
+ },
+ {
+ "name": "gpt-5.6-responses_stream",
+ "covers": "quota_management.spend_tracking.scripted_wire.logs_cost",
+ "model": "gpt-5.6",
+ "endpoint": "/v1/responses",
+ "request": {
+ "model": "$MODEL",
+ "input": "stream this text",
+ "stream": true
+ },
+ "response": {
+ "content_type": "text/event-stream",
+ "frames": [
+ "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_$REQUEST_ID\",\"object\":\"response\",\"created_at\":1700000000,\"status\":\"in_progress\",\"model\":\"gpt-5.6\",\"output\":[{\"type\":\"message\",\"id\":\"msg_$REQUEST_ID\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"scripted response\",\"annotations\":[]}]}],\"usage\":null}}",
+ "event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\",\"id\":\"msg_$REQUEST_ID\",\"status\":\"in_progress\",\"role\":\"assistant\",\"content\":[]}}",
+ "event: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"item_id\":\"msg_$REQUEST_ID\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"content_part\",\"text\":\"\"}}",
+ "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_$REQUEST_ID\",\"output_index\":0,\"content_index\":0,\"delta\":\"scripted \"}",
+ "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_$REQUEST_ID\",\"output_index\":0,\"content_index\":0,\"delta\":\"response\"}",
+ "event: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"item_id\":\"msg_$REQUEST_ID\",\"output_index\":0,\"content_index\":0,\"text\":\"scripted response\"}",
+ "event: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"item_id\":\"msg_$REQUEST_ID\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"scripted response\",\"annotations\":[]}}",
+ "event: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"message\",\"id\":\"msg_$REQUEST_ID\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"scripted response\",\"annotations\":[]}]}}",
+ "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_$REQUEST_ID\",\"object\":\"response\",\"created_at\":1700000000,\"status\":\"completed\",\"model\":\"gpt-5.6\",\"output\":[{\"type\":\"message\",\"id\":\"msg_$REQUEST_ID\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"scripted response\",\"annotations\":[]}]}],\"usage\":{\"input_tokens\":1840,\"output_tokens\":412,\"total_tokens\":2252,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens_details\":{\"reasoning_tokens\":0}}}}"
+ ]
+ },
+ "expected": {
+ "spend": 0.008988,
+ "input_cost": 0.00322,
+ "output_cost": 0.005768,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412
+ }
+ },
+ {
+ "name": "gpt-5.6-responses_stream_cache_read",
+ "covers": "quota_management.spend_tracking.scripted_wire.logs_cost",
+ "model": "gpt-5.6",
+ "endpoint": "/v1/responses",
+ "request": {
+ "model": "$MODEL",
+ "input": "stream cached text",
+ "stream": true
+ },
+ "response": {
+ "content_type": "text/event-stream",
+ "frames": [
+ "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_$REQUEST_ID\",\"object\":\"response\",\"created_at\":1700000000,\"status\":\"in_progress\",\"model\":\"gpt-5.6\",\"output\":[{\"type\":\"message\",\"id\":\"msg_$REQUEST_ID\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"scripted response\",\"annotations\":[]}]}],\"usage\":null}}",
+ "event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\",\"id\":\"msg_$REQUEST_ID\",\"status\":\"in_progress\",\"role\":\"assistant\",\"content\":[]}}",
+ "event: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"item_id\":\"msg_$REQUEST_ID\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"content_part\",\"text\":\"\"}}",
+ "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_$REQUEST_ID\",\"output_index\":0,\"content_index\":0,\"delta\":\"scripted \"}",
+ "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_$REQUEST_ID\",\"output_index\":0,\"content_index\":0,\"delta\":\"response\"}",
+ "event: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"item_id\":\"msg_$REQUEST_ID\",\"output_index\":0,\"content_index\":0,\"text\":\"scripted response\"}",
+ "event: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"item_id\":\"msg_$REQUEST_ID\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"scripted response\",\"annotations\":[]}}",
+ "event: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"message\",\"id\":\"msg_$REQUEST_ID\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"scripted response\",\"annotations\":[]}]}}",
+ "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_$REQUEST_ID\",\"object\":\"response\",\"created_at\":1700000000,\"status\":\"completed\",\"model\":\"gpt-5.6\",\"output\":[{\"type\":\"message\",\"id\":\"msg_$REQUEST_ID\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"scripted response\",\"annotations\":[]}]}],\"usage\":{\"input_tokens\":12928,\"output_tokens\":380,\"total_tokens\":13308,\"input_tokens_details\":{\"cached_tokens\":12288},\"output_tokens_details\":{\"reasoning_tokens\":0}}}}"
+ ]
+ },
+ "expected": {
+ "spend": 0.0085904,
+ "input_cost": 0.0032704,
+ "output_cost": 0.00532,
+ "prompt_tokens": 12928,
+ "completion_tokens": 380,
+ "cache_read_cost": 0.0021504
+ }
+ },
+ {
+ "name": "gpt-5.6-responses_incomplete",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gpt-5.6",
+ "endpoint": "/v1/responses",
+ "request": {
+ "model": "$MODEL",
+ "input": "truncate this text",
+ "max_output_tokens": 100
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "resp_$REQUEST_ID",
+ "object": "response",
+ "created_at": 1700000000,
+ "status": "incomplete",
+ "model": "gpt-5.6",
+ "output": [
+ {
+ "type": "message",
+ "id": "msg_$REQUEST_ID",
+ "status": "completed",
+ "role": "assistant",
+ "content": [
+ {
+ "type": "output_text",
+ "text": "scripted response",
+ "annotations": []
+ }
+ ]
+ }
+ ],
+ "usage": {
+ "input_tokens": 1840,
+ "output_tokens": 100,
+ "total_tokens": 1940,
+ "input_tokens_details": {
+ "cached_tokens": 0
+ },
+ "output_tokens_details": {
+ "reasoning_tokens": 0
+ }
+ },
+ "incomplete_details": {
+ "reason": "max_output_tokens"
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.00462,
+ "input_cost": 0.00322,
+ "output_cost": 0.0014,
+ "prompt_tokens": 1840,
+ "completion_tokens": 100
+ }
+ },
+ {
+ "name": "gpt-5.6-responses_previous_response_id",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gpt-5.6",
+ "endpoint": "/v1/responses",
+ "request": {
+ "model": "$MODEL",
+ "input": "continue this text",
+ "previous_response_id": "resp_scripted_prior"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "resp_$REQUEST_ID",
+ "object": "response",
+ "created_at": 1700000000,
+ "status": "completed",
+ "model": "gpt-5.6",
+ "output": [
+ {
+ "type": "message",
+ "id": "msg_$REQUEST_ID",
+ "status": "completed",
+ "role": "assistant",
+ "content": [
+ {
+ "type": "output_text",
+ "text": "scripted response",
+ "annotations": []
+ }
+ ]
+ }
+ ],
+ "usage": {
+ "input_tokens": 1840,
+ "output_tokens": 412,
+ "total_tokens": 2252,
+ "input_tokens_details": {
+ "cached_tokens": 0
+ },
+ "output_tokens_details": {
+ "reasoning_tokens": 0
+ }
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.008988,
+ "input_cost": 0.00322,
+ "output_cost": 0.005768,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412
+ }
+ },
+ {
+ "name": "gpt-5.6-responses_web_search_medium",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gpt-5.6",
+ "endpoint": "/v1/responses",
+ "request": {
+ "model": "$MODEL",
+ "input": "search this text",
+ "tools": [
+ {
+ "type": "web_search_preview",
+ "search_context_size": "medium"
+ }
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "resp_$REQUEST_ID",
+ "object": "response",
+ "created_at": 1700000000,
+ "status": "completed",
+ "model": "gpt-5.6",
+ "output": [
+ {
+ "type": "web_search_call",
+ "id": "ws_$REQUEST_ID",
+ "status": "completed",
+ "action": {
+ "type": "search",
+ "query": "scripted query"
+ }
+ },
+ {
+ "type": "message",
+ "id": "msg_$REQUEST_ID",
+ "status": "completed",
+ "role": "assistant",
+ "content": [
+ {
+ "type": "output_text",
+ "text": "scripted response",
+ "annotations": []
+ }
+ ]
+ }
+ ],
+ "usage": {
+ "input_tokens": 1840,
+ "output_tokens": 412,
+ "total_tokens": 2252,
+ "input_tokens_details": {
+ "cached_tokens": 0
+ },
+ "output_tokens_details": {
+ "reasoning_tokens": 0
+ }
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.021488,
+ "input_cost": 0.00322,
+ "output_cost": 0.005768,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412,
+ "tool_usage_cost": 0.0125
+ }
+ },
+ {
+ "name": "gpt-5.3-codex-responses_file_search",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gpt-5.3-codex",
+ "endpoint": "/v1/responses",
+ "request": {
+ "model": "$MODEL",
+ "input": "search files",
+ "tools": [
+ {
+ "type": "file_search",
+ "vector_store_ids": [
+ "vs_scripted"
+ ]
+ }
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "resp_$REQUEST_ID",
+ "object": "response",
+ "created_at": 1700000000,
+ "status": "completed",
+ "model": "gpt-5.3-codex",
+ "output": [
+ {
+ "type": "file_search_call",
+ "id": "fs_$REQUEST_ID",
+ "status": "completed",
+ "queries": [
+ "scripted query"
+ ],
+ "results": []
+ },
+ {
+ "type": "message",
+ "id": "msg_$REQUEST_ID",
+ "status": "completed",
+ "role": "assistant",
+ "content": [
+ {
+ "type": "output_text",
+ "text": "scripted response",
+ "annotations": []
+ }
+ ]
+ }
+ ],
+ "usage": {
+ "input_tokens": 1840,
+ "output_tokens": 412,
+ "total_tokens": 2252,
+ "input_tokens_details": {
+ "cached_tokens": 0
+ },
+ "output_tokens_details": {
+ "reasoning_tokens": 0
+ }
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.010204,
+ "input_cost": 0.00276,
+ "output_cost": 0.004944,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412,
+ "tool_usage_cost": 0.0025
+ }
+ },
+ {
+ "name": "gpt-5.6-responses_service_tier_flex",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gpt-5.6",
+ "endpoint": "/v1/responses",
+ "request": {
+ "model": "$MODEL",
+ "input": "flex text",
+ "service_tier": "flex"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "resp_$REQUEST_ID",
+ "object": "response",
+ "created_at": 1700000000,
+ "status": "completed",
+ "model": "gpt-5.6",
+ "output": [
+ {
+ "type": "message",
+ "id": "msg_$REQUEST_ID",
+ "status": "completed",
+ "role": "assistant",
+ "content": [
+ {
+ "type": "output_text",
+ "text": "scripted response",
+ "annotations": []
+ }
+ ]
+ }
+ ],
+ "usage": {
+ "input_tokens": 1840,
+ "output_tokens": 412,
+ "total_tokens": 2252,
+ "input_tokens_details": {
+ "cached_tokens": 0
+ },
+ "output_tokens_details": {
+ "reasoning_tokens": 0
+ },
+ "service_tier": "flex"
+ },
+ "service_tier": "flex"
+ }
+ },
+ "expected": {
+ "spend": 0.004494,
+ "input_cost": 0.00161,
+ "output_cost": 0.002884,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412
+ }
+ },
+ {
+ "name": "gpt-5.6-responses_service_tier_priority",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gpt-5.6",
+ "endpoint": "/v1/responses",
+ "request": {
+ "model": "$MODEL",
+ "input": "priority text",
+ "service_tier": "priority"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "resp_$REQUEST_ID",
+ "object": "response",
+ "created_at": 1700000000,
+ "status": "completed",
+ "model": "gpt-5.6",
+ "output": [
+ {
+ "type": "message",
+ "id": "msg_$REQUEST_ID",
+ "status": "completed",
+ "role": "assistant",
+ "content": [
+ {
+ "type": "output_text",
+ "text": "scripted response",
+ "annotations": []
+ }
+ ]
+ }
+ ],
+ "usage": {
+ "input_tokens": 1840,
+ "output_tokens": 412,
+ "total_tokens": 2252,
+ "input_tokens_details": {
+ "cached_tokens": 0
+ },
+ "output_tokens_details": {
+ "reasoning_tokens": 0
+ },
+ "service_tier": "priority"
+ },
+ "service_tier": "priority"
+ }
+ },
+ "expected": {
+ "spend": 0.017976,
+ "input_cost": 0.00644,
+ "output_cost": 0.011536,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412
+ }
+ },
+ {
+ "name": "claude-sonnet-5-messages_input_text",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "claude-sonnet-5",
+ "endpoint": "/v1/messages",
+ "request": {
+ "model": "$MODEL",
+ "max_tokens": 412,
+ "messages": [
+ {
+ "role": "user",
+ "content": "summarize the attached material in one line"
+ }
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "msg_$REQUEST_ID",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-sonnet-5",
+ "content": [
+ {
+ "type": "text",
+ "text": "scripted response"
+ }
+ ],
+ "stop_reason": "end_turn",
+ "usage": {
+ "input_tokens": 1840,
+ "output_tokens": 412
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.0117,
+ "input_cost": 0.00552,
+ "output_cost": 0.00618,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412
+ }
+ },
+ {
+ "name": "claude-sonnet-5-messages_cache_read",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "claude-sonnet-5",
+ "endpoint": "/v1/messages",
+ "request": {
+ "model": "$MODEL",
+ "max_tokens": 412,
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "cached text",
+ "cache_control": {
+ "type": "ephemeral"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "msg_$REQUEST_ID",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-sonnet-5",
+ "content": [
+ {
+ "type": "text",
+ "text": "scripted response"
+ }
+ ],
+ "stop_reason": "end_turn",
+ "usage": {
+ "input_tokens": 640,
+ "output_tokens": 380,
+ "cache_read_input_tokens": 12288
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.0113064,
+ "input_cost": 0.0056064,
+ "output_cost": 0.0057,
+ "prompt_tokens": 12928,
+ "completion_tokens": 380,
+ "cache_read_cost": 0.0036864
+ }
+ },
+ {
+ "name": "claude-sonnet-5-messages_cache_write_5m",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "claude-sonnet-5",
+ "endpoint": "/v1/messages",
+ "request": {
+ "model": "$MODEL",
+ "max_tokens": 350,
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "cache this text",
+ "cache_control": {
+ "type": "ephemeral"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "msg_$REQUEST_ID",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-sonnet-5",
+ "content": [
+ {
+ "type": "text",
+ "text": "scripted response"
+ }
+ ],
+ "stop_reason": "end_turn",
+ "usage": {
+ "input_tokens": 512,
+ "output_tokens": 350,
+ "cache_creation_input_tokens": 9216,
+ "cache_creation": {
+ "ephemeral_5m_input_tokens": 9216,
+ "ephemeral_1h_input_tokens": 0
+ }
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.041346,
+ "input_cost": 0.036096,
+ "output_cost": 0.00525,
+ "prompt_tokens": 9728,
+ "completion_tokens": 350,
+ "cache_creation_cost": 0.03456
+ }
+ },
+ {
+ "name": "claude-sonnet-5-messages_cache_write_1h",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "claude-sonnet-5",
+ "endpoint": "/v1/messages",
+ "request": {
+ "model": "$MODEL",
+ "max_tokens": 350,
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "cache this text for an hour",
+ "cache_control": {
+ "type": "ephemeral",
+ "ttl": "1h"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "msg_$REQUEST_ID",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-sonnet-5",
+ "content": [
+ {
+ "type": "text",
+ "text": "scripted response"
+ }
+ ],
+ "stop_reason": "end_turn",
+ "usage": {
+ "input_tokens": 512,
+ "output_tokens": 350,
+ "cache_creation_input_tokens": 9216,
+ "cache_creation": {
+ "ephemeral_5m_input_tokens": 2048,
+ "ephemeral_1h_input_tokens": 7168
+ }
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.057474,
+ "input_cost": 0.052224,
+ "output_cost": 0.00525,
+ "prompt_tokens": 9728,
+ "completion_tokens": 350,
+ "cache_creation_cost": 0.050688
+ }
+ },
+ {
+ "name": "claude-sonnet-5-messages_web_search",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "claude-sonnet-5",
+ "endpoint": "/v1/messages",
+ "request": {
+ "model": "$MODEL",
+ "max_tokens": 412,
+ "messages": [
+ {
+ "role": "user",
+ "content": "summarize the attached material in one line"
+ }
+ ],
+ "tools": [
+ {
+ "type": "web_search_20250305",
+ "name": "web_search"
+ }
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "msg_$REQUEST_ID",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-sonnet-5",
+ "content": [
+ {
+ "type": "server_tool_use",
+ "id": "srv_$REQUEST_ID",
+ "name": "web_search",
+ "input": {
+ "query": "scripted query"
+ }
+ },
+ {
+ "type": "web_search_tool_result",
+ "tool_use_id": "srv_$REQUEST_ID",
+ "content": [
+ {
+ "type": "web_search_result",
+ "title": "scripted result",
+ "url": "https://scripted.example"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "text": "scripted response"
+ }
+ ],
+ "stop_reason": "end_turn",
+ "usage": {
+ "input_tokens": 1840,
+ "output_tokens": 412,
+ "server_tool_use": {
+ "web_search_requests": 2
+ }
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.0317,
+ "input_cost": 0.00552,
+ "output_cost": 0.00618,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412,
+ "tool_usage_cost": 0.02
+ }
+ },
+ {
+ "name": "claude-sonnet-5-messages_stream",
+ "covers": "quota_management.spend_tracking.scripted_wire.logs_cost",
+ "model": "claude-sonnet-5",
+ "endpoint": "/v1/messages",
+ "request": {
+ "model": "$MODEL",
+ "max_tokens": 412,
+ "messages": [
+ {
+ "role": "user",
+ "content": "summarize the attached material in one line"
+ }
+ ],
+ "stream": true
+ },
+ "response": {
+ "content_type": "text/event-stream",
+ "frames": [
+ "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_$REQUEST_ID\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-5\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":1840}}}",
+ "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}",
+ "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"scripted \"}}",
+ "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"response\"}}",
+ "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}",
+ "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":412}}",
+ "event: message_stop\ndata: {\"type\":\"message_stop\"}"
+ ]
+ },
+ "expected": {
+ "spend": 0.0117,
+ "input_cost": 0.00552,
+ "output_cost": 0.00618,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412
+ }
+ },
+ {
+ "name": "claude-sonnet-5-messages_stream_cache_read",
+ "covers": "quota_management.spend_tracking.scripted_wire.logs_cost",
+ "model": "claude-sonnet-5",
+ "endpoint": "/v1/messages",
+ "request": {
+ "model": "$MODEL",
+ "max_tokens": 380,
+ "messages": [
+ {
+ "role": "user",
+ "content": "summarize the attached material in one line"
+ }
+ ],
+ "stream": true
+ },
+ "response": {
+ "content_type": "text/event-stream",
+ "frames": [
+ "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_$REQUEST_ID\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-5\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":640,\"cache_read_input_tokens\":12288}}}",
+ "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}",
+ "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"scripted \"}}",
+ "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"response\"}}",
+ "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}",
+ "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":380}}",
+ "event: message_stop\ndata: {\"type\":\"message_stop\"}"
+ ]
+ },
+ "expected": {
+ "spend": 0.0113064,
+ "input_cost": 0.0056064,
+ "output_cost": 0.0057,
+ "prompt_tokens": 12928,
+ "completion_tokens": 380,
+ "cache_read_cost": 0.0036864
+ }
+ },
+ {
+ "name": "claude-sonnet-5-messages_tiered_input_above_200k",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "claude-sonnet-5",
+ "endpoint": "/v1/messages",
+ "request": {
+ "model": "$MODEL",
+ "max_tokens": 620,
+ "messages": [
+ {
+ "role": "user",
+ "content": "summarize the attached material in one line"
+ }
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "msg_$REQUEST_ID",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-sonnet-5",
+ "content": [
+ {
+ "type": "text",
+ "text": "scripted response"
+ }
+ ],
+ "stop_reason": "end_turn",
+ "usage": {
+ "input_tokens": 210000,
+ "output_tokens": 620
+ }
+ }
+ },
+ "expected": {
+ "spend": 1.2693,
+ "input_cost": 1.26,
+ "output_cost": 0.0093,
+ "prompt_tokens": 210000,
+ "completion_tokens": 620
+ }
+ },
+ {
+ "name": "claude-haiku-4-5-messages_input_text",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "claude-haiku-4-5",
+ "endpoint": "/v1/messages",
+ "request": {
+ "model": "$MODEL",
+ "max_tokens": 412,
+ "messages": [
+ {
+ "role": "user",
+ "content": "summarize the attached material in one line"
+ }
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "msg_$REQUEST_ID",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-haiku-4-5",
+ "content": [
+ {
+ "type": "text",
+ "text": "scripted response"
+ }
+ ],
+ "stop_reason": "end_turn",
+ "usage": {
+ "input_tokens": 1840,
+ "output_tokens": 412
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.0039,
+ "input_cost": 0.00184,
+ "output_cost": 0.00206,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412
+ }
+ },
+ {
+ "name": "us.anthropic.claude-opus-5-v1:0-messages_input_text",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "us.anthropic.claude-opus-5-v1:0",
+ "endpoint": "/v1/messages",
+ "request": {
+ "model": "$MODEL",
+ "max_tokens": 412,
+ "messages": [
+ {
+ "role": "user",
+ "content": "summarize the attached material in one line"
+ }
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "output": {
+ "message": {
+ "role": "assistant",
+ "content": [
+ {
+ "text": "scripted response"
+ }
+ ]
+ }
+ },
+ "stopReason": "end_turn",
+ "usage": {
+ "inputTokens": 1840,
+ "outputTokens": 412
+ },
+ "metrics": {
+ "latencyMs": 42
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.02145,
+ "input_cost": 0.01012,
+ "output_cost": 0.01133,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412
+ }
+ },
+ {
+ "name": "anthropic.claude-sonnet-5-v1:0-messages_cache_read",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "anthropic.claude-sonnet-5-v1:0",
+ "endpoint": "/v1/messages",
+ "request": {
+ "model": "$MODEL",
+ "max_tokens": 412,
+ "messages": [
+ {
+ "role": "user",
+ "content": "summarize the attached material in one line"
+ }
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "output": {
+ "message": {
+ "role": "assistant",
+ "content": [
+ {
+ "text": "scripted response"
+ }
+ ]
+ }
+ },
+ "stopReason": "end_turn",
+ "usage": {
+ "inputTokens": 640,
+ "outputTokens": 380,
+ "totalTokens": 13308,
+ "cacheReadInputTokens": 12288
+ },
+ "metrics": {
+ "latencyMs": 42
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.01243704,
+ "input_cost": 0.00616704,
+ "output_cost": 0.00627,
+ "prompt_tokens": 12928,
+ "completion_tokens": 380,
+ "cache_read_cost": 0.00405504
+ }
}
]
}
diff --git a/tests/integration/cost_calculation/test_cost_tracking.py b/tests/integration/cost_calculation/test_cost_tracking.py
index 5b876974c63..fd18c400b4e 100644
--- a/tests/integration/cost_calculation/test_cost_tracking.py
+++ b/tests/integration/cost_calculation/test_cost_tracking.py
@@ -42,7 +42,9 @@ def _assert_stream_has_no_error(response_text: str) -> None:
if payload == "[DONE]":
continue
parsed = JSON_OBJECT.validate_json(payload)
- assert "error" not in parsed, f"stream carried an error event: {parsed}"
+ assert (
+ "error" not in parsed and parsed.get("type") not in {"error", "response.failed"}
+ ), f"stream carried an error event: {parsed}"
@pytest.mark.parametrize("case", _CASES)
From 7b3e8afaece0b1aa4f1d9101c11daff3f6ab75c3 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Sat, 19 Sep 2026 19:52:56 +0000
Subject: [PATCH 067/317] registry: add cache_read_input_image_token_cost field
for azure_ai/gpt-image-2
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/types/utils.py | 1 +
tests/test_litellm/test_utils.py | 2 ++
2 files changed, 3 insertions(+)
diff --git a/litellm/types/utils.py b/litellm/types/utils.py
index d416e2af33a..de4126a0947 100644
--- a/litellm/types/utils.py
+++ b/litellm/types/utils.py
@@ -254,6 +254,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
cache_creation_input_token_cost_ultrafast: ReadOnly[float | None] # OpenAI ultrafast service tier pricing
cache_read_input_token_cost: float | None
cache_read_input_audio_token_cost: ReadOnly[float | None]
+ cache_read_input_image_token_cost: ReadOnly[float | None]
cache_read_input_token_cost_flex: float | None # OpenAI flex service tier pricing
cache_read_input_token_cost_priority: float | None # OpenAI priority service tier pricing
cache_read_input_token_cost_ultrafast: ReadOnly[float | None] # OpenAI ultrafast service tier pricing
diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py
index b40c10de428..8986753af3e 100644
--- a/tests/test_litellm/test_utils.py
+++ b/tests/test_litellm/test_utils.py
@@ -652,6 +652,7 @@ def validate_model_cost_values(model_data, exceptions=None):
"cache_creation_input_audio_token_cost",
"cache_read_input_token_cost",
"cache_read_input_audio_token_cost",
+ "cache_read_input_image_token_cost",
"input_dbu_cost_per_token",
"output_db_cost_per_token",
"output_dbu_cost_per_token",
@@ -740,6 +741,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"cache_read_input_token_cost_above_512k_tokens": {"type": "number"},
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": {"type": "number"},
"cache_read_input_audio_token_cost": {"type": "number"},
+ "cache_read_input_image_token_cost": {"type": "number"},
"audio_transcription_config": {"type": "string"},
"deprecation_date": {"type": "string"},
"input_cost_per_audio_per_second": {"type": "number"},
From a30e0d14ea8b7a64d3a4dc9cbfa4612924a414a3 Mon Sep 17 00:00:00 2001
From: Moe Khalil
Date: Sat, 19 Sep 2026 19:56:54 +0000
Subject: [PATCH 068/317] test(auto-router): preserve classifier literal in
context fixture
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../build_updated_complexity_router_config.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
index e5e2c61933c..604d2c9113d 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
@@ -258,7 +258,7 @@ describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
const STORED_LLM = {
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
- classifier_type: "llm",
+ classifier_type: "llm" as const,
classifier_llm_config: { model: "gpt-4o-mini", timeout_ms: 3000, reasoning_effort: "low" },
classifier_context_window_size: 5,
classifier_context_per_turn_chars: 300,
From 4a4475fd7046ba0a5eda547aa5a1b14dc717a67d Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Sat, 19 Sep 2026 20:03:58 +0000
Subject: [PATCH 069/317] registry: add cache_read_input_image_token_cost to
CustomPricingLiteLLMParams denylist
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/types/utils.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/litellm/types/utils.py b/litellm/types/utils.py
index de4126a0947..9b78628c726 100644
--- a/litellm/types/utils.py
+++ b/litellm/types/utils.py
@@ -3619,6 +3619,7 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
cache_read_input_token_cost_above_272k_tokens_priority: float | None = None
cache_read_input_token_cost_above_272k_tokens_flex: float | None = None
cache_read_input_audio_token_cost: float | None = None
+ cache_read_input_image_token_cost: float | None = None
input_cost_per_character_above_128k_tokens: float | None = None
input_cost_per_audio_token: float | None = None
input_cost_per_token_cache_hit: float | None = None
From a65b0c213660447f064a0bc0bc16653e25843894 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Sat, 19 Sep 2026 20:08:25 +0000
Subject: [PATCH 070/317] registry: regen schema.d.ts for
cache_read_input_image_token_cost
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 7aa34c5752c..f300492e902 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -30756,6 +30756,8 @@ export interface components {
cache_creation_input_token_cost_ultrafast?: number | null;
/** Cache Read Input Audio Token Cost */
cache_read_input_audio_token_cost?: number | null;
+ /** Cache Read Input Image Token Cost */
+ cache_read_input_image_token_cost?: number | null;
/** Cache Read Input Token Cost */
cache_read_input_token_cost?: number | null;
/** Cache Read Input Token Cost Above 200K Tokens */
@@ -41410,6 +41412,8 @@ export interface components {
cache_creation_input_token_cost_ultrafast?: number | null;
/** Cache Read Input Audio Token Cost */
cache_read_input_audio_token_cost?: number | null;
+ /** Cache Read Input Image Token Cost */
+ cache_read_input_image_token_cost?: number | null;
/** Cache Read Input Token Cost */
cache_read_input_token_cost?: number | null;
/** Cache Read Input Token Cost Above 200K Tokens */
From ca8d0e500c56018ce1a9a4dae54f741ffc6edfe2 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 20:11:24 +0000
Subject: [PATCH 071/317] test(integration): correct responses reasoning and
messages tiered expectations
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../cost_calculation/cost_tracking_cases.json | 21 ++++++++++++-------
1 file changed, 14 insertions(+), 7 deletions(-)
diff --git a/tests/integration/cost_calculation/cost_tracking_cases.json b/tests/integration/cost_calculation/cost_tracking_cases.json
index 8f36e679e9f..ac4c6c125fb 100644
--- a/tests/integration/cost_calculation/cost_tracking_cases.json
+++ b/tests/integration/cost_calculation/cost_tracking_cases.json
@@ -25884,12 +25884,12 @@
}
},
"expected": {
- "spend": 0.067716,
+ "spend": 0.06569,
"input_cost": 0.00217,
- "output_cost": 0.065484,
+ "output_cost": 0.06352,
"prompt_tokens": 1240,
"completion_tokens": 4040,
- "reasoning_cost": 0.05742
+ "reasoning_cost": 0.05568
}
},
{
@@ -26349,6 +26349,7 @@
}
],
"stop_reason": "end_turn",
+ "stop_sequence": null,
"usage": {
"input_tokens": 1840,
"output_tokens": 412
@@ -26400,6 +26401,7 @@
}
],
"stop_reason": "end_turn",
+ "stop_sequence": null,
"usage": {
"input_tokens": 640,
"output_tokens": 380,
@@ -26453,6 +26455,7 @@
}
],
"stop_reason": "end_turn",
+ "stop_sequence": null,
"usage": {
"input_tokens": 512,
"output_tokens": 350,
@@ -26511,6 +26514,7 @@
}
],
"stop_reason": "end_turn",
+ "stop_sequence": null,
"usage": {
"input_tokens": 512,
"output_tokens": 350,
@@ -26585,6 +26589,7 @@
}
],
"stop_reason": "end_turn",
+ "stop_sequence": null,
"usage": {
"input_tokens": 1840,
"output_tokens": 412,
@@ -26622,7 +26627,7 @@
"response": {
"content_type": "text/event-stream",
"frames": [
- "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_$REQUEST_ID\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-5\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":1840}}}",
+ "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_$REQUEST_ID\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-5\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":1840}}}",
"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}",
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"scripted \"}}",
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"response\"}}",
@@ -26658,7 +26663,7 @@
"response": {
"content_type": "text/event-stream",
"frames": [
- "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_$REQUEST_ID\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-5\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":640,\"cache_read_input_tokens\":12288}}}",
+ "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_$REQUEST_ID\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-5\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":640,\"cache_read_input_tokens\":12288}}}",
"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}",
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"scripted \"}}",
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"response\"}}",
@@ -26705,6 +26710,7 @@
}
],
"stop_reason": "end_turn",
+ "stop_sequence": null,
"usage": {
"input_tokens": 210000,
"output_tokens": 620
@@ -26712,9 +26718,9 @@
}
},
"expected": {
- "spend": 1.2693,
+ "spend": 1.27395,
"input_cost": 1.26,
- "output_cost": 0.0093,
+ "output_cost": 0.01395,
"prompt_tokens": 210000,
"completion_tokens": 620
}
@@ -26748,6 +26754,7 @@
}
],
"stop_reason": "end_turn",
+ "stop_sequence": null,
"usage": {
"input_tokens": 1840,
"output_tokens": 412
From 125bda30e9356551ae37657bd7481022a51d6365 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 20:58:13 +0000
Subject: [PATCH 072/317] test(integration): embeddings, rerank, completions
and moderations cost cases
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/integration/contracts.json | 120 +++
.../integration/cost_calculation/conftest.py | 2 +-
.../cost_calculation/cost_tracking_case.py | 22 +-
.../cost_calculation/cost_tracking_cases.json | 937 ++++++++++++++++++
.../cost_calculation/test_cost_tracking.py | 2 +-
5 files changed, 1080 insertions(+), 3 deletions(-)
diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json
index 338ace07908..d86f44cf24a 100644
--- a/tests/integration/contracts.json
+++ b/tests/integration/contracts.json
@@ -1320,6 +1320,126 @@
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[us.anthropic.claude-opus-5-v1:0-stream_full_usage]": [
"quota_management.spend_tracking.scripted_wire.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-embedding-4-small-single]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-embedding-4-small-batch]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-embedding-4-small-token-array]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-embedding-4-large-dimensions]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[azure-text-embedding-4-large-deployment]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[cohere-embed-v5]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[bedrock-titan-embed-v3]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[bedrock-cohere-embed-v4]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[vertex-text-embedding-006]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gemini-embedding-002]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[together-embedding-v1]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[fireworks-embedding-v1]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-completion-openai-basic]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-completion-openai-stream-usage]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-completion-openai-n-best]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[together-completions]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[omni-moderation-next-single]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[omni-moderation-next-list]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[azure-text-embeddings-4-large-deployment]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[bedrock-cohere-embeddings-v4]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[bedrock-cohere-rerank-v4]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[bedrock-embeddings-titan-v2]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[cohere-embeddings-v5]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[cohere-rerank-v4-one]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[cohere-rerank-v4-three]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[cohere-rerank-v4-total-tokens-fallback]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[fireworks-embeddings-v1]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gemini-embeddings-002]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[omni-moderations-next-list]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[omni-moderations-next-single]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-completions-openai-basic]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-completions-openai-n-best]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-completions-openai-stream-usage]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-embeddings-3-large-dimensions]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-embeddings-4-small-batch]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-embeddings-4-small-single]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-embeddings-4-small-token-array]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[together-completions-v1]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[together-embeddings-v1]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[vertex-embeddings-text-006]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
]
},
"browser": {
diff --git a/tests/integration/cost_calculation/conftest.py b/tests/integration/cost_calculation/conftest.py
index 166488e36a5..414711648aa 100644
--- a/tests/integration/cost_calculation/conftest.py
+++ b/tests/integration/cost_calculation/conftest.py
@@ -165,7 +165,7 @@ def register_scenario_deployment(
**case.litellm_params,
**(
{"vertex_credentials": _vertex_service_account_json(control_url)}
- if case.rates.litellm_provider == "vertex_ai-language-models"
+ if case.rates.litellm_provider.startswith("vertex_ai")
else {}
),
}
diff --git a/tests/integration/cost_calculation/cost_tracking_case.py b/tests/integration/cost_calculation/cost_tracking_case.py
index 9737508e27c..e24f5d76d0d 100644
--- a/tests/integration/cost_calculation/cost_tracking_case.py
+++ b/tests/integration/cost_calculation/cost_tracking_case.py
@@ -35,7 +35,10 @@ class CostMapEntry(BaseModel):
max_output_tokens: int | None = None
supports_function_calling: bool | None = None
input_cost_per_token: float | None = None
+ input_cost_per_query: float | None = None
output_cost_per_token: float | None = None
+ output_vector_size: int | None = None
+ input_cost_per_token_batches: float | None = None
cache_read_input_token_cost: float | None = None
cache_creation_input_token_cost: float | None = None
cache_creation_input_token_cost_above_1hr: float | None = None
@@ -172,7 +175,8 @@ class CostTrackingTestCase(BaseModel):
provider: Final = self.rates.litellm_provider
prefix: Final = (
"openai"
- if provider == "openai" and self.rates.mode == "chat"
+ if provider == "openai"
+ and (self.endpoint == "/v1/responses" or self.rates.mode in {"chat", "embedding", "moderation"})
else "openai/responses"
if provider == "openai"
else _PROVIDER_PREFIXES.get(provider)
@@ -207,7 +211,11 @@ _PROVIDER_PREFIXES: Final[Mapping[str, str]] = MappingProxyType(
{
"anthropic": "anthropic",
"bedrock_converse": "bedrock/converse",
+ "text-completion-openai": "text-completion-openai",
+ "cohere": "cohere",
+ "bedrock": "bedrock",
"vertex_ai-language-models": "vertex_ai",
+ "vertex_ai-embedding-models": "vertex_ai",
"gemini": "",
"together_ai": "",
"fireworks_ai": "",
@@ -224,9 +232,21 @@ _LITELLM_PARAMS: Final[Mapping[str, Mapping[str, str]]] = MappingProxyType(
"aws_region_name": "us-east-1",
}
),
+ "text-completion-openai": MappingProxyType({}),
+ "cohere": MappingProxyType({}),
+ "bedrock": MappingProxyType(
+ {
+ "aws_access_key_id": "AKIASCRIPTEDPROVIDER",
+ "aws_secret_access_key": "scripted-secret",
+ "aws_region_name": "us-east-1",
+ }
+ ),
"vertex_ai-language-models": MappingProxyType(
{"vertex_project": "cc-scripted-project", "vertex_location": "us-central1"}
),
+ "vertex_ai-embedding-models": MappingProxyType(
+ {"vertex_project": "cc-scripted-project", "vertex_location": "us-central1"}
+ ),
"gemini": MappingProxyType({}),
"together_ai": MappingProxyType({}),
"fireworks_ai": MappingProxyType({}),
diff --git a/tests/integration/cost_calculation/cost_tracking_cases.json b/tests/integration/cost_calculation/cost_tracking_cases.json
index b0ad270f860..a076381c4e3 100644
--- a/tests/integration/cost_calculation/cost_tracking_cases.json
+++ b/tests/integration/cost_calculation/cost_tracking_cases.json
@@ -408,6 +408,98 @@
"mode": "chat",
"output_cost_per_token": 3.6e-06,
"supports_function_calling": true
+ },
+ "text-embedding-4-small": {
+ "input_cost_per_token": 1.01e-06,
+ "output_cost_per_token": 0,
+ "litellm_provider": "openai",
+ "mode": "embedding"
+ },
+ "text-embedding-3-large-next": {
+ "input_cost_per_token": 1.02e-06,
+ "output_cost_per_token": 0,
+ "litellm_provider": "openai",
+ "mode": "embedding"
+ },
+ "azure/text-embedding-4-large": {
+ "input_cost_per_token": 1.03e-06,
+ "output_cost_per_token": 0,
+ "litellm_provider": "azure",
+ "mode": "embedding"
+ },
+ "embed-v5": {
+ "input_cost_per_token": 1.04e-06,
+ "output_cost_per_token": 0,
+ "litellm_provider": "cohere",
+ "mode": "embedding"
+ },
+ "amazon.titan-embed-text-v2:0": {
+ "input_cost_per_token": 1.05e-06,
+ "output_cost_per_token": 0,
+ "litellm_provider": "bedrock",
+ "mode": "embedding"
+ },
+ "cohere.embed-english-v4": {
+ "input_cost_per_token": 1.06e-06,
+ "output_cost_per_token": 0,
+ "litellm_provider": "bedrock",
+ "mode": "embedding"
+ },
+ "text-embedding-006": {
+ "input_cost_per_token": 1.07e-06,
+ "output_cost_per_token": 0,
+ "litellm_provider": "vertex_ai-embedding-models",
+ "mode": "embedding"
+ },
+ "gemini/gemini-embedding-002": {
+ "input_cost_per_token": 1.08e-06,
+ "output_cost_per_token": 0,
+ "litellm_provider": "gemini",
+ "mode": "embedding"
+ },
+ "together_ai/together-embed-v1": {
+ "input_cost_per_token": 1.09e-06,
+ "output_cost_per_token": 0,
+ "litellm_provider": "together_ai",
+ "mode": "embedding"
+ },
+ "fireworks_ai/fireworks-embed-v1": {
+ "input_cost_per_token": 1.1e-06,
+ "output_cost_per_token": 0,
+ "litellm_provider": "fireworks_ai",
+ "mode": "embedding"
+ },
+ "rerank-v4": {
+ "input_cost_per_token": 1.11e-06,
+ "output_cost_per_token": 0,
+ "input_cost_per_query": 0.0021,
+ "litellm_provider": "cohere",
+ "mode": "rerank"
+ },
+ "cohere.rerank-v4:0": {
+ "input_cost_per_token": 1.12e-06,
+ "output_cost_per_token": 0,
+ "input_cost_per_query": 0.0022,
+ "litellm_provider": "bedrock",
+ "mode": "rerank"
+ },
+ "gpt-3.5-turbo-instruct-next": {
+ "input_cost_per_token": 1.14e-06,
+ "output_cost_per_token": 2.14e-06,
+ "litellm_provider": "text-completion-openai",
+ "mode": "completion"
+ },
+ "omni-moderation-next": {
+ "input_cost_per_token": null,
+ "output_cost_per_token": 0,
+ "litellm_provider": "openai",
+ "mode": "moderation"
+ },
+ "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo": {
+ "input_cost_per_token": 1.16e-06,
+ "output_cost_per_token": 2.16e-06,
+ "litellm_provider": "together_ai",
+ "mode": "completion"
}
},
"cases": [
@@ -25777,6 +25869,851 @@
"prompt_tokens": 11056,
"completion_tokens": 412
}
+ },
+ {
+ "name": "text-embeddings-4-small-single",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "text-embedding-4-small",
+ "endpoint": "/v1/embeddings",
+ "request": {
+ "model": "$MODEL",
+ "input": "one embedding"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "object": "list",
+ "data": [
+ {
+ "object": "embedding",
+ "embedding": [
+ 0.1,
+ 0.2,
+ 0.3
+ ],
+ "index": 0
+ }
+ ],
+ "model": "text-embedding-4-small",
+ "usage": {
+ "prompt_tokens": 7,
+ "total_tokens": 7
+ }
+ }
+ },
+ "expected": {
+ "spend": 7.07e-06,
+ "input_cost": 7.07e-06,
+ "output_cost": 0.0,
+ "prompt_tokens": 7,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "text-embeddings-4-small-batch",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "text-embedding-4-small",
+ "endpoint": "/v1/embeddings",
+ "request": {
+ "model": "$MODEL",
+ "input": [
+ "one",
+ "two",
+ "three"
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "object": "list",
+ "data": [
+ {
+ "object": "embedding",
+ "embedding": [
+ 0.1,
+ 0.2,
+ 0.3
+ ],
+ "index": 0
+ },
+ {
+ "object": "embedding",
+ "embedding": [
+ 0.1,
+ 0.2,
+ 0.3
+ ],
+ "index": 1
+ },
+ {
+ "object": "embedding",
+ "embedding": [
+ 0.1,
+ 0.2,
+ 0.3
+ ],
+ "index": 2
+ }
+ ],
+ "model": "text-embedding-4-small",
+ "usage": {
+ "prompt_tokens": 21,
+ "total_tokens": 21
+ }
+ }
+ },
+ "expected": {
+ "spend": 2.1210000000000002e-05,
+ "input_cost": 2.1210000000000002e-05,
+ "output_cost": 0.0,
+ "prompt_tokens": 21,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "text-embeddings-4-small-token-array",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "text-embedding-4-small",
+ "endpoint": "/v1/embeddings",
+ "request": {
+ "model": "$MODEL",
+ "input": [
+ 1,
+ 2,
+ 3,
+ 4
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "object": "list",
+ "data": [
+ {
+ "object": "embedding",
+ "embedding": [
+ 0.1,
+ 0.2,
+ 0.3
+ ],
+ "index": 0
+ }
+ ],
+ "model": "text-embedding-4-small",
+ "usage": {
+ "prompt_tokens": 9,
+ "total_tokens": 9
+ }
+ }
+ },
+ "expected": {
+ "spend": 9.090000000000001e-06,
+ "input_cost": 9.090000000000001e-06,
+ "output_cost": 0.0,
+ "prompt_tokens": 9,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "text-embeddings-3-large-dimensions",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "text-embedding-3-large-next",
+ "endpoint": "/v1/embeddings",
+ "request": {
+ "model": "$MODEL",
+ "input": "large embedding",
+ "dimensions": 3
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "object": "list",
+ "data": [
+ {
+ "object": "embedding",
+ "embedding": [
+ 0.1,
+ 0.2,
+ 0.3
+ ],
+ "index": 0
+ }
+ ],
+ "model": "text-embedding-3-large-next",
+ "usage": {
+ "prompt_tokens": 8,
+ "total_tokens": 8
+ }
+ }
+ },
+ "expected": {
+ "spend": 8.16e-06,
+ "input_cost": 8.16e-06,
+ "output_cost": 0.0,
+ "prompt_tokens": 8,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "azure-text-embeddings-4-large-deployment",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "azure/text-embedding-4-large",
+ "endpoint": "/v1/embeddings",
+ "request": {
+ "model": "$MODEL",
+ "input": "azure embedding"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "object": "list",
+ "data": [
+ {
+ "object": "embedding",
+ "embedding": [
+ 0.1,
+ 0.2,
+ 0.3
+ ],
+ "index": 0
+ }
+ ],
+ "model": "azure/text-embedding-4-large",
+ "usage": {
+ "prompt_tokens": 8,
+ "total_tokens": 8
+ }
+ }
+ },
+ "expected": {
+ "spend": 8.24e-06,
+ "input_cost": 8.24e-06,
+ "output_cost": 0.0,
+ "prompt_tokens": 8,
+ "completion_tokens": 0
+ },
+ "deployment": {
+ "model": "azure/cc-pinned-embedding-deployment",
+ "base_model": "azure/text-embedding-4-large"
+ }
+ },
+ {
+ "name": "cohere-embeddings-v5",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "embed-v5",
+ "endpoint": "/v1/embeddings",
+ "request": {
+ "model": "$MODEL",
+ "input": "cohere embedding",
+ "input_type": "search_query"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "emb-1",
+ "embeddings": {
+ "float": [
+ [
+ 0.1,
+ 0.2,
+ 0.3
+ ]
+ ]
+ },
+ "meta": {
+ "billed_units": {
+ "input_tokens": 11
+ }
+ }
+ }
+ },
+ "expected": {
+ "spend": 1.144e-05,
+ "input_cost": 1.144e-05,
+ "output_cost": 0.0,
+ "prompt_tokens": 11,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "bedrock-embeddings-titan-v2",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "amazon.titan-embed-text-v2:0",
+ "endpoint": "/v1/embeddings",
+ "request": {
+ "model": "$MODEL",
+ "input": "titan embedding"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "embedding": [
+ 0.1,
+ 0.2,
+ 0.3
+ ],
+ "inputTextTokenCount": 10
+ }
+ },
+ "expected": {
+ "spend": 1.05e-05,
+ "input_cost": 1.05e-05,
+ "output_cost": 0.0,
+ "prompt_tokens": 10,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "bedrock-cohere-embeddings-v4",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "cohere.embed-english-v4",
+ "endpoint": "/v1/embeddings",
+ "request": {
+ "model": "$MODEL",
+ "input": "bedrock cohere embedding"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "embeddings": [
+ [
+ 0.1,
+ 0.2,
+ 0.3
+ ]
+ ],
+ "id": "emb-bedrock-cohere-1",
+ "response_type": "embeddings_floats",
+ "texts": [
+ "bedrock cohere embedding"
+ ]
+ }
+ },
+ "expected": {
+ "spend": 5.3e-06,
+ "input_cost": 5.3e-06,
+ "output_cost": 0.0,
+ "prompt_tokens": 5,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "vertex-embeddings-text-006",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "text-embedding-006",
+ "endpoint": "/v1/embeddings",
+ "request": {
+ "model": "$MODEL",
+ "input": "vertex embedding"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "predictions": [
+ {
+ "embeddings": {
+ "values": [
+ 0.1,
+ 0.2,
+ 0.3
+ ],
+ "statistics": {
+ "token_count": 7,
+ "truncated": false
+ }
+ }
+ }
+ ]
+ }
+ },
+ "expected": {
+ "spend": 7.4899999999999994e-06,
+ "input_cost": 7.4899999999999994e-06,
+ "output_cost": 0.0,
+ "prompt_tokens": 7,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "gemini-embeddings-002",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gemini/gemini-embedding-002",
+ "endpoint": "/v1/embeddings",
+ "request": {
+ "model": "$MODEL",
+ "input": "gemini embedding"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "embeddings": [
+ {
+ "values": [
+ 0.1,
+ 0.2,
+ 0.3
+ ]
+ }
+ ],
+ "usageMetadata": {
+ "promptTokenCount": 7,
+ "totalTokenCount": 7
+ }
+ }
+ },
+ "expected": {
+ "spend": 3.24e-06,
+ "input_cost": 3.24e-06,
+ "output_cost": 0.0,
+ "prompt_tokens": 3,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "together-embeddings-v1",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "together_ai/together-embed-v1",
+ "endpoint": "/v1/embeddings",
+ "request": {
+ "model": "$MODEL",
+ "input": "together embedding"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "object": "list",
+ "data": [
+ {
+ "object": "embedding",
+ "embedding": [
+ 0.1,
+ 0.2,
+ 0.3
+ ],
+ "index": 0
+ }
+ ],
+ "model": "together-embed-v1",
+ "usage": {
+ "prompt_tokens": 7,
+ "total_tokens": 7
+ }
+ }
+ },
+ "expected": {
+ "spend": 7.63e-06,
+ "input_cost": 7.63e-06,
+ "output_cost": 0.0,
+ "prompt_tokens": 7,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "fireworks-embeddings-v1",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "fireworks_ai/fireworks-embed-v1",
+ "endpoint": "/v1/embeddings",
+ "request": {
+ "model": "$MODEL",
+ "input": "fireworks embedding"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "object": "list",
+ "data": [
+ {
+ "object": "embedding",
+ "embedding": [
+ 0.1,
+ 0.2,
+ 0.3
+ ],
+ "index": 0
+ }
+ ],
+ "model": "fireworks-embed-v1",
+ "usage": {
+ "prompt_tokens": 7,
+ "total_tokens": 7
+ }
+ }
+ },
+ "expected": {
+ "spend": 7.7e-06,
+ "input_cost": 7.7e-06,
+ "output_cost": 0.0,
+ "prompt_tokens": 7,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "cohere-rerank-v4-one",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "rerank-v4",
+ "endpoint": "/v1/rerank",
+ "request": {
+ "model": "$MODEL",
+ "query": "rank this",
+ "documents": [
+ "a",
+ "b"
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "rr-$REQUEST_ID",
+ "results": [
+ {
+ "index": 0,
+ "relevance_score": 0.9
+ }
+ ],
+ "meta": {
+ "api_version": {
+ "version": "2"
+ },
+ "billed_units": {
+ "search_units": 1
+ }
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.0021,
+ "input_cost": 0.0021,
+ "output_cost": 0.0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "cohere-rerank-v4-three",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "rerank-v4",
+ "endpoint": "/v1/rerank",
+ "request": {
+ "model": "$MODEL",
+ "query": "rank this",
+ "documents": [
+ "a long document",
+ "another long document",
+ "third long document"
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "rr-three-$REQUEST_ID",
+ "results": [
+ {
+ "index": 0,
+ "relevance_score": 0.9
+ }
+ ],
+ "meta": {
+ "api_version": {
+ "version": "2"
+ },
+ "billed_units": {
+ "search_units": 3
+ }
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.0063,
+ "input_cost": 0.0063,
+ "output_cost": 0.0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "cohere-rerank-v4-total-tokens-fallback",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "rerank-v4",
+ "endpoint": "/v1/rerank",
+ "request": {
+ "model": "$MODEL",
+ "query": "rank this",
+ "documents": [
+ "fallback a",
+ "fallback b"
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "rr-fallback-$REQUEST_ID",
+ "results": [
+ {
+ "index": 0,
+ "relevance_score": 0.8
+ }
+ ],
+ "meta": {
+ "billed_units": {
+ "total_tokens": 99
+ }
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.0,
+ "input_cost": 0.0,
+ "output_cost": 0.0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "bedrock-cohere-rerank-v4",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "cohere.rerank-v4:0",
+ "endpoint": "/v1/rerank",
+ "request": {
+ "model": "$MODEL",
+ "query": "rank this",
+ "documents": [
+ "a",
+ "b"
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "results": [
+ {
+ "index": 0,
+ "relevanceScore": 0.9
+ }
+ ],
+ "response_id": "rr-3",
+ "token_count": 1
+ }
+ },
+ "expected": {
+ "spend": 0.0022,
+ "input_cost": 0.0022,
+ "output_cost": 0.0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "text-completions-openai-basic",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gpt-3.5-turbo-instruct-next",
+ "endpoint": "/v1/completions",
+ "request": {
+ "model": "$MODEL",
+ "prompt": "complete this"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "cmpl-basic-$REQUEST_ID",
+ "object": "text_completion",
+ "choices": [
+ {
+ "text": "done",
+ "index": 0,
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 9,
+ "completion_tokens": 4,
+ "total_tokens": 13
+ }
+ }
+ },
+ "expected": {
+ "spend": 1.882e-05,
+ "input_cost": 1.026e-05,
+ "output_cost": 8.56e-06,
+ "prompt_tokens": 9,
+ "completion_tokens": 4
+ }
+ },
+ {
+ "name": "text-completions-openai-stream-usage",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gpt-3.5-turbo-instruct-next",
+ "endpoint": "/v1/completions",
+ "request": {
+ "model": "$MODEL",
+ "prompt": "complete this",
+ "stream": true,
+ "stream_options": {
+ "include_usage": true
+ }
+ },
+ "response": {
+ "content_type": "text/event-stream",
+ "frames": [
+ "data: {\"id\": \"cmpl-$REQUEST_ID\", \"object\": \"text_completion\", \"created\": 1789789000, \"model\": \"gpt-3.5-turbo-instruct-next\", \"choices\": [{\"text\": \"done\", \"index\": 0, \"finish_reason\": null}], \"usage\": null}",
+ "data: {\"id\": \"cmpl-$REQUEST_ID\", \"object\": \"text_completion\", \"created\": 1789789000, \"model\": \"gpt-3.5-turbo-instruct-next\", \"choices\": [], \"usage\": {\"prompt_tokens\": 9, \"completion_tokens\": 4, \"total_tokens\": 13}}",
+ "data: [DONE]"
+ ]
+ },
+ "expected": {
+ "spend": 1.882e-05,
+ "input_cost": 1.026e-05,
+ "output_cost": 8.56e-06,
+ "prompt_tokens": 9,
+ "completion_tokens": 4
+ }
+ },
+ {
+ "name": "text-completions-openai-n-best",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gpt-3.5-turbo-instruct-next",
+ "endpoint": "/v1/completions",
+ "request": {
+ "model": "$MODEL",
+ "prompt": "complete this twice",
+ "n": 2
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "cmpl-n-best-$REQUEST_ID",
+ "object": "text_completion",
+ "choices": [
+ {
+ "text": "done",
+ "index": 0,
+ "finish_reason": "stop"
+ },
+ {
+ "text": "also done",
+ "index": 1,
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 9,
+ "completion_tokens": 8,
+ "total_tokens": 17
+ }
+ }
+ },
+ "expected": {
+ "spend": 2.738e-05,
+ "input_cost": 1.026e-05,
+ "output_cost": 1.712e-05,
+ "prompt_tokens": 9,
+ "completion_tokens": 8
+ }
+ },
+ {
+ "name": "together-completions-v1",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo",
+ "endpoint": "/v1/completions",
+ "request": {
+ "model": "$MODEL",
+ "prompt": "together complete"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "cmpl-together-$REQUEST_ID",
+ "object": "text_completion",
+ "choices": [
+ {
+ "text": "done",
+ "index": 0,
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 9,
+ "completion_tokens": 4,
+ "total_tokens": 13
+ }
+ }
+ },
+ "expected": {
+ "spend": 1.908e-05,
+ "input_cost": 1.0439999999999998e-05,
+ "output_cost": 8.64e-06,
+ "prompt_tokens": 9,
+ "completion_tokens": 4
+ }
+ },
+ {
+ "name": "omni-moderations-next-single",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "omni-moderation-next",
+ "endpoint": "/v1/moderations",
+ "request": {
+ "model": "$MODEL",
+ "input": "safe text"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "modr-single-$REQUEST_ID",
+ "model": "omni-moderation-next",
+ "results": [
+ {
+ "flagged": false,
+ "categories": {},
+ "category_scores": {}
+ }
+ ]
+ }
+ },
+ "expected": {
+ "spend": 0.0,
+ "input_cost": 0.0,
+ "output_cost": 0.0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "omni-moderations-next-list",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "omni-moderation-next",
+ "endpoint": "/v1/moderations",
+ "request": {
+ "model": "$MODEL",
+ "input": [
+ "safe text",
+ "more safe text"
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "modr-list-$REQUEST_ID",
+ "model": "omni-moderation-next",
+ "results": [
+ {
+ "flagged": false,
+ "categories": {},
+ "category_scores": {}
+ },
+ {
+ "flagged": false,
+ "categories": {},
+ "category_scores": {}
+ }
+ ]
+ }
+ },
+ "expected": {
+ "spend": 0.0,
+ "input_cost": 0.0,
+ "output_cost": 0.0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
}
]
}
diff --git a/tests/integration/cost_calculation/test_cost_tracking.py b/tests/integration/cost_calculation/test_cost_tracking.py
index 5b876974c63..6c1ac166196 100644
--- a/tests/integration/cost_calculation/test_cost_tracking.py
+++ b/tests/integration/cost_calculation/test_cost_tracking.py
@@ -92,7 +92,7 @@ def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase)
assert isinstance(expected, ExactExpected)
if case.response.content_type == "application/json":
header: Final = cast(str | None, response.headers.get("x-litellm-response-cost"))
- assert header is not None and approx_equal(float(header), expected.spend), (
+ assert expected.spend == 0 or (header is not None and approx_equal(float(header), expected.spend)), (
f"{case.name}: x-litellm-response-cost {header} != expected {expected.spend}"
)
assert row.spend is not None and approx_equal(row.spend, expected.spend), (
From 8137d878a00c640de1db098d7993be52e81bd252 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 20:59:05 +0000
Subject: [PATCH 073/317] test(integration): drop contract nodes left behind by
case renames
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/integration/contracts.json | 54 --------------------------------
1 file changed, 54 deletions(-)
diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json
index d86f44cf24a..f6353bfe0da 100644
--- a/tests/integration/contracts.json
+++ b/tests/integration/contracts.json
@@ -1321,60 +1321,6 @@
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[us.anthropic.claude-opus-5-v1:0-stream_full_usage]": [
"quota_management.spend_tracking.scripted_wire.logs_cost"
],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-embedding-4-small-single]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-embedding-4-small-batch]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-embedding-4-small-token-array]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-embedding-4-large-dimensions]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[azure-text-embedding-4-large-deployment]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[cohere-embed-v5]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[bedrock-titan-embed-v3]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[bedrock-cohere-embed-v4]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[vertex-text-embedding-006]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gemini-embedding-002]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[together-embedding-v1]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[fireworks-embedding-v1]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-completion-openai-basic]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-completion-openai-stream-usage]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[text-completion-openai-n-best]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[together-completions]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[omni-moderation-next-single]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[omni-moderation-next-list]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[azure-text-embeddings-4-large-deployment]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
From 23f5df05f2b55f88aa39b6e58c6d57b3b46a3d95 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 21:03:51 +0000
Subject: [PATCH 074/317] test(integration): require a zero cost header to read
zero when the case bills nothing
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/integration/cost_calculation/test_cost_tracking.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/tests/integration/cost_calculation/test_cost_tracking.py b/tests/integration/cost_calculation/test_cost_tracking.py
index 6c1ac166196..3822b5cb3c6 100644
--- a/tests/integration/cost_calculation/test_cost_tracking.py
+++ b/tests/integration/cost_calculation/test_cost_tracking.py
@@ -92,7 +92,11 @@ def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase)
assert isinstance(expected, ExactExpected)
if case.response.content_type == "application/json":
header: Final = cast(str | None, response.headers.get("x-litellm-response-cost"))
- assert expected.spend == 0 or (header is not None and approx_equal(float(header), expected.spend)), (
+ assert (
+ (header is None or approx_equal(float(header), 0.0))
+ if expected.spend == 0
+ else (header is not None and approx_equal(float(header), expected.spend))
+ ), (
f"{case.name}: x-litellm-response-cost {header} != expected {expected.spend}"
)
assert row.spend is not None and approx_equal(row.spend, expected.spend), (
From c0c5cc84f8cbebea97883c720d816f4d16d5b181 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 21:19:29 +0000
Subject: [PATCH 075/317] test(integration): audio, image and per-unit cost
cases
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/integration/_support/client.py | 15 +
tests/integration/_support/upstream.py | 6 +
tests/integration/contracts.json | 48 ++
.../integration/cost_calculation/conftest.py | 11 +-
.../cost_calculation/cost_tracking_case.py | 54 +-
.../cost_calculation/cost_tracking_cases.json | 589 ++++++++++++++++++
.../cost_calculation/test_cost_tracking.py | 186 ++++--
7 files changed, 836 insertions(+), 73 deletions(-)
diff --git a/tests/integration/_support/client.py b/tests/integration/_support/client.py
index 9f1118ab1e3..5d206c10f3d 100644
--- a/tests/integration/_support/client.py
+++ b/tests/integration/_support/client.py
@@ -67,6 +67,21 @@ class Gateway:
headers={"Authorization": f"Bearer {self.key if key is None else key}"},
)
+ def request_multipart(
+ self,
+ path: str,
+ fields: Mapping[str, str],
+ files: Mapping[str, tuple[str, bytes, str]],
+ *,
+ key: str | None = None,
+ ) -> httpx.Response:
+ return self.client.post(
+ path,
+ data=fields,
+ files=files,
+ headers={"Authorization": f"Bearer {self.key if key is None else key}"},
+ )
+
def post(self, path: str, body: Mapping[str, JsonValue], *, key: str | None = None) -> dict[str, JsonValue]:
response: Final = self.request("POST", path, body, key=key)
assert response.status_code == 200, f"POST {path}: {response.status_code} {response.text}"
diff --git a/tests/integration/_support/upstream.py b/tests/integration/_support/upstream.py
index a289589b2dc..22f6cdcde78 100644
--- a/tests/integration/_support/upstream.py
+++ b/tests/integration/_support/upstream.py
@@ -22,6 +22,7 @@ from starlette.routing import Route
from _fake_openai_endpoint_server import chat_completions, completions, embeddings, health, moderations
from integration.cost_calculation.cost_tracking_case import (
+ BinaryResponse,
EventStreamResponse,
JsonResponse,
SseResponse,
@@ -212,6 +213,11 @@ class Provider:
media_type=response.content_type,
status_code=response.status,
)
+ case BinaryResponse():
+ return Response(
+ content=b"\x00" * response.length,
+ media_type=response.content_type,
+ )
case SseResponse():
stream_body: Final = ("\n\n".join(response.frames) + "\n\n").replace(
"$REQUEST_ID", scenario_id
diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json
index 338ace07908..927fc24f03b 100644
--- a/tests/integration/contracts.json
+++ b/tests/integration/contracts.json
@@ -1320,6 +1320,54 @@
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[us.anthropic.claude-opus-5-v1:0-stream_full_usage]": [
"quota_management.spend_tracking.scripted_wire.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[whisper-next-transcriptions-per-second]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[whisper-verbose-next-transcriptions-duration]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-4o-transcribe-next-transcriptions-tokens]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[nova-next-transcriptions-per-second]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[azure-whisper-next-transcriptions-deployment]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[tts-next-speech-per-character]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[tts-next-hd-speech-per-character]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[azure-tts-next-speech-deployment]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[dall-e-3-next-images-standard]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[dall-e-3-next-images-hd]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[dall-e-3-next-images-wide]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[dall-e-3-next-images-two]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-image-next-images-low]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[imagen-next-images-one]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[amazon-nova-canvas-next-images-one]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gpt-image-next-images-edit]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
]
},
"browser": {
diff --git a/tests/integration/cost_calculation/conftest.py b/tests/integration/cost_calculation/conftest.py
index 166488e36a5..173d48b052d 100644
--- a/tests/integration/cost_calculation/conftest.py
+++ b/tests/integration/cost_calculation/conftest.py
@@ -45,9 +45,8 @@ class CostRow(BaseModel):
metadata: CostMetadata | None = None
@property
- def breakdown(self) -> CostBreakdown:
- assert self.metadata is not None and self.metadata.cost_breakdown is not None
- return self.metadata.cost_breakdown
+ def breakdown(self) -> CostBreakdown | None:
+ return self.metadata.cost_breakdown if self.metadata is not None else None
class FailureRow(BaseModel):
@@ -65,6 +64,8 @@ def approx_equal(actual: float, expected: float) -> bool:
def assert_total_is_sum_of_components(row: CostRow, context: str) -> None:
breakdown: Final = row.breakdown
+ if breakdown is None:
+ return
total: Final = sum(
cost or 0.0
for cost in (breakdown.input_cost, breakdown.output_cost, breakdown.tool_usage_cost)
@@ -83,7 +84,7 @@ def _row(value: Mapping[str, object]) -> CostRow | None:
metadata_value: Final = value.get("metadata")
metadata: Final = json.loads(metadata_value) if isinstance(metadata_value, str) else metadata_value
parsed: Final = CostRow.model_validate({**value, "metadata": metadata})
- return parsed if parsed.metadata and parsed.metadata.cost_breakdown else None
+ return parsed
def poll_cost_row(key: str) -> CostRow:
@@ -165,7 +166,7 @@ def register_scenario_deployment(
**case.litellm_params,
**(
{"vertex_credentials": _vertex_service_account_json(control_url)}
- if case.rates.litellm_provider == "vertex_ai-language-models"
+ if case.rates.litellm_provider.startswith("vertex_ai")
else {}
),
}
diff --git a/tests/integration/cost_calculation/cost_tracking_case.py b/tests/integration/cost_calculation/cost_tracking_case.py
index 9737508e27c..23180ad16ef 100644
--- a/tests/integration/cost_calculation/cost_tracking_case.py
+++ b/tests/integration/cost_calculation/cost_tracking_case.py
@@ -43,8 +43,15 @@ class CostMapEntry(BaseModel):
cache_creation_input_token_cost_above_200k_tokens: float | None = None
output_cost_per_reasoning_token: float | None = None
input_cost_per_audio_token: float | None = None
+ input_cost_per_second: float | None = None
+ output_cost_per_second: float | None = None
+ input_cost_per_character: float | None = None
+ output_cost_per_character: float | None = None
+ input_cost_per_image: float | None = None
+ output_cost_per_image: float | None = None
output_cost_per_audio_token: float | None = None
input_cost_per_image_token: float | None = None
+ output_cost_per_image_token: float | None = None
input_cost_per_video_token: float | None = None
input_cost_per_token_above_200k_tokens: float | None = None
output_cost_per_token_above_200k_tokens: float | None = None
@@ -66,6 +73,22 @@ class Deployment(BaseModel):
base_model: str | None = None
+class WavUpload(BaseModel):
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ kind: Literal["wav"]
+ seconds: float
+
+
+class PngUpload(BaseModel):
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ kind: Literal["png"]
+
+
+Upload: TypeAlias = Annotated[WavUpload | PngUpload, Field(discriminator="kind")]
+
+
class JsonResponse(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -95,8 +118,15 @@ class EventStreamResponse(BaseModel):
events: tuple[EventStreamEvent, ...]
+class BinaryResponse(BaseModel):
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ content_type: Literal["audio/mpeg"]
+ length: int
+
+
StoredResponse: TypeAlias = Annotated[
- JsonResponse | SseResponse | EventStreamResponse,
+ JsonResponse | SseResponse | EventStreamResponse | BinaryResponse,
Field(discriminator="content_type"),
]
@@ -157,8 +187,13 @@ class CostTrackingTestCase(BaseModel):
"/v1/rerank",
"/v1/completions",
"/v1/moderations",
+ "/v1/audio/transcriptions",
+ "/v1/audio/speech",
+ "/v1/images/generations",
+ "/v1/images/edits",
] = "/v1/chat/completions"
deployment: Deployment | None = None
+ upload: Upload | None = None
request: dict[str, JsonValue]
response: StoredResponse
expected: Expected
@@ -172,7 +207,8 @@ class CostTrackingTestCase(BaseModel):
provider: Final = self.rates.litellm_provider
prefix: Final = (
"openai"
- if provider == "openai" and self.rates.mode == "chat"
+ if provider == "openai"
+ and self.rates.mode in {"chat", "audio_transcription", "audio_speech", "image_generation"}
else "openai/responses"
if provider == "openai"
else _PROVIDER_PREFIXES.get(provider)
@@ -206,8 +242,11 @@ class _CasesFile(BaseModel):
_PROVIDER_PREFIXES: Final[Mapping[str, str]] = MappingProxyType(
{
"anthropic": "anthropic",
+ "bedrock": "bedrock",
"bedrock_converse": "bedrock/converse",
+ "deepgram": "deepgram",
"vertex_ai-language-models": "vertex_ai",
+ "vertex_ai-image-models": "vertex_ai",
"gemini": "",
"together_ai": "",
"fireworks_ai": "",
@@ -217,6 +256,13 @@ _PROVIDER_PREFIXES: Final[Mapping[str, str]] = MappingProxyType(
_LITELLM_PARAMS: Final[Mapping[str, Mapping[str, str]]] = MappingProxyType(
{
"anthropic": MappingProxyType({}),
+ "bedrock": MappingProxyType(
+ {
+ "aws_access_key_id": "AKIASCRIPTEDPROVIDER",
+ "aws_secret_access_key": "scripted-secret",
+ "aws_region_name": "us-east-1",
+ }
+ ),
"bedrock_converse": MappingProxyType(
{
"aws_access_key_id": "AKIASCRIPTEDPROVIDER",
@@ -224,9 +270,13 @@ _LITELLM_PARAMS: Final[Mapping[str, Mapping[str, str]]] = MappingProxyType(
"aws_region_name": "us-east-1",
}
),
+ "deepgram": MappingProxyType({}),
"vertex_ai-language-models": MappingProxyType(
{"vertex_project": "cc-scripted-project", "vertex_location": "us-central1"}
),
+ "vertex_ai-image-models": MappingProxyType(
+ {"vertex_project": "cc-scripted-project", "vertex_location": "us-central1"}
+ ),
"gemini": MappingProxyType({}),
"together_ai": MappingProxyType({}),
"fireworks_ai": MappingProxyType({}),
diff --git a/tests/integration/cost_calculation/cost_tracking_cases.json b/tests/integration/cost_calculation/cost_tracking_cases.json
index b0ad270f860..40f9c7a6762 100644
--- a/tests/integration/cost_calculation/cost_tracking_cases.json
+++ b/tests/integration/cost_calculation/cost_tracking_cases.json
@@ -408,6 +408,89 @@
"mode": "chat",
"output_cost_per_token": 3.6e-06,
"supports_function_calling": true
+ },
+ "whisper-next": {
+ "litellm_provider": "openai",
+ "mode": "audio_transcription",
+ "input_cost_per_second": 0.0001
+ },
+ "whisper-verbose-next": {
+ "litellm_provider": "openai",
+ "mode": "audio_transcription",
+ "input_cost_per_second": 0.0002
+ },
+ "gpt-4o-transcribe-next": {
+ "litellm_provider": "openai",
+ "mode": "audio_transcription",
+ "input_cost_per_token": 2.11e-06,
+ "output_cost_per_token": 3.11e-06,
+ "input_cost_per_audio_token": 1e-05
+ },
+ "nova-next": {
+ "litellm_provider": "deepgram",
+ "mode": "audio_transcription",
+ "input_cost_per_second": 0.0003
+ },
+ "azure/whisper-next": {
+ "litellm_provider": "azure",
+ "mode": "audio_transcription",
+ "input_cost_per_second": 0.00011
+ },
+ "tts-next": {
+ "litellm_provider": "openai",
+ "mode": "audio_speech",
+ "input_cost_per_character": 1e-05
+ },
+ "tts-next-hd": {
+ "litellm_provider": "openai",
+ "mode": "audio_speech",
+ "input_cost_per_character": 2e-05
+ },
+ "azure/tts-next": {
+ "litellm_provider": "azure",
+ "mode": "audio_speech",
+ "input_cost_per_character": 1.1e-05
+ },
+ "gpt-image-next": {
+ "litellm_provider": "openai",
+ "mode": "image_generation",
+ "input_cost_per_token": 1.71e-06,
+ "output_cost_per_token": 4.3e-06,
+ "input_cost_per_image_token": 2.2e-06,
+ "output_cost_per_image_token": 5.1e-06
+ },
+ "1024-x-1024/dall-e-3-next": {
+ "litellm_provider": "openai",
+ "mode": "image_generation",
+ "input_cost_per_image": 0.04
+ },
+ "hd/1024-x-1024/dall-e-3-next": {
+ "litellm_provider": "openai",
+ "mode": "image_generation",
+ "input_cost_per_image": 0.08
+ },
+ "1792-x-1024/dall-e-3-next": {
+ "litellm_provider": "openai",
+ "mode": "image_generation",
+ "input_cost_per_image": 0.06
+ },
+ "low/1024-x-1024/gpt-image-next": {
+ "litellm_provider": "openai",
+ "mode": "image_generation",
+ "input_cost_per_token": 1.7e-06,
+ "output_cost_per_token": 4.3e-06,
+ "input_cost_per_image_token": 2.2e-06,
+ "output_cost_per_image_token": 5.1e-06
+ },
+ "1024-x-1024/imagen-next": {
+ "litellm_provider": "vertex_ai-image-models",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05
+ },
+ "amazon.nova-canvas-next": {
+ "litellm_provider": "bedrock",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.045
}
},
"cases": [
@@ -25777,6 +25860,512 @@
"prompt_tokens": 11056,
"completion_tokens": 412
}
+ },
+ {
+ "name": "whisper-next-transcriptions-per-second",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "whisper-next",
+ "endpoint": "/v1/audio/transcriptions",
+ "upload": {
+ "kind": "wav",
+ "seconds": 3.5
+ },
+ "request": {
+ "language": "en",
+ "response_format": "json"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "text": "hello"
+ }
+ },
+ "expected": {
+ "spend": 0.00035,
+ "input_cost": 0.00035,
+ "output_cost": 0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "whisper-verbose-next-transcriptions-duration",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "whisper-verbose-next",
+ "endpoint": "/v1/audio/transcriptions",
+ "upload": {
+ "kind": "wav",
+ "seconds": 3.5
+ },
+ "request": {
+ "response_format": "verbose_json"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "text": "hello",
+ "duration": 12.25
+ }
+ },
+ "expected": {
+ "spend": 0.00245,
+ "input_cost": 0.00245,
+ "output_cost": 0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "gpt-4o-transcribe-next-transcriptions-tokens",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gpt-4o-transcribe-next",
+ "endpoint": "/v1/audio/transcriptions",
+ "upload": {
+ "kind": "wav",
+ "seconds": 1.0
+ },
+ "request": {
+ "response_format": "json"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "text": "hello",
+ "usage": {
+ "type": "tokens",
+ "input_tokens": 10,
+ "output_tokens": 2,
+ "total_tokens": 12,
+ "input_token_details": {
+ "text_tokens": 2,
+ "audio_tokens": 8
+ }
+ }
+ }
+ },
+ "expected": {
+ "spend": 9.044e-05,
+ "input_cost": 8.422e-05,
+ "output_cost": 6.22e-06,
+ "prompt_tokens": 10,
+ "completion_tokens": 2
+ }
+ },
+ {
+ "name": "nova-next-transcriptions-per-second",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "nova-next",
+ "endpoint": "/v1/audio/transcriptions",
+ "upload": {
+ "kind": "wav",
+ "seconds": 4.0
+ },
+ "request": {},
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "results": {
+ "channels": [
+ {
+ "alternatives": [
+ {
+ "transcript": "hello",
+ "confidence": 0.9
+ }
+ ]
+ }
+ ]
+ },
+ "metadata": {
+ "duration": 4.0,
+ "channels": 1
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.0012,
+ "input_cost": 0.0012,
+ "output_cost": 0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "azure-whisper-next-transcriptions-deployment",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "azure/whisper-next",
+ "endpoint": "/v1/audio/transcriptions",
+ "deployment": {
+ "model": "azure/cc-whisper-deployment",
+ "base_model": "azure/whisper-next"
+ },
+ "upload": {
+ "kind": "wav",
+ "seconds": 3.5
+ },
+ "request": {
+ "response_format": "json"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "text": "hello"
+ }
+ },
+ "expected": {
+ "spend": 0.000385,
+ "input_cost": 0.000385,
+ "output_cost": 0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "tts-next-speech-per-character",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "tts-next",
+ "endpoint": "/v1/audio/speech",
+ "request": {
+ "input": "hello world",
+ "voice": "alloy",
+ "response_format": "mp3"
+ },
+ "response": {
+ "content_type": "audio/mpeg",
+ "length": 2048
+ },
+ "expected": {
+ "spend": 0.0001,
+ "input_cost": 0.0001,
+ "output_cost": 0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "tts-next-hd-speech-per-character",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "tts-next-hd",
+ "endpoint": "/v1/audio/speech",
+ "request": {
+ "input": "hello world",
+ "voice": "alloy",
+ "response_format": "mp3"
+ },
+ "response": {
+ "content_type": "audio/mpeg",
+ "length": 2048
+ },
+ "expected": {
+ "spend": 0.0002,
+ "input_cost": 0.0002,
+ "output_cost": 0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "azure-tts-next-speech-deployment",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "azure/tts-next",
+ "endpoint": "/v1/audio/speech",
+ "deployment": {
+ "model": "azure/cc-tts-deployment",
+ "base_model": "azure/tts-next"
+ },
+ "request": {
+ "input": "hello world",
+ "voice": "alloy",
+ "response_format": "mp3"
+ },
+ "response": {
+ "content_type": "audio/mpeg",
+ "length": 2048
+ },
+ "expected": {
+ "spend": 0.00011,
+ "input_cost": 0.00011,
+ "output_cost": 0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "dall-e-3-next-images-standard",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "1024-x-1024/dall-e-3-next",
+ "endpoint": "/v1/images/generations",
+ "deployment": {
+ "model": "openai/dall-e-3-next"
+ },
+ "request": {
+ "prompt": "a deterministic square",
+ "size": "1024x1024",
+ "quality": "standard",
+ "n": 1
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "created": 1700000000,
+ "data": [
+ {
+ "url": "https://x/1.png"
+ }
+ ]
+ }
+ },
+ "expected": {
+ "spend": 0.04,
+ "input_cost": 0.04,
+ "output_cost": 0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "dall-e-3-next-images-hd",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "hd/1024-x-1024/dall-e-3-next",
+ "endpoint": "/v1/images/generations",
+ "deployment": {
+ "model": "openai/dall-e-3-next"
+ },
+ "request": {
+ "prompt": "a deterministic square",
+ "size": "1024x1024",
+ "quality": "hd",
+ "n": 1
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "created": 1700000001,
+ "data": [
+ {
+ "url": "https://x/1.png"
+ }
+ ]
+ }
+ },
+ "expected": {
+ "spend": 0.08,
+ "input_cost": 0.08,
+ "output_cost": 0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "dall-e-3-next-images-wide",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "1792-x-1024/dall-e-3-next",
+ "endpoint": "/v1/images/generations",
+ "deployment": {
+ "model": "openai/dall-e-3-next"
+ },
+ "request": {
+ "prompt": "a deterministic wide image",
+ "size": "1792x1024",
+ "quality": "standard",
+ "n": 1
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "created": 1700000002,
+ "data": [
+ {
+ "url": "https://x/1.png"
+ }
+ ]
+ }
+ },
+ "expected": {
+ "spend": 0.06,
+ "input_cost": 0.06,
+ "output_cost": 0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "dall-e-3-next-images-two",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "1024-x-1024/dall-e-3-next",
+ "endpoint": "/v1/images/generations",
+ "deployment": {
+ "model": "openai/dall-e-3-next"
+ },
+ "request": {
+ "prompt": "two deterministic squares",
+ "size": "1024x1024",
+ "quality": "standard",
+ "n": 2
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "created": 1700000003,
+ "data": [
+ {
+ "url": "https://x/1.png"
+ },
+ {
+ "url": "https://x/2.png"
+ }
+ ]
+ }
+ },
+ "expected": {
+ "spend": 0.08,
+ "input_cost": 0.08,
+ "output_cost": 0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "gpt-image-next-images-low",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gpt-image-next",
+ "endpoint": "/v1/images/generations",
+ "deployment": {
+ "model": "openai/gpt-image-next"
+ },
+ "request": {
+ "prompt": "a deterministic generated image",
+ "size": "1024x1024",
+ "quality": "low",
+ "n": 1
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "created": 1700000004,
+ "data": [
+ {
+ "b64_json": "AA=="
+ }
+ ],
+ "usage": {
+ "total_tokens": 30,
+ "input_tokens": 10,
+ "output_tokens": 20,
+ "input_tokens_details": {
+ "text_tokens": 10,
+ "image_tokens": 0
+ }
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.0001191,
+ "input_cost": 1.71e-05,
+ "output_cost": 0.000102,
+ "prompt_tokens": 10,
+ "completion_tokens": 20
+ }
+ },
+ {
+ "name": "imagen-next-images-one",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "1024-x-1024/imagen-next",
+ "endpoint": "/v1/images/generations",
+ "request": {
+ "prompt": "a deterministic vertex image",
+ "sampleCount": 1
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "predictions": [
+ {
+ "bytesBase64Encoded": "AA==",
+ "mimeType": "image/png"
+ }
+ ]
+ }
+ },
+ "expected": {
+ "spend": 0.05,
+ "input_cost": 0.05,
+ "output_cost": 0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "amazon-nova-canvas-next-images-one",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "amazon.nova-canvas-next",
+ "endpoint": "/v1/images/generations",
+ "deployment": {
+ "model": "amazon.nova-canvas-next"
+ },
+ "request": {
+ "prompt": "a deterministic bedrock image"
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "images": [
+ "AA=="
+ ]
+ }
+ },
+ "expected": {
+ "spend": 0.045,
+ "input_cost": 0.045,
+ "output_cost": 0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0
+ }
+ },
+ {
+ "name": "gpt-image-next-images-edit",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "low/1024-x-1024/gpt-image-next",
+ "endpoint": "/v1/images/edits",
+ "deployment": {
+ "model": "openai/gpt-image-next"
+ },
+ "upload": {
+ "kind": "png"
+ },
+ "request": {
+ "prompt": "edit this deterministic image",
+ "size": "1024x1024",
+ "quality": "low",
+ "n": 1
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "created": 1700000005,
+ "data": [
+ {
+ "b64_json": "AA=="
+ }
+ ],
+ "usage": {
+ "total_tokens": 30,
+ "input_tokens": 10,
+ "output_tokens": 20,
+ "input_tokens_details": {
+ "text_tokens": 10,
+ "image_tokens": 0
+ }
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.000119,
+ "input_cost": 1.7e-05,
+ "output_cost": 0.000102,
+ "prompt_tokens": 10,
+ "completion_tokens": 20
+ }
}
]
}
diff --git a/tests/integration/cost_calculation/test_cost_tracking.py b/tests/integration/cost_calculation/test_cost_tracking.py
index 5b876974c63..944d903625b 100644
--- a/tests/integration/cost_calculation/test_cost_tracking.py
+++ b/tests/integration/cost_calculation/test_cost_tracking.py
@@ -2,9 +2,15 @@
from __future__ import annotations
+import io
+import json
from hashlib import sha256
+import struct
from typing import Final, cast
+import wave
+import zlib
+import httpx
import pytest
from integration._support.client import JSON_OBJECT, Gateway
@@ -16,6 +22,7 @@ from integration.cost_calculation.conftest import (
register_scenario_deployment,
)
from integration.cost_calculation.cost_tracking_case import (
+ BinaryResponse,
CASES,
CostTrackingTestCase,
ExactExpected,
@@ -34,6 +41,47 @@ _CASES: Final = tuple(
)
+def _wav_bytes(seconds: float) -> bytes:
+ frame_count: Final = round(16000 * seconds)
+ output: Final = io.BytesIO()
+ with wave.open(output, "wb") as wav:
+ wav.setnchannels(1)
+ wav.setsampwidth(2)
+ wav.setframerate(16000)
+ wav.writeframes(b"\x00\x00" * frame_count)
+ return output.getvalue()
+
+
+def _png_bytes() -> bytes:
+ def chunk(kind: bytes, payload: bytes) -> bytes:
+ return (
+ struct.pack(">I", len(payload))
+ + kind
+ + payload
+ + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF)
+ )
+
+ return (
+ b"\x89PNG\r\n\x1a\n"
+ + chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 6, 0, 0, 0))
+ + chunk(b"IDAT", zlib.compress(b"\x00\x00\x00\x00\x00"))
+ + chunk(b"IEND", b"")
+ )
+
+
+def _multipart_request(gateway: Gateway, case: CostTrackingTestCase, model_name: str, key: str) -> httpx.Response:
+ assert case.upload is not None
+ fields: Final = {
+ field: value if isinstance(value, str) else json.dumps(value, separators=(",", ":"))
+ for field, value in {**case.request, "model": model_name}.items()
+ }
+ if case.upload.kind == "wav":
+ files: Final = {"file": ("audio.wav", _wav_bytes(case.upload.seconds), "audio/wav")}
+ else:
+ files = {"image": ("image.png", _png_bytes(), "image/png")}
+ return gateway.request_multipart(case.endpoint, fields, files, key=key)
+
+
def _assert_stream_has_no_error(response_text: str) -> None:
for line in response_text.splitlines():
if not line.startswith("data:"):
@@ -51,11 +99,10 @@ def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase)
with gateway.scenario() as scenario:
key: Final = scenario.key()
model_name: Final = register_scenario_deployment(scenario, case, marker, key)
- response: Final = gateway.request(
- "POST",
- case.endpoint,
- {**case.request, "model": model_name},
- key=key,
+ response: Final = (
+ _multipart_request(gateway, case, model_name, key)
+ if case.upload is not None
+ else gateway.request("POST", case.endpoint, {**case.request, "model": model_name}, key=key)
)
if isinstance(case.expected, FailureExpected):
assert response.status_code == case.expected.failure.status, (
@@ -90,7 +137,13 @@ def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase)
return
expected: Final = case.expected
assert isinstance(expected, ExactExpected)
- if case.response.content_type == "application/json":
+ if isinstance(case.response, BinaryResponse):
+ header: Final = response.headers.get("x-litellm-response-cost")
+ if header is not None:
+ assert approx_equal(float(header), expected.spend), (
+ f"{case.name}: x-litellm-response-cost {header} != expected {expected.spend}"
+ )
+ elif case.response.content_type == "application/json":
header: Final = cast(str | None, response.headers.get("x-litellm-response-cost"))
assert header is not None and approx_equal(float(header), expected.spend), (
f"{case.name}: x-litellm-response-cost {header} != expected {expected.spend}"
@@ -100,68 +153,69 @@ def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase)
f"(breakdown {row.breakdown.model_dump()})"
)
breakdown: Final = row.breakdown
- assert breakdown.input_cost is not None and approx_equal(breakdown.input_cost, expected.input_cost), (
- f"{case.name}: input_cost {breakdown.input_cost} != expected {expected.input_cost}"
- )
- assert breakdown.output_cost is not None and approx_equal(breakdown.output_cost, expected.output_cost), (
- f"{case.name}: output_cost {breakdown.output_cost} != expected {expected.output_cost}"
- )
- for field, header_name, actual_component, expected_component in (
- (
- "cache_read_cost",
- "x-litellm-response-cost-cache-read",
- breakdown.cache_read_cost,
- expected.cache_read_cost,
- ),
- (
- "cache_creation_cost",
- "x-litellm-response-cost-cache-creation",
- breakdown.cache_creation_cost,
- expected.cache_creation_cost,
- ),
- (
- "reasoning_cost",
- "x-litellm-response-cost-reasoning",
- breakdown.reasoning_cost,
- expected.reasoning_cost,
- ),
- (
- "tool_usage_cost",
- "x-litellm-response-cost-tool-usage",
- breakdown.tool_usage_cost,
- expected.tool_usage_cost,
- ),
- ):
- if expected_component is None:
- continue
- assert actual_component is not None and approx_equal(actual_component, expected_component), (
- f"{case.name}: {field} {actual_component} != expected {expected_component}"
+ if breakdown is not None:
+ assert breakdown.input_cost is not None and approx_equal(breakdown.input_cost, expected.input_cost), (
+ f"{case.name}: input_cost {breakdown.input_cost} != expected {expected.input_cost}"
)
- if case.response.content_type == "application/json":
- header: Final = response.headers.get(header_name)
- assert header is not None and approx_equal(float(header), expected_component), (
- f"{case.name}: {header_name} {header} != expected {expected_component}"
+ assert breakdown.output_cost is not None and approx_equal(breakdown.output_cost, expected.output_cost), (
+ f"{case.name}: output_cost {breakdown.output_cost} != expected {expected.output_cost}"
+ )
+ for field, header_name, actual_component, expected_component in (
+ (
+ "cache_read_cost",
+ "x-litellm-response-cost-cache-read",
+ breakdown.cache_read_cost,
+ expected.cache_read_cost,
+ ),
+ (
+ "cache_creation_cost",
+ "x-litellm-response-cost-cache-creation",
+ breakdown.cache_creation_cost,
+ expected.cache_creation_cost,
+ ),
+ (
+ "reasoning_cost",
+ "x-litellm-response-cost-reasoning",
+ breakdown.reasoning_cost,
+ expected.reasoning_cost,
+ ),
+ (
+ "tool_usage_cost",
+ "x-litellm-response-cost-tool-usage",
+ breakdown.tool_usage_cost,
+ expected.tool_usage_cost,
+ ),
+ ):
+ if expected_component is None:
+ continue
+ assert actual_component is not None and approx_equal(actual_component, expected_component), (
+ f"{case.name}: {field} {actual_component} != expected {expected_component}"
+ )
+ if case.response.content_type == "application/json":
+ header: Final = response.headers.get(header_name)
+ assert header is not None and approx_equal(float(header), expected_component), (
+ f"{case.name}: {header_name} {header} != expected {expected_component}"
+ )
+ if case.response.content_type == "application/json" and any(
+ component is not None
+ for component in (
+ expected.cache_read_cost,
+ expected.cache_creation_cost,
+ expected.reasoning_cost,
+ expected.tool_usage_cost,
+ )
+ ):
+ input_header: Final = response.headers.get("x-litellm-response-cost-input")
+ output_header: Final = response.headers.get("x-litellm-response-cost-output")
+ expected_input_header: Final = expected.input_cost - (
+ expected.cache_read_cost or 0.0
+ ) - (expected.cache_creation_cost or 0.0)
+ assert input_header is not None and approx_equal(float(input_header), expected_input_header), (
+ f"{case.name}: x-litellm-response-cost-input {input_header} != expected {expected_input_header}"
+ )
+ assert output_header is not None and approx_equal(float(output_header), expected.output_cost), (
+ f"{case.name}: x-litellm-response-cost-output {output_header} != expected {expected.output_cost}"
)
- if case.response.content_type == "application/json" and any(
- component is not None
- for component in (
- expected.cache_read_cost,
- expected.cache_creation_cost,
- expected.reasoning_cost,
- expected.tool_usage_cost,
- )
- ):
- input_header: Final = response.headers.get("x-litellm-response-cost-input")
- output_header: Final = response.headers.get("x-litellm-response-cost-output")
- expected_input_header: Final = expected.input_cost - (
- expected.cache_read_cost or 0.0
- ) - (expected.cache_creation_cost or 0.0)
- assert input_header is not None and approx_equal(float(input_header), expected_input_header), (
- f"{case.name}: x-litellm-response-cost-input {input_header} != expected {expected_input_header}"
- )
- assert output_header is not None and approx_equal(float(output_header), expected.output_cost), (
- f"{case.name}: x-litellm-response-cost-output {output_header} != expected {expected.output_cost}"
- )
assert row.prompt_tokens == expected.prompt_tokens, (
f"{case.name}: prompt_tokens {row.prompt_tokens} != expected {expected.prompt_tokens}"
)
From 5b97d98b7b43136e8192786e5a3fd910ed3229be Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 21:24:56 +0000
Subject: [PATCH 076/317] test(integration): require persisted cost breakdowns
unless a case opts out
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../integration/cost_calculation/conftest.py | 11 +-
.../cost_calculation/cost_tracking_case.py | 1 +
.../cost_calculation/cost_tracking_cases.json | 24 ++-
.../cost_calculation/test_cost_tracking.py | 145 ++++++++++--------
4 files changed, 102 insertions(+), 79 deletions(-)
diff --git a/tests/integration/cost_calculation/conftest.py b/tests/integration/cost_calculation/conftest.py
index 173d48b052d..4d4c7b6356a 100644
--- a/tests/integration/cost_calculation/conftest.py
+++ b/tests/integration/cost_calculation/conftest.py
@@ -40,6 +40,7 @@ class CostRow(BaseModel):
model_config = ConfigDict(extra="ignore")
spend: float | None = None
+ status: str | None = None
prompt_tokens: int | None = None
completion_tokens: int | None = None
metadata: CostMetadata | None = None
@@ -62,10 +63,7 @@ def approx_equal(actual: float, expected: float) -> bool:
return abs(actual - expected) <= max(1e-9, abs(expected) * 1e-2)
-def assert_total_is_sum_of_components(row: CostRow, context: str) -> None:
- breakdown: Final = row.breakdown
- if breakdown is None:
- return
+def assert_total_is_sum_of_components(row: CostRow, breakdown: CostBreakdown, context: str) -> None:
total: Final = sum(
cost or 0.0
for cost in (breakdown.input_cost, breakdown.output_cost, breakdown.tool_usage_cost)
@@ -84,7 +82,7 @@ def _row(value: Mapping[str, object]) -> CostRow | None:
metadata_value: Final = value.get("metadata")
metadata: Final = json.loads(metadata_value) if isinstance(metadata_value, str) else metadata_value
parsed: Final = CostRow.model_validate({**value, "metadata": metadata})
- return parsed
+ return parsed if parsed.metadata is not None or (parsed.spend is not None and parsed.status is not None) else None
def poll_cost_row(key: str) -> CostRow:
@@ -92,7 +90,8 @@ def poll_cost_row(key: str) -> CostRow:
def read() -> CostRow | None:
rows: Final = read_rows(
- 'SELECT spend, metadata, prompt_tokens, completion_tokens FROM "LiteLLM_SpendLogs" WHERE api_key=%s',
+ 'SELECT spend, status, metadata, prompt_tokens, completion_tokens '
+ 'FROM "LiteLLM_SpendLogs" WHERE api_key=%s',
(digest,),
)
return next((parsed for row in rows if (parsed := _row(row)) is not None), None)
diff --git a/tests/integration/cost_calculation/cost_tracking_case.py b/tests/integration/cost_calculation/cost_tracking_case.py
index 23180ad16ef..376477f44f1 100644
--- a/tests/integration/cost_calculation/cost_tracking_case.py
+++ b/tests/integration/cost_calculation/cost_tracking_case.py
@@ -143,6 +143,7 @@ class ExactExpected(BaseModel):
cache_creation_cost: float | None = None
reasoning_cost: float | None = None
tool_usage_cost: float | None = None
+ breakdown_persisted: bool = True
class RecountRates(BaseModel):
diff --git a/tests/integration/cost_calculation/cost_tracking_cases.json b/tests/integration/cost_calculation/cost_tracking_cases.json
index 40f9c7a6762..b7da4642cc8 100644
--- a/tests/integration/cost_calculation/cost_tracking_cases.json
+++ b/tests/integration/cost_calculation/cost_tracking_cases.json
@@ -26120,7 +26120,8 @@
"input_cost": 0.04,
"output_cost": 0,
"prompt_tokens": 0,
- "completion_tokens": 0
+ "completion_tokens": 0,
+ "breakdown_persisted": false
}
},
{
@@ -26153,7 +26154,8 @@
"input_cost": 0.08,
"output_cost": 0,
"prompt_tokens": 0,
- "completion_tokens": 0
+ "completion_tokens": 0,
+ "breakdown_persisted": false
}
},
{
@@ -26186,7 +26188,8 @@
"input_cost": 0.06,
"output_cost": 0,
"prompt_tokens": 0,
- "completion_tokens": 0
+ "completion_tokens": 0,
+ "breakdown_persisted": false
}
},
{
@@ -26222,7 +26225,8 @@
"input_cost": 0.08,
"output_cost": 0,
"prompt_tokens": 0,
- "completion_tokens": 0
+ "completion_tokens": 0,
+ "breakdown_persisted": false
}
},
{
@@ -26264,7 +26268,8 @@
"input_cost": 1.71e-05,
"output_cost": 0.000102,
"prompt_tokens": 10,
- "completion_tokens": 20
+ "completion_tokens": 20,
+ "breakdown_persisted": false
}
},
{
@@ -26292,7 +26297,8 @@
"input_cost": 0.05,
"output_cost": 0,
"prompt_tokens": 0,
- "completion_tokens": 0
+ "completion_tokens": 0,
+ "breakdown_persisted": false
}
},
{
@@ -26319,7 +26325,8 @@
"input_cost": 0.045,
"output_cost": 0,
"prompt_tokens": 0,
- "completion_tokens": 0
+ "completion_tokens": 0,
+ "breakdown_persisted": false
}
},
{
@@ -26364,7 +26371,8 @@
"input_cost": 1.7e-05,
"output_cost": 0.000102,
"prompt_tokens": 10,
- "completion_tokens": 20
+ "completion_tokens": 20,
+ "breakdown_persisted": false
}
}
]
diff --git a/tests/integration/cost_calculation/test_cost_tracking.py b/tests/integration/cost_calculation/test_cost_tracking.py
index 944d903625b..3e314c66b74 100644
--- a/tests/integration/cost_calculation/test_cost_tracking.py
+++ b/tests/integration/cost_calculation/test_cost_tracking.py
@@ -15,6 +15,7 @@ import pytest
from integration._support.client import JSON_OBJECT, Gateway
from integration.cost_calculation.conftest import (
+ CostBreakdown,
approx_equal,
assert_total_is_sum_of_components,
poll_cost_row,
@@ -93,6 +94,76 @@ def _assert_stream_has_no_error(response_text: str) -> None:
assert "error" not in parsed, f"stream carried an error event: {parsed}"
+def _assert_breakdown(
+ case: CostTrackingTestCase,
+ expected: ExactExpected,
+ breakdown: CostBreakdown,
+ response: httpx.Response,
+) -> None:
+ assert breakdown.input_cost is not None and approx_equal(breakdown.input_cost, expected.input_cost), (
+ f"{case.name}: input_cost {breakdown.input_cost} != expected {expected.input_cost}"
+ )
+ assert breakdown.output_cost is not None and approx_equal(breakdown.output_cost, expected.output_cost), (
+ f"{case.name}: output_cost {breakdown.output_cost} != expected {expected.output_cost}"
+ )
+ for field, header_name, actual_component, expected_component in (
+ (
+ "cache_read_cost",
+ "x-litellm-response-cost-cache-read",
+ breakdown.cache_read_cost,
+ expected.cache_read_cost,
+ ),
+ (
+ "cache_creation_cost",
+ "x-litellm-response-cost-cache-creation",
+ breakdown.cache_creation_cost,
+ expected.cache_creation_cost,
+ ),
+ (
+ "reasoning_cost",
+ "x-litellm-response-cost-reasoning",
+ breakdown.reasoning_cost,
+ expected.reasoning_cost,
+ ),
+ (
+ "tool_usage_cost",
+ "x-litellm-response-cost-tool-usage",
+ breakdown.tool_usage_cost,
+ expected.tool_usage_cost,
+ ),
+ ):
+ if expected_component is None:
+ continue
+ assert actual_component is not None and approx_equal(actual_component, expected_component), (
+ f"{case.name}: {field} {actual_component} != expected {expected_component}"
+ )
+ if case.response.content_type == "application/json":
+ header: Final = response.headers.get(header_name)
+ assert header is not None and approx_equal(float(header), expected_component), (
+ f"{case.name}: {header_name} {header} != expected {expected_component}"
+ )
+ if case.response.content_type == "application/json" and any(
+ component is not None
+ for component in (
+ expected.cache_read_cost,
+ expected.cache_creation_cost,
+ expected.reasoning_cost,
+ expected.tool_usage_cost,
+ )
+ ):
+ input_header: Final = response.headers.get("x-litellm-response-cost-input")
+ output_header: Final = response.headers.get("x-litellm-response-cost-output")
+ expected_input_header: Final = expected.input_cost - (
+ expected.cache_read_cost or 0.0
+ ) - (expected.cache_creation_cost or 0.0)
+ assert input_header is not None and approx_equal(float(input_header), expected_input_header), (
+ f"{case.name}: x-litellm-response-cost-input {input_header} != expected {expected_input_header}"
+ )
+ assert output_header is not None and approx_equal(float(output_header), expected.output_cost), (
+ f"{case.name}: x-litellm-response-cost-output {output_header} != expected {expected.output_cost}"
+ )
+
+
@pytest.mark.parametrize("case", _CASES)
def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase) -> None:
marker: Final = sha256(case.name.encode()).hexdigest()[:12]
@@ -133,7 +204,9 @@ def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase)
assert row.spend is not None and approx_equal(row.spend, recount), (
f"{case.name}: spend {row.spend} != recount {recount} at map rates"
)
- assert_total_is_sum_of_components(row, case.name)
+ breakdown: Final = row.breakdown
+ assert breakdown is not None, f"{case.name}: no cost_breakdown persisted"
+ assert_total_is_sum_of_components(row, breakdown, case.name)
return
expected: Final = case.expected
assert isinstance(expected, ExactExpected)
@@ -150,76 +223,18 @@ def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase)
)
assert row.spend is not None and approx_equal(row.spend, expected.spend), (
f"{case.name}: spend {row.spend} != expected {expected.spend} "
- f"(breakdown {row.breakdown.model_dump()})"
+ f"(breakdown {row.breakdown.model_dump() if row.breakdown is not None else None})"
)
breakdown: Final = row.breakdown
+ if expected.breakdown_persisted:
+ assert breakdown is not None, f"{case.name}: no cost_breakdown persisted"
if breakdown is not None:
- assert breakdown.input_cost is not None and approx_equal(breakdown.input_cost, expected.input_cost), (
- f"{case.name}: input_cost {breakdown.input_cost} != expected {expected.input_cost}"
- )
- assert breakdown.output_cost is not None and approx_equal(breakdown.output_cost, expected.output_cost), (
- f"{case.name}: output_cost {breakdown.output_cost} != expected {expected.output_cost}"
- )
- for field, header_name, actual_component, expected_component in (
- (
- "cache_read_cost",
- "x-litellm-response-cost-cache-read",
- breakdown.cache_read_cost,
- expected.cache_read_cost,
- ),
- (
- "cache_creation_cost",
- "x-litellm-response-cost-cache-creation",
- breakdown.cache_creation_cost,
- expected.cache_creation_cost,
- ),
- (
- "reasoning_cost",
- "x-litellm-response-cost-reasoning",
- breakdown.reasoning_cost,
- expected.reasoning_cost,
- ),
- (
- "tool_usage_cost",
- "x-litellm-response-cost-tool-usage",
- breakdown.tool_usage_cost,
- expected.tool_usage_cost,
- ),
- ):
- if expected_component is None:
- continue
- assert actual_component is not None and approx_equal(actual_component, expected_component), (
- f"{case.name}: {field} {actual_component} != expected {expected_component}"
- )
- if case.response.content_type == "application/json":
- header: Final = response.headers.get(header_name)
- assert header is not None and approx_equal(float(header), expected_component), (
- f"{case.name}: {header_name} {header} != expected {expected_component}"
- )
- if case.response.content_type == "application/json" and any(
- component is not None
- for component in (
- expected.cache_read_cost,
- expected.cache_creation_cost,
- expected.reasoning_cost,
- expected.tool_usage_cost,
- )
- ):
- input_header: Final = response.headers.get("x-litellm-response-cost-input")
- output_header: Final = response.headers.get("x-litellm-response-cost-output")
- expected_input_header: Final = expected.input_cost - (
- expected.cache_read_cost or 0.0
- ) - (expected.cache_creation_cost or 0.0)
- assert input_header is not None and approx_equal(float(input_header), expected_input_header), (
- f"{case.name}: x-litellm-response-cost-input {input_header} != expected {expected_input_header}"
- )
- assert output_header is not None and approx_equal(float(output_header), expected.output_cost), (
- f"{case.name}: x-litellm-response-cost-output {output_header} != expected {expected.output_cost}"
- )
+ _assert_breakdown(case, expected, breakdown, response)
assert row.prompt_tokens == expected.prompt_tokens, (
f"{case.name}: prompt_tokens {row.prompt_tokens} != expected {expected.prompt_tokens}"
)
assert row.completion_tokens == expected.completion_tokens, (
f"{case.name}: completion_tokens {row.completion_tokens} != expected {expected.completion_tokens}"
)
- assert_total_is_sum_of_components(row, case.name)
+ if breakdown is not None:
+ assert_total_is_sum_of_components(row, breakdown, case.name)
From 110d4c2ad1e6c6c1a0a8cf52880fc2c930c45357 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 22:05:57 +0000
Subject: [PATCH 077/317] test(integration): add passthrough cost cases
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.circleci/scripts/run_integration.sh | 4 +
tests/integration/_support/client.py | 7 +-
tests/integration/_support/upstream.py | 8 +-
tests/integration/contracts.json | 18 +
.../cost_calculation/cost_tracking_case.py | 55 ++-
.../cost_calculation/cost_tracking_cases.json | 373 ++++++++++++++++++
.../cost_calculation/test_cost_tracking.py | 71 +++-
7 files changed, 512 insertions(+), 24 deletions(-)
diff --git a/.circleci/scripts/run_integration.sh b/.circleci/scripts/run_integration.sh
index 0d6cdcabd57..08b0281b30f 100644
--- a/.circleci/scripts/run_integration.sh
+++ b/.circleci/scripts/run_integration.sh
@@ -121,6 +121,10 @@ start_proxy() {
"LITELLM_MODEL_COST_MAP_URL=$INTEGRATION_UPSTREAM_URL/_cost_map"
"MODEL_COST_MAP_MIN_MODEL_COUNT=1"
"MODEL_COST_MAP_MAX_SHRINK_RATIO=0"
+ "GEMINI_API_BASE=$INTEGRATION_UPSTREAM_URL"
+ "ANTHROPIC_API_BASE=$INTEGRATION_UPSTREAM_URL"
+ "GEMINI_API_KEY=sk-scripted-provider"
+ "ANTHROPIC_API_KEY=sk-scripted-provider"
)
else
cost_map_env=("LITELLM_LOCAL_MODEL_COST_MAP=True")
diff --git a/tests/integration/_support/client.py b/tests/integration/_support/client.py
index 5d206c10f3d..0b6771623c0 100644
--- a/tests/integration/_support/client.py
+++ b/tests/integration/_support/client.py
@@ -58,13 +58,18 @@ class Gateway:
*,
key: str | None = None,
params: Mapping[str, str] | None = None,
+ headers: Mapping[str, str] | None = None,
) -> httpx.Response:
+ request_headers: Final = {
+ "Authorization": f"Bearer {self.key if key is None else key}",
+ **(headers or {}),
+ }
return self.client.request(
method,
path,
json=body,
params=params,
- headers={"Authorization": f"Bearer {self.key if key is None else key}"},
+ headers=request_headers,
)
def request_multipart(
diff --git a/tests/integration/_support/upstream.py b/tests/integration/_support/upstream.py
index 22f6cdcde78..90325b770ab 100644
--- a/tests/integration/_support/upstream.py
+++ b/tests/integration/_support/upstream.py
@@ -194,9 +194,11 @@ class Provider:
async def scripted(self, request: Request) -> Response:
segments: Final = tuple(segment for segment in cast(str, request.path_params["path"]).split("/") if segment)
- if not segments:
- return JSONResponse({"error": "Unknown scenario"}, status_code=404)
- scenario_id: Final = segments[0].split(":", 1)[0]
+ scenario_id: Final = (
+ segments[0].split(":", 1)[0]
+ if segments and self.scenario_store.get(segments[0].split(":", 1)[0]) is not None
+ else request.headers.get("x-scripted-scenario", "")
+ )
response: Final = self.scenario_store.get(scenario_id)
if response is None:
return JSONResponse({"error": "Unknown scenario"}, status_code=404)
diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json
index bb6f7399293..e86536e13b5 100644
--- a/tests/integration/contracts.json
+++ b/tests/integration/contracts.json
@@ -1431,6 +1431,24 @@
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[anthropic.claude-sonnet-5-v1:0-messages_cache_read]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gemini-3.1-pro-passthrough-generate_content_priced_via_gemini_key]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gemini-3.1-pro-passthrough-stream_generate_content_priced_via_vertex_key]": [
+ "quota_management.spend_tracking.scripted_wire.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[claude-sonnet-5-passthrough-messages]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[claude-sonnet-5-passthrough-messages_cache_read]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[anthropic.claude-sonnet-5-v1:0-passthrough-converse]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[anthropic.claude-sonnet-5-v1:0-passthrough-converse_stream]": [
+ "quota_management.spend_tracking.scripted_wire.logs_cost"
]
},
"browser": {
diff --git a/tests/integration/cost_calculation/cost_tracking_case.py b/tests/integration/cost_calculation/cost_tracking_case.py
index 1869a394016..ba64cab81ed 100644
--- a/tests/integration/cost_calculation/cost_tracking_case.py
+++ b/tests/integration/cost_calculation/cost_tracking_case.py
@@ -144,6 +144,7 @@ class ExactExpected(BaseModel):
reasoning_cost: float | None = None
tool_usage_cost: float | None = None
breakdown_persisted: bool = True
+ cost_header: bool = True
class RecountRates(BaseModel):
@@ -180,19 +181,22 @@ class CostTrackingTestCase(BaseModel):
name: str
covers: str
model: str
- endpoint: Literal[
- "/v1/chat/completions",
- "/v1/responses",
- "/v1/messages",
- "/v1/embeddings",
- "/v1/rerank",
- "/v1/completions",
- "/v1/moderations",
- "/v1/audio/transcriptions",
- "/v1/audio/speech",
- "/v1/images/generations",
- "/v1/images/edits",
- ] = "/v1/chat/completions"
+ endpoint: (
+ Literal[
+ "/v1/chat/completions",
+ "/v1/responses",
+ "/v1/messages",
+ "/v1/embeddings",
+ "/v1/rerank",
+ "/v1/completions",
+ "/v1/moderations",
+ "/v1/audio/transcriptions",
+ "/v1/audio/speech",
+ "/v1/images/generations",
+ "/v1/images/edits",
+ ]
+ | Annotated[str, Field(pattern=r"^/(gemini|anthropic|bedrock)/")]
+ ) = "/v1/chat/completions"
deployment: Deployment | None = None
upload: Upload | None = None
request: dict[str, JsonValue]
@@ -235,6 +239,17 @@ class CostTrackingTestCase(BaseModel):
def base_model(self) -> str | None:
return self.deployment.base_model if self.deployment else None
+ @property
+ def passthrough_provider(self) -> Literal["gemini", "anthropic", "bedrock"] | None:
+ provider: Final = self.endpoint.removeprefix("/").split("/", 1)[0]
+ if provider == "gemini":
+ return "gemini"
+ if provider == "anthropic":
+ return "anthropic"
+ if provider == "bedrock":
+ return "bedrock"
+ return None
+
class _CasesFile(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -362,6 +377,19 @@ def data_errors() -> tuple[str, ...]:
and case.response.status != 200
)
)
+ invalid_opt_outs: Final = sorted(
+ case.name
+ for case in CASES
+ if isinstance(case.expected, ExactExpected)
+ and (
+ (
+ not case.expected.breakdown_persisted
+ and case.passthrough_provider is None
+ and case.rates.mode != "image_generation"
+ )
+ or (not case.expected.cost_header and case.passthrough_provider is None)
+ )
+ )
return tuple(
message
for message in (
@@ -374,6 +402,7 @@ def data_errors() -> tuple[str, ...]:
f"failure response statuses are inconsistent: {failure_response_mismatches}"
if failure_response_mismatches
else None,
+ f"invalid passthrough opt-outs: {invalid_opt_outs}" if invalid_opt_outs else None,
)
if message is not None
)
diff --git a/tests/integration/cost_calculation/cost_tracking_cases.json b/tests/integration/cost_calculation/cost_tracking_cases.json
index be0d62d360a..1179dd47592 100644
--- a/tests/integration/cost_calculation/cost_tracking_cases.json
+++ b/tests/integration/cost_calculation/cost_tracking_cases.json
@@ -27460,6 +27460,379 @@
"completion_tokens": 380,
"cache_read_cost": 0.00405504
}
+ },
+ {
+ "name": "gemini-3.1-pro-passthrough-generate_content_priced_via_gemini_key",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gemini/gemini-3.1-pro",
+ "request": {
+ "model": "$MODEL",
+ "messages": [
+ {
+ "role": "system",
+ "content": [
+ {
+ "type": "text",
+ "text": "You are a deterministic pricing-harness assistant. Keep answers to a single short line."
+ }
+ ]
+ },
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "5fdf6b7dd9b9 summarize the attached material in one line and name the city weather"
+ }
+ ]
+ }
+ ],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "text": "scripted answer 5fdf6b7dd9b9"
+ }
+ ],
+ "role": "model"
+ },
+ "finishReason": "STOP",
+ "index": 0
+ }
+ ],
+ "usageMetadata": {
+ "promptTokenCount": 1840,
+ "candidatesTokenCount": 412,
+ "totalTokenCount": 2252,
+ "promptTokensDetails": [
+ {
+ "modality": "TEXT",
+ "tokenCount": 1840
+ }
+ ]
+ },
+ "modelVersion": "gemini-3.1-pro"
+ }
+ },
+ "expected": {
+ "spend": 0.008624,
+ "input_cost": 0.00368,
+ "output_cost": 0.004944,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412,
+ "breakdown_persisted": false,
+ "cost_header": false
+ },
+ "endpoint": "/gemini/v1beta/models/$MODEL:generateContent"
+ },
+ {
+ "name": "gemini-3.1-pro-passthrough-stream_generate_content_priced_via_vertex_key",
+ "covers": "quota_management.spend_tracking.scripted_wire.logs_cost",
+ "model": "gemini-3.1-pro",
+ "request": {
+ "model": "$MODEL",
+ "messages": [
+ {
+ "role": "system",
+ "content": [
+ {
+ "type": "text",
+ "text": "You are a deterministic pricing-harness assistant. Keep answers to a single short line."
+ }
+ ]
+ },
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "52b6a80ff038 summarize the attached material in one line and name the city weather"
+ }
+ ]
+ }
+ ],
+ "stream": true,
+ "stream_options": {
+ "include_usage": true
+ },
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "text/event-stream",
+ "frames": [
+ "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"scripted answer 52b6a80ff038\"}], \"role\": \"model\"}, \"finishReason\": \"STOP\", \"index\": 0}], \"modelVersion\": \"gemini-3.1-pro\"}",
+ "data: {\"candidates\": [], \"usageMetadata\": {\"promptTokenCount\": 1840, \"candidatesTokenCount\": 412, \"totalTokenCount\": 2252, \"promptTokensDetails\": [{\"modality\": \"TEXT\", \"tokenCount\": 1840}]}, \"modelVersion\": \"gemini-3.1-pro\"}"
+ ]
+ },
+ "expected": {
+ "spend": 0.0090552,
+ "input_cost": 0.003864,
+ "output_cost": 0.0051912,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412,
+ "breakdown_persisted": false,
+ "cost_header": false
+ },
+ "endpoint": "/gemini/v1beta/models/$MODEL:streamGenerateContent?alt=sse"
+ },
+ {
+ "name": "claude-sonnet-5-passthrough-messages",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "claude-sonnet-5",
+ "endpoint": "/anthropic/v1/messages",
+ "request": {
+ "model": "$MODEL",
+ "max_tokens": 412,
+ "messages": [
+ {
+ "role": "user",
+ "content": "summarize the attached material in one line"
+ }
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "msg_$REQUEST_ID",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-sonnet-5",
+ "content": [
+ {
+ "type": "text",
+ "text": "scripted response"
+ }
+ ],
+ "stop_reason": "end_turn",
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 1840,
+ "output_tokens": 412
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.0117,
+ "input_cost": 0.00552,
+ "output_cost": 0.00618,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412,
+ "breakdown_persisted": false,
+ "cost_header": false
+ }
+ },
+ {
+ "name": "claude-sonnet-5-passthrough-messages_cache_read",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "claude-sonnet-5",
+ "endpoint": "/anthropic/v1/messages",
+ "request": {
+ "model": "$MODEL",
+ "max_tokens": 412,
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "cached text",
+ "cache_control": {
+ "type": "ephemeral"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "msg_$REQUEST_ID",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-sonnet-5",
+ "content": [
+ {
+ "type": "text",
+ "text": "scripted response"
+ }
+ ],
+ "stop_reason": "end_turn",
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 640,
+ "output_tokens": 380,
+ "cache_read_input_tokens": 12288
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.0113064,
+ "input_cost": 0.0056064,
+ "output_cost": 0.0057,
+ "prompt_tokens": 12928,
+ "completion_tokens": 380,
+ "cache_read_cost": 0.0036864,
+ "breakdown_persisted": false,
+ "cost_header": false
+ }
+ },
+ {
+ "name": "anthropic.claude-sonnet-5-v1:0-passthrough-converse",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "anthropic.claude-sonnet-5-v1:0",
+ "request": {
+ "model": "$MODEL",
+ "messages": [
+ {
+ "role": "system",
+ "content": [
+ {
+ "type": "text",
+ "text": "You are a deterministic pricing-harness assistant. Keep answers to a single short line."
+ }
+ ]
+ },
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "9aad4de0556c summarize the attached material in one line and name the city weather"
+ }
+ ]
+ }
+ ],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "output": {
+ "message": {
+ "role": "assistant",
+ "content": [
+ {
+ "text": "scripted answer 9aad4de0556c"
+ }
+ ]
+ }
+ },
+ "stopReason": "end_turn",
+ "usage": {
+ "inputTokens": 1840,
+ "outputTokens": 412,
+ "totalTokens": 2252
+ },
+ "metrics": {
+ "latencyMs": 42
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.01287,
+ "input_cost": 0.006072,
+ "output_cost": 0.006798,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412,
+ "cost_header": false
+ },
+ "endpoint": "/bedrock/model/$MODEL/converse"
+ },
+ {
+ "name": "anthropic.claude-sonnet-5-v1:0-passthrough-converse_stream",
+ "covers": "quota_management.spend_tracking.scripted_wire.logs_cost",
+ "model": "anthropic.claude-sonnet-5-v1:0",
+ "request": {
+ "model": "$MODEL",
+ "messages": [
+ {
+ "role": "system",
+ "content": [
+ {
+ "type": "text",
+ "text": "You are a deterministic pricing-harness assistant. Keep answers to a single short line."
+ }
+ ]
+ },
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "a9257967d38a summarize the attached material in one line and name the city weather"
+ }
+ ]
+ }
+ ],
+ "stream": true,
+ "stream_options": {
+ "include_usage": true
+ },
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/vnd.amazon.eventstream",
+ "events": [
+ {
+ "event_type": "messageStart",
+ "payload": {
+ "role": "assistant"
+ }
+ },
+ {
+ "event_type": "contentBlockDelta",
+ "payload": {
+ "delta": {
+ "text": "scripted answer a9257967d38a"
+ },
+ "contentBlockIndex": 0
+ }
+ },
+ {
+ "event_type": "contentBlockStop",
+ "payload": {
+ "contentBlockIndex": 0
+ }
+ },
+ {
+ "event_type": "messageStop",
+ "payload": {
+ "stopReason": "end_turn"
+ }
+ },
+ {
+ "event_type": "metadata",
+ "payload": {
+ "usage": {
+ "inputTokens": 1840,
+ "outputTokens": 412,
+ "totalTokens": 2252
+ },
+ "metrics": {
+ "latencyMs": 42
+ }
+ }
+ }
+ ]
+ },
+ "expected": {
+ "spend": 0.01287,
+ "input_cost": 0.006072,
+ "output_cost": 0.006798,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412,
+ "cost_header": false
+ },
+ "endpoint": "/bedrock/model/$MODEL/converse-stream"
}
]
}
diff --git a/tests/integration/cost_calculation/test_cost_tracking.py b/tests/integration/cost_calculation/test_cost_tracking.py
index f6547406827..c2ac6e77a3f 100644
--- a/tests/integration/cost_calculation/test_cost_tracking.py
+++ b/tests/integration/cost_calculation/test_cost_tracking.py
@@ -12,8 +12,10 @@ import zlib
import httpx
import pytest
+from pydantic import JsonValue
from integration._support.client import JSON_OBJECT, Gateway
+from integration._support.upstream import delete_scenario, register_scenario
from integration.cost_calculation.conftest import (
CostBreakdown,
approx_equal,
@@ -96,6 +98,16 @@ def _assert_stream_has_no_error(response_text: str) -> None:
), f"stream carried an error event: {parsed}"
+def _replace_model(value: JsonValue, model_name: str) -> JsonValue:
+ if isinstance(value, str):
+ return value.replace("$MODEL", model_name)
+ if isinstance(value, list):
+ return [_replace_model(item, model_name) for item in value]
+ if isinstance(value, dict):
+ return {key: _replace_model(item, model_name) for key, item in value.items()}
+ return value
+
+
def _assert_breakdown(
case: CostTrackingTestCase,
expected: ExactExpected,
@@ -139,12 +151,12 @@ def _assert_breakdown(
assert actual_component is not None and approx_equal(actual_component, expected_component), (
f"{case.name}: {field} {actual_component} != expected {expected_component}"
)
- if case.response.content_type == "application/json":
+ if expected.cost_header and case.response.content_type == "application/json":
header: Final = response.headers.get(header_name)
assert header is not None and approx_equal(float(header), expected_component), (
f"{case.name}: {header_name} {header} != expected {expected_component}"
)
- if case.response.content_type == "application/json" and any(
+ if expected.cost_header and case.response.content_type == "application/json" and any(
component is not None
for component in (
expected.cache_read_cost,
@@ -171,11 +183,51 @@ def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase)
marker: Final = sha256(case.name.encode()).hexdigest()[:12]
with gateway.scenario() as scenario:
key: Final = scenario.key()
- model_name: Final = register_scenario_deployment(scenario, case, marker, key)
+ passthrough_provider: Final = case.passthrough_provider
+ scenario_id: Final = f"sc-{marker}-{sha256(key.encode()).hexdigest()[:12]}"
+ scenario_handle: Final = (
+ register_scenario(scenario_id, case.response)
+ if passthrough_provider in {"gemini", "anthropic"}
+ else None
+ )
+ if scenario_handle is not None:
+ scenario.cleanups.callback(delete_scenario, scenario_handle)
+ model_name: Final = (
+ case.model
+ if passthrough_provider in {"gemini", "anthropic"}
+ else register_scenario_deployment(scenario, case, marker, key)
+ )
+ request_model: Final = (
+ case.model.rsplit("/", 1)[-1]
+ if passthrough_provider in {"gemini", "anthropic"}
+ else model_name
+ )
+ request_body: Final = JSON_OBJECT.validate_python(
+ _replace_model(case.request, request_model)
+ if passthrough_provider is not None
+ else {**case.request, "model": model_name}
+ )
+ request_headers: Final = (
+ {
+ "x-pass-x-scripted-scenario": scenario_id,
+ **(
+ {"x-goog-api-key": key}
+ if passthrough_provider == "gemini"
+ else {}
+ ),
+ }
+ if passthrough_provider is not None
+ else {}
+ )
+ request_path: Final = (
+ case.endpoint.replace("$MODEL", request_model)
+ if passthrough_provider is not None
+ else case.endpoint
+ )
response: Final = (
_multipart_request(gateway, case, model_name, key)
if case.upload is not None
- else gateway.request("POST", case.endpoint, {**case.request, "model": model_name}, key=key)
+ else gateway.request("POST", request_path, request_body, key=key, headers=request_headers)
)
if isinstance(case.expected, FailureExpected):
assert response.status_code == case.expected.failure.status, (
@@ -220,9 +272,14 @@ def test_case_bills_expected_cost(gateway: Gateway, case: CostTrackingTestCase)
)
elif case.response.content_type == "application/json":
header: Final = cast(str | None, response.headers.get("x-litellm-response-cost"))
- assert header is not None and approx_equal(float(header), expected.spend), (
- f"{case.name}: x-litellm-response-cost {header} != expected {expected.spend}"
- )
+ if expected.cost_header:
+ assert header is not None and approx_equal(float(header), expected.spend), (
+ f"{case.name}: x-litellm-response-cost {header} != expected {expected.spend}"
+ )
+ elif header is not None:
+ assert approx_equal(float(header), expected.spend), (
+ f"{case.name}: x-litellm-response-cost {header} != expected {expected.spend}"
+ )
assert row.spend is not None and approx_equal(row.spend, expected.spend), (
f"{case.name}: spend {row.spend} != expected {expected.spend} "
f"(breakdown {row.breakdown.model_dump() if row.breakdown is not None else None})"
From 3a0cabacf8efd58c2e68cb0ed65cae72784a1d3d Mon Sep 17 00:00:00 2001
From: yassin
Date: Sat, 19 Sep 2026 20:54:44 +0000
Subject: [PATCH 078/317] fix(proxy): park requeued spend logs in Redis so they
survive a pod restart during a DB outage
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/caching/redis_cache.py | 45 ++++
litellm/constants.py | 3 +
.../redis_update_buffer.py | 60 +++++
litellm/proxy/utils.py | 101 ++++++--
tests/proxy_unit_tests/test_update_spend.py | 1 +
.../test_litellm/caching/test_redis_cache.py | 48 ++++
.../test_redis_update_buffer.py | 50 ++++
.../proxy/utils/prisma_and_spend/conftest.py | 49 ++++
.../test_proxy_update_spend.py | 34 +++
.../prisma_and_spend/test_spend_functions.py | 222 ++++++++++++++++++
10 files changed, 599 insertions(+), 14 deletions(-)
diff --git a/litellm/caching/redis_cache.py b/litellm/caching/redis_cache.py
index b4b2b1a334c..c810278f566 100644
--- a/litellm/caching/redis_cache.py
+++ b/litellm/caching/redis_cache.py
@@ -1999,6 +1999,51 @@ class RedisCache(BaseCache):
log_redis_failure(verbose_logger, logging.ERROR, "LiteLLM Redis Cache RPUSH: - Got exception from REDIS", e)
raise e
+ @_redis_circuit_breaker_guard
+ async def async_rpush_and_trim(
+ self,
+ key: str,
+ values: Sequence[str | bytes | int | float],
+ max_len: int,
+ ) -> int:
+ """Append values and keep only the newest ``max_len`` entries in one MULTI/EXEC.
+
+ Returns the list length right after the push, so callers can tell how many
+ of the oldest entries the trim dropped.
+ """
+ _redis_client: Final = self._async_commands()
+ namespaced_key: Final = self.check_and_fix_namespace(key=key)
+ start_time: Final = time.time()
+ try:
+ async with _redis_client.pipeline(transaction=True) as pipe:
+ pipe.rpush(namespaced_key, *values)
+ pipe.ltrim(namespaced_key, -max_len, -1)
+ results: Final = await pipe.execute()
+ for r in results:
+ if isinstance(r, Exception):
+ raise r
+ asyncio.create_task(
+ self.service_logger_obj.async_service_success_hook(
+ service=ServiceTypes.REDIS,
+ duration=time.time() - start_time,
+ call_type=f"async_rpush_and_trim <- {_get_call_stack_info()}",
+ )
+ )
+ return int(results[0])
+ except Exception as e:
+ asyncio.create_task(
+ self.service_logger_obj.async_service_failure_hook(
+ service=ServiceTypes.REDIS,
+ duration=time.time() - start_time,
+ error=e,
+ call_type=f"async_rpush_and_trim <- {_get_call_stack_info()}",
+ )
+ )
+ log_redis_failure(
+ verbose_logger, logging.ERROR, "LiteLLM Redis Cache RPUSH+LTRIM: - Got exception from REDIS", e
+ )
+ raise e
+
async def _pipeline_rpush_helper(
self,
pipe: pipeline,
diff --git a/litellm/constants.py b/litellm/constants.py
index d62cad74a36..4c8c51ee860 100644
--- a/litellm/constants.py
+++ b/litellm/constants.py
@@ -370,6 +370,9 @@ REDIS_DAILY_AGENT_SPEND_UPDATE_BUFFER_KEY: Final = "litellm_daily_agent_spend_up
REDIS_DAILY_TAG_SPEND_UPDATE_BUFFER_KEY: Final = "litellm_daily_tag_spend_update_buffer"
REDIS_WINDOW_SPEND_UPDATE_BUFFER_KEY: Final = "litellm_window_spend_update_buffer"
MAX_REDIS_BUFFER_DEQUEUE_COUNT: Final = int(os.getenv("MAX_REDIS_BUFFER_DEQUEUE_COUNT", 100))
+REDIS_SPEND_LOGS_BUFFER_KEY: Final = "litellm_spend_logs_buffer"
+REDIS_SPEND_LOGS_BUFFER_MAX_ROWS: Final = 100000
+REDIS_SPEND_LOGS_BUFFER_DEQUEUE_COUNT: Final = 1000
# Bounds asyncio.Queue() instances (log queues, spend update queues, etc.) to prevent unbounded memory growth
LITELLM_ASYNCIO_QUEUE_MAXSIZE: Final = int(os.getenv("LITELLM_ASYNCIO_QUEUE_MAXSIZE", 1000))
TOOL_POLICY_CACHE_TTL_SECONDS: Final = int(os.getenv("TOOL_POLICY_CACHE_TTL_SECONDS", 60))
diff --git a/litellm/proxy/db/db_transaction_queue/redis_update_buffer.py b/litellm/proxy/db/db_transaction_queue/redis_update_buffer.py
index cead63795a2..9044bbb3d3b 100644
--- a/litellm/proxy/db/db_transaction_queue/redis_update_buffer.py
+++ b/litellm/proxy/db/db_transaction_queue/redis_update_buffer.py
@@ -7,6 +7,7 @@ This is to prevent deadlocks and improve reliability
import asyncio
import json
from collections.abc import Mapping, Sequence
+from datetime import datetime
from functools import reduce
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypeVar, cast
@@ -22,6 +23,8 @@ from litellm.constants import (
REDIS_DAILY_SPEND_UPDATE_BUFFER_KEY,
REDIS_DAILY_TAG_SPEND_UPDATE_BUFFER_KEY,
REDIS_DAILY_TEAM_SPEND_UPDATE_BUFFER_KEY,
+ REDIS_SPEND_LOGS_BUFFER_KEY,
+ REDIS_SPEND_LOGS_BUFFER_MAX_ROWS,
REDIS_UPDATE_BUFFER_KEY,
REDIS_WINDOW_SPEND_UPDATE_BUFFER_KEY,
)
@@ -48,6 +51,7 @@ from litellm.proxy.db.db_transaction_queue.window_spend_update_queue import (
WindowSpendUpdateQueue,
to_wire_payload,
)
+from litellm.proxy.db.spend_log_batching import SpendLogRow
from litellm.secret_managers.main import str_to_bool
from litellm.types.caching import (
RedisPipelineLpopOperation,
@@ -93,6 +97,19 @@ _SPEND_TRANSACTION_FIELDS: Final[tuple[_SpendTransactionField, ...]] = (
_ValueT = TypeVar("_ValueT")
+def _spend_log_json_default(value: object) -> str:
+ return value.isoformat() if isinstance(value, datetime) else str(value)
+
+
+def _encode_spend_log_row(row: SpendLogRow) -> str:
+ return json.dumps(row, default=_spend_log_json_default)
+
+
+def _decode_spend_log_row(encoded: str) -> dict[str, object] | None:
+ decoded: Final = json.loads(encoded)
+ return decoded if isinstance(decoded, dict) else None
+
+
def _accumulated_spend(totals: Mapping[str, float], entities: Mapping[str, float]) -> dict[str, float]:
return {**totals, **{entity_id: totals.get(entity_id, 0) + amount for entity_id, amount in entities.items()}}
@@ -526,6 +543,49 @@ class RedisUpdateBuffer:
str(e),
)
+ async def store_spend_logs_in_redis(
+ self,
+ rows: Sequence[SpendLogRow],
+ max_rows: int = REDIS_SPEND_LOGS_BUFFER_MAX_ROWS,
+ ) -> bool:
+ """Park spend-log rows in Redis so they outlive this pod, dropping the oldest past ``max_rows``."""
+ if self.redis_cache is None or len(rows) == 0 or not self._should_commit_spend_updates_to_redis():
+ return False
+ try:
+ buffer_size: Final = await self.redis_cache.async_rpush_and_trim(
+ key=REDIS_SPEND_LOGS_BUFFER_KEY,
+ values=[_encode_spend_log_row(row) for row in rows],
+ max_len=max_rows,
+ )
+ overflow: Final = buffer_size - max_rows
+ if overflow > 0:
+ verbose_proxy_logger.error(
+ "Spend tracking - Redis spend log buffer is at its %d row cap; dropped the %d oldest spend logs",
+ max_rows,
+ overflow,
+ )
+ except Exception as e: # noqa: BLE001 # the caller falls back to the in-memory queue on any Redis fault
+ verbose_proxy_logger.error(
+ "Spend tracking - failed to park %d spend log rows in Redis. Error: %s", len(rows), str(e)
+ )
+ return False
+ verbose_proxy_logger.info("Spend tracking - parked %d spend log rows in Redis for a later flush", len(rows))
+ return True
+
+ async def get_spend_logs_from_redis_buffer(self, limit: int) -> tuple[dict[str, object], ...]:
+ """Atomically take up to ``limit`` parked spend-log rows out of Redis."""
+ if self.redis_cache is None or not self._should_commit_spend_updates_to_redis():
+ return ()
+ popped: Final[str | list[str] | None] = await self.redis_cache.async_lpop(
+ key=REDIS_SPEND_LOGS_BUFFER_KEY,
+ count=limit,
+ )
+ if popped is None:
+ return ()
+ encoded_rows: Final = popped if isinstance(popped, list) else [popped]
+ decoded_rows: Final = (_decode_spend_log_row(encoded) for encoded in encoded_rows)
+ return tuple(row for row in decoded_rows if row is not None)
+
@staticmethod
def _number_of_transactions_to_store_in_redis(
db_spend_update_transactions: DBSpendUpdateTransactions,
diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py
index b078a65759e..434a6179d14 100644
--- a/litellm/proxy/utils.py
+++ b/litellm/proxy/utils.py
@@ -51,6 +51,7 @@ from litellm.constants import (
DEFAULT_MODEL_CREATED_AT_TIME,
LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL,
MAX_TEAM_LIST_LIMIT,
+ REDIS_SPEND_LOGS_BUFFER_DEQUEUE_COUNT,
SPEND_LOG_QUEUE_MAX_BYTES,
SPEND_LOG_WRITE_BATCH_MAX_BYTES,
SPEND_LOG_WRITE_BATCH_MAX_ROWS,
@@ -4167,6 +4168,7 @@ class PrismaClient:
spend_log_flush_requested: "asyncio.Event | None" = None
spend_log_queue_bytes: ClassVar[int] = 0
spend_logs_queue_monitor_task: "asyncio.Task[None] | None" = None
+ spend_log_write_lock = asyncio.Lock()
tool_usage_transactions: list["ToolUsageTransaction"] = []
_tool_usage_transactions_lock = asyncio.Lock()
autorouter_turn_transactions: ClassVar[
@@ -7062,7 +7064,7 @@ class ProxyUpdateSpend:
except Exception as e:
if not _is_transient_spend_log_write_error(e):
if PrismaDBExceptionHandler.is_prisma_error(e):
- await enqueue_spend_logs(prisma_client, logs_to_process, at_head=True)
+ await requeue_spend_logs(prisma_client, proxy_logging_obj, logs_to_process)
verbose_proxy_logger.warning(
"Spend tracking - DB error writing spend logs, requeued %d rows for the next flush. error=%s",
len(logs_to_process),
@@ -7077,7 +7079,7 @@ class ProxyUpdateSpend:
str(e),
)
if i >= n_retry_times:
- await enqueue_spend_logs(prisma_client, logs_to_process, at_head=True)
+ await requeue_spend_logs(prisma_client, proxy_logging_obj, logs_to_process)
raise
await asyncio.sleep(2**i)
except Exception as e:
@@ -7127,6 +7129,7 @@ async def update_spend(
)
### UPDATE SPEND LOGS ###
+ await recover_parked_spend_logs(prisma_client, proxy_logging_obj)
# Check queue size with lock protection
queue_size: Final = await _total_queued_spend_transactions(prisma_client)
verbose_proxy_logger.debug("Spend Logs transactions: %s", queue_size)
@@ -7144,6 +7147,51 @@ async def update_spend(
)
+async def _park_spend_logs_in_redis(proxy_logging_obj: ProxyLogging, rows: Sequence[Mapping[str, object]]) -> bool:
+ try:
+ return await proxy_logging_obj.db_spend_update_writer.redis_update_buffer.store_spend_logs_in_redis(rows)
+ except Exception as e: # noqa: BLE001 # a Redis fault falls back to the in-memory queue, never loses the rows
+ verbose_proxy_logger.warning(
+ "Spend tracking - could not park spend logs in Redis, keeping them in memory: %s", e
+ )
+ return False
+
+
+async def requeue_spend_logs(
+ prisma_client: PrismaClient,
+ proxy_logging_obj: ProxyLogging,
+ rows: Sequence[Mapping[str, object]],
+) -> None:
+ """Park rows from a failed or cancelled write in Redis, falling back to the head of the in-memory queue."""
+ if await _park_spend_logs_in_redis(proxy_logging_obj, rows):
+ return
+ await enqueue_spend_logs(prisma_client, rows, at_head=True)
+
+
+async def recover_parked_spend_logs(
+ prisma_client: PrismaClient,
+ proxy_logging_obj: ProxyLogging,
+ limit: int = REDIS_SPEND_LOGS_BUFFER_DEQUEUE_COUNT,
+) -> int:
+ """Move spend-log rows parked in Redis back to the head of the in-memory queue for the next write."""
+ try:
+ rows: Final = (
+ await proxy_logging_obj.db_spend_update_writer.redis_update_buffer.get_spend_logs_from_redis_buffer(limit)
+ )
+ except Exception as e: # noqa: BLE001 # Redis being down must not stop the regular in-memory flush
+ verbose_proxy_logger.warning("Spend tracking - could not read parked spend logs from Redis: %s", e)
+ return 0
+ if len(rows) == 0:
+ return 0
+ try:
+ await enqueue_spend_logs(prisma_client, rows, at_head=True)
+ except BaseException:
+ await _park_spend_logs_in_redis(proxy_logging_obj, rows)
+ raise
+ verbose_proxy_logger.info("Spend tracking - recovered %d parked spend log rows from Redis", len(rows))
+ return len(rows)
+
+
async def _total_queued_spend_transactions(prisma_client: PrismaClient) -> int:
"""Pending entries across every request-time spend queue, sized under each queue's
lock. Every drain trigger reads this one owner, so a queue added later joins the
@@ -7215,14 +7263,19 @@ async def update_spend_logs_job(
This job is triggered based on queue size rather than time.
Pops the batch once, writes spend logs, then runs guardrail usage tracking.
"""
- n_retry_times: Final = 3
- MAX_LOGS_PER_INTERVAL: Final = 10000
-
- # Atomically pop batch from queue. The tool usage queue counts toward the
- # emptiness check: a spend-log write failure aborts a run before the tool
- # drain below, and those entries must not strand once the spend queue drains.
if await _total_queued_spend_transactions(prisma_client) == 0:
return
+ async with prisma_client.spend_log_write_lock:
+ await _run_spend_logs_job(prisma_client, db_writer_client, proxy_logging_obj)
+
+
+async def _run_spend_logs_job(
+ prisma_client: PrismaClient,
+ db_writer_client: AsyncHTTPHandler | None,
+ proxy_logging_obj: ProxyLogging,
+) -> None:
+ n_retry_times: Final = 3
+ MAX_LOGS_PER_INTERVAL: Final = 10000
logs_to_process: Final = await dequeue_spend_logs(prisma_client, MAX_LOGS_PER_INTERVAL)
@@ -7235,7 +7288,7 @@ async def update_spend_logs_job(
logs_to_process=logs_to_process,
)
except asyncio.CancelledError:
- await enqueue_spend_logs(prisma_client, logs_to_process, at_head=True)
+ await requeue_spend_logs(prisma_client, proxy_logging_obj, logs_to_process)
verbose_proxy_logger.warning(
"Spend tracking - spend log write cancelled, requeued %d rows for the next flush",
len(logs_to_process),
@@ -7321,14 +7374,22 @@ async def drain_spend_logs_queue(
await monitor_task
prisma_client.spend_logs_queue_monitor_task = None # rebind-ok: the client owns its monitor handle
+ async with prisma_client.spend_log_write_lock:
+ try:
+ await _drain_spend_logs_queue_to_db(prisma_client, db_writer_client, proxy_logging_obj)
+ finally:
+ await _park_remaining_spend_logs(prisma_client, proxy_logging_obj)
+
+
+async def _drain_spend_logs_queue_to_db(
+ prisma_client: PrismaClient,
+ db_writer_client: "AsyncHTTPHandler | None",
+ proxy_logging_obj: ProxyLogging,
+) -> None:
for _ in range(MAX_SPEND_LOG_DRAIN_ITERATIONS):
if await _total_queued_spend_transactions(prisma_client) == 0:
return
- await update_spend_logs_job(
- prisma_client=prisma_client,
- db_writer_client=db_writer_client,
- proxy_logging_obj=proxy_logging_obj,
- )
+ await _run_spend_logs_job(prisma_client, db_writer_client, proxy_logging_obj)
remaining: Final = await _total_queued_spend_transactions(prisma_client)
if remaining > 0:
@@ -7339,6 +7400,17 @@ async def drain_spend_logs_queue(
)
+async def _park_remaining_spend_logs(prisma_client: PrismaClient, proxy_logging_obj: ProxyLogging) -> None:
+ rows: Final = await dequeue_spend_logs(prisma_client, sys.maxsize)
+ if len(rows) == 0 or await _park_spend_logs_in_redis(proxy_logging_obj, rows):
+ return
+ await enqueue_spend_logs(prisma_client, rows, at_head=True)
+ spend_log_error(
+ "Spend tracking - %d spend log rows could not be written or parked in Redis and will be lost on exit",
+ len(rows),
+ )
+
+
async def _monitor_spend_logs_queue(
prisma_client: PrismaClient,
db_writer_client: AsyncHTTPHandler | None,
@@ -7372,6 +7444,7 @@ async def _monitor_spend_logs_queue(
while True:
try:
+ await recover_parked_spend_logs(prisma_client, proxy_logging_obj)
# Check queue sizes with lock protection; the tool usage queue keeps
# the monitor firing when a prior failed run left it nonempty.
queue_size = await _total_queued_spend_transactions(prisma_client)
diff --git a/tests/proxy_unit_tests/test_update_spend.py b/tests/proxy_unit_tests/test_update_spend.py
index a28a78cc4a1..8d78b4b61c1 100644
--- a/tests/proxy_unit_tests/test_update_spend.py
+++ b/tests/proxy_unit_tests/test_update_spend.py
@@ -42,6 +42,7 @@ class MockPrismaClient:
import asyncio
self._spend_log_transactions_lock = asyncio.Lock()
+ self.spend_log_write_lock = asyncio.Lock()
self._tool_usage_transactions_lock = asyncio.Lock()
self._autorouter_turn_transactions_lock = asyncio.Lock()
diff --git a/tests/test_litellm/caching/test_redis_cache.py b/tests/test_litellm/caching/test_redis_cache.py
index 19638c60b4b..44227b8ed33 100644
--- a/tests/test_litellm/caching/test_redis_cache.py
+++ b/tests/test_litellm/caching/test_redis_cache.py
@@ -1502,3 +1502,51 @@ async def test_async_set_cache_pipeline_with_ttls_keeps_each_entry_ttl(monkeypat
("ns:u1", '{"user_id": "u1"}', timedelta(seconds=7)),
("ns:org_id:o1", '{"a": 1}', timedelta(seconds=300)),
]
+
+
+class _ListPipeline:
+ def __init__(self, rows: list[str]) -> None:
+ self.rows = rows
+ self.queued: list[tuple[str, ...]] = []
+
+ async def __aenter__(self) -> "_ListPipeline":
+ return self
+
+ async def __aexit__(self, *exc: object) -> None:
+ return None
+
+ def rpush(self, key: str, *values: str) -> None:
+ self.queued.append(("rpush", key, *values))
+
+ def ltrim(self, key: str, start: int, end: int) -> None:
+ self.queued.append(("ltrim", key, str(start), str(end)))
+
+ async def execute(self) -> list[object]:
+ results: list[object] = []
+ for op in self.queued:
+ if op[0] == "rpush":
+ self.rows.extend(op[2:])
+ results.append(len(self.rows))
+ else:
+ start, end = int(op[2]), int(op[3])
+ del self.rows[: max(len(self.rows) + start, 0) if start < 0 else start]
+ results.append(True)
+ return results
+
+
+@pytest.mark.asyncio
+async def test_async_rpush_and_trim_runs_push_and_trim_in_one_transaction(monkeypatch, redis_no_ping):
+ monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
+ redis_cache = RedisCache(namespace="ns")
+ rows = ["a", "b"]
+ pipe = _ListPipeline(rows)
+ client = MagicMock()
+ client.pipeline = MagicMock(return_value=pipe)
+
+ with patch.object(redis_cache, "init_async_client", return_value=client):
+ pushed_len = await redis_cache.async_rpush_and_trim(key="buf", values=["c", "d"], max_len=3)
+
+ client.pipeline.assert_called_once_with(transaction=True)
+ assert pushed_len == 4
+ assert rows == ["b", "c", "d"]
+ assert [op[:2] for op in pipe.queued] == [("rpush", "ns:buf"), ("ltrim", "ns:buf")]
diff --git a/tests/test_litellm/proxy/db/db_transaction_queue/test_redis_update_buffer.py b/tests/test_litellm/proxy/db/db_transaction_queue/test_redis_update_buffer.py
index cc8b10150bd..e04e2402e1b 100644
--- a/tests/test_litellm/proxy/db/db_transaction_queue/test_redis_update_buffer.py
+++ b/tests/test_litellm/proxy/db/db_transaction_queue/test_redis_update_buffer.py
@@ -651,3 +651,53 @@ async def test_store_in_memory_spend_updates_restores_budget_window_spend_on_rpu
restored = await window_queue.flush_and_get_aggregated_window_spend_transactions()
assert [payload["spend"] for payload in restored] == [4.0]
assert [payload["entity_id"] for payload in restored] == ["team-1"]
+
+
+class _ListRedis:
+ def __init__(self) -> None:
+ self.rows: list[str] = []
+
+ async def async_rpush_and_trim(self, key: str, values: list[str], max_len: int) -> int:
+ self.rows.extend(values)
+ pushed_len = len(self.rows)
+ del self.rows[:-max_len]
+ return pushed_len
+
+ async def async_lpop(self, key: str, count: int | None = None, **kwargs: object) -> list[str] | None:
+ if not self.rows:
+ return None
+ popped = self.rows[:count]
+ del self.rows[:count]
+ return popped
+
+
+@pytest.mark.asyncio
+async def test_store_spend_logs_in_redis_drops_oldest_rows_past_the_cap():
+ redis = _ListRedis()
+ buffer = RedisUpdateBuffer(redis_cache=redis)
+ buffer._should_commit_spend_updates_to_redis = MagicMock(return_value=True)
+
+ assert await buffer.store_spend_logs_in_redis([{"request_id": "old"}, {"request_id": "mid"}], max_rows=2) is True
+ assert await buffer.store_spend_logs_in_redis([{"request_id": "new"}], max_rows=2) is True
+
+ parked = await buffer.get_spend_logs_from_redis_buffer(limit=10)
+ assert [row["request_id"] for row in parked] == ["mid", "new"]
+ assert await buffer.get_spend_logs_from_redis_buffer(limit=10) == ()
+
+
+@pytest.mark.asyncio
+async def test_store_spend_logs_in_redis_reports_failure_without_redis():
+ buffer = RedisUpdateBuffer(redis_cache=None)
+
+ assert await buffer.store_spend_logs_in_redis([{"request_id": "a"}]) is False
+ assert await buffer.get_spend_logs_from_redis_buffer(limit=10) == ()
+
+
+@pytest.mark.asyncio
+async def test_store_spend_logs_in_redis_is_off_unless_transaction_buffering_is_enabled():
+ redis = _ListRedis()
+ buffer = RedisUpdateBuffer(redis_cache=redis)
+ buffer._should_commit_spend_updates_to_redis = MagicMock(return_value=False)
+
+ assert await buffer.store_spend_logs_in_redis([{"request_id": "a"}]) is False
+ assert redis.rows == []
diff --git a/tests/test_litellm/proxy/utils/prisma_and_spend/conftest.py b/tests/test_litellm/proxy/utils/prisma_and_spend/conftest.py
index fce51c9296c..c502fe4800e 100644
--- a/tests/test_litellm/proxy/utils/prisma_and_spend/conftest.py
+++ b/tests/test_litellm/proxy/utils/prisma_and_spend/conftest.py
@@ -130,6 +130,7 @@ def mock_prisma_client() -> MagicMock:
client.spend_log_transactions = []
client._spend_log_transactions_lock = asyncio.Lock()
client.spend_logs_queue_monitor_task = None
+ client.spend_log_write_lock = asyncio.Lock()
client.tool_usage_transactions = []
client._tool_usage_transactions_lock = asyncio.Lock()
client.jsonify_object = lambda data: dict(data)
@@ -313,6 +314,54 @@ def make_spend_log_row() -> Callable[..., Dict[str, Any]]:
return _make
+class FakeRedisList:
+ def __init__(self) -> None:
+ self.items: dict[str, list[str]] = {}
+ self.down = False
+
+ def _check_up(self) -> None:
+ if self.down:
+ raise ConnectionError("redis unreachable")
+
+ async def async_rpush_and_trim(self, key: str, values: list[str], max_len: int) -> int:
+ self._check_up()
+ stored = self.items.setdefault(key, [])
+ stored.extend(str(v) for v in values)
+ pushed_len = len(stored)
+ del stored[:-max_len]
+ return pushed_len
+
+ async def async_lpop(self, key: str, count: int | None = None, **kwargs: object) -> str | list[str] | None:
+ self._check_up()
+ stored = self.items.get(key, [])
+ if not stored:
+ return None
+ if count is None:
+ return stored.pop(0)
+ popped = stored[:count]
+ del stored[:count]
+ return popped
+
+
+@pytest.fixture
+def fake_redis() -> FakeRedisList:
+ return FakeRedisList()
+
+
+@pytest.fixture
+def proxy_logging_with_redis(fake_redis: FakeRedisList) -> MagicMock:
+ from litellm.proxy.db.db_transaction_queue.redis_update_buffer import RedisUpdateBuffer
+
+ proxy_logging = MagicMock()
+ proxy_logging.failure_handler = AsyncMock()
+ proxy_logging.db_spend_update_writer = MagicMock()
+ proxy_logging.db_spend_update_writer.db_update_spend_transaction_handler = AsyncMock()
+ buffer = RedisUpdateBuffer(redis_cache=fake_redis)
+ buffer._should_commit_spend_updates_to_redis = MagicMock(return_value=True)
+ proxy_logging.db_spend_update_writer.redis_update_buffer = buffer
+ return proxy_logging
+
+
@dataclass
class _SentMessage:
from_addr: Optional[str]
diff --git a/tests/test_litellm/proxy/utils/prisma_and_spend/test_proxy_update_spend.py b/tests/test_litellm/proxy/utils/prisma_and_spend/test_proxy_update_spend.py
index d671a4ffc1f..7099101db1c 100644
--- a/tests/test_litellm/proxy/utils/prisma_and_spend/test_proxy_update_spend.py
+++ b/tests/test_litellm/proxy/utils/prisma_and_spend/test_proxy_update_spend.py
@@ -883,3 +883,37 @@ def test_disable_spend_updates_error_when_general_settings_unavailable(
monkeypatch.delattr(proxy_server_mod, "general_settings", raising=False)
with pytest.raises(ImportError):
ProxyUpdateSpend.disable_spend_updates()
+
+
+@pytest.mark.asyncio
+async def test_update_spend_logs_parks_failed_batch_in_redis_with_wire_safe_datetimes(
+ mock_prisma_client: Any, make_spend_log_row: Any, proxy_logging_with_redis: MagicMock, fake_redis: Any
+) -> None:
+ """Regression: a batch the DB rejected used to go back to process memory only. With Redis
+ wired in it must be parked there, and datetimes must come back as ISO strings the DB write
+ accepts, since the row is replayed by a process that never saw the original objects.
+ """
+ from datetime import datetime, timezone
+
+ from prisma.errors import TableNotFoundError
+
+ started = datetime(2026, 9, 19, 20, 0, 5, 123000, tzinfo=timezone.utc)
+ err = TableNotFoundError(
+ {"user_facing_error": {"error_code": "P2021", "message": "The table does not exist", "meta": {}}}
+ )
+ mock_prisma_client.db.litellm_spendlogs.create_many = AsyncMock(side_effect=err)
+ mock_prisma_client.spend_log_transactions = []
+
+ with pytest.raises(TableNotFoundError):
+ await ProxyUpdateSpend.update_spend_logs(
+ n_retry_times=2,
+ prisma_client=mock_prisma_client,
+ db_writer_client=None,
+ proxy_logging_obj=proxy_logging_with_redis,
+ logs_to_process=[make_spend_log_row(request_id="a", startTime=started)],
+ )
+
+ buffer = proxy_logging_with_redis.db_spend_update_writer.redis_update_buffer
+ parked = await buffer.get_spend_logs_from_redis_buffer(limit=10)
+ assert mock_prisma_client.spend_log_transactions == []
+ assert [(row["request_id"], row["startTime"]) for row in parked] == [("a", started.isoformat())]
diff --git a/tests/test_litellm/proxy/utils/prisma_and_spend/test_spend_functions.py b/tests/test_litellm/proxy/utils/prisma_and_spend/test_spend_functions.py
index c8b87bd671e..d6f41ba55db 100644
--- a/tests/test_litellm/proxy/utils/prisma_and_spend/test_spend_functions.py
+++ b/tests/test_litellm/proxy/utils/prisma_and_spend/test_spend_functions.py
@@ -11,17 +11,20 @@ Symbols pinned here:
from __future__ import annotations
import asyncio
+import json
from contextlib import suppress
from typing import Any, Dict, Final, List
from unittest.mock import AsyncMock, MagicMock
import pytest
+from litellm.constants import REDIS_SPEND_LOGS_BUFFER_KEY
from litellm.proxy.utils import (
MAX_SPEND_LOG_DRAIN_ITERATIONS,
_monitor_spend_logs_queue,
_raise_failed_update_spend_exception,
drain_spend_logs_queue,
+ recover_parked_spend_logs,
update_daily_tag_spend,
update_spend,
update_spend_logs_job,
@@ -719,3 +722,222 @@ def test_raise_failed_update_spend_exception_raises_original_error() -> None:
with pytest.raises(ValueError, match="specific"):
asyncio.run(_runner())
+
+
+def _table_gone_error() -> Exception:
+ from prisma.errors import TableNotFoundError
+
+ return TableNotFoundError(
+ {"user_facing_error": {"error_code": "P2021", "message": "The table does not exist", "meta": {}}}
+ )
+
+
+def _parked_request_ids(fake_redis: Any) -> list[str]:
+ return [json.loads(row)["request_id"] for row in fake_redis.items.get(REDIS_SPEND_LOGS_BUFFER_KEY, [])]
+
+
+@pytest.mark.asyncio
+async def test_drain_spend_logs_queue_parks_unwritable_rows_in_redis_on_shutdown(
+ mock_prisma_client: Any, make_spend_log_row: Any, proxy_logging_with_redis: MagicMock, fake_redis: Any
+) -> None:
+ from prisma.errors import TableNotFoundError
+
+ mock_prisma_client.spend_log_transactions = [
+ make_spend_log_row(request_id="r1"),
+ make_spend_log_row(request_id="r2"),
+ ]
+ mock_prisma_client.db.litellm_spendlogs.create_many = AsyncMock(side_effect=_table_gone_error())
+
+ with pytest.raises(TableNotFoundError):
+ await drain_spend_logs_queue(
+ prisma_client=mock_prisma_client,
+ db_writer_client=None,
+ proxy_logging_obj=proxy_logging_with_redis,
+ )
+
+ assert mock_prisma_client.spend_log_transactions == []
+ assert sorted(_parked_request_ids(fake_redis)) == ["r1", "r2"]
+
+
+@pytest.mark.asyncio
+async def test_drain_spend_logs_queue_waits_for_an_in_flight_write_before_parking(
+ mock_prisma_client: Any, make_spend_log_row: Any, proxy_logging_with_redis: MagicMock, fake_redis: Any
+) -> None:
+ db_outage_seen: Final = asyncio.Event()
+ mock_prisma_client.spend_log_transactions = [make_spend_log_row(request_id="in-flight")]
+
+ async def _fail_once_shutdown_starts(*args: Any, **kwargs: Any) -> None:
+ await db_outage_seen.wait()
+ raise _table_gone_error()
+
+ mock_prisma_client.db.litellm_spendlogs.create_many = AsyncMock(side_effect=_fail_once_shutdown_starts)
+ scheduler_write: Final = asyncio.ensure_future(
+ update_spend_logs_job(
+ prisma_client=mock_prisma_client,
+ db_writer_client=None,
+ proxy_logging_obj=proxy_logging_with_redis,
+ )
+ )
+ await asyncio.sleep(0)
+ assert mock_prisma_client.spend_log_transactions == []
+
+ async def _release_after_shutdown_started() -> None:
+ await asyncio.sleep(0.05)
+ db_outage_seen.set()
+
+ release: Final = asyncio.ensure_future(_release_after_shutdown_started())
+ await drain_spend_logs_queue(
+ prisma_client=mock_prisma_client,
+ db_writer_client=None,
+ proxy_logging_obj=proxy_logging_with_redis,
+ )
+
+ assert _parked_request_ids(fake_redis) == ["in-flight"]
+ assert mock_prisma_client.spend_log_transactions == []
+ await release
+ with suppress(Exception):
+ await scheduler_write
+
+
+@pytest.mark.asyncio
+async def test_drain_spend_logs_queue_parks_rows_left_after_max_passes(
+ mock_prisma_client: Any,
+ make_spend_log_row: Any,
+ monkeypatch: pytest.MonkeyPatch,
+ proxy_logging_with_redis: MagicMock,
+ fake_redis: Any,
+) -> None:
+ import litellm.proxy.db.spend_log_tool_index as tool_mod
+ import litellm.proxy.guardrails.usage_tracking as guard_mod
+
+ monkeypatch.setattr(guard_mod, "process_spend_logs_guardrail_usage", AsyncMock(), raising=False)
+ monkeypatch.setattr(tool_mod, "flush_tool_usage_transactions", AsyncMock(), raising=False)
+ mock_prisma_client.spend_log_transactions = [make_spend_log_row(request_id="r0")]
+
+ async def _write_and_refill(*args: Any, **kwargs: Any) -> None:
+ mock_prisma_client.spend_log_transactions.append(make_spend_log_row(request_id="late"))
+
+ mock_prisma_client.db.litellm_spendlogs.create_many = AsyncMock(side_effect=_write_and_refill)
+
+ await drain_spend_logs_queue(
+ prisma_client=mock_prisma_client,
+ db_writer_client=None,
+ proxy_logging_obj=proxy_logging_with_redis,
+ )
+
+ assert mock_prisma_client.spend_log_transactions == []
+ assert _parked_request_ids(fake_redis) == ["late"]
+
+
+@pytest.mark.asyncio
+async def test_drain_spend_logs_queue_keeps_rows_in_memory_when_redis_is_down(
+ mock_prisma_client: Any, make_spend_log_row: Any, proxy_logging_with_redis: MagicMock, fake_redis: Any
+) -> None:
+ from prisma.errors import TableNotFoundError
+
+ fake_redis.down = True
+ mock_prisma_client.spend_log_transactions = [make_spend_log_row(request_id="r1")]
+ mock_prisma_client.db.litellm_spendlogs.create_many = AsyncMock(side_effect=_table_gone_error())
+
+ with pytest.raises(TableNotFoundError):
+ await drain_spend_logs_queue(
+ prisma_client=mock_prisma_client,
+ db_writer_client=None,
+ proxy_logging_obj=proxy_logging_with_redis,
+ )
+
+ assert [row["request_id"] for row in mock_prisma_client.spend_log_transactions] == ["r1"]
+ assert fake_redis.items == {}
+
+
+@pytest.mark.asyncio
+async def test_update_spend_writes_rows_parked_in_redis_by_a_previous_pod(
+ mock_prisma_client: Any,
+ make_spend_log_row: Any,
+ monkeypatch: pytest.MonkeyPatch,
+ proxy_logging_with_redis: MagicMock,
+ fake_redis: Any,
+) -> None:
+ import litellm.proxy.db.spend_log_tool_index as tool_mod
+ import litellm.proxy.guardrails.usage_tracking as guard_mod
+
+ monkeypatch.setattr(guard_mod, "process_spend_logs_guardrail_usage", AsyncMock(), raising=False)
+ monkeypatch.setattr(tool_mod, "flush_tool_usage_transactions", AsyncMock(), raising=False)
+ buffer = proxy_logging_with_redis.db_spend_update_writer.redis_update_buffer
+ assert await buffer.store_spend_logs_in_redis([make_spend_log_row(request_id="parked")]) is True
+ mock_prisma_client.spend_log_transactions = []
+ mock_prisma_client.db.litellm_spendlogs.create_many = AsyncMock()
+
+ await update_spend(
+ prisma_client=mock_prisma_client,
+ db_writer_client=None,
+ proxy_logging_obj=proxy_logging_with_redis,
+ )
+
+ written = mock_prisma_client.db.litellm_spendlogs.create_many.await_args.kwargs["data"]
+ assert [row["request_id"] for row in written] == ["parked"]
+ assert _parked_request_ids(fake_redis) == []
+ assert mock_prisma_client.spend_log_transactions == []
+
+
+@pytest.mark.asyncio
+async def test_recover_parked_spend_logs_re_parks_rows_when_the_enqueue_is_cancelled(
+ mock_prisma_client: Any, make_spend_log_row: Any, proxy_logging_with_redis: MagicMock, fake_redis: Any
+) -> None:
+ buffer = proxy_logging_with_redis.db_spend_update_writer.redis_update_buffer
+ assert await buffer.store_spend_logs_in_redis([make_spend_log_row(request_id="parked")]) is True
+ mock_prisma_client.spend_log_transactions = []
+ await mock_prisma_client._spend_log_transactions_lock.acquire()
+ recovery: Final = asyncio.ensure_future(
+ recover_parked_spend_logs(prisma_client=mock_prisma_client, proxy_logging_obj=proxy_logging_with_redis)
+ )
+ await asyncio.sleep(0.01)
+ assert _parked_request_ids(fake_redis) == []
+
+ recovery.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await recovery
+ mock_prisma_client._spend_log_transactions_lock.release()
+
+ assert _parked_request_ids(fake_redis) == ["parked"]
+ assert mock_prisma_client.spend_log_transactions == []
+
+
+@pytest.mark.asyncio
+async def test_monitor_spend_logs_queue_pulls_parked_rows_before_each_flush(
+ mock_prisma_client: Any,
+ make_spend_log_row: Any,
+ monkeypatch: pytest.MonkeyPatch,
+ proxy_logging_with_redis: MagicMock,
+) -> None:
+ import litellm.constants as constants_mod
+ import litellm.proxy.utils as utils_mod
+
+ monkeypatch.setattr(constants_mod, "SPEND_LOG_QUEUE_POLL_INTERVAL", 0.0, raising=False)
+ buffer = proxy_logging_with_redis.db_spend_update_writer.redis_update_buffer
+ assert await buffer.store_spend_logs_in_redis([make_spend_log_row(request_id="parked")]) is True
+ mock_prisma_client.spend_log_transactions = []
+ seen: list[list[str]] = []
+ polls = {"n": 0}
+
+ async def _fake_job(*args: Any, **kwargs: Any) -> None:
+ seen.append([row["request_id"] for row in mock_prisma_client.spend_log_transactions])
+ raise asyncio.CancelledError()
+
+ async def _poll(*args: Any, **kwargs: Any) -> bool:
+ polls["n"] += 1
+ if polls["n"] >= 3:
+ raise asyncio.CancelledError()
+ return False
+
+ monkeypatch.setattr(utils_mod, "update_spend_logs_job", _fake_job)
+ monkeypatch.setattr(utils_mod, "_wait_for_spend_log_flush_request", _poll)
+
+ with pytest.raises(asyncio.CancelledError):
+ await _monitor_spend_logs_queue(
+ prisma_client=mock_prisma_client,
+ db_writer_client=None,
+ proxy_logging_obj=proxy_logging_with_redis,
+ )
+
+ assert seen == [["parked"]]
From 72b007a4aeeb9d9279b232d833ce89a5f139c23e Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 22:46:43 +0000
Subject: [PATCH 079/317] test(integration): pricing dimension and provider
cost cases
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/integration/contracts.json | 48 ++
.../cost_calculation/cost_tracking_case.py | 27 +-
.../cost_calculation/cost_tracking_cases.json | 491 ++++++++++++++++++
3 files changed, 564 insertions(+), 2 deletions(-)
diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json
index e86536e13b5..0a74171da29 100644
--- a/tests/integration/contracts.json
+++ b/tests/integration/contracts.json
@@ -1449,6 +1449,54 @@
],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[anthropic.claude-sonnet-5-v1:0-passthrough-converse_stream]": [
"quota_management.spend_tracking.scripted_wire.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[dashscope-qwen4-max-tiered_input]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[dashscope-qwen4-max-tiered_boundary_stays_lower_tier]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[dashscope-qwen4-max-tiered_second_tier]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[dashscope-qwen4-max-tiered_above_top_range]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gemini-gemini-3.8-flash-lite-input_below_128k]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[gemini-gemini-3.8-flash-lite-input_above_128k]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[claude-sonnet-5-cache_creation_1h_above_200k]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[openrouter-anthropic-claude-sonnet-5-provider_reported_cost]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[openrouter-anthropic-claude-sonnet-5-token_priced]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[perplexity-sonar-next-search_queries_and_citations]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[perplexity-sonar-next-no_search]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[deepseek-deepseek-v4-chat-prompt_cache_hit]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[deepseek-deepseek-v4-chat-no_cache_fields_bills_zero_cache]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[xai-grok-5-reasoning_folded_into_completion]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[xai-grok-5-live_search]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
+ ],
+ "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[xai-grok-5-provider_reported_cost]": [
+ "quota_management.spend_tracking.cost_matrix.logs_cost"
]
},
"browser": {
diff --git a/tests/integration/cost_calculation/cost_tracking_case.py b/tests/integration/cost_calculation/cost_tracking_case.py
index ba64cab81ed..2da0e969458 100644
--- a/tests/integration/cost_calculation/cost_tracking_case.py
+++ b/tests/integration/cost_calculation/cost_tracking_case.py
@@ -25,6 +25,14 @@ class ProviderSpecificEntry(BaseModel):
us: float | None = None
+class TieredPrice(BaseModel):
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ range: tuple[float, float]
+ input_cost_per_token: float
+ output_cost_per_token: float
+
+
class CostMapEntry(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -36,11 +44,18 @@ class CostMapEntry(BaseModel):
supports_function_calling: bool | None = None
input_cost_per_token: float | None = None
output_cost_per_token: float | None = None
+ input_cost_per_token_above_128k_tokens: float | None = None
+ output_cost_per_token_above_128k_tokens: float | None = None
cache_read_input_token_cost: float | None = None
cache_creation_input_token_cost: float | None = None
cache_creation_input_token_cost_above_1hr: float | None = None
+ cache_creation_input_token_cost_above_1hr_above_200k_tokens: float | None = None
cache_read_input_token_cost_above_200k_tokens: float | None = None
cache_creation_input_token_cost_above_200k_tokens: float | None = None
+ input_cost_per_token_above_200k_tokens: float | None = None
+ output_cost_per_token_above_200k_tokens: float | None = None
+ citation_cost_per_token: float | None = None
+ tiered_pricing: tuple[TieredPrice, ...] | None = None
output_cost_per_reasoning_token: float | None = None
input_cost_per_audio_token: float | None = None
input_cost_per_second: float | None = None
@@ -53,8 +68,6 @@ class CostMapEntry(BaseModel):
input_cost_per_image_token: float | None = None
output_cost_per_image_token: float | None = None
input_cost_per_video_token: float | None = None
- input_cost_per_token_above_200k_tokens: float | None = None
- output_cost_per_token_above_200k_tokens: float | None = None
input_cost_per_token_flex: float | None = None
output_cost_per_token_flex: float | None = None
input_cost_per_token_priority: float | None = None
@@ -270,6 +283,11 @@ _PROVIDER_PREFIXES: Final[Mapping[str, str]] = MappingProxyType(
"together_ai": "",
"fireworks_ai": "",
"azure": "",
+ "dashscope": "",
+ "openrouter": "",
+ "perplexity": "",
+ "deepseek": "",
+ "xai": "",
}
)
_LITELLM_PARAMS: Final[Mapping[str, Mapping[str, str]]] = MappingProxyType(
@@ -301,6 +319,11 @@ _LITELLM_PARAMS: Final[Mapping[str, Mapping[str, str]]] = MappingProxyType(
"fireworks_ai": MappingProxyType({}),
"azure": MappingProxyType({"api_version": "2025-04-01-preview"}),
"openai": MappingProxyType({}),
+ "dashscope": MappingProxyType({}),
+ "openrouter": MappingProxyType({}),
+ "perplexity": MappingProxyType({}),
+ "deepseek": MappingProxyType({}),
+ "xai": MappingProxyType({}),
}
)
diff --git a/tests/integration/cost_calculation/cost_tracking_cases.json b/tests/integration/cost_calculation/cost_tracking_cases.json
index 1179dd47592..f6183f79d25 100644
--- a/tests/integration/cost_calculation/cost_tracking_cases.json
+++ b/tests/integration/cost_calculation/cost_tracking_cases.json
@@ -1,5 +1,74 @@
{
"cost_map": {
+ "dashscope/qwen4-max": {
+ "litellm_provider": "dashscope",
+ "mode": "chat",
+ "max_input_tokens": 252000,
+ "max_output_tokens": 65536,
+ "tiered_pricing": [
+ {
+ "range": [0, 32000],
+ "input_cost_per_token": 1.3e-06,
+ "output_cost_per_token": 6.5e-06
+ },
+ {
+ "range": [32000, 128000],
+ "input_cost_per_token": 2.6e-06,
+ "output_cost_per_token": 1.3e-05
+ },
+ {
+ "range": [128000, 252000],
+ "input_cost_per_token": 3.1e-06,
+ "output_cost_per_token": 1.55e-05
+ }
+ ]
+ },
+ "gemini/gemini-3.8-flash-lite": {
+ "litellm_provider": "gemini",
+ "mode": "chat",
+ "input_cost_per_token": 1.1e-07,
+ "output_cost_per_token": 4.4e-07,
+ "input_cost_per_token_above_128k_tokens": 2.2e-07,
+ "output_cost_per_token_above_128k_tokens": 8.8e-07
+ },
+ "openrouter/anthropic/claude-sonnet-5": {
+ "litellm_provider": "openrouter",
+ "mode": "chat",
+ "input_cost_per_token": 3.2e-06,
+ "output_cost_per_token": 1.6e-05
+ },
+ "perplexity/sonar-next": {
+ "litellm_provider": "perplexity",
+ "mode": "chat",
+ "input_cost_per_token": 1.05e-06,
+ "output_cost_per_token": 1.05e-06,
+ "citation_cost_per_token": 2e-06,
+ "search_context_cost_per_query": {
+ "search_context_size_low": 0.005,
+ "search_context_size_medium": 0.008,
+ "search_context_size_high": 0.012
+ }
+ },
+ "deepseek/deepseek-v4-chat": {
+ "litellm_provider": "deepseek",
+ "mode": "chat",
+ "input_cost_per_token": 2.9e-07,
+ "output_cost_per_token": 4.3e-07,
+ "cache_read_input_token_cost": 2.9e-08,
+ "cache_creation_input_token_cost": 0.0
+ },
+ "xai/grok-5": {
+ "litellm_provider": "xai",
+ "mode": "chat",
+ "input_cost_per_token": 1.35e-06,
+ "output_cost_per_token": 2.7e-06,
+ "cache_read_input_token_cost": 2.1e-07,
+ "search_context_cost_per_query": {
+ "search_context_size_low": 0.005,
+ "search_context_size_medium": 0.005,
+ "search_context_size_high": 0.005
+ }
+ },
"gpt-5.6": {
"cache_read_input_token_cost": 1.75e-07,
"input_cost_per_audio_token": 4e-05,
@@ -165,6 +234,7 @@
"claude-sonnet-5": {
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
+ "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05,
"cache_creation_input_token_cost_above_200k_tokens": 7.5e-06,
"cache_read_input_token_cost": 3e-07,
"cache_read_input_token_cost_above_200k_tokens": 6e-07,
@@ -27834,5 +27904,426 @@
},
"endpoint": "/bedrock/model/$MODEL/converse-stream"
}
+ ,
+ {
+ "name": "dashscope-qwen4-max-tiered_input",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "dashscope/qwen4-max",
+ "request": {
+ "model": "$MODEL",
+ "messages": [{"role": "user", "content": "tiered input"}],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "dashscope-tiered-input",
+ "object": "chat.completion",
+ "model": "qwen4-max",
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
+ "usage": {"prompt_tokens": 1840, "completion_tokens": 412, "total_tokens": 2252}
+ }
+ },
+ "expected": {"spend": 0.00507, "input_cost": 0.002392, "output_cost": 0.002678, "prompt_tokens": 1840, "completion_tokens": 412}
+ },
+ {
+ "name": "dashscope-qwen4-max-tiered_boundary_stays_lower_tier",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "dashscope/qwen4-max",
+ "request": {
+ "model": "$MODEL",
+ "messages": [{"role": "user", "content": "tier boundary"}],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "dashscope-tiered-boundary",
+ "object": "chat.completion",
+ "model": "qwen4-max",
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
+ "usage": {"prompt_tokens": 32000, "completion_tokens": 412, "total_tokens": 32412}
+ }
+ },
+ "expected": {"spend": 0.044278, "input_cost": 0.0416, "output_cost": 0.002678, "prompt_tokens": 32000, "completion_tokens": 412}
+ },
+ {
+ "name": "dashscope-qwen4-max-tiered_second_tier",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "dashscope/qwen4-max",
+ "request": {
+ "model": "$MODEL",
+ "messages": [{"role": "user", "content": "tier two"}],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "dashscope-tiered-second",
+ "object": "chat.completion",
+ "model": "qwen4-max",
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
+ "usage": {"prompt_tokens": 40000, "completion_tokens": 412, "total_tokens": 40412}
+ }
+ },
+ "expected": {"spend": 0.109356, "input_cost": 0.104, "output_cost": 0.005356, "prompt_tokens": 40000, "completion_tokens": 412}
+ },
+ {
+ "name": "dashscope-qwen4-max-tiered_above_top_range",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "dashscope/qwen4-max",
+ "request": {
+ "model": "$MODEL",
+ "messages": [{"role": "user", "content": "top tier"}],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "dashscope-tiered-top",
+ "object": "chat.completion",
+ "model": "qwen4-max",
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
+ "usage": {"prompt_tokens": 300000, "completion_tokens": 412, "total_tokens": 300412}
+ }
+ },
+ "expected": {"spend": 0.936386, "input_cost": 0.93, "output_cost": 0.006386, "prompt_tokens": 300000, "completion_tokens": 412}
+ },
+ {
+ "name": "gemini-gemini-3.8-flash-lite-input_below_128k",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gemini/gemini-3.8-flash-lite",
+ "request": {
+ "model": "$MODEL",
+ "messages": [{"role": "user", "content": "base pricing"}],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "candidates": [{"content": {"parts": [{"text": "ok"}], "role": "model"}, "finishReason": "STOP", "index": 0}],
+ "usageMetadata": {"promptTokenCount": 1840, "candidatesTokenCount": 412, "totalTokenCount": 2252},
+ "modelVersion": "gemini-3.8-flash-lite"
+ }
+ },
+ "expected": {"spend": 0.00038368, "input_cost": 0.0002024, "output_cost": 0.00018128, "prompt_tokens": 1840, "completion_tokens": 412}
+ },
+ {
+ "name": "gemini-gemini-3.8-flash-lite-input_above_128k",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "gemini/gemini-3.8-flash-lite",
+ "request": {
+ "model": "$MODEL",
+ "messages": [{"role": "user", "content": "above threshold"}],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "candidates": [{"content": {"parts": [{"text": "ok"}], "role": "model"}, "finishReason": "STOP", "index": 0}],
+ "usageMetadata": {"promptTokenCount": 130000, "candidatesTokenCount": 412, "totalTokenCount": 130412},
+ "modelVersion": "gemini-3.8-flash-lite"
+ }
+ },
+ "expected": {"spend": 0.02896256, "input_cost": 0.0286, "output_cost": 0.00036256, "prompt_tokens": 130000, "completion_tokens": 412}
+ },
+ {
+ "name": "claude-sonnet-5-cache_creation_1h_above_200k",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "claude-sonnet-5",
+ "request": {
+ "model": "$MODEL",
+ "messages": [{"role": "user", "content": "one hour cache"}],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "msg-cache-1h",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-sonnet-5",
+ "content": [{"type": "text", "text": "ok"}],
+ "stop_reason": "end_turn",
+ "usage": {
+ "input_tokens": 150000,
+ "cache_creation_input_tokens": 60000,
+ "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 60000},
+ "cache_read_input_tokens": 0,
+ "output_tokens": 412
+ }
+ }
+ },
+ "expected": {
+ "spend": 1.62927,
+ "input_cost": 1.62,
+ "output_cost": 0.00927,
+ "cache_creation_cost": 0.72,
+ "prompt_tokens": 210000,
+ "completion_tokens": 412
+ }
+ },
+ {
+ "name": "openrouter-anthropic-claude-sonnet-5-provider_reported_cost",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "openrouter/anthropic/claude-sonnet-5",
+ "request": {
+ "model": "$MODEL",
+ "messages": [{"role": "user", "content": "reported cost"}],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "or-reported",
+ "object": "chat.completion",
+ "model": "anthropic/claude-sonnet-5",
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
+ "usage": {"prompt_tokens": 1840, "completion_tokens": 412, "total_tokens": 2252, "cost": 0.0421}
+ }
+ },
+ "expected": {"spend": 0.0421, "input_cost": 0.0, "output_cost": 0.0421, "prompt_tokens": 1840, "completion_tokens": 412}
+ },
+ {
+ "name": "openrouter-anthropic-claude-sonnet-5-token_priced",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "openrouter/anthropic/claude-sonnet-5",
+ "request": {
+ "model": "$MODEL",
+ "messages": [{"role": "user", "content": "token pricing"}],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "or-token",
+ "object": "chat.completion",
+ "model": "anthropic/claude-sonnet-5",
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
+ "usage": {"prompt_tokens": 1840, "completion_tokens": 412, "total_tokens": 2252}
+ }
+ },
+ "expected": {"spend": 0.01248, "input_cost": 0.005888, "output_cost": 0.006592, "prompt_tokens": 1840, "completion_tokens": 412}
+ },
+ {
+ "name": "perplexity-sonar-next-search_queries_and_citations",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "perplexity/sonar-next",
+ "request": {
+ "model": "$MODEL",
+ "messages": [{"role": "user", "content": "search"}],
+ "web_search_options": {"search_context_size": "high"},
+ "stream": false,
+ "allowed_openai_params": ["web_search_options"]
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "pplx-search",
+ "object": "chat.completion",
+ "model": "sonar-next",
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
+ "citations": [
+ "https://e.co/aaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "https://e.co/bbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "https://e.co/ccccccccccccccccccccccccccc",
+ "https://e.co/ddddddddddddddddddddddddddd"
+ ],
+ "usage": {"prompt_tokens": 1840, "completion_tokens": 412, "total_tokens": 2252, "num_search_queries": 3}
+ }
+ },
+ "expected": {
+ "spend": 0.0174446,
+ "input_cost": 0.002012,
+ "output_cost": 0.0004326,
+ "tool_usage_cost": 0.015,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412
+ }
+ },
+ {
+ "name": "perplexity-sonar-next-no_search",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "perplexity/sonar-next",
+ "request": {
+ "model": "$MODEL",
+ "messages": [{"role": "user", "content": "no search"}],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "pplx-no-search",
+ "object": "chat.completion",
+ "model": "sonar-next",
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
+ "usage": {"prompt_tokens": 1840, "completion_tokens": 412, "total_tokens": 2252}
+ }
+ },
+ "expected": {"spend": 0.0023646, "input_cost": 0.001932, "output_cost": 0.0004326, "prompt_tokens": 1840, "completion_tokens": 412}
+ },
+ {
+ "name": "deepseek-deepseek-v4-chat-prompt_cache_hit",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "deepseek/deepseek-v4-chat",
+ "request": {
+ "model": "$MODEL",
+ "messages": [{"role": "user", "content": "cache hit"}],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "deepseek-cache",
+ "object": "chat.completion",
+ "model": "deepseek-v4-chat",
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
+ "usage": {
+ "prompt_tokens": 1840,
+ "completion_tokens": 412,
+ "total_tokens": 2252,
+ "prompt_cache_hit_tokens": 1200,
+ "prompt_cache_miss_tokens": 640,
+ "prompt_tokens_details": {"cached_tokens": 1200}
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.00039756,
+ "input_cost": 0.0002204,
+ "output_cost": 0.00017716,
+ "cache_read_cost": 0.0000348,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412
+ }
+ },
+ {
+ "name": "deepseek-deepseek-v4-chat-no_cache_fields_bills_zero_cache",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "deepseek/deepseek-v4-chat",
+ "request": {
+ "model": "$MODEL",
+ "messages": [{"role": "user", "content": "no cache"}],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "deepseek-no-cache",
+ "object": "chat.completion",
+ "model": "deepseek-v4-chat",
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
+ "usage": {"prompt_tokens": 1840, "completion_tokens": 412, "total_tokens": 2252}
+ }
+ },
+ "expected": {
+ "spend": 0.00071076,
+ "input_cost": 0.0005336,
+ "output_cost": 0.00017716,
+ "cache_read_cost": 0.0,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412
+ }
+ },
+ {
+ "name": "xai-grok-5-reasoning_folded_into_completion",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "xai/grok-5",
+ "request": {
+ "model": "$MODEL",
+ "messages": [{"role": "user", "content": "reasoning"}],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "xai-reasoning",
+ "object": "chat.completion",
+ "model": "grok-5",
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
+ "usage": {
+ "prompt_tokens": 1840,
+ "completion_tokens": 412,
+ "total_tokens": 2552,
+ "completion_tokens_details": {"reasoning_tokens": 300}
+ }
+ }
+ },
+ "expected": {"spend": 0.0044064, "input_cost": 0.002484, "output_cost": 0.0019224, "prompt_tokens": 1840, "completion_tokens": 712}
+ },
+ {
+ "name": "xai-grok-5-live_search",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "xai/grok-5",
+ "request": {
+ "model": "$MODEL",
+ "messages": [{"role": "user", "content": "live search"}],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "xai-search",
+ "object": "chat.completion",
+ "model": "grok-5",
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
+ "usage": {
+ "prompt_tokens": 1840,
+ "completion_tokens": 412,
+ "total_tokens": 2252,
+ "server_side_tool_usage_details": {"web_search_calls": 2}
+ }
+ }
+ },
+ "expected": {
+ "spend": 0.0135964,
+ "input_cost": 0.002484,
+ "output_cost": 0.0011224,
+ "tool_usage_cost": 0.01,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412
+ }
+ },
+ {
+ "name": "xai-grok-5-provider_reported_cost",
+ "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
+ "model": "xai/grok-5",
+ "request": {
+ "model": "$MODEL",
+ "messages": [{"role": "user", "content": "reported xai cost"}],
+ "stream": false,
+ "allowed_openai_params": []
+ },
+ "response": {
+ "content_type": "application/json",
+ "body": {
+ "id": "xai-reported",
+ "object": "chat.completion",
+ "model": "grok-5",
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
+ "usage": {
+ "prompt_tokens": 1840,
+ "completion_tokens": 412,
+ "total_tokens": 2252,
+ "cost": 0.0421
+ }
+ }
+ },
+ "expected": {"spend": 0.0421, "input_cost": 0.0, "output_cost": 0.0421, "prompt_tokens": 1840, "completion_tokens": 412}
+ }
]
}
From c7113f043afe69582265d055d3b99795f073d555 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 23:01:25 +0000
Subject: [PATCH 080/317] test(integration): use per-run request ids, drop
unbillable perplexity search case
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/integration/contracts.json | 3 -
.../cost_calculation/cost_tracking_case.py | 9 ++-
.../cost_calculation/cost_tracking_cases.json | 72 ++++++-------------
.../cost_calculation/test_cost_tracking.py | 6 +-
4 files changed, 33 insertions(+), 57 deletions(-)
diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json
index 0a74171da29..15ddaf14fd4 100644
--- a/tests/integration/contracts.json
+++ b/tests/integration/contracts.json
@@ -1477,9 +1477,6 @@
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[openrouter-anthropic-claude-sonnet-5-token_priced]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
- "tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[perplexity-sonar-next-search_queries_and_citations]": [
- "quota_management.spend_tracking.cost_matrix.logs_cost"
- ],
"tests/integration/cost_calculation/test_cost_tracking.py::test_case_bills_expected_cost[perplexity-sonar-next-no_search]": [
"quota_management.spend_tracking.cost_matrix.logs_cost"
],
diff --git a/tests/integration/cost_calculation/cost_tracking_case.py b/tests/integration/cost_calculation/cost_tracking_case.py
index 2da0e969458..269e1edb9eb 100644
--- a/tests/integration/cost_calculation/cost_tracking_case.py
+++ b/tests/integration/cost_calculation/cost_tracking_case.py
@@ -54,7 +54,6 @@ class CostMapEntry(BaseModel):
cache_creation_input_token_cost_above_200k_tokens: float | None = None
input_cost_per_token_above_200k_tokens: float | None = None
output_cost_per_token_above_200k_tokens: float | None = None
- citation_cost_per_token: float | None = None
tiered_pricing: tuple[TieredPrice, ...] | None = None
output_cost_per_reasoning_token: float | None = None
input_cost_per_audio_token: float | None = None
@@ -263,6 +262,13 @@ class CostTrackingTestCase(BaseModel):
return "bedrock"
return None
+ @property
+ def reports_provider_cost(self) -> bool:
+ if not isinstance(self.response, JsonResponse):
+ return False
+ usage: Final = self.response.body.get("usage")
+ return isinstance(usage, dict) and isinstance(usage.get("cost"), (int, float))
+
class _CasesFile(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -409,6 +415,7 @@ def data_errors() -> tuple[str, ...]:
not case.expected.breakdown_persisted
and case.passthrough_provider is None
and case.rates.mode != "image_generation"
+ and not case.reports_provider_cost
)
or (not case.expected.cost_header and case.passthrough_provider is None)
)
diff --git a/tests/integration/cost_calculation/cost_tracking_cases.json b/tests/integration/cost_calculation/cost_tracking_cases.json
index f6183f79d25..ead2435f2ac 100644
--- a/tests/integration/cost_calculation/cost_tracking_cases.json
+++ b/tests/integration/cost_calculation/cost_tracking_cases.json
@@ -42,7 +42,6 @@
"mode": "chat",
"input_cost_per_token": 1.05e-06,
"output_cost_per_token": 1.05e-06,
- "citation_cost_per_token": 2e-06,
"search_context_cost_per_query": {
"search_context_size_low": 0.005,
"search_context_size_medium": 0.008,
@@ -27918,7 +27917,7 @@
"response": {
"content_type": "application/json",
"body": {
- "id": "dashscope-tiered-input",
+ "id": "$REQUEST_ID",
"object": "chat.completion",
"model": "qwen4-max",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
@@ -27940,7 +27939,7 @@
"response": {
"content_type": "application/json",
"body": {
- "id": "dashscope-tiered-boundary",
+ "id": "$REQUEST_ID",
"object": "chat.completion",
"model": "qwen4-max",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
@@ -27962,7 +27961,7 @@
"response": {
"content_type": "application/json",
"body": {
- "id": "dashscope-tiered-second",
+ "id": "$REQUEST_ID",
"object": "chat.completion",
"model": "qwen4-max",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
@@ -27984,7 +27983,7 @@
"response": {
"content_type": "application/json",
"body": {
- "id": "dashscope-tiered-top",
+ "id": "$REQUEST_ID",
"object": "chat.completion",
"model": "qwen4-max",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
@@ -28046,7 +28045,7 @@
"response": {
"content_type": "application/json",
"body": {
- "id": "msg-cache-1h",
+ "id": "msg_$REQUEST_ID",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5",
@@ -28083,14 +28082,21 @@
"response": {
"content_type": "application/json",
"body": {
- "id": "or-reported",
+ "id": "chatcmpl-$REQUEST_ID",
"object": "chat.completion",
"model": "anthropic/claude-sonnet-5",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1840, "completion_tokens": 412, "total_tokens": 2252, "cost": 0.0421}
}
},
- "expected": {"spend": 0.0421, "input_cost": 0.0, "output_cost": 0.0421, "prompt_tokens": 1840, "completion_tokens": 412}
+ "expected": {
+ "spend": 0.0421,
+ "input_cost": 0.0,
+ "output_cost": 0.0421,
+ "prompt_tokens": 1840,
+ "completion_tokens": 412,
+ "breakdown_persisted": false
+ }
},
{
"name": "openrouter-anthropic-claude-sonnet-5-token_priced",
@@ -28105,7 +28111,7 @@
"response": {
"content_type": "application/json",
"body": {
- "id": "or-token",
+ "id": "chatcmpl-$REQUEST_ID",
"object": "chat.completion",
"model": "anthropic/claude-sonnet-5",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
@@ -28114,42 +28120,6 @@
},
"expected": {"spend": 0.01248, "input_cost": 0.005888, "output_cost": 0.006592, "prompt_tokens": 1840, "completion_tokens": 412}
},
- {
- "name": "perplexity-sonar-next-search_queries_and_citations",
- "covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
- "model": "perplexity/sonar-next",
- "request": {
- "model": "$MODEL",
- "messages": [{"role": "user", "content": "search"}],
- "web_search_options": {"search_context_size": "high"},
- "stream": false,
- "allowed_openai_params": ["web_search_options"]
- },
- "response": {
- "content_type": "application/json",
- "body": {
- "id": "pplx-search",
- "object": "chat.completion",
- "model": "sonar-next",
- "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
- "citations": [
- "https://e.co/aaaaaaaaaaaaaaaaaaaaaaaaaaa",
- "https://e.co/bbbbbbbbbbbbbbbbbbbbbbbbbbb",
- "https://e.co/ccccccccccccccccccccccccccc",
- "https://e.co/ddddddddddddddddddddddddddd"
- ],
- "usage": {"prompt_tokens": 1840, "completion_tokens": 412, "total_tokens": 2252, "num_search_queries": 3}
- }
- },
- "expected": {
- "spend": 0.0174446,
- "input_cost": 0.002012,
- "output_cost": 0.0004326,
- "tool_usage_cost": 0.015,
- "prompt_tokens": 1840,
- "completion_tokens": 412
- }
- },
{
"name": "perplexity-sonar-next-no_search",
"covers": "quota_management.spend_tracking.cost_matrix.logs_cost",
@@ -28163,7 +28133,7 @@
"response": {
"content_type": "application/json",
"body": {
- "id": "pplx-no-search",
+ "id": "chatcmpl-$REQUEST_ID",
"object": "chat.completion",
"model": "sonar-next",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
@@ -28185,7 +28155,7 @@
"response": {
"content_type": "application/json",
"body": {
- "id": "deepseek-cache",
+ "id": "chatcmpl-$REQUEST_ID",
"object": "chat.completion",
"model": "deepseek-v4-chat",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
@@ -28221,7 +28191,7 @@
"response": {
"content_type": "application/json",
"body": {
- "id": "deepseek-no-cache",
+ "id": "chatcmpl-$REQUEST_ID",
"object": "chat.completion",
"model": "deepseek-v4-chat",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
@@ -28250,7 +28220,7 @@
"response": {
"content_type": "application/json",
"body": {
- "id": "xai-reasoning",
+ "id": "chatcmpl-$REQUEST_ID",
"object": "chat.completion",
"model": "grok-5",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
@@ -28277,7 +28247,7 @@
"response": {
"content_type": "application/json",
"body": {
- "id": "xai-search",
+ "id": "chatcmpl-$REQUEST_ID",
"object": "chat.completion",
"model": "grok-5",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
@@ -28311,7 +28281,7 @@
"response": {
"content_type": "application/json",
"body": {
- "id": "xai-reported",
+ "id": "chatcmpl-$REQUEST_ID",
"object": "chat.completion",
"model": "grok-5",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
diff --git a/tests/integration/cost_calculation/test_cost_tracking.py b/tests/integration/cost_calculation/test_cost_tracking.py
index c2ac6e77a3f..d97bfe642a2 100644
--- a/tests/integration/cost_calculation/test_cost_tracking.py
+++ b/tests/integration/cost_calculation/test_cost_tracking.py
@@ -148,12 +148,14 @@ def _assert_breakdown(
):
if expected_component is None:
continue
- assert actual_component is not None and approx_equal(actual_component, expected_component), (
+ actual_value: Final = actual_component or 0.0
+ assert approx_equal(actual_value, expected_component), (
f"{case.name}: {field} {actual_component} != expected {expected_component}"
)
if expected.cost_header and case.response.content_type == "application/json":
header: Final = response.headers.get(header_name)
- assert header is not None and approx_equal(float(header), expected_component), (
+ header_value: Final = float(header) if header is not None else 0.0
+ assert approx_equal(header_value, expected_component), (
f"{case.name}: {header_name} {header} != expected {expected_component}"
)
if expected.cost_header and case.response.content_type == "application/json" and any(
From 789f0c61bf31a885ca8fd7f0f951e6078b37b78c Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 23:12:43 +0000
Subject: [PATCH 081/317] test(integration): only accept omitted breakdown
components when the case expects zero, fix xai output cost
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../cost_calculation/cost_tracking_cases.json | 2 +-
.../cost_calculation/test_cost_tracking.py | 15 +++++++--------
2 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/tests/integration/cost_calculation/cost_tracking_cases.json b/tests/integration/cost_calculation/cost_tracking_cases.json
index ead2435f2ac..3a52dfee93a 100644
--- a/tests/integration/cost_calculation/cost_tracking_cases.json
+++ b/tests/integration/cost_calculation/cost_tracking_cases.json
@@ -28262,7 +28262,7 @@
"expected": {
"spend": 0.0135964,
"input_cost": 0.002484,
- "output_cost": 0.0011224,
+ "output_cost": 0.0011124,
"tool_usage_cost": 0.01,
"prompt_tokens": 1840,
"completion_tokens": 412
diff --git a/tests/integration/cost_calculation/test_cost_tracking.py b/tests/integration/cost_calculation/test_cost_tracking.py
index d97bfe642a2..8c5f306dd9d 100644
--- a/tests/integration/cost_calculation/test_cost_tracking.py
+++ b/tests/integration/cost_calculation/test_cost_tracking.py
@@ -148,16 +148,15 @@ def _assert_breakdown(
):
if expected_component is None:
continue
- actual_value: Final = actual_component or 0.0
- assert approx_equal(actual_value, expected_component), (
- f"{case.name}: {field} {actual_component} != expected {expected_component}"
- )
+ omitted_component_allowed: Final = expected_component == 0.0
+ assert (actual_component is None and omitted_component_allowed) or (
+ actual_component is not None and approx_equal(actual_component, expected_component)
+ ), f"{case.name}: {field} {actual_component} != expected {expected_component}"
if expected.cost_header and case.response.content_type == "application/json":
header: Final = response.headers.get(header_name)
- header_value: Final = float(header) if header is not None else 0.0
- assert approx_equal(header_value, expected_component), (
- f"{case.name}: {header_name} {header} != expected {expected_component}"
- )
+ assert (header is None and omitted_component_allowed) or (
+ header is not None and approx_equal(float(header), expected_component)
+ ), f"{case.name}: {header_name} {header} != expected {expected_component}"
if expected.cost_header and case.response.content_type == "application/json" and any(
component is not None
for component in (
From d7b1318e55d19dc75388135aca884da36fca3e33 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 16:17:39 -0700
Subject: [PATCH 082/317] fix(azure_ai): bridge gpt-5.4+ function tools with
reasoning to the Foundry Responses API
---
litellm/main.py | 20 +++--
tests/test_litellm/test_main.py | 145 ++++++++++++++++++++++++++++++++
2 files changed, 159 insertions(+), 6 deletions(-)
diff --git a/litellm/main.py b/litellm/main.py
index 34410f9497c..6ab2fcd4b03 100644
--- a/litellm/main.py
+++ b/litellm/main.py
@@ -100,6 +100,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
from litellm.litellm_core_utils.request_timeout_resolver import (
get_configured_request_timeout,
)
+from litellm.llms.azure_ai.common_utils import azure_ai_supports_native_responses
from litellm.llms.base_llm import BaseConfig, BaseImageGenerationConfig
from litellm.llms.base_llm.base_model_iterator import (
convert_model_response_to_streaming,
@@ -1118,16 +1119,23 @@ def responses_api_bridge_check(
reasoning_active = reasoning_effort != "none"
# The reasoning+tools constraint is enforced by the real OpenAI backend behind any api.openai.com
# host (the default URL or a PrivateLink hostname such as .privatelink.api.openai.com) and
- # by Azure OpenAI. Resolve the effective base arg>global>env>default exactly as the chat handler
- # does, so a custom base set via litellm.api_base or OPENAI_BASE_URL/OPENAI_API_BASE isn't misread
- # as the default and bridged to a /responses route it lacks. A whitespace-only base collapses to
- # the default too.
+ # by Azure OpenAI, whether reached through the azure provider or as a Foundry OpenAI v1 host through
+ # the azure_ai provider. Resolve the effective OpenAI base arg>global>env>default exactly as the chat
+ # handler does, so a custom base set via litellm.api_base or OPENAI_BASE_URL/OPENAI_API_BASE isn't
+ # misread as the default and bridged to a /responses route it lacks. A whitespace-only base
+ # collapses to the default too.
resolved_api_base: Final = _resolve_openai_api_base(api_base).strip()
+ on_foundry_openai_endpoint: Final = custom_llm_provider == "azure_ai" and azure_ai_supports_native_responses(
+ model, api_base
+ )
on_constraint_enforcing_endpoint: Final = (
- custom_llm_provider == "azure" or resolved_api_base == "" or _is_openai_backed_api_base(resolved_api_base)
+ custom_llm_provider == "azure"
+ or on_foundry_openai_endpoint
+ or resolved_api_base == ""
+ or _is_openai_backed_api_base(resolved_api_base)
)
if (
- custom_llm_provider in ("openai", "azure")
+ (custom_llm_provider in ("openai", "azure") or on_foundry_openai_endpoint)
and model_info.get("mode") != "responses"
and OpenAIGPT5Config.is_model_gpt_5_model(model)
and not OpenAIGPT5Config.is_model_gpt_5_search_model(model)
diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py
index 3c90675d04d..e0ca0c1cbf9 100644
--- a/tests/test_litellm/test_main.py
+++ b/tests/test_litellm/test_main.py
@@ -1308,6 +1308,71 @@ def test_responses_api_bridge_check_azure_with_api_base_and_unset_effort_routes(
assert model_info.get("mode") == "responses"
+_FOUNDRY_API_BASE: Final = "https://myproject.services.ai.azure.com"
+_FOUNDRY_FUNCTION_TOOL: Final = ({"type": "function", "function": {"name": "get_weather"}},)
+
+
+@pytest.mark.parametrize(
+ "api_base, reasoning_effort",
+ [
+ pytest.param(_FOUNDRY_API_BASE, None, id="foundry-host-unset-effort"),
+ pytest.param(_FOUNDRY_API_BASE, "low", id="foundry-host-explicit-effort"),
+ pytest.param("https://myresource.openai.azure.com", None, id="azure-openai-host-unset-effort"),
+ ],
+)
+def test_responses_api_bridge_check_azure_ai_foundry_gpt_5_4_plus_tools_routes_to_responses(api_base, reasoning_effort):
+ """
+ An azure_ai deployment of a gpt-5.4+ model on a Foundry OpenAI v1 host is the same Azure OpenAI
+ backend the azure provider bridges: its chat surface rejects function tools whenever reasoning is
+ on, and for gpt-6-astra it rejects reasoning_effort "none" too, so the Responses route on the same
+ endpoint is the only way to serve the request. Regression guard: the gate used to bridge only the
+ openai and azure providers, so these requests died at Foundry's /models/chat/completions.
+ """
+ from litellm.main import responses_api_bridge_check
+
+ model_info, model = responses_api_bridge_check(
+ model="gpt-6-astra",
+ custom_llm_provider="azure_ai",
+ tools=_FOUNDRY_FUNCTION_TOOL,
+ reasoning_effort=reasoning_effort,
+ api_base=api_base,
+ )
+
+ assert model == "gpt-6-astra"
+ assert model_info.get("mode") == "responses"
+
+
+@pytest.mark.parametrize(
+ "model_name, api_base, reasoning_effort",
+ [
+ pytest.param("gpt-6-astra", _FOUNDRY_API_BASE, "none", id="explicit-none-stays-chat"),
+ pytest.param("gpt-6-astra", "https://myproject.models.ai.azure.com", None, id="serverless-host-stays-chat"),
+ pytest.param("Mistral-large-2411", _FOUNDRY_API_BASE, None, id="non-gpt-5-model-stays-chat"),
+ pytest.param("claude-opus-4-1", _FOUNDRY_API_BASE, None, id="claude-on-foundry-stays-chat"),
+ ],
+)
+def test_responses_api_bridge_check_azure_ai_without_foundry_responses_route_stays_chat(
+ model_name, api_base, reasoning_effort
+):
+ """
+ The azure_ai bridge fires only where the Foundry Responses config is selectable: a serverless
+ host, a non-OpenAI model, and claude-on-Foundry have no Responses route to bridge to, and an
+ explicit reasoning_effort "none" keeps the request chat-servable on the same terms as azure.
+ """
+ from litellm.main import responses_api_bridge_check
+
+ model_info, model = responses_api_bridge_check(
+ model=model_name,
+ custom_llm_provider="azure_ai",
+ tools=_FOUNDRY_FUNCTION_TOOL,
+ reasoning_effort=reasoning_effort,
+ api_base=api_base,
+ )
+
+ assert model == model_name
+ assert model_info.get("mode") != "responses"
+
+
def test_responses_api_bridge_check_older_gpt_5_tools_without_reasoning_stays_chat():
"""Pre-5.4 GPT-5 names keep the old boundary: tools alone never bridge."""
from litellm.main import responses_api_bridge_check
@@ -1488,6 +1553,86 @@ def test_responses_bridge_preserves_reasoning_effort_with_drop_params(
assert request_body["reasoning"] == {"effort": "high"}
+_FOUNDRY_RESPONSES_FUNCTION_CALL_BODY: Final = {
+ "id": "resp_foundry",
+ "object": "response",
+ "created_at": 1789852145,
+ "status": "completed",
+ "model": "gpt-6-astra",
+ "output": [
+ {
+ "id": "fc_1",
+ "type": "function_call",
+ "status": "completed",
+ "arguments": '{"city":"Paris"}',
+ "call_id": "call_1",
+ "name": "get_weather",
+ }
+ ],
+ "parallel_tool_calls": True,
+ "usage": {
+ "input_tokens": 53,
+ "output_tokens": 18,
+ "total_tokens": 71,
+ "output_tokens_details": {"reasoning_tokens": 0},
+ },
+ "error": None,
+ "incomplete_details": None,
+ "instructions": None,
+ "metadata": {},
+ "temperature": 1.0,
+ "tool_choice": "auto",
+ "tools": [],
+ "top_p": 1.0,
+ "max_output_tokens": 200,
+ "previous_response_id": None,
+ "reasoning": {"effort": "medium", "summary": None},
+ "truncation": "disabled",
+ "user": None,
+}
+
+
+def test_completion_bridges_azure_ai_foundry_gpt_5_4_plus_function_tools_to_responses(
+ respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
+):
+ """
+ The bridged azure_ai call is posted to /openai/v1/responses with the tool in Responses
+ shape and Foundry's api-key header, never to /models/chat/completions, and comes back as a
+ chat completion carrying the function call.
+ """
+ monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
+ responses_route: Final = respx_mock.post(f"{_FOUNDRY_API_BASE}/openai/v1/responses").respond(
+ json=_FOUNDRY_RESPONSES_FUNCTION_CALL_BODY
+ )
+
+ response: Final = litellm.completion(
+ model="azure_ai/gpt-6-astra",
+ messages=[{"role": "user", "content": "What is the weather in Paris? Use the tool."}],
+ tools=[
+ {
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "description": "Get weather for a city",
+ "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
+ },
+ }
+ ],
+ max_tokens=200,
+ api_base=_FOUNDRY_API_BASE,
+ api_key="fake-foundry-key",
+ )
+
+ assert [str(call.request.url) for call in respx_mock.calls] == [f"{_FOUNDRY_API_BASE}/openai/v1/responses"]
+ request: Final = responses_route.calls[0].request
+ request_body: Final = json.loads(request.content)
+ assert request_body["tools"][0]["type"] == "function"
+ assert request_body["tools"][0]["name"] == "get_weather"
+ assert request.headers["api-key"] == "fake-foundry-key"
+ assert response.choices[0].finish_reason == "tool_calls"
+ assert response.choices[0].message.tool_calls[0].function.name == "get_weather"
+
+
@pytest.mark.parametrize(
"model, model_info, expected_model_param, expected_base_model_param",
[
From 9a63e06c63e60b3b30126aa7461115a2ccc041fe Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 23:28:51 +0000
Subject: [PATCH 083/317] test(integration): cover fal Seedance video queue
wire contract
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../llm_nonconversational.yaml | 1 -
tests/e2e/coverage_registry/schema.py | 2 -
.../LLM_TRANSLATION_COVERAGE_MATRIX.md | 2 -
tests/e2e/llm_translation/endpoints_client.py | 36 +---------
.../test_video_generation_e2e.py | 69 ------------------
tests/integration/contracts.json | 3 +
.../providers/test_fal_ai_video_wire.py | 72 +++++++++++++++++++
7 files changed, 76 insertions(+), 109 deletions(-)
delete mode 100644 tests/e2e/llm_translation/test_video_generation_e2e.py
create mode 100644 tests/integration/providers/test_fal_ai_video_wire.py
diff --git a/tests/e2e/coverage_registry/llm_nonconversational.yaml b/tests/e2e/coverage_registry/llm_nonconversational.yaml
index 6970567b6f0..50f9b9808b2 100644
--- a/tests/e2e/coverage_registry/llm_nonconversational.yaml
+++ b/tests/e2e/coverage_registry/llm_nonconversational.yaml
@@ -80,7 +80,6 @@
- {id: llm.images_generations.vertex.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: images_generations, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "vertex_ai/image_generation/image_generation_handler.py", rationale: "Vertex Imagen"}
- {id: llm.images_generations.bedrock.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: images_generations, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "bedrock/image_generation/image_handler.py", rationale: "Bedrock Titan Image"}
- {id: llm.images_generations.black_forest_labs.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: images_generations, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "black_forest_labs/image_generation/handler.py", rationale: "BFL Flux via OpenAI-compat"}
-- {id: llm.videos.fal_ai.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: videos, route: fal_ai, capability: basic, streaming: nonstream, assertions: [works], source: "test_video_generation_e2e.py", rationale: "fal queue video create, poll, content download"}
- {id: llm.audio_speech.openai.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: audio_speech, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_audio_speech_e2e.py:22", rationale: "OpenAI TTS binary audio"}
- {id: llm.audio_speech.openai.basic.stream.works, module: llm, tier: P1, subject_endpoint: audio_speech, route: openai, capability: basic, streaming: stream, assertions: [works], source: "proxy_server.py:9043", rationale: "TTS streaming chunk generator"}
- {id: llm.audio_speech.openai.input_validation.nonstream.works, module: llm, tier: P1, subject_endpoint: audio_speech, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "vendor strategy §9.6 / LIT-4778", rationale: "TTS missing input/model, invalid voice, empty input rejected"}
diff --git a/tests/e2e/coverage_registry/schema.py b/tests/e2e/coverage_registry/schema.py
index 3ae17432863..fa6dad90126 100644
--- a/tests/e2e/coverage_registry/schema.py
+++ b/tests/e2e/coverage_registry/schema.py
@@ -44,7 +44,6 @@ LlmEndpoint = Literal[
"vector_stores",
"ocr",
"bedrock_native",
- "videos",
]
LlmRoute = Literal[
@@ -54,7 +53,6 @@ LlmRoute = Literal[
"bedrock_converse",
"bedrock_invoke",
"cohere",
- "fal_ai",
"gemini",
"hosted_vllm",
"openai",
diff --git a/tests/e2e/llm_translation/LLM_TRANSLATION_COVERAGE_MATRIX.md b/tests/e2e/llm_translation/LLM_TRANSLATION_COVERAGE_MATRIX.md
index 178af054f2b..44d6e79122e 100644
--- a/tests/e2e/llm_translation/LLM_TRANSLATION_COVERAGE_MATRIX.md
+++ b/tests/e2e/llm_translation/LLM_TRANSLATION_COVERAGE_MATRIX.md
@@ -48,7 +48,6 @@ most likely to silently break and the one a mock can't prove works.
|----------|---------------|-----------|------------|-------------|--------|
| Chat | live (spend suite) | live (spend suite) | gap | live | partial |
| Embeddings | live (spend suite) | n/a | n/a | live | covered |
-| Video | live (fal.ai Seedance) | n/a | n/a | - | partial |
| Responses / image / audio / rerank / realtime | - | - | - | - | gap |
## This suite's files
@@ -62,7 +61,6 @@ most likely to silently break and the one a mock can't prove works.
| `test_anthropic_passthrough_streaming_logs_cost` | anthropic native, stream, cost |
| `test_anthropic_passthrough_tool_call_logs_cost` | anthropic native, tool call, cost |
| `test_vertex_passthrough_via_managed_model_logs_cost` | vertex_ai native, non-stream, cost |
-| `test_fal_seedance_video_completes_and_downloads` | fal.ai Seedance video create, poll, and content download |
Vertex keeps the credential on the proxy like gemini/anthropic, but the deployment is
added at runtime instead of declared in the gateway config: the test POSTs `/model/new`
diff --git a/tests/e2e/llm_translation/endpoints_client.py b/tests/e2e/llm_translation/endpoints_client.py
index 165a83e76c0..4d2c73e7078 100644
--- a/tests/e2e/llm_translation/endpoints_client.py
+++ b/tests/e2e/llm_translation/endpoints_client.py
@@ -13,7 +13,7 @@ from dataclasses import dataclass
from typing import Literal
from e2e_config import SLOW_PROVIDER_TIMEOUT_SECONDS
-from e2e_http import BinaryStream, NoBody, Result, StreamingResponse
+from e2e_http import BinaryStream, Result, StreamingResponse
from models import CacheControl, ChatMessage, LiteLLMParamsBody, RichMessage, TextBlock
from proxy_client import ProxyClient
from pydantic import BaseModel
@@ -26,8 +26,6 @@ __all__ = [
"TextBlock",
"TranscriptionForm",
"TranscriptionResult",
- "VideoObject",
- "VideoRequest",
]
@@ -129,13 +127,6 @@ class ImageRequest(BaseModel):
size: str = "1024x1024"
-class VideoRequest(BaseModel):
- model: str
- prompt: str
- seconds: str = "4"
- size: str = "1280x720"
-
-
class ImageEditForm(BaseModel):
model: str
prompt: str
@@ -276,12 +267,6 @@ class ImagesResult(BaseModel):
data: list[ImageItem] = []
-class VideoObject(BaseModel):
- id: str
- status: str
- model: str | None = None
-
-
class TranscriptionResult(BaseModel):
text: str = ""
@@ -455,25 +440,6 @@ class EndpointsClient:
"/v1/images/generations", key, ImageRequest(model=model, prompt=prompt)
)
- def videos(self, key: str, model: str, prompt: str) -> StreamingResponse:
- return self._send(
- "/v1/videos", key, VideoRequest(model=model, prompt=prompt)
- )
-
- def video_status(self, key: str, video_id: str) -> Result[VideoObject]:
- return self.proxy.transport.get(
- f"/v1/videos/{video_id}",
- headers=self.proxy.transport.bearer(key),
- params=NoBody(),
- response_type=VideoObject,
- )
-
- def video_content(self, key: str, video_id: str) -> StreamingResponse:
- return self.proxy.transport.download(
- f"/v1/videos/{video_id}/content",
- headers=self.proxy.transport.bearer(key),
- )
-
def image_edit(
self, key: str, model: str, prompt: str, image: bytes, *, filename: str = "image.png"
) -> Result[ImagesResult]:
diff --git a/tests/e2e/llm_translation/test_video_generation_e2e.py b/tests/e2e/llm_translation/test_video_generation_e2e.py
deleted file mode 100644
index b65529aa260..00000000000
--- a/tests/e2e/llm_translation/test_video_generation_e2e.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""Live e2e: POST /v1/videos creates a video and serves its content.
-
-Registers a fal.ai Seedance deployment at runtime, polls the queued video until it
-completes, and asserts the generated content is returned as binary data.
-"""
-
-from __future__ import annotations
-
-import time
-from typing import Final
-
-import pytest
-from e2e_config import unique_marker
-from e2e_http import require_successful_call, unwrap
-from endpoints_client import EndpointsClient, VideoObject
-from lifecycle import ResourceManager
-from models import LiteLLMParamsBody
-
-pytestmark = pytest.mark.e2e
-
-_POLL_INTERVAL_SECONDS: Final[float] = 5.0
-_POLL_TIMEOUT_SECONDS: Final[float] = 600.0
-
-
-def _wait_for_completion(
- endpoints_client: EndpointsClient, key: str, created: VideoObject
-) -> VideoObject:
- deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
- while time.monotonic() < deadline:
- status = unwrap(endpoints_client.video_status(key, created.id))
- assert status.id == created.id
- if status.status == "completed":
- return status
- if status.status == "failed":
- pytest.fail(f"fal.ai video generation failed: {status}")
- time.sleep(_POLL_INTERVAL_SECONDS)
- pytest.fail(f"fal.ai video {created.id!r} did not complete within {_POLL_TIMEOUT_SECONDS}s")
-
-
-class TestVideoGeneration:
- @pytest.mark.covers("llm.videos.fal_ai.basic.nonstream.works")
- def test_fal_seedance_video_completes_and_downloads(
- self, endpoints_client: EndpointsClient, resources: ResourceManager
- ) -> None:
- model = f"e2e-fal-video-{unique_marker()}"
- model_id = endpoints_client.create_model(
- model,
- LiteLLMParamsBody(
- model="fal_ai/bytedance/seedance-2.5/text-to-video",
- api_key="os.environ/FAL_AI_API_KEY",
- ),
- )
- resources.defer(lambda: endpoints_client.delete_model(model_id))
- key = resources.key()
-
- result = endpoints_client.videos(
- key, model, "a red fox running through snow at dawn"
- )
- require_successful_call(result)
- created = VideoObject.model_validate_json(result.body)
- assert created.id
- assert created.model
-
- _wait_for_completion(endpoints_client, key, created)
-
- content = endpoints_client.video_content(key, created.id)
- require_successful_call(content)
- assert len(content.body) > 0
- assert not (content.content_type or "").startswith("application/json")
diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json
index 6958ade50f7..01f7af6e8fe 100644
--- a/tests/integration/contracts.json
+++ b/tests/integration/contracts.json
@@ -160,6 +160,9 @@
"other.provider_wire.anthropic.tool_history_system_cache_and_internal_fields",
"quota_management.spend_tracking.cache_tokens.disjoint_classes_use_explicit_rates"
],
+ "tests/integration/providers/test_fal_ai_video_wire.py::test_fal_video_create_status_and_content_follow_queue_wire_contract": [
+ "other.provider_wire.fal_ai.video_queue_create_status_and_content_download"
+ ],
"tests/integration/mcp/test_mcp_lifecycle.py::test_saved_headers_reach_real_mcp_tool_and_survive_unrelated_edit": [
"mcp.call_tool.saved_headers.reach_actual_transport"
],
diff --git a/tests/integration/providers/test_fal_ai_video_wire.py b/tests/integration/providers/test_fal_ai_video_wire.py
new file mode 100644
index 00000000000..c1a2655f0aa
--- /dev/null
+++ b/tests/integration/providers/test_fal_ai_video_wire.py
@@ -0,0 +1,72 @@
+import json
+import sys
+import uuid
+from typing import Final
+
+import pytest
+from integration._support.client import Gateway
+from integration._support.wire import Reply, Request, wire_server
+
+_MODEL: Final = "bytedance/seedance-2.5/text-to-video"
+_MP4: Final = b"\x00\x00\x00\x18ftypmp42" + uuid.uuid4().bytes * 4
+
+
+@pytest.mark.covers("other.provider_wire.fal_ai.video_queue_create_status_and_content_download")
+def test_fal_video_create_status_and_content_follow_queue_wire_contract(gateway: Gateway) -> None:
+ request_id: Final = "fal-req-" + uuid.uuid4().hex
+
+ def respond(request: Request) -> Reply:
+ if request.target == f"/files/{request_id}.mp4":
+ assert request.method == "GET"
+ return Reply(body=_MP4, content_type="video/mp4")
+ assert request.headers["authorization"] == "Key synthetic-fal-key"
+ if request.method == "POST":
+ assert request.target == f"/{_MODEL}"
+ assert json.loads(request.body) == {
+ "prompt": "a cat playing volleyball on a beach",
+ "duration": "4",
+ "resolution": "720p",
+ "aspect_ratio": "16:9",
+ }
+ return Reply(
+ body=json.dumps({"status": "IN_QUEUE", "request_id": request_id, "queue_position": 0}).encode()
+ )
+ assert request.method == "GET"
+ if request.target == f"/bytedance/seedance-2.5/requests/{request_id}/status":
+ return Reply(body=json.dumps({"status": "COMPLETED", "request_id": request_id}).encode())
+ assert request.target == f"/bytedance/seedance-2.5/requests/{request_id}"
+ return Reply(body=json.dumps({"video": {"url": f"{wire_url}/files/{request_id}.mp4"}}).encode())
+
+ with wire_server(respond) as wire, gateway.scenario() as scenario:
+ wire_url: Final = wire.url
+ model: Final = scenario.model(
+ model=f"fal_ai/{_MODEL}",
+ api_base=wire.url,
+ api_key="synthetic-fal-key",
+ )
+ created: Final = gateway.post(
+ "/v1/videos",
+ {
+ "model": model,
+ "prompt": "a cat playing volleyball on a beach",
+ "seconds": "4",
+ "size": "1280x720",
+ },
+ )
+ assert created["status"] == "queued"
+ video_id: Final = created["id"]
+ assert isinstance(video_id, str) and video_id
+ status: Final = gateway.get(f"/v1/videos/{video_id}")
+ assert status["status"] == "completed"
+ status_id_matches_created_id: Final = status["id"] == video_id
+ sys.stdout.write(f"status_id_matches_created_id={status_id_matches_created_id}\n")
+ content: Final = gateway.request("GET", f"/v1/videos/{video_id}/content")
+ assert content.status_code == 200, content.text
+ assert content.headers["content-type"].startswith("video/mp4")
+ assert content.content == _MP4
+ assert [(request.method, request.target) for request in wire.drain()] == [
+ ("POST", f"/{_MODEL}"),
+ ("GET", f"/bytedance/seedance-2.5/requests/{request_id}/status"),
+ ("GET", f"/bytedance/seedance-2.5/requests/{request_id}"),
+ ("GET", f"/files/{request_id}.mp4"),
+ ]
From 21a2ed62448ebda3ab9de1245b2550aa0bf164e0 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sat, 19 Sep 2026 23:29:25 +0000
Subject: [PATCH 084/317] test(integration): drop id diagnostic from fal video
wire test
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/integration/providers/test_fal_ai_video_wire.py | 3 ---
1 file changed, 3 deletions(-)
diff --git a/tests/integration/providers/test_fal_ai_video_wire.py b/tests/integration/providers/test_fal_ai_video_wire.py
index c1a2655f0aa..8c72810ffb6 100644
--- a/tests/integration/providers/test_fal_ai_video_wire.py
+++ b/tests/integration/providers/test_fal_ai_video_wire.py
@@ -1,5 +1,4 @@
import json
-import sys
import uuid
from typing import Final
@@ -58,8 +57,6 @@ def test_fal_video_create_status_and_content_follow_queue_wire_contract(gateway:
assert isinstance(video_id, str) and video_id
status: Final = gateway.get(f"/v1/videos/{video_id}")
assert status["status"] == "completed"
- status_id_matches_created_id: Final = status["id"] == video_id
- sys.stdout.write(f"status_id_matches_created_id={status_id_matches_created_id}\n")
content: Final = gateway.request("GET", f"/v1/videos/{video_id}/content")
assert content.status_code == 200, content.text
assert content.headers["content-type"].startswith("video/mp4")
From f2b6c0da81ca247a3b6e7e52c85c51d313310a78 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 16:41:12 -0700
Subject: [PATCH 085/317] feat(bedrock_mantle): serve /v1/messages for Claude
models on Mantle's native Anthropic Messages API
---
.../messages/handler.py | 3 +-
.../llms/bedrock_mantle/messages/__init__.py | 0
.../bedrock_mantle/messages/transformation.py | 101 +++++
litellm/utils.py | 7 +
..._bedrock_mantle_messages_transformation.py | 346 ++++++++++++++++++
tests/test_litellm/test_utils.py | 22 ++
6 files changed, 478 insertions(+), 1 deletion(-)
create mode 100644 litellm/llms/bedrock_mantle/messages/__init__.py
create mode 100644 litellm/llms/bedrock_mantle/messages/transformation.py
create mode 100644 tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py
diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py
index 87a4801f987..e1309ea4063 100644
--- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py
+++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py
@@ -501,6 +501,7 @@ def anthropic_messages_handler(
api_base=litellm_params.api_base,
api_key=litellm_params.api_key,
)
+ resolved_api_base: Final = dynamic_api_base if dynamic_api_base is not None else api_base
# Store agentic loop params in logging object for agentic hooks
# This provides original request context needed for follow-up calls
@@ -662,7 +663,7 @@ def anthropic_messages_handler(
litellm_params=litellm_params,
logging_obj=litellm_logging_obj,
api_key=api_key,
- api_base=api_base,
+ api_base=resolved_api_base,
stream=stream,
kwargs=kwargs,
)
diff --git a/litellm/llms/bedrock_mantle/messages/__init__.py b/litellm/llms/bedrock_mantle/messages/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/litellm/llms/bedrock_mantle/messages/transformation.py b/litellm/llms/bedrock_mantle/messages/transformation.py
new file mode 100644
index 00000000000..a4365cfa49b
--- /dev/null
+++ b/litellm/llms/bedrock_mantle/messages/transformation.py
@@ -0,0 +1,101 @@
+from collections.abc import Mapping
+from typing import Final
+
+from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
+ DEFAULT_ANTHROPIC_API_VERSION,
+)
+from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
+from litellm.llms.bedrock.common_utils import MANTLE_MESSAGES_PATH
+from litellm.llms.bedrock.messages.mantle_transformation import AmazonMantleMessagesConfig
+from litellm.llms.bedrock_mantle.common_utils import (
+ MANTLE_HOST_RE,
+ BedrockMantleAuthMixin,
+ resolve_mantle_region,
+)
+from litellm.secret_managers.main import get_secret_str
+from litellm.types.router import GenericLiteLLMParams
+
+_BASE_SUFFIXES_TO_STRIP: Final = (
+ MANTLE_MESSAGES_PATH,
+ "/v1/messages",
+ "/messages",
+ "/anthropic/v1",
+ "/openai/v1",
+ "/v1",
+)
+
+
+def build_mantle_native_messages_url(api_base: str | None, litellm_params: Mapping[str, object]) -> str:
+ region: Final = resolve_mantle_region({**litellm_params, "api_base": api_base})
+ configured: Final = (
+ api_base or get_secret_str("BEDROCK_MANTLE_API_BASE") or f"https://bedrock-mantle.{region}.api.aws"
+ ).rstrip("/")
+ stripped: Final = next(
+ (configured[: -len(suffix)] for suffix in _BASE_SUFFIXES_TO_STRIP if configured.endswith(suffix)),
+ configured,
+ )
+ host: Final = f"https://bedrock-mantle.{region}.api.aws" if MANTLE_HOST_RE.match(stripped) else stripped
+ return f"{host}{MANTLE_MESSAGES_PATH}"
+
+
+class BedrockMantleAnthropicMessagesConfig(BedrockMantleAuthMixin, AmazonMantleMessagesConfig):
+ def __init__(self, aws_signer: BaseAWSLLM | None = None) -> None:
+ AmazonMantleMessagesConfig.__init__(self)
+ self._aws_signer = aws_signer or self
+
+ @property
+ def custom_llm_provider(self) -> str | None:
+ return "bedrock_mantle"
+
+ def get_complete_url(
+ self,
+ api_base: str | None,
+ api_key: str | None,
+ model: str,
+ optional_params: dict,
+ litellm_params: dict,
+ stream: bool | None = None,
+ ) -> str:
+ return build_mantle_native_messages_url(api_base=api_base, litellm_params=litellm_params)
+
+ def validate_anthropic_messages_environment(
+ self,
+ headers: dict,
+ model: str,
+ messages: list[dict],
+ optional_params: dict,
+ litellm_params: dict,
+ api_key: str | None = None,
+ api_base: str | None = None,
+ ) -> tuple[dict, str | None]:
+ merged_headers, resolved_api_base = super().validate_anthropic_messages_environment(
+ headers=headers,
+ model=model,
+ messages=messages,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ api_key=api_key,
+ api_base=api_base,
+ )
+ if any(name.lower() == "anthropic-version" for name in merged_headers):
+ return merged_headers, resolved_api_base
+ return {**merged_headers, "anthropic-version": DEFAULT_ANTHROPIC_API_VERSION}, resolved_api_base
+
+ def transform_anthropic_messages_request(
+ self,
+ model: str,
+ messages: list[dict],
+ anthropic_messages_optional_request_params: dict,
+ litellm_params: GenericLiteLLMParams,
+ headers: dict,
+ ) -> dict:
+ request: Final = super().transform_anthropic_messages_request(
+ model=model,
+ messages=messages,
+ anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
+ litellm_params=litellm_params,
+ headers=headers,
+ )
+ if "anthropic_version" in anthropic_messages_optional_request_params:
+ return request
+ return {key: value for key, value in request.items() if key != "anthropic_version"}
diff --git a/litellm/utils.py b/litellm/utils.py
index b724313641f..3439a21b560 100644
--- a/litellm/utils.py
+++ b/litellm/utils.py
@@ -8681,6 +8681,13 @@ class ProviderConfigManager:
from litellm.llms.bedrock.common_utils import BedrockModelInfo
return BedrockModelInfo.get_bedrock_provider_config_for_messages_api(model)
+ elif litellm.LlmProviders.BEDROCK_MANTLE == provider:
+ if "claude" in model_lower:
+ from litellm.llms.bedrock_mantle.messages.transformation import (
+ BedrockMantleAnthropicMessagesConfig,
+ )
+
+ return BedrockMantleAnthropicMessagesConfig()
elif litellm.LlmProviders.VERTEX_AI == provider:
if "claude" in model_lower:
from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.experimental_pass_through.transformation import (
diff --git a/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py
new file mode 100644
index 00000000000..2961eee925c
--- /dev/null
+++ b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py
@@ -0,0 +1,346 @@
+"""
+Unit tests for the bedrock_mantle native Anthropic Messages route.
+
+Mantle serves its Claude models only on `/anthropic/v1/messages` (the OpenAI
+paths reject them), so `bedrock_mantle/anthropic.claude-*` requests on
+/v1/messages must hit that endpoint directly instead of the chat-completions
+bridge. These tests lock the dispatcher gate, the URL derivation from the
+OpenAI-surface base that get_llm_provider pre-fills, the version header, the
+Bearer/SigV4 auth chain, and the wire request through the public entrypoint.
+"""
+
+import json
+from unittest.mock import MagicMock
+
+import httpx
+import pytest
+import respx
+
+import litellm
+from litellm.caching.llm_caching_handler import LLMClientCache
+from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
+from litellm.llms.bedrock_mantle.messages.transformation import (
+ BedrockMantleAnthropicMessagesConfig,
+ build_mantle_native_messages_url,
+)
+from litellm.types.router import GenericLiteLLMParams
+from litellm.utils import ProviderConfigManager
+
+MESSAGES_PATH = "/anthropic/v1/messages"
+
+
+@pytest.fixture(autouse=True)
+def _httpx_transport_with_fresh_clients(monkeypatch):
+ monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
+ monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", LLMClientCache())
+
+
+@pytest.fixture(autouse=True)
+def _no_ambient_mantle_env(monkeypatch):
+ monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
+ monkeypatch.delenv("BEDROCK_MANTLE_API_KEY", raising=False)
+ monkeypatch.delenv("BEDROCK_MANTLE_API_BASE", raising=False)
+ monkeypatch.delenv("BEDROCK_MANTLE_REGION", raising=False)
+ monkeypatch.delenv("AWS_REGION_NAME", raising=False)
+ monkeypatch.delenv("AWS_REGION", raising=False)
+
+
+def _anthropic_response() -> httpx.Response:
+ return httpx.Response(
+ status_code=200,
+ json={
+ "id": "msg_test",
+ "type": "message",
+ "role": "assistant",
+ "model": "anthropic.claude-sonnet-5",
+ "content": [{"type": "text", "text": "pong"}],
+ "stop_reason": "end_turn",
+ "stop_sequence": None,
+ "usage": {"input_tokens": 3, "output_tokens": 1},
+ },
+ )
+
+
+_SSE_EVENTS = (
+ (
+ "message_start",
+ {
+ "type": "message_start",
+ "message": {
+ "id": "msg_stream",
+ "type": "message",
+ "role": "assistant",
+ "model": "anthropic.claude-sonnet-5",
+ "content": [],
+ "stop_reason": None,
+ "stop_sequence": None,
+ "usage": {"input_tokens": 3, "output_tokens": 1},
+ },
+ },
+ ),
+ ("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
+ ("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "pong"}}),
+ ("content_block_stop", {"type": "content_block_stop", "index": 0}),
+ ("message_delta", {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 1}}),
+ ("message_stop", {"type": "message_stop"}),
+)
+
+
+def _sse_response() -> httpx.Response:
+ body = "".join(f"event: {event}\ndata: {json.dumps(payload)}\n\n" for event, payload in _SSE_EVENTS).encode()
+ return httpx.Response(status_code=200, content=body, headers={"content-type": "text/event-stream"})
+
+
+def _mantle_messages_route(region: str) -> respx.Route:
+ return respx.post(f"https://bedrock-mantle.{region}.api.aws{MESSAGES_PATH}")
+
+
+def _sent_body(route: respx.Route) -> dict:
+ return json.loads(route.calls.last.request.content)
+
+
+class TestDispatch:
+ def test_claude_models_get_the_native_messages_config(self):
+ config = ProviderConfigManager.get_provider_anthropic_messages_config(
+ model="anthropic.claude-sonnet-5", provider=litellm.LlmProviders.BEDROCK_MANTLE
+ )
+ assert isinstance(config, BedrockMantleAnthropicMessagesConfig)
+ assert config.custom_llm_provider == "bedrock_mantle"
+
+ @pytest.mark.parametrize("model", ["openai.gpt-5.6-sol", "openai.gpt-oss-120b-1:0", "google.gemma-4-31b"])
+ def test_non_claude_models_keep_the_bridge(self, model):
+ assert (
+ ProviderConfigManager.get_provider_anthropic_messages_config(
+ model=model, provider=litellm.LlmProviders.BEDROCK_MANTLE
+ )
+ is None
+ )
+
+
+class TestURL:
+ @pytest.mark.parametrize(
+ "api_base",
+ [
+ "https://bedrock-mantle.us-east-1.api.aws/v1",
+ "https://bedrock-mantle.us-east-1.api.aws/openai/v1",
+ "https://bedrock-mantle.us-east-1.api.aws/openai/v1/",
+ "https://bedrock-mantle.us-east-1.api.aws",
+ "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages",
+ ],
+ )
+ def test_prefilled_openai_base_becomes_the_messages_endpoint(self, api_base):
+ url = build_mantle_native_messages_url(api_base, {"aws_region_name": "us-east-1"})
+ assert url == f"https://bedrock-mantle.us-east-1.api.aws{MESSAGES_PATH}"
+
+ def test_aws_region_name_wins_over_the_prefilled_host_region(self):
+ url = build_mantle_native_messages_url(
+ "https://bedrock-mantle.us-east-1.api.aws/v1", {"aws_region_name": "us-east-2"}
+ )
+ assert url == f"https://bedrock-mantle.us-east-2.api.aws{MESSAGES_PATH}"
+
+ def test_host_region_is_used_when_no_region_param(self):
+ url = build_mantle_native_messages_url("https://bedrock-mantle.eu-west-1.api.aws/v1", {})
+ assert url == f"https://bedrock-mantle.eu-west-1.api.aws{MESSAGES_PATH}"
+
+ def test_custom_host_is_preserved(self):
+ url = build_mantle_native_messages_url("https://vpce-abc.bedrock-mantle.example.com/v1", {})
+ assert url == f"https://vpce-abc.bedrock-mantle.example.com{MESSAGES_PATH}"
+
+ def test_env_base_is_used_without_api_base(self, monkeypatch):
+ monkeypatch.setenv("BEDROCK_MANTLE_API_BASE", "https://mantle-proxy.internal/openai/v1")
+ assert build_mantle_native_messages_url(None, {}) == f"https://mantle-proxy.internal{MESSAGES_PATH}"
+
+ def test_default_host_comes_from_mantle_region_env(self, monkeypatch):
+ monkeypatch.setenv("BEDROCK_MANTLE_REGION", "ap-northeast-1")
+ assert build_mantle_native_messages_url(None, {}) == f"https://bedrock-mantle.ap-northeast-1.api.aws{MESSAGES_PATH}"
+
+ def test_config_get_complete_url_reads_litellm_params(self):
+ config = BedrockMantleAnthropicMessagesConfig()
+ url = config.get_complete_url(
+ api_base="https://bedrock-mantle.us-east-1.api.aws/v1",
+ api_key=None,
+ model="anthropic.claude-sonnet-5",
+ optional_params={},
+ litellm_params={"aws_region_name": "us-west-2"},
+ )
+ assert url == f"https://bedrock-mantle.us-west-2.api.aws{MESSAGES_PATH}"
+
+
+class TestEnvironment:
+ def _validate(self, headers: dict, litellm_params: dict) -> dict:
+ config = BedrockMantleAnthropicMessagesConfig()
+ merged, _ = config.validate_anthropic_messages_environment(
+ headers=headers,
+ model="anthropic.claude-sonnet-5",
+ messages=[],
+ optional_params={},
+ litellm_params=litellm_params,
+ )
+ return merged
+
+ def test_adds_the_anthropic_version_header(self):
+ assert self._validate({}, {})["anthropic-version"] == "2023-06-01"
+
+ def test_keeps_a_caller_supplied_version_header(self):
+ merged = self._validate({"Anthropic-Version": "2024-01-01"}, {})
+ assert merged["Anthropic-Version"] == "2024-01-01"
+ assert "anthropic-version" not in merged
+
+ def test_project_id_becomes_the_workspace_header(self):
+ assert self._validate({}, {"aws_bedrock_project_id": "proj_123"})["anthropic-workspace"] == "proj_123"
+
+
+class TestRequestBody:
+ def test_body_carries_model_and_stream_but_not_the_invoke_version(self):
+ config = BedrockMantleAnthropicMessagesConfig()
+ body = config.transform_anthropic_messages_request(
+ model="anthropic.claude-sonnet-5",
+ messages=[{"role": "user", "content": "ping"}],
+ anthropic_messages_optional_request_params={"max_tokens": 8, "stream": True},
+ litellm_params=GenericLiteLLMParams(),
+ headers={},
+ )
+ assert body["model"] == "anthropic.claude-sonnet-5"
+ assert body["stream"] is True
+ assert body["max_tokens"] == 8
+ assert "anthropic_version" not in body
+
+ def test_body_omits_stream_when_not_streaming(self):
+ config = BedrockMantleAnthropicMessagesConfig()
+ body = config.transform_anthropic_messages_request(
+ model="anthropic.claude-sonnet-5",
+ messages=[{"role": "user", "content": "ping"}],
+ anthropic_messages_optional_request_params={"max_tokens": 8},
+ litellm_params=GenericLiteLLMParams(),
+ headers={},
+ )
+ assert "stream" not in body
+
+
+class TestAuth:
+ def test_bearer_from_api_key_skips_aws_credentials(self):
+ signer = BaseAWSLLM()
+ signer.get_credentials = MagicMock(side_effect=AssertionError("must not resolve AWS credentials"))
+ config = BedrockMantleAnthropicMessagesConfig(aws_signer=signer)
+ headers, signed = config.sign_request(
+ headers={"anthropic-version": "2023-06-01"},
+ optional_params={},
+ request_data={"model": "anthropic.claude-sonnet-5"},
+ api_base=f"https://bedrock-mantle.us-east-1.api.aws{MESSAGES_PATH}",
+ api_key="arg-bearer",
+ )
+ assert headers["Authorization"] == "Bearer arg-bearer"
+ assert headers["anthropic-version"] == "2023-06-01"
+ assert signed == b'{"model": "anthropic.claude-sonnet-5"}'
+
+ def test_bearer_from_mantle_env_key(self, monkeypatch):
+ monkeypatch.setenv("BEDROCK_MANTLE_API_KEY", "env-bearer")
+ config = BedrockMantleAnthropicMessagesConfig()
+ headers, _ = config.sign_request(
+ headers={},
+ optional_params={},
+ request_data={},
+ api_base=f"https://bedrock-mantle.us-east-1.api.aws{MESSAGES_PATH}",
+ api_key=None,
+ )
+ assert headers["Authorization"] == "Bearer env-bearer"
+
+ def test_sigv4_scope_is_pinned_to_the_url_host_region(self):
+ config = BedrockMantleAnthropicMessagesConfig()
+ headers, signed = config.sign_request(
+ headers={"anthropic-version": "2023-06-01"},
+ optional_params={
+ "aws_access_key_id": "AKIAEXAMPLE",
+ "aws_secret_access_key": "c2VjcmV0LXRlc3Qtc2VjcmV0LXRlc3Qtc2VjcmV0",
+ "aws_region_name": "us-east-1",
+ },
+ request_data={"model": "anthropic.claude-sonnet-5"},
+ api_base=f"https://bedrock-mantle.us-west-2.api.aws{MESSAGES_PATH}",
+ api_key=None,
+ )
+ assert headers["Authorization"].startswith("AWS4-HMAC-SHA256")
+ assert "/us-west-2/bedrock/aws4_request" in headers["Authorization"]
+ assert signed == b'{"model": "anthropic.claude-sonnet-5"}'
+
+
+class TestWireRequest:
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_claude_request_hits_the_native_messages_endpoint(self):
+ route = _mantle_messages_route("us-east-1").mock(return_value=_anthropic_response())
+
+ response = await litellm.anthropic_messages(
+ model="bedrock_mantle/anthropic.claude-sonnet-5",
+ messages=[{"role": "user", "content": "ping"}],
+ max_tokens=8,
+ api_key="test-bearer",
+ aws_region_name="us-east-1",
+ )
+
+ assert response["content"][0]["text"] == "pong"
+ assert route.call_count == 1
+ sent = route.calls.last.request
+ assert sent.headers["authorization"] == "Bearer test-bearer"
+ assert sent.headers["anthropic-version"] == "2023-06-01"
+ assert "x-api-key" not in sent.headers
+ body = _sent_body(route)
+ assert body["model"] == "anthropic.claude-sonnet-5"
+ assert body["messages"] == [{"role": "user", "content": "ping"}]
+ assert "anthropic_version" not in body
+ assert "stream" not in body
+
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_region_prefix_selects_the_host_and_is_not_sent_as_model(self):
+ route = _mantle_messages_route("us-east-2").mock(return_value=_anthropic_response())
+
+ await litellm.anthropic_messages(
+ model="bedrock_mantle/us-east-2/anthropic.claude-haiku-4-5",
+ messages=[{"role": "user", "content": "ping"}],
+ max_tokens=8,
+ api_key="test-bearer",
+ )
+
+ assert route.call_count == 1
+ assert _sent_body(route)["model"] == "anthropic.claude-haiku-4-5"
+
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_streaming_sends_stream_and_passes_the_sse_through(self):
+ route = _mantle_messages_route("us-east-1").mock(return_value=_sse_response())
+
+ response = await litellm.anthropic_messages(
+ model="bedrock_mantle/anthropic.claude-sonnet-5",
+ messages=[{"role": "user", "content": "ping"}],
+ max_tokens=8,
+ stream=True,
+ api_key="test-bearer",
+ aws_region_name="us-east-1",
+ )
+ raw = b"".join([chunk async for chunk in response])
+
+ assert route.call_count == 1
+ assert _sent_body(route)["stream"] is True
+ text = raw.decode()
+ assert "event: message_start" in text
+ assert '"text": "pong"' in text
+ assert "event: message_stop" in text
+
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_sigv4_request_signs_against_the_messages_url(self):
+ route = _mantle_messages_route("us-east-1").mock(return_value=_anthropic_response())
+
+ await litellm.anthropic_messages(
+ model="bedrock_mantle/anthropic.claude-sonnet-5",
+ messages=[{"role": "user", "content": "ping"}],
+ max_tokens=8,
+ aws_access_key_id="AKIAEXAMPLE",
+ aws_secret_access_key="c2VjcmV0LXRlc3Qtc2VjcmV0LXRlc3Qtc2VjcmV0",
+ aws_region_name="us-east-1",
+ )
+
+ assert route.call_count == 1
+ authorization = route.calls.last.request.headers["authorization"]
+ assert authorization.startswith("AWS4-HMAC-SHA256")
+ assert "/us-east-1/bedrock/aws4_request" in authorization
diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py
index 3336ad6d33a..a4c06122189 100644
--- a/tests/test_litellm/test_utils.py
+++ b/tests/test_litellm/test_utils.py
@@ -3640,6 +3640,28 @@ class TestGetOptionalParamsTencent:
assert isinstance(config, TencentAnthropicMessagesConfig)
assert config.custom_llm_provider == "tencent"
+ def test_bedrock_mantle_claude_messages_config_routing(self):
+ import litellm
+ from litellm.llms.bedrock_mantle.messages.transformation import (
+ BedrockMantleAnthropicMessagesConfig,
+ )
+
+ config = ProviderConfigManager.get_provider_anthropic_messages_config(
+ model="anthropic.claude-sonnet-5",
+ provider=litellm.LlmProviders.BEDROCK_MANTLE,
+ )
+ assert isinstance(config, BedrockMantleAnthropicMessagesConfig)
+ assert config.custom_llm_provider == "bedrock_mantle"
+
+ def test_bedrock_mantle_openai_models_keep_the_messages_bridge(self):
+ import litellm
+
+ config = ProviderConfigManager.get_provider_anthropic_messages_config(
+ model="openai.gpt-5.6-sol",
+ provider=litellm.LlmProviders.BEDROCK_MANTLE,
+ )
+ assert config is None
+
class TestValidateEnvironmentTencent:
"""Tests that validate_environment resolves TENCENT_API_KEY for the tencent provider."""
From 2bb603ab4cf825ea15a6059302e0c8e234993b44 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 16:46:27 -0700
Subject: [PATCH 086/317] test(azure_ai): drop docstrings from the Foundry
bridge tests
---
tests/test_litellm/test_main.py | 17 -----------------
1 file changed, 17 deletions(-)
diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py
index e0ca0c1cbf9..ab09d242e98 100644
--- a/tests/test_litellm/test_main.py
+++ b/tests/test_litellm/test_main.py
@@ -1321,13 +1321,6 @@ _FOUNDRY_FUNCTION_TOOL: Final = ({"type": "function", "function": {"name": "get_
],
)
def test_responses_api_bridge_check_azure_ai_foundry_gpt_5_4_plus_tools_routes_to_responses(api_base, reasoning_effort):
- """
- An azure_ai deployment of a gpt-5.4+ model on a Foundry OpenAI v1 host is the same Azure OpenAI
- backend the azure provider bridges: its chat surface rejects function tools whenever reasoning is
- on, and for gpt-6-astra it rejects reasoning_effort "none" too, so the Responses route on the same
- endpoint is the only way to serve the request. Regression guard: the gate used to bridge only the
- openai and azure providers, so these requests died at Foundry's /models/chat/completions.
- """
from litellm.main import responses_api_bridge_check
model_info, model = responses_api_bridge_check(
@@ -1354,11 +1347,6 @@ def test_responses_api_bridge_check_azure_ai_foundry_gpt_5_4_plus_tools_routes_t
def test_responses_api_bridge_check_azure_ai_without_foundry_responses_route_stays_chat(
model_name, api_base, reasoning_effort
):
- """
- The azure_ai bridge fires only where the Foundry Responses config is selectable: a serverless
- host, a non-OpenAI model, and claude-on-Foundry have no Responses route to bridge to, and an
- explicit reasoning_effort "none" keeps the request chat-servable on the same terms as azure.
- """
from litellm.main import responses_api_bridge_check
model_info, model = responses_api_bridge_check(
@@ -1595,11 +1583,6 @@ _FOUNDRY_RESPONSES_FUNCTION_CALL_BODY: Final = {
def test_completion_bridges_azure_ai_foundry_gpt_5_4_plus_function_tools_to_responses(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
):
- """
- The bridged azure_ai call is posted to /openai/v1/responses with the tool in Responses
- shape and Foundry's api-key header, never to /models/chat/completions, and comes back as a
- chat completion carrying the function call.
- """
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
responses_route: Final = respx_mock.post(f"{_FOUNDRY_API_BASE}/openai/v1/responses").respond(
json=_FOUNDRY_RESPONSES_FUNCTION_CALL_BODY
From 401baf32c3f6bb11bce52dee3bd253e0a6e8d9e0 Mon Sep 17 00:00:00 2001
From: Moe Khalil
Date: Sun, 20 Sep 2026 00:26:11 +0000
Subject: [PATCH 087/317] fix(auto-router): preserve JEV transport across
dashboard edits
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../model_management_endpoints.py | 58 ++++++++++++++---
.../test_model_management_endpoints.py | 62 +++++++++++++++++++
...d_updated_complexity_router_config.test.ts | 34 ++++++++++
3 files changed, 144 insertions(+), 10 deletions(-)
diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py
index 554daf030c7..ea124776d0b 100644
--- a/litellm/proxy/management_endpoints/model_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/model_management_endpoints.py
@@ -22,7 +22,7 @@ from types import MappingProxyType
from typing import TYPE_CHECKING, Annotated, Final, Literal, Protocol, TypeVar, cast, runtime_checkable
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
-from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
+from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, field_validator
import litellm
from litellm._logging import verbose_proxy_logger
@@ -289,7 +289,11 @@ def _strategy_router_write_violation(
if incoming_params is None:
return None
config_violation: Final = validate_complexity_router_config_write(
- complexity_router_config=incoming_params.complexity_router_config
+ complexity_router_config=(
+ _effective_complexity_router_config(incoming_params, existing_params)
+ if incoming_params.complexity_router_config is not None
+ else None
+ )
)
if config_violation is not None:
return config_violation
@@ -350,11 +354,33 @@ WHERE model_id <> $1
def _effective_complexity_router_config(
incoming_params: GenericLiteLLMParams | None, existing_params: GenericLiteLLMParams | None
) -> object:
- """The complexity config a write leaves on the row: the incoming one when the write carries it, else the stored one."""
incoming: Final = None if incoming_params is None else incoming_params.complexity_router_config
- if incoming is not None or existing_params is None:
+ existing: Final = None if existing_params is None else existing_params.complexity_router_config
+ if incoming is None:
+ return existing
+ if existing is None or incoming.get("classifier_type") != "jev" or existing.get("classifier_type") != "jev":
return incoming
- return existing_params.complexity_router_config
+ incoming_jev: Final[object] = incoming.get("jev_classifier_config")
+ existing_jev: Final[object] = existing.get("jev_classifier_config")
+ if not isinstance(incoming_jev, Mapping) or not isinstance(existing_jev, Mapping):
+ return incoming
+ supplied: Final = TypeAdapter(dict[str, object]).validate_python(incoming_jev)
+ stored: Final = TypeAdapter(dict[str, object]).validate_python(existing_jev)
+ same_base: Final = "api_base" not in supplied or supplied["api_base"] == stored.get("api_base")
+ transport: Final = MappingProxyType(
+ {
+ key: value
+ for key, value in stored.items()
+ if key in ("api_key", "api_base") and (key != "api_key" or same_base)
+ }
+ )
+ return { # mutable-ok: persisted JSON requires concrete nested dicts
+ **incoming,
+ "jev_classifier_config": { # mutable-ok: json.dumps cannot serialize MappingProxyType
+ **transport,
+ **supplied,
+ },
+ }
def _effective_model(
@@ -886,7 +912,12 @@ def update_db_model(db_model: Deployment, updated_patch: updateDeployment) -> Pr
if updated_patch.litellm_params:
# Encrypt any sensitive values
encrypted_params: Final = {
- k: encrypt_value_helper(v) for k, v in updated_patch.litellm_params.model_dump(exclude_none=True).items()
+ k: (
+ _effective_complexity_router_config(updated_patch.litellm_params, db_model.litellm_params)
+ if k == "complexity_router_config"
+ else encrypt_value_helper(v)
+ )
+ for k, v in updated_patch.litellm_params.model_dump(exclude_none=True).items()
}
merged_litellm_params.update(encrypted_params)
@@ -2528,14 +2559,21 @@ async def update_model(
_new_litellm_params_dict: Final = model_params.litellm_params.dict(exclude_none=True)
### ENCRYPT PARAMS ###
- for k, v in _new_litellm_params_dict.items():
- encrypted_value = encrypt_value_helper(value=v)
- model_params.litellm_params[k] = encrypted_value
+ encrypted_params: Final = MappingProxyType(
+ {
+ k: (
+ _effective_complexity_router_config(model_params.litellm_params, deployment.litellm_params)
+ if k == "complexity_router_config"
+ else encrypt_value_helper(value=v)
+ )
+ for k, v in _new_litellm_params_dict.items()
+ }
+ )
### MERGE WITH EXISTING DATA ###
_mp: Final[dict[str, object]] = model_params.litellm_params.dict()
merged_dictionary: Final = {
- key: _existing_litellm_params_dict[key] if value is None else value
+ key: _existing_litellm_params_dict[key] if value is None else encrypted_params[key]
for key, value in _mp.items()
if value is not None or _existing_litellm_params_dict.get(key) is not None
}
diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py
index daaad6efe4c..376309d8a7e 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py
@@ -17,6 +17,7 @@ from litellm.proxy._types import (
LiteLLM_TeamTable,
LitellmUserRoles,
Member,
+ ProxyException,
ReconcileOutcome,
UserAPIKeyAuth,
)
@@ -27,6 +28,8 @@ from litellm.proxy.management_endpoints.model_management_endpoints import (
_raise_if_rate_limits_required_but_missing,
clear_cache,
delete_team_models,
+ patch_model,
+ update_model,
)
from litellm.proxy.utils import PrismaClient
from litellm.router import Router
@@ -6602,6 +6605,65 @@ class TestTeamMemberAutoRouterWrites:
assert saved_info["team_id"] == "member-team"
assert saved_info["access_groups"] == ["retained-admin-group"]
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("endpoint", ["patch", "legacy"])
+ @pytest.mark.parametrize("change", ["save", "rotate", "move", "move-without-key", "reset", "heuristic"])
+ async def test_jev_dashboard_save_preserves_server_transport(self, endpoint: str, change: str) -> None:
+ original: Final = self._row()
+ transport: Final = {"api_key": "synthetic-original-jev-key", "api_base": "https://jev.example.com"}
+ stored_config: Final = {
+ "classifier_type": "jev",
+ "tiers": {"SIMPLE": "allowed"},
+ "jev_classifier_config": {**transport, "instructions": "Old instructions", "timeout_ms": 6100},
+ }
+ row: Final = original.model_copy(
+ update={
+ "litellm_params": {
+ "model": "auto_router/complexity_router",
+ "complexity_router_config": stored_config,
+ },
+ }
+ )
+ database: Final = self._database(self._team(), row)
+ overrides: Final = {
+ "save": {},
+ "rotate": {"api_key": "synthetic-replacement-jev-key"},
+ "move": {"api_base": "https://new-jev.example.com", "api_key": "synthetic-replacement-jev-key"},
+ "move-without-key": {"api_base": "https://new-jev.example.com"},
+ "reset": {"api_key": None, "api_base": None},
+ "heuristic": {},
+ }[change]
+ config: Final = {
+ "tiers": {"SIMPLE": "allowed"},
+ "classifier_type": "heuristic" if change == "heuristic" else "jev",
+ **({} if change == "heuristic" else {"jev_classifier_config": {"timeout_ms": 8100, **overrides}}),
+ }
+ request: Final = updateDeployment(
+ litellm_params=updateLiteLLMParams(complexity_router_config=config),
+ model_info=ModelInfo(id=row.model_id),
+ )
+ actor: Final = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
+ with self._environment(database, row):
+ operation: Final = (
+ patch_model(row.model_id, request, actor) if endpoint == "patch" else update_model(request, actor)
+ )
+ if change == "move-without-key":
+ with pytest.raises(ProxyException, match="api_base requires"):
+ await operation
+ database.db.litellm_proxymodeltable.update.assert_not_awaited()
+ return
+ await operation
+ written: Final = database.db.litellm_proxymodeltable.update.await_args.kwargs["data"]
+ saved: Final = json.loads(written["litellm_params"])["complexity_router_config"]
+ expected: Final = (
+ config
+ if change == "heuristic"
+ else {**config, "jev_classifier_config": {**transport, "timeout_ms": 8100, **overrides}}
+ )
+ assert saved == expected
+ assert row.litellm_params["complexity_router_config"] == stored_config
+ assert request.litellm_params.complexity_router_config == config
+
@pytest.mark.asyncio
@pytest.mark.parametrize("endpoint", ["patch", "legacy"])
@pytest.mark.parametrize("access", ["owner", "peer", "limited-key"])
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
index 604d2c9113d..85439d99f21 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
@@ -48,6 +48,40 @@ const hydratedState: KeywordMatchingState = {
};
describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
+ it.each([false, true])("omits masked JEV credentials from dashboard saves, edited: %s", (edited) => {
+ const stored = {
+ classifier_type: "jev" as const,
+ tiers: FORM_VALUE.tiers,
+ jev_classifier_config: {
+ model: "jev-configured",
+ timeout_ms: 6100,
+ instructions: "Existing instructions",
+ api_key: "sk-s****************cret",
+ api_base: "https://jev.example.com",
+ },
+ };
+ const hydrated = hydrateComplexityRouterConfig(stored, undefined);
+ expect(hydrated.jev_classifier_config).not.toHaveProperty("api_key");
+ expect(hydrated.jev_classifier_config).not.toHaveProperty("api_base");
+ const value = edited
+ ? {
+ ...hydrated,
+ jev_classifier_config: { model: "jev-updated", timeout_ms: 8100, instructions: "" },
+ }
+ : hydrated;
+ const saved = buildUpdatedComplexityRouterConfig(stored, value);
+ expect(saved.jev_classifier_config).toEqual({
+ ...(edited
+ ? { model: "jev-updated", timeout_ms: 8100 }
+ : { model: "jev-configured", timeout_ms: 6100, instructions: "Existing instructions" }),
+ });
+ for (const classifierType of ["llm", "heuristic"] as const) {
+ expect(
+ buildUpdatedComplexityRouterConfig(saved, transitionClassifierType(value, classifierType)),
+ ).not.toHaveProperty("jev_classifier_config");
+ }
+ });
+
it("hydrates nullable JEV instructions without resetting the server configuration", () => {
const stored = {
classifier_type: "jev" as const,
From 2e23c2d6536b883e2a37d3aa4dcd5e5647f8c040 Mon Sep 17 00:00:00 2001
From: yucheng
Date: Sun, 20 Sep 2026 00:50:18 +0000
Subject: [PATCH 088/317] fix(user_update): evict cached user on max_budget
change so the personal key ceiling refreshes on every worker
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../internal_user_endpoints.py | 5 ++-
.../test_internal_user_endpoints.py | 43 +++++++++++++++++++
2 files changed, 46 insertions(+), 2 deletions(-)
diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py
index ba7a3309a90..0e5028d797b 100644
--- a/litellm/proxy/management_endpoints/internal_user_endpoints.py
+++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py
@@ -101,6 +101,7 @@ if TYPE_CHECKING:
router: Final = APIRouter()
_USER_MODEL_BUDGET_ADAPTER: Final = TypeAdapter(dict[str, float | BudgetConfig])
_USER_BUDGET_CACHE_INVALIDATION_BATCH_SIZE: Final = 50
+_USER_BUDGET_CACHE_FIELDS: Final = frozenset({"max_budget", "model_max_budget"})
def _user_table(
@@ -1561,7 +1562,7 @@ async def _update_single_user_helper(
await _invalidate_user_spend_counter_if_changed(non_default_values)
- if "model_max_budget" in non_default_values:
+ if not _USER_BUDGET_CACHE_FIELDS.isdisjoint(non_default_values):
await evict_and_broadcast(
cache_keys=(non_default_values["user_id"],),
user_api_key_cache=user_api_key_cache,
@@ -1892,7 +1893,7 @@ async def bulk_user_update(
),
)
- if "model_max_budget" in non_default_values:
+ if not _USER_BUDGET_CACHE_FIELDS.isdisjoint(non_default_values):
for start in range(0, len(all_users_in_db), _USER_BUDGET_CACHE_INVALIDATION_BATCH_SIZE):
await asyncio.gather(
*(
diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
index 0d8b19345f1..7655e6f80ff 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
@@ -2269,6 +2269,49 @@ async def test_bulk_user_model_budget_clear_serializes_and_refreshes_cache(mocke
broadcast.assert_awaited_once_with(cache_key=saved_user.user_id)
+@pytest.mark.asyncio
+@pytest.mark.parametrize("all_users", [False, True], ids=["single-user", "bulk-all-users"])
+async def test_user_max_budget_update_evicts_cached_user_on_every_worker(mocker: MockerFixture, all_users: bool) -> None:
+ from litellm.proxy._types import LiteLLM_UserTable
+ from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
+ from litellm.proxy.management_endpoints.internal_user_endpoints import _update_single_user_helper, bulk_user_update
+ from litellm.types.proxy.management_endpoints.internal_user_endpoints import BulkUpdateUserRequest
+
+ saved_user: Final = LiteLLM_UserTable(user_id="user-spruce", max_budget=500.0)
+ prisma_client: Final = mocker.MagicMock()
+ prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=saved_user)
+ prisma_client.db.litellm_usertable.find_many = mocker.AsyncMock(return_value=[saved_user])
+ prisma_client.db.litellm_usertable.update_many = mocker.AsyncMock(return_value=1)
+ prisma_client.get_data = mocker.AsyncMock(return_value=[saved_user])
+ prisma_client.update_data = mocker.AsyncMock(return_value={"user_id": saved_user.user_id, "data": saved_user})
+ mocker.patch("litellm.proxy.proxy_server.prisma_client", prisma_client) # test-quality-ok: substitute the database dependency
+ cache: Final = UserApiKeyCache()
+ await cache.async_set_cache(key=saved_user.user_id, value=saved_user, model_type=LiteLLM_UserTable)
+ mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache) # test-quality-ok: exercise a real isolated cache
+ broadcast: Final = mocker.patch( # test-quality-ok: observe the Redis publication boundary
+ "litellm.proxy.common_utils.auth_cache_invalidation_pubsub.publish_auth_cache_invalidation",
+ new_callable=mocker.AsyncMock,
+ )
+ admin: Final = UserAPIKeyAuth(user_id="admin-spruce", user_role=LitellmUserRoles.PROXY_ADMIN)
+
+ if all_users:
+ await bulk_user_update(
+ data=BulkUpdateUserRequest(all_users=True, user_updates={"max_budget": 50.0}),
+ user_api_key_dict=admin,
+ litellm_changed_by=None,
+ )
+ prisma_client.db.litellm_usertable.update_many.assert_awaited_once_with(where={}, data={"max_budget": 50.0})
+ else:
+ await _update_single_user_helper(
+ user_request=UpdateUserRequest(user_id=saved_user.user_id, max_budget=50.0),
+ user_api_key_dict=admin,
+ )
+ assert prisma_client.update_data.call_args.kwargs["data"]["max_budget"] == 50.0
+
+ assert await cache.async_get_cache(key=saved_user.user_id, model_type=LiteLLM_UserTable) is None
+ broadcast.assert_awaited_once_with(cache_key=saved_user.user_id)
+
+
def test_generate_request_base_validator():
"""
Test that GenerateRequestBase validator converts empty string to None for max_budget
From 97c54e278e1da08f3c770a554c67fb2a47eb1424 Mon Sep 17 00:00:00 2001
From: Moe Khalil
Date: Sun, 20 Sep 2026 00:54:17 +0000
Subject: [PATCH 089/317] fix(auto-router): resolve saved JEV probes on the
server
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../auto_router_endpoints.py | 68 ++++++++++----
.../auto_router_endpoints.py | 5 +
.../test_auto_router_endpoints.py | 93 ++++++++++++++++++-
.../JevConnectionTest.integration.test.tsx | 13 ++-
...d_auto_router_routing_test_request.test.ts | 30 ++++--
.../build_auto_router_routing_test_request.ts | 9 +-
.../src/components/model_info_view.tsx | 3 +-
.../src/components/networking.tsx | 1 +
ui/litellm-dashboard/src/lib/http/schema.d.ts | 5 +
9 files changed, 187 insertions(+), 40 deletions(-)
diff --git a/litellm/proxy/management_endpoints/auto_router_endpoints.py b/litellm/proxy/management_endpoints/auto_router_endpoints.py
index 19d6d9b4e42..07dee3edf15 100644
--- a/litellm/proxy/management_endpoints/auto_router_endpoints.py
+++ b/litellm/proxy/management_endpoints/auto_router_endpoints.py
@@ -384,6 +384,40 @@ async def validate_complexity_router_config(
return ComplexityRouterConfigValidationResponse(valid=error is None, error=error)
+async def _resolve_saved_routing_test(
+ data: AutoRouterRoutingTestRequest,
+ user_api_key_dict: UserAPIKeyAuth,
+ llm_router: "Router",
+) -> AutoRouterRoutingTestRequest:
+ if data.saved_model_id is None:
+ return data
+ deployment: Final = llm_router.get_deployment(data.saved_model_id)
+ if deployment is None or deployment.model_info.blocked:
+ raise HTTPException(status_code=404, detail="Saved auto router is unavailable")
+ if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN and deployment.model_info.team_id != data.team_id:
+ raise HTTPException(status_code=403, detail="Saved auto router belongs to a different team")
+ await can_key_call_resolved_model(
+ model=deployment.model_info.team_public_model_name or deployment.model_name,
+ llm_model_list=llm_router.model_list,
+ valid_token=user_api_key_dict,
+ llm_router=llm_router,
+ )
+ params: Final = deployment.litellm_params
+ if classify_strategy_router_model(params.model or "") != "complexity" or params.complexity_router_config is None:
+ raise HTTPException(status_code=400, detail="Saved deployment is not a complexity auto router")
+ return data.model_copy(
+ update=MappingProxyType(
+ {
+ "complexity_router_config": RequestComplexityRouterConfig.model_validate(
+ params.complexity_router_config
+ ),
+ "default_model": params.complexity_router_default_model,
+ "router_name": deployment.model_name,
+ }
+ )
+ )
+
+
@router.post(
"/auto_router/test_routing",
tags=["model management"], # mutable-ok: fastapi's decorator signature types tags as a list
@@ -439,10 +473,18 @@ async def preview_auto_router_routing(
from litellm.proxy.utils import get_available_models_for_user
member_team: Final = await _authorize_router_dry_run(user_api_key_dict=user_api_key_dict, team_id=data.team_id)
+ if llm_router is None:
+ raise HTTPException(
+ status_code=500,
+ detail={ # mutable-ok: HTTPException detail must be a plain mapping
+ "error": CommonProxyErrors.no_llm_router.value
+ },
+ )
+ resolved: Final = await _resolve_saved_routing_test(data, user_api_key_dict, llm_router)
actor: Final = (
await _authorize_member_dry_run_config(
- config=data.complexity_router_config.model_dump(exclude_none=True),
- default_model=data.default_model,
+ config=resolved.complexity_router_config.model_dump(exclude_none=True),
+ default_model=resolved.default_model,
user_api_key_dict=user_api_key_dict,
team=member_team,
)
@@ -450,12 +492,12 @@ async def preview_auto_router_routing(
else user_api_key_dict
)
request_data: Final[dict[str, object]] = { # mutable-ok: auth and routing enrich this request in place
- **data.wire_body(),
+ **resolved.wire_body(),
"metadata": {}, # mutable-ok: centralized auth and identity stamping share this metadata bucket
"proxy_server_request": {"body": None}, # mutable-ok: the snapshot owner fills this body in place
}
- if member_team is not None and _models_this_test_can_call(data.complexity_router_config):
+ if member_team is not None and _models_this_test_can_call(resolved.complexity_router_config):
from litellm.proxy.auth.user_api_key_auth import (
_run_centralized_common_checks, # pyright: ignore[reportPrivateUsage] # reuse the serving admission policy
)
@@ -467,25 +509,17 @@ async def preview_auto_router_routing(
route="/auto_router/test_routing",
)
- if llm_router is None:
- raise HTTPException(
- status_code=500,
- detail={ # mutable-ok: HTTPException detail must be a plain mapping
- "error": CommonProxyErrors.no_llm_router.value
- },
- )
-
await _authorize_models_this_test_can_call(
- config=data.complexity_router_config,
+ config=resolved.complexity_router_config,
user_api_key_dict=actor,
llm_router=llm_router,
)
complexity_router: Final = ComplexityRouter(
- model_name=data.router_name,
+ model_name=resolved.router_name,
litellm_router_instance=llm_router,
- complexity_router_config=data.complexity_router_config.model_dump(exclude_none=True),
- default_model=data.default_model,
+ complexity_router_config=resolved.complexity_router_config.model_dump(exclude_none=True),
+ default_model=resolved.default_model,
derive_savings_baseline=False,
)
@@ -498,7 +532,7 @@ async def preview_auto_router_routing(
try:
hook_response: Final = await complexity_router.async_pre_routing_hook(
- model=data.router_name,
+ model=resolved.router_name,
request_kwargs=request_kwargs,
messages=request_kwargs["messages"],
)
diff --git a/litellm/types/management_endpoints/auto_router_endpoints.py b/litellm/types/management_endpoints/auto_router_endpoints.py
index fd2202a1156..93ea925bd9e 100644
--- a/litellm/types/management_endpoints/auto_router_endpoints.py
+++ b/litellm/types/management_endpoints/auto_router_endpoints.py
@@ -72,6 +72,11 @@ class AutoRouterRoutingTestRequest(BaseModel):
complexity_router_config: RequestComplexityRouterConfig = Field(
description="The complexity router config to route against, in the shape /model/new accepts",
)
+ saved_model_id: str | None = Field(
+ default=None,
+ min_length=1,
+ description="Test this saved deployment's server-side configuration instead of the supplied config and default model",
+ )
default_model: str | None = Field(
default=None,
description="Model to route to when no tier resolves, i.e. complexity_router_default_model",
diff --git a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
index f9b618234b6..9235a00bda6 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
@@ -7,13 +7,13 @@ from pathlib import Path
from typing import Final
import httpx
-import litellm.llms.custom_httpx.http_handler as http_handler
-import litellm.router_strategy.complexity_router.complexity_router as complexity_module
import pytest
import respx
from fastapi import HTTPException, Request
from pydantic import ValidationError
+import litellm.llms.custom_httpx.http_handler as http_handler
+import litellm.router_strategy.complexity_router.complexity_router as complexity_module
from litellm.proxy import proxy_server
from litellm.proxy._types import (
LitellmUserRoles,
@@ -29,6 +29,7 @@ from litellm.types.management_endpoints.auto_router_endpoints import (
AutoRouterBenchmarksResponse,
AutoRouterRoutingTestRequest,
)
+from litellm.types.router import Deployment
from litellm.types.utils import Choices, Message, ModelResponse
ROUTING_HTTP_REQUEST: Final = Request(
@@ -2382,6 +2383,94 @@ async def test_jev_test_routing_authorizes_paid_evaluation_before_contacting_typ
await handler.client.aclose()
+@pytest.mark.asyncio
+@pytest.mark.parametrize("case", ["allowed", "missing", "blocked", "key", "budget", "team", "not-router"])
+async def test_saved_jev_probe_uses_authorized_server_configuration(monkeypatch: pytest.MonkeyPatch, case: str) -> None:
+ router: Final = RecordingRouter("SIMPLE")
+ stored_key: Final = "synthetic-server-jev-key"
+ stored_config: Final = {
+ "classifier_type": "jev",
+ "tiers": TIERS,
+ "jev_classifier_config": {"api_key": stored_key, "api_base": "https://saved-jev.test"},
+ }
+ router.add_deployment(
+ Deployment.model_validate(
+ {
+ "model_name": "saved-jev",
+ "litellm_params": {
+ "model": "openai/gpt-4o-mini" if case == "not-router" else "auto_router/complexity_router",
+ "complexity_router_config": stored_config,
+ },
+ "model_info": {
+ "id": "saved-jev-id",
+ "blocked": case == "blocked",
+ "team_id": "owner-team" if case == "team" else None,
+ },
+ }
+ )
+ )
+ monkeypatch.setattr(proxy_server, "llm_router", router)
+ actor: Final = (
+ _configure_member_preview(monkeypatch)
+ if case == "team"
+ else UserAPIKeyAuth(
+ user_role=LitellmUserRoles.PROXY_ADMIN,
+ api_key="sk-probe",
+ user_id="admin",
+ models=["typesafe/jev-latest"] if case == "key" else ["saved-jev", "typesafe/jev-latest"],
+ max_budget=1,
+ spend=1 if case == "budget" else 0,
+ )
+ )
+ request: Final = _request_from(
+ {
+ "prompt": "what is 2+2",
+ "saved_model_id": "missing-id" if case == "missing" else "saved-jev-id",
+ "team_id": "member-preview-team" if case == "team" else None,
+ },
+ classifier_type="jev",
+ jev_classifier_config={"api_key": "masked-key", "api_base": "https://browser-override.test"},
+ )
+ with respx.mock(assert_all_called=False) as http:
+ handler: Final = http_handler.AsyncHTTPHandler()
+ handler.client = httpx.AsyncClient(transport=httpx.MockTransport(http.async_handler))
+
+ def http_client(_provider: object) -> http_handler.AsyncHTTPHandler:
+ return handler
+
+ monkeypatch.setattr(complexity_module, "get_async_httpx_client", http_client)
+ evaluation: Final = http.post("https://saved-jev.test/v1/systemone").mock(
+ return_value=httpx.Response(
+ 200,
+ json={
+ "answers": {
+ "tier": {"type": "choice", "choice": "SIMPLE", "confidence": 1, "probabilities": {"SIMPLE": 1}}
+ }
+ },
+ )
+ )
+ operation: Final = preview_auto_router_routing(request, actor, ROUTING_HTTP_REQUEST)
+ if case in ("missing", "blocked", "team", "not-router"):
+ with pytest.raises(HTTPException) as denied:
+ await operation
+ assert denied.value.status_code == {"missing": 404, "blocked": 404, "team": 403, "not-router": 400}[case]
+ elif case in ("key", "budget"):
+ with pytest.raises(ProxyException) as forbidden:
+ await operation
+ assert forbidden.value.type == (
+ ProxyErrorTypes.key_model_access_denied if case == "key" else ProxyErrorTypes.budget_exceeded
+ )
+ else:
+ result: Final = await operation
+ assert result.routing_decision["cause"] == "jev_classifier"
+ assert result.routed_model == "cheap-model"
+ assert evaluation.calls.last.request.headers["authorization"] == f"Bearer {stored_key}"
+ assert stored_key not in result.model_dump_json()
+ assert evaluation.call_count == (1 if case == "allowed" else 0)
+ assert router.recorded_calls == []
+ await handler.client.aclose()
+
+
@pytest.mark.asyncio
async def test_list_shadow_eval_jobs_filters_to_jobs_containing_the_key(monkeypatch: pytest.MonkeyPatch):
"""The filter matches a key anywhere in a job's key set and still returns the whole
diff --git a/ui/litellm-dashboard/src/components/add_model/JevConnectionTest.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/JevConnectionTest.integration.test.tsx
index 2a00e8bb45e..72acda7622a 100644
--- a/ui/litellm-dashboard/src/components/add_model/JevConnectionTest.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/JevConnectionTest.integration.test.tsx
@@ -45,7 +45,13 @@ const configParams: BuildComplexityRouterConfigParams = {
returnRawModelName: false,
};
const config = buildComplexityRouterConfig(configParams);
-const request = buildSavedJevConnectionTestRequest(JSON.stringify(config), "fast", "my-router");
+const request = buildSavedJevConnectionTestRequest(
+ JSON.stringify({
+ ...config,
+ jev_classifier_config: { api_key: "sk-masked****", api_base: "https://custom-jev.test" },
+ }),
+ "saved-id",
+);
const targets = buildAutoRouterTestTargets({
tiers: Object.entries(config.tiers),
semanticMatchingEnabled: false,
@@ -95,9 +101,8 @@ describe("JEV network probes", () => {
const routingCall = fetchMock.mock.calls.find(([url]) => String(url).endsWith("/auto_router/test_routing"));
const expectedRequest = {
prompt: JEV_CONNECTION_TEST_PROMPT,
- complexity_router_config: config,
- default_model: "fast",
- router_name: "my-router",
+ complexity_router_config: { ...config, jev_classifier_config: undefined },
+ saved_model_id: "saved-id",
};
expect(JSON.parse(String(routingCall?.[1]?.body))).toEqual(expectedRequest);
expect(fetchMock).toHaveBeenCalledTimes(5);
diff --git a/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.test.ts b/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.test.ts
index fba4ca47e00..174f93eae6c 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.test.ts
@@ -20,6 +20,22 @@ const params = {
};
describe("buildAutoRouterRoutingTestRequest", () => {
+ it("references the saved deployment without copying masked credentials or client overrides", () => {
+ const request = buildSavedJevConnectionTestRequest(
+ {
+ classifier_type: "jev",
+ tiers: CONFIG.tiers,
+ jev_classifier_config: { api_key: "sk-masked****", api_base: "https://custom-jev.test" },
+ },
+ "saved-id",
+ );
+ const expectedRequest = {
+ prompt: JEV_CONNECTION_TEST_PROMPT,
+ complexity_router_config: { classifier_type: "jev", tiers: CONFIG.tiers },
+ saved_model_id: "saved-id",
+ };
+ expect(request).toEqual(expectedRequest);
+ });
it.each(["object", "json"])("probes saved JEV %s configuration with custom tiers and team context", (format) => {
const config = {
classifier_type: "jev",
@@ -31,24 +47,18 @@ describe("buildAutoRouterRoutingTestRequest", () => {
};
const expectedRequest = {
prompt: JEV_CONNECTION_TEST_PROMPT,
- complexity_router_config: config,
- default_model: "strong",
- router_name: "saved-router",
+ complexity_router_config: { ...config, jev_classifier_config: undefined },
+ saved_model_id: "saved-id",
team_id: "team-1",
};
expect(
- buildSavedJevConnectionTestRequest(
- format === "json" ? JSON.stringify(config) : config,
- "strong",
- "saved-router",
- "team-1",
- ),
+ buildSavedJevConnectionTestRequest(format === "json" ? JSON.stringify(config) : config, "saved-id", "team-1"),
).toEqual(expectedRequest);
});
it.each([undefined, null, "not json", "[]", {}, { classifier_type: "llm", tiers: {} }, { classifier_type: "jev" }])(
"does not build a JEV probe for invalid or other classifier configurations: %j",
(config) => {
- expect(buildSavedJevConnectionTestRequest(config)).toBeUndefined();
+ expect(buildSavedJevConnectionTestRequest(config, "saved-id")).toBeUndefined();
},
);
it("sends the prompt with the config being edited", () => {
diff --git a/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.ts b/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.ts
index 022bd8ad539..4679f3c50bf 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.ts
@@ -6,10 +6,10 @@ export const JEV_CONNECTION_TEST_PROMPT = "What is 2 plus 2?";
export const buildSavedJevConnectionTestRequest = (
rawConfig: unknown,
- defaultModel?: string,
- routerName?: string,
+ savedModelId?: string,
teamId?: string,
): AutoRouterRoutingTestRequest | undefined => {
+ if (!savedModelId) return undefined;
const parsed: unknown =
typeof rawConfig === "string"
? (() => {
@@ -27,9 +27,8 @@ export const buildSavedJevConnectionTestRequest = (
if (!result.success) return undefined;
return {
prompt: JEV_CONNECTION_TEST_PROMPT,
- complexity_router_config: result.data,
- ...(defaultModel && { default_model: defaultModel }),
- ...(routerName && { router_name: routerName }),
+ complexity_router_config: { ...result.data, jev_classifier_config: undefined },
+ saved_model_id: savedModelId,
...(teamId && { team_id: teamId }),
};
};
diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx
index 4e5ba81f2a4..7641a78cc6b 100644
--- a/ui/litellm-dashboard/src/components/model_info_view.tsx
+++ b/ui/litellm-dashboard/src/components/model_info_view.tsx
@@ -849,8 +849,7 @@ export default function ModelInfoView({
targets={autoRouterTestTargets}
jevRequest={buildSavedJevConnectionTestRequest(
(localModelData ?? modelData)?.litellm_params?.complexity_router_config,
- (localModelData ?? modelData)?.litellm_params?.complexity_router_default_model,
- (localModelData ?? modelData)?.model_name,
+ (localModelData ?? modelData)?.model_info?.id,
(localModelData ?? modelData)?.model_info?.team_id,
)}
/>
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
index 83378f984e6..b05cb48eddc 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -2327,6 +2327,7 @@ export const testModelGroupConnection = async (
export interface AutoRouterRoutingTestRequest {
prompt: string;
complexity_router_config: ComplexityRouterConfigPayload | Record;
+ saved_model_id?: string;
default_model?: string;
router_name?: string;
team_id?: string;
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index a2a6f553da5..1f3911b3cff 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -24214,6 +24214,11 @@ export interface components {
* @default auto_router_routing_test
*/
router_name: string;
+ /**
+ * Saved Model Id
+ * @description Test this saved deployment's server-side configuration instead of the supplied config and default model
+ */
+ saved_model_id?: string | null;
/**
* System
* @description The top-level system prompt an Anthropic /v1/messages body carries beside its messages
From 8898d11f6ed04f0f574a567274c24b2693e513d6 Mon Sep 17 00:00:00 2001
From: Moe Khalil
Date: Sun, 20 Sep 2026 00:59:48 +0000
Subject: [PATCH 090/317] test(auto-router): keep editor probe on unsaved
configuration
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../add_model/JevClassifierConfig.integration.test.tsx | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.integration.test.tsx
index aae32f09959..896fde3a446 100644
--- a/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/JevClassifierConfig.integration.test.tsx
@@ -12,7 +12,7 @@ import {
} from "../edit_auto_router/edit_auto_router_modal";
import { applyTierSetAction } from "./tier_set_actions";
import { testAutoRouterRouting } from "../networking";
-import { buildSavedJevConnectionTestRequest } from "./build_auto_router_routing_test_request";
+import { JEV_CONNECTION_TEST_PROMPT } from "./build_auto_router_routing_test_request";
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: vi.fn(() => ({
@@ -81,8 +81,11 @@ function Form() {
{
- const request = buildSavedJevConnectionTestRequest(buildUpdatedComplexityRouterConfig({}, value));
- if (request) void testAutoRouterRouting("token", request);
+ const request = {
+ prompt: JEV_CONNECTION_TEST_PROMPT,
+ complexity_router_config: buildUpdatedComplexityRouterConfig({}, value),
+ };
+ void testAutoRouterRouting("token", request);
}}
>
Probe current config
From 24b7a38b5f8b202c125ea61cd5eadd25f29e1352 Mon Sep 17 00:00:00 2001
From: Moe Khalil
Date: Sun, 20 Sep 2026 01:06:41 +0000
Subject: [PATCH 091/317] fix(auto-router): validate saved JEV probe payloads
without credentials
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../test_auto_router_endpoints.py | 12 +++++++++---
.../add_model/JevConnectionTest.integration.test.tsx | 2 +-
.../build_auto_router_routing_test_request.test.ts | 11 +++++++++--
.../build_auto_router_routing_test_request.ts | 9 +++++++--
4 files changed, 26 insertions(+), 8 deletions(-)
diff --git a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
index 9235a00bda6..03325d5296e 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
@@ -2384,7 +2384,9 @@ async def test_jev_test_routing_authorizes_paid_evaluation_before_contacting_typ
@pytest.mark.asyncio
-@pytest.mark.parametrize("case", ["allowed", "missing", "blocked", "key", "budget", "team", "not-router"])
+@pytest.mark.parametrize(
+ "case", ["allowed", "credential-free", "missing", "blocked", "key", "budget", "team", "not-router"]
+)
async def test_saved_jev_probe_uses_authorized_server_configuration(monkeypatch: pytest.MonkeyPatch, case: str) -> None:
router: Final = RecordingRouter("SIMPLE")
stored_key: Final = "synthetic-server-jev-key"
@@ -2429,7 +2431,11 @@ async def test_saved_jev_probe_uses_authorized_server_configuration(monkeypatch:
"team_id": "member-preview-team" if case == "team" else None,
},
classifier_type="jev",
- jev_classifier_config={"api_key": "masked-key", "api_base": "https://browser-override.test"},
+ jev_classifier_config=(
+ {"model": "jev-latest", "timeout_ms": 3000}
+ if case == "credential-free"
+ else {"api_key": "masked-key", "api_base": "https://browser-override.test"}
+ ),
)
with respx.mock(assert_all_called=False) as http:
handler: Final = http_handler.AsyncHTTPHandler()
@@ -2466,7 +2472,7 @@ async def test_saved_jev_probe_uses_authorized_server_configuration(monkeypatch:
assert result.routed_model == "cheap-model"
assert evaluation.calls.last.request.headers["authorization"] == f"Bearer {stored_key}"
assert stored_key not in result.model_dump_json()
- assert evaluation.call_count == (1 if case == "allowed" else 0)
+ assert evaluation.call_count == (1 if case in ("allowed", "credential-free") else 0)
assert router.recorded_calls == []
await handler.client.aclose()
diff --git a/ui/litellm-dashboard/src/components/add_model/JevConnectionTest.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/JevConnectionTest.integration.test.tsx
index 72acda7622a..c85c757e391 100644
--- a/ui/litellm-dashboard/src/components/add_model/JevConnectionTest.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/JevConnectionTest.integration.test.tsx
@@ -101,7 +101,7 @@ describe("JEV network probes", () => {
const routingCall = fetchMock.mock.calls.find(([url]) => String(url).endsWith("/auto_router/test_routing"));
const expectedRequest = {
prompt: JEV_CONNECTION_TEST_PROMPT,
- complexity_router_config: { ...config, jev_classifier_config: undefined },
+ complexity_router_config: config,
saved_model_id: "saved-id",
};
expect(JSON.parse(String(routingCall?.[1]?.body))).toEqual(expectedRequest);
diff --git a/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.test.ts b/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.test.ts
index 174f93eae6c..de0fb6fe6e1 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.test.ts
@@ -5,6 +5,7 @@ import {
JEV_CONNECTION_TEST_PROMPT,
} from "./build_auto_router_routing_test_request";
import { ComplexityRouterConfigPayload } from "./build_complexity_router_config";
+import { defaultJevClassifierConfig } from "./jev_classifier_config";
const CONFIG = {
tiers: { SIMPLE: ["cheap"], MEDIUM: ["mid"], COMPLEX: ["strong"], REASONING: ["o3"] },
@@ -31,10 +32,16 @@ describe("buildAutoRouterRoutingTestRequest", () => {
);
const expectedRequest = {
prompt: JEV_CONNECTION_TEST_PROMPT,
- complexity_router_config: { classifier_type: "jev", tiers: CONFIG.tiers },
+ complexity_router_config: {
+ classifier_type: "jev",
+ tiers: CONFIG.tiers,
+ jev_classifier_config: defaultJevClassifierConfig(),
+ },
saved_model_id: "saved-id",
};
expect(request).toEqual(expectedRequest);
+ expect(request?.complexity_router_config.jev_classifier_config).not.toHaveProperty("api_key");
+ expect(request?.complexity_router_config.jev_classifier_config).not.toHaveProperty("api_base");
});
it.each(["object", "json"])("probes saved JEV %s configuration with custom tiers and team context", (format) => {
const config = {
@@ -47,7 +54,7 @@ describe("buildAutoRouterRoutingTestRequest", () => {
};
const expectedRequest = {
prompt: JEV_CONNECTION_TEST_PROMPT,
- complexity_router_config: { ...config, jev_classifier_config: undefined },
+ complexity_router_config: config,
saved_model_id: "saved-id",
team_id: "team-1",
};
diff --git a/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.ts b/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.ts
index 4679f3c50bf..6a9d1ce7d92 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_auto_router_routing_test_request.ts
@@ -1,6 +1,7 @@
import { AutoRouterRoutingTestRequest } from "../networking";
import { ComplexityRouterConfigPayload } from "./build_complexity_router_config";
import { z } from "zod";
+import { jevClassifierConfigSchema } from "./jev_classifier_config";
export const JEV_CONNECTION_TEST_PROMPT = "What is 2 plus 2?";
@@ -21,13 +22,17 @@ export const buildSavedJevConnectionTestRequest = (
})()
: rawConfig;
const result = z
- .object({ classifier_type: z.literal("jev"), tiers: z.record(z.unknown()) })
+ .object({
+ classifier_type: z.literal("jev"),
+ tiers: z.record(z.unknown()),
+ jev_classifier_config: jevClassifierConfigSchema.default({}),
+ })
.passthrough()
.safeParse(parsed);
if (!result.success) return undefined;
return {
prompt: JEV_CONNECTION_TEST_PROMPT,
- complexity_router_config: { ...result.data, jev_classifier_config: undefined },
+ complexity_router_config: result.data,
saved_model_id: savedModelId,
...(teamId && { team_id: teamId }),
};
From 368a8396400bdf5f986f8379d84ac44c43b808c2 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 18:08:41 -0700
Subject: [PATCH 092/317] fix(bedrock_mantle): send anthropic betas in the
header Mantle reads on /v1/messages
---
litellm/anthropic_beta_headers_config.json | 35 +++++
.../anthropic_claude3_transformation.py | 22 ++--
.../bedrock_mantle/messages/transformation.py | 36 +++--
..._bedrock_mantle_messages_transformation.py | 123 +++++++++++++++++-
4 files changed, 196 insertions(+), 20 deletions(-)
diff --git a/litellm/anthropic_beta_headers_config.json b/litellm/anthropic_beta_headers_config.json
index eb31cc17a15..1331de4c266 100644
--- a/litellm/anthropic_beta_headers_config.json
+++ b/litellm/anthropic_beta_headers_config.json
@@ -131,6 +131,41 @@
"web-fetch-2025-09-10": null,
"web-search-2025-03-05": null
},
+ "bedrock_mantle": {
+ "advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19",
+ "advisor-tool-2026-03-01": null,
+ "bash_20241022": null,
+ "bash_20250124": null,
+ "claude-code-20250219": "claude-code-20250219",
+ "code-execution-2025-08-25": null,
+ "compact-2026-01-12": "compact-2026-01-12",
+ "computer-use-2025-01-24": "computer-use-2025-01-24",
+ "computer-use-2025-11-24": "computer-use-2025-11-24",
+ "context-1m-2025-08-07": "context-1m-2025-08-07",
+ "context-management-2025-06-27": "context-management-2025-06-27",
+ "effort-2025-11-24": "effort-2025-11-24",
+ "fast-mode-2026-02-01": null,
+ "files-api-2025-04-14": null,
+ "fine-grained-tool-streaming-2025-05-14": "fine-grained-tool-streaming-2025-05-14",
+ "interleaved-thinking-2025-05-14": "interleaved-thinking-2025-05-14",
+ "mcp-client-2025-04-04": null,
+ "mcp-client-2025-11-20": null,
+ "mcp-servers-2025-12-04": null,
+ "output-128k-2025-02-19": "output-128k-2025-02-19",
+ "per-turn-control-2026-07-01": "per-turn-control-2026-07-01",
+ "prompt-caching-scope-2026-01-05": null,
+ "skills-2025-10-02": null,
+ "structured-output-2024-03-01": null,
+ "structured-outputs-2025-11-13": "structured-outputs-2025-11-13",
+ "text_editor_20241022": null,
+ "text_editor_20250124": null,
+ "thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01",
+ "token-efficient-tools-2025-02-19": "token-efficient-tools-2025-02-19",
+ "tool-examples-2025-10-29": "tool-examples-2025-10-29",
+ "tool-search-tool-2025-10-19": "tool-search-tool-2025-10-19",
+ "web-fetch-2025-09-10": null,
+ "web-search-2025-03-05": "web-search-2025-03-05"
+ },
"vertex_ai": {
"advisor-tool-2026-03-01": null,
"advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19",
diff --git a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py
index d2be1ad9156..4b52a3bafe6 100644
--- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py
+++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py
@@ -1,4 +1,4 @@
-from collections.abc import AsyncIterator
+from collections.abc import AsyncIterator, Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, cast
@@ -445,13 +445,16 @@ class AmazonAnthropicClaudeMessagesConfig(
# Bedrock InvokeModel DOES support ``clear_tool_uses_20250919`` under the
# ``context-management-2025-06-27`` beta. AWS docs:
# https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-tool-use.md
- _BEDROCK_INVOKE_SUPPORTED_CONTEXT_MANAGEMENT_EDITS: dict[str, str] = {
- "compact_20260112": ANTHROPIC_BETA_HEADER_VALUES.COMPACT_2026_01_12.value,
- "clear_tool_uses_20250919": ANTHROPIC_BETA_HEADER_VALUES.CONTEXT_MANAGEMENT_2025_06_27.value,
- }
+ _BEDROCK_INVOKE_SUPPORTED_CONTEXT_MANAGEMENT_EDITS: Mapping[str, str] = MappingProxyType(
+ {
+ "compact_20260112": ANTHROPIC_BETA_HEADER_VALUES.COMPACT_2026_01_12.value,
+ "clear_tool_uses_20250919": ANTHROPIC_BETA_HEADER_VALUES.CONTEXT_MANAGEMENT_2025_06_27.value,
+ }
+ )
- @staticmethod
+ @classmethod
def _filter_context_management_for_bedrock_invoke(
+ cls,
anthropic_messages_request: dict,
beta_set: set,
) -> None:
@@ -481,7 +484,7 @@ class AmazonAnthropicClaudeMessagesConfig(
anthropic_messages_request.pop("context_management", None)
return
- supported: Final = AmazonAnthropicClaudeMessagesConfig._BEDROCK_INVOKE_SUPPORTED_CONTEXT_MANAGEMENT_EDITS
+ supported: Final = cls._BEDROCK_INVOKE_SUPPORTED_CONTEXT_MANAGEMENT_EDITS
retained_edits: Final = [e for e in edits if isinstance(e, dict) and e.get("type") in supported]
if not retained_edits:
anthropic_messages_request.pop("context_management", None)
@@ -546,15 +549,16 @@ class AmazonAnthropicClaudeMessagesConfig(
if "tool-search-tool-2025-10-19" in beta_set:
beta_set.add("tool-examples-2025-10-29")
+ beta_provider: Final = self.custom_llm_provider or "bedrock"
filtered_betas: Final = sorted(
filter_and_transform_beta_headers(
beta_headers=list(beta_set),
- provider="bedrock",
+ provider=beta_provider,
)
)
dropped_user_betas: Final = sorted(
- b for b in user_beta_set if not filter_and_transform_beta_headers([b], provider="bedrock")
+ b for b in user_beta_set if not filter_and_transform_beta_headers([b], provider=beta_provider)
)
if dropped_user_betas:
verbose_logger.warning(
diff --git a/litellm/llms/bedrock_mantle/messages/transformation.py b/litellm/llms/bedrock_mantle/messages/transformation.py
index a4365cfa49b..480c09a0476 100644
--- a/litellm/llms/bedrock_mantle/messages/transformation.py
+++ b/litellm/llms/bedrock_mantle/messages/transformation.py
@@ -1,6 +1,9 @@
from collections.abc import Mapping
+from types import MappingProxyType
from typing import Final
+from pydantic import TypeAdapter
+
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
DEFAULT_ANTHROPIC_API_VERSION,
)
@@ -13,6 +16,7 @@ from litellm.llms.bedrock_mantle.common_utils import (
resolve_mantle_region,
)
from litellm.secret_managers.main import get_secret_str
+from litellm.types.llms.anthropic import ANTHROPIC_BETA_HEADER_VALUES
from litellm.types.router import GenericLiteLLMParams
_BASE_SUFFIXES_TO_STRIP: Final = (
@@ -23,6 +27,9 @@ _BASE_SUFFIXES_TO_STRIP: Final = (
"/openai/v1",
"/v1",
)
+_BODY_FIELDS_MANTLE_READS_FROM_HEADERS: Final = frozenset({"anthropic_version", "anthropic_beta"})
+_ANTHROPIC_BETAS: Final = TypeAdapter(tuple[str, ...])
+_MANTLE_REQUEST: Final = TypeAdapter(dict[str, object])
def build_mantle_native_messages_url(api_base: str | None, litellm_params: Mapping[str, object]) -> str:
@@ -39,6 +46,13 @@ def build_mantle_native_messages_url(api_base: str | None, litellm_params: Mappi
class BedrockMantleAnthropicMessagesConfig(BedrockMantleAuthMixin, AmazonMantleMessagesConfig):
+ _BEDROCK_INVOKE_SUPPORTED_CONTEXT_MANAGEMENT_EDITS: Mapping[str, str] = MappingProxyType(
+ {
+ **AmazonMantleMessagesConfig._BEDROCK_INVOKE_SUPPORTED_CONTEXT_MANAGEMENT_EDITS,
+ "clear_thinking_20251015": ANTHROPIC_BETA_HEADER_VALUES.CONTEXT_MANAGEMENT_2025_06_27.value,
+ }
+ )
+
def __init__(self, aws_signer: BaseAWSLLM | None = None) -> None:
AmazonMantleMessagesConfig.__init__(self)
self._aws_signer = aws_signer or self
@@ -89,13 +103,17 @@ class BedrockMantleAnthropicMessagesConfig(BedrockMantleAuthMixin, AmazonMantleM
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> dict:
- request: Final = super().transform_anthropic_messages_request(
- model=model,
- messages=messages,
- anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
- litellm_params=litellm_params,
- headers=headers,
+ request: Final = _MANTLE_REQUEST.validate_python(
+ super().transform_anthropic_messages_request(
+ model=model,
+ messages=messages,
+ anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
+ litellm_params=litellm_params,
+ headers=headers,
+ ),
)
- if "anthropic_version" in anthropic_messages_optional_request_params:
- return request
- return {key: value for key, value in request.items() if key != "anthropic_version"}
+ betas: Final = request.get("anthropic_beta")
+ if betas is not None:
+ header_betas: Final = ",".join(_ANTHROPIC_BETAS.validate_python(betas))
+ headers["anthropic-beta"] = header_betas # rebind-ok: the handler signs and sends this same dict
+ return {key: value for key, value in request.items() if key not in _BODY_FIELDS_MANTLE_READS_FROM_HEADERS}
diff --git a/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py
index 2961eee925c..3544262996c 100644
--- a/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py
+++ b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py
@@ -79,7 +79,10 @@ _SSE_EVENTS = (
},
),
("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
- ("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "pong"}}),
+ (
+ "content_block_delta",
+ {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "pong"}},
+ ),
("content_block_stop", {"type": "content_block_stop", "index": 0}),
("message_delta", {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 1}}),
("message_stop", {"type": "message_stop"}),
@@ -152,7 +155,10 @@ class TestURL:
def test_default_host_comes_from_mantle_region_env(self, monkeypatch):
monkeypatch.setenv("BEDROCK_MANTLE_REGION", "ap-northeast-1")
- assert build_mantle_native_messages_url(None, {}) == f"https://bedrock-mantle.ap-northeast-1.api.aws{MESSAGES_PATH}"
+ assert (
+ build_mantle_native_messages_url(None, {})
+ == f"https://bedrock-mantle.ap-northeast-1.api.aws{MESSAGES_PATH}"
+ )
def test_config_get_complete_url_reads_litellm_params(self):
config = BedrockMantleAnthropicMessagesConfig()
@@ -344,3 +350,116 @@ class TestWireRequest:
authorization = route.calls.last.request.headers["authorization"]
assert authorization.startswith("AWS4-HMAC-SHA256")
assert "/us-east-1/bedrock/aws4_request" in authorization
+
+
+def _sent_betas(route: respx.Route) -> list[str]:
+ return route.calls.last.request.headers["anthropic-beta"].split(",")
+
+
+@pytest.mark.usefixtures("local_beta_headers_config")
+class TestBetaHeadersOnTheWire:
+ async def _send(self, **request_params) -> respx.Route:
+ route = _mantle_messages_route("us-east-1").mock(return_value=_anthropic_response())
+ await litellm.anthropic_messages(
+ model="bedrock_mantle/anthropic.claude-sonnet-5",
+ messages=[{"role": "user", "content": "ping"}],
+ max_tokens=8,
+ api_key="test-bearer",
+ aws_region_name="us-east-1",
+ **request_params,
+ )
+ return route
+
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_betas_mantle_accepts_reach_it_in_the_header(self):
+ route = await self._send(
+ extra_headers={
+ "anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14,context-management-2025-06-27"
+ }
+ )
+
+ assert _sent_betas(route) == [
+ "claude-code-20250219",
+ "context-management-2025-06-27",
+ "interleaved-thinking-2025-05-14",
+ ]
+
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_betas_mantle_rejects_are_dropped_before_the_request(self):
+ route = await self._send(
+ extra_headers={"anthropic-beta": "code-execution-2025-08-25,context-1m-2025-08-07,files-api-2025-04-14"}
+ )
+
+ assert _sent_betas(route) == ["context-1m-2025-08-07"]
+
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_no_beta_header_is_sent_when_every_value_is_rejected(self):
+ route = await self._send(extra_headers={"anthropic-beta": "code-execution-2025-08-25"})
+
+ assert "anthropic-beta" not in route.calls.last.request.headers
+
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_advanced_tool_use_is_renamed_to_the_beta_mantle_knows(self):
+ route = await self._send(extra_headers={"anthropic-beta": "advanced-tool-use-2025-11-20"})
+
+ assert "tool-search-tool-2025-10-19" in _sent_betas(route)
+ assert "advanced-tool-use-2025-11-20" not in _sent_betas(route)
+
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_a_feature_beta_joins_the_callers_betas_in_the_header(self):
+ route = await self._send(
+ extra_headers={"anthropic-beta": "context-1m-2025-08-07"},
+ context_management={"edits": [{"type": "clear_tool_uses_20250919"}]},
+ )
+
+ assert _sent_betas(route) == ["context-1m-2025-08-07", "context-management-2025-06-27"]
+ assert _sent_body(route)["context_management"] == {"edits": [{"type": "clear_tool_uses_20250919"}]}
+
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_betas_and_version_never_travel_in_the_body(self):
+ route = await self._send(
+ extra_headers={"anthropic-beta": "context-1m-2025-08-07"},
+ context_management={"edits": [{"type": "clear_tool_uses_20250919"}]},
+ anthropic_version="bedrock-2023-05-31",
+ )
+
+ body = _sent_body(route)
+ assert "anthropic_beta" not in body
+ assert "anthropic_version" not in body
+ assert route.calls.last.request.headers["anthropic-version"] == "2023-06-01"
+
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_clear_thinking_edit_is_forwarded_with_thinking_on(self):
+ edits = [{"type": "clear_thinking_20251015", "keep": "all"}, {"type": "clear_tool_uses_20250919"}]
+ route = await self._send(
+ context_management={"edits": edits},
+ thinking={"type": "adaptive"},
+ )
+
+ body = _sent_body(route)
+ assert body["context_management"] == {"edits": edits}
+ assert body["thinking"] == {"type": "adaptive"}
+ assert "context-management-2025-06-27" in _sent_betas(route)
+
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_tools_reach_mantle_unchanged(self):
+ tools = [
+ {
+ "name": "get_weather",
+ "description": "Look up the weather",
+ "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
+ }
+ ]
+ route = await self._send(tools=tools, tool_choice={"type": "auto"})
+
+ body = _sent_body(route)
+ assert body["tools"] == tools
+ assert body["tool_choice"] == {"type": "auto"}
From a82f0a0bd2d31a435755640953ccd43476ac961d Mon Sep 17 00:00:00 2001
From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 18:10:49 -0700
Subject: [PATCH 093/317] fix(auth): reject deactivated JWT users and
invalidate cached status
---
litellm/proxy/auth/user_api_key_auth.py | 10 +++
.../internal_user_endpoints.py | 2 +-
.../proxy/auth/test_user_api_key_auth.py | 67 ++++++++++++++++---
.../test_internal_user_endpoints.py | 45 +++++++++++++
4 files changed, 115 insertions(+), 9 deletions(-)
diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py
index de0131772bc..49aab20e461 100644
--- a/litellm/proxy/auth/user_api_key_auth.py
+++ b/litellm/proxy/auth/user_api_key_auth.py
@@ -1730,6 +1730,16 @@ async def _user_api_key_auth_builder(
)
return JWTAuthManager.user_api_key_auth_from_result(result, parent_otel_span)
+ if (
+ user_object is not None
+ and isinstance(user_object.metadata, dict)
+ and user_object.metadata.get("scim_active") is False
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=f"User={user_id} has been deactivated via SCIM. Keys owned by this user cannot be used.",
+ )
+
valid_token = JWTAuthManager.user_api_key_auth_from_result(result, parent_otel_span)
# AUTO_REGISTER deferred from _resolve_jwt_to_virtual_key.
diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py
index 4832c2f4c21..029a968e156 100644
--- a/litellm/proxy/management_endpoints/internal_user_endpoints.py
+++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py
@@ -1571,7 +1571,7 @@ async def _update_single_user_helper(
await _invalidate_user_spend_counter_if_changed(non_default_values)
- if "model_max_budget" in non_default_values:
+ if "model_max_budget" in non_default_values or "metadata" in data_json:
await evict_and_broadcast(
cache_keys=(non_default_values["user_id"],),
user_api_key_cache=user_api_key_cache,
diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py
index 8593be751fa..dcbc0713404 100644
--- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py
+++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py
@@ -2091,7 +2091,8 @@ async def test_auto_register_binds_api_key_to_token_hash():
@pytest.mark.asyncio
-async def test_auto_register_first_request_propagates_user_email():
+@pytest.mark.parametrize("active", [True, False])
+async def test_auto_register_first_request_propagates_user_email(active: bool) -> None:
"""
The first auto-registered JWT request must also carry user_email (resolved
from the validated LiteLLM_UserTable), so attribution is consistent with the
@@ -2120,6 +2121,7 @@ async def test_auto_register_first_request_propagates_user_email():
user_id="validated-user",
user_email="validated@example.com",
user_role="internal_user",
+ metadata={"scim_active": active},
)
mock_jwt_result = {
"is_proxy_admin": False,
@@ -2150,7 +2152,7 @@ async def test_auto_register_first_request_propagates_user_email():
patch("litellm.proxy.proxy_server.master_key", "sk-master"),
patch("litellm.proxy.proxy_server.prisma_client", prisma_client),
patch("litellm.proxy.proxy_server.user_api_key_cache", user_api_key_cache),
- patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
+ patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock(post_call_failure_hook=AsyncMock(return_value=None))),
patch("litellm.proxy.proxy_server.jwt_handler", jwt_handler),
patch(
"litellm.proxy.auth.user_api_key_auth._resolve_jwt_to_virtual_key",
@@ -2170,8 +2172,22 @@ async def test_auto_register_first_request_propagates_user_email():
"litellm.proxy.auth.user_api_key_auth._auto_register_jwt_mapping",
new_callable=AsyncMock,
return_value=auto_registered_key,
- ),
+ ) as auto_register,
):
+ if not active:
+ with pytest.raises(ProxyException, match="deactivated via SCIM") as exc:
+ await _user_api_key_auth_builder(
+ request=mock_request,
+ api_key=jwt_token,
+ azure_api_key_header="",
+ anthropic_api_key_header=None,
+ google_ai_studio_api_key_header=None,
+ azure_apim_header=None,
+ request_data={},
+ )
+ assert int(exc.value.code) == 401
+ auto_register.assert_not_awaited()
+ return
result = await _user_api_key_auth_builder(
request=mock_request,
api_key=jwt_token,
@@ -7315,15 +7331,15 @@ class TestJWTAuthUserEmail:
the Prometheus `user_email` label and `user_api_key_user_email` in
StandardLogging/SpendLogs metadata, which were always None for JWT traffic."""
- def _jwt_request(self, jwt_token):
+ def _jwt_request(self, jwt_token, route="/v1/chat/completions"):
mock_request = MagicMock()
- mock_request.url.path = "/v1/chat/completions"
- mock_request.method = "POST"
+ mock_request.url.path = route
+ mock_request.method = "GET" if route.endswith("/list") else "POST"
mock_request.headers = {"authorization": f"Bearer {jwt_token}"}
mock_request.query_params = {}
return mock_request
- async def _run_jwt_auth(self, mock_jwt_result, jwt_token):
+ async def _run_jwt_auth(self, mock_jwt_result, jwt_token, route="/v1/chat/completions"):
with (
patch(
"litellm.proxy.proxy_server.general_settings",
@@ -7344,7 +7360,7 @@ class TestJWTAuthUserEmail:
litellm_jwtauth=LiteLLM_JWTAuth(),
)
return await user_api_key_auth(
- request=self._jwt_request(jwt_token),
+ request=self._jwt_request(jwt_token, route),
api_key=f"Bearer {jwt_token}",
)
@@ -7376,6 +7392,41 @@ class TestJWTAuthUserEmail:
assert result.user_id == "jwt-human-user"
assert result.user_email == "resolved@example.com"
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("route", ["/mcp-rest/tools/list", "/mcp-rest/tools/call", "/v1/chat/completions"])
+ @pytest.mark.parametrize("active", [False, True, None, "false", 0])
+ async def test_jwt_auth_rejects_deactivated_user(self, route: str, active: bool | str | int | None) -> None:
+ from typing import Final
+
+ jwt_token: Final = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature"
+ result: Final = {
+ "is_proxy_admin": False,
+ "team_object": None,
+ "user_object": LiteLLM_UserTable(
+ user_id="jwt-human-user",
+ user_role=LitellmUserRoles.INTERNAL_USER.value,
+ metadata={} if active is None else {"scim_active": active},
+ ),
+ "end_user_object": None,
+ "org_object": None,
+ "token": jwt_token,
+ "team_id": None,
+ "user_id": "jwt-human-user",
+ "user_email": None,
+ "end_user_id": None,
+ "org_id": None,
+ "team_membership": None,
+ "jwt_claims": {"sub": "user1"},
+ }
+
+ if active is False:
+ with pytest.raises(ProxyException, match="deactivated via SCIM") as exc:
+ await self._run_jwt_auth(result, jwt_token, route)
+ assert int(exc.value.code) == 401
+ else:
+ token: Final = await self._run_jwt_auth(result, jwt_token, route)
+ assert token.user_id == "jwt-human-user"
+
@pytest.mark.asyncio
async def test_jwt_auth_populates_user_email_on_proxy_admin(self):
jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature"
diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
index 3f2ba365a04..b64f7c8fb7c 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
@@ -2228,6 +2228,51 @@ async def test_user_model_budget_update_by_email_refreshes_cached_user(mocker: M
broadcast.assert_awaited_once_with(cache_key=saved_user.user_id)
+@pytest.mark.asyncio
+@pytest.mark.parametrize("by_email", [False, True])
+@pytest.mark.parametrize("active", [False, True, None])
+async def test_user_status_update_refreshes_cached_user(
+ mocker: MockerFixture, by_email: bool, active: bool | None
+) -> None:
+ from litellm.proxy._types import LiteLLM_UserTable
+ from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
+ from litellm.proxy.management_endpoints.internal_user_endpoints import _update_single_user_helper
+
+ saved_user: Final = LiteLLM_UserTable(
+ user_id="user-spruce",
+ user_email="spruce@example.test",
+ metadata={"scim_active": False if active is None else not active, "department": "engineering"},
+ )
+ prisma_client: Final = mocker.MagicMock()
+ prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=saved_user)
+ prisma_client.get_data = mocker.AsyncMock(return_value=[saved_user])
+ prisma_client.update_data = mocker.AsyncMock(return_value={"user_id": saved_user.user_id, "data": saved_user})
+ mocker.patch("litellm.proxy.proxy_server.prisma_client", prisma_client) # test-quality-ok: substitute the database dependency
+ cache: Final = UserApiKeyCache()
+ await cache.async_set_cache(key=saved_user.user_id, value=saved_user, model_type=LiteLLM_UserTable)
+ mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache) # test-quality-ok: exercise a real isolated cache
+ broadcast: Final = mocker.patch( # test-quality-ok: observe the Redis publication boundary
+ "litellm.proxy.common_utils.auth_cache_invalidation_pubsub.publish_auth_cache_invalidation",
+ new_callable=mocker.AsyncMock,
+ )
+
+ await _update_single_user_helper(
+ user_request=UpdateUserRequest(
+ user_id=None if by_email else saved_user.user_id,
+ user_email=saved_user.user_email if by_email else None,
+ metadata={"department": "engineering"} if active is None else {"scim_active": active},
+ ),
+ user_api_key_dict=UserAPIKeyAuth(user_id="admin-spruce", user_role=LitellmUserRoles.PROXY_ADMIN),
+ )
+
+ assert prisma_client.update_data.call_args.kwargs["user_id"] == saved_user.user_id
+ assert prisma_client.update_data.call_args.kwargs["data"]["metadata"] == (
+ {"department": "engineering"} if active is None else {"scim_active": active}
+ )
+ assert await cache.async_get_cache(key=saved_user.user_id, model_type=LiteLLM_UserTable) is None
+ broadcast.assert_awaited_once_with(cache_key=saved_user.user_id)
+
+
@pytest.mark.asyncio
async def test_bulk_user_model_budget_clear_serializes_and_refreshes_cache(mocker: MockerFixture) -> None:
from litellm.proxy._types import LiteLLM_UserTable
From e833bdccdeb2e782b8482a612282027e224b35d2 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 18:19:02 -0700
Subject: [PATCH 094/317] fix(azure_ai): bridge Foundry function-tool requests
only where the chat surface rejects them
Foundry's OpenAI v1 chat surface rejects function tools with an explicit
reasoning_effort from gpt-5.6 on and with reasoning left on from gpt-6 on,
while gpt-5.4, gpt-5.5 and unset-effort gpt-5.6 serve them. Key the
azure_ai bridge on those measured boundaries instead of the azure
provider's gpt-5.4+ rule so working chat traffic keeps its n, logprobs,
seed and chatcmpl ids.
---
litellm/llms/azure_ai/common_utils.py | 9 ++++
.../llms/openai/chat/gpt_5_transformation.py | 33 ++++++++----
litellm/main.py | 45 +++++++++-------
.../llms/openai/test_is_model_gpt_5_model.py | 52 +++++++++++++++++++
tests/test_litellm/test_main.py | 23 +++++---
5 files changed, 125 insertions(+), 37 deletions(-)
diff --git a/litellm/llms/azure_ai/common_utils.py b/litellm/llms/azure_ai/common_utils.py
index d5a05cb8ea5..cffe9049de6 100644
--- a/litellm/llms/azure_ai/common_utils.py
+++ b/litellm/llms/azure_ai/common_utils.py
@@ -6,6 +6,7 @@ from urllib.parse import urlparse
import litellm
from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter
+from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllMessageValues
from litellm.types.router import GenericLiteLLMParams
@@ -150,6 +151,14 @@ def azure_ai_supports_native_responses(model: str | None, api_base: str | None)
return AzureFoundryModelInfo.get_azure_ai_route(model) == "default"
+def foundry_chat_rejects_function_tools_while_reasoning(
+ model: str, reasoning_effort: str | Mapping[str, object] | None
+) -> bool:
+ if reasoning_effort is None:
+ return OpenAIGPT5Config.is_model_gpt_6_plus_model(model)
+ return OpenAIGPT5Config.is_model_gpt_5_6_plus_model(model)
+
+
class AzureFoundryModelInfo(BaseLLMModelInfo):
"""Model info for Azure AI / Azure Foundry models."""
diff --git a/litellm/llms/openai/chat/gpt_5_transformation.py b/litellm/llms/openai/chat/gpt_5_transformation.py
index 1b93df95341..d0e5ff01e71 100644
--- a/litellm/llms/openai/chat/gpt_5_transformation.py
+++ b/litellm/llms/openai/chat/gpt_5_transformation.py
@@ -1,5 +1,6 @@
"""Support for OpenAI gpt-5 model family."""
+import re
from typing import Final
import litellm
@@ -11,6 +12,8 @@ from litellm.utils import (
from .gpt_transformation import OpenAIGPTConfig
+_GPT_SERIES_VERSION: Final = re.compile(r"^gpt-(\d+)(?:\.(\d+))?(?=[.-]|$)")
+
def _catalogue_declares_default_effort() -> bool:
"""Whether the loaded cost map carries default_reasoning_effort for ANY entry.
@@ -112,20 +115,28 @@ class OpenAIGPT5Config(OpenAIGPTConfig):
model_name: Final = model.split("/")[-1]
return model_name.startswith("gpt-5.4")
+ @staticmethod
+ def _gpt_series_version(model: str) -> tuple[int, int] | None:
+ match: Final = _GPT_SERIES_VERSION.match(model.split("/")[-1])
+ if match is None:
+ return None
+ return int(match.group(1)), int(match.group(2) or 0)
+
@classmethod
def is_model_gpt_5_4_plus_model(cls, model: str) -> bool:
"""Check if the model is gpt-5.4 or newer (5.4, 5.5, 5.6, etc., including pro)."""
- model_name: Final = model.split("/")[-1]
- if model_name.startswith("gpt-6"):
- return True
- if not model_name.startswith("gpt-5."):
- return False
- try:
- version_str: Final = model_name.replace("gpt-5.", "").split("-")[0]
- major: Final = version_str.split(".")[0]
- return int(major) >= 4
- except (ValueError, IndexError):
- return False
+ version: Final = cls._gpt_series_version(model)
+ return version is not None and version >= (5, 4)
+
+ @classmethod
+ def is_model_gpt_5_6_plus_model(cls, model: str) -> bool:
+ version: Final = cls._gpt_series_version(model)
+ return version is not None and version >= (5, 6)
+
+ @classmethod
+ def is_model_gpt_6_plus_model(cls, model: str) -> bool:
+ version: Final = cls._gpt_series_version(model)
+ return version is not None and version >= (6, 0)
@classmethod
def _model_map_lookup_name(cls, model: str) -> str:
diff --git a/litellm/main.py b/litellm/main.py
index 6ab2fcd4b03..93b6c730d86 100644
--- a/litellm/main.py
+++ b/litellm/main.py
@@ -100,7 +100,10 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
from litellm.litellm_core_utils.request_timeout_resolver import (
get_configured_request_timeout,
)
-from litellm.llms.azure_ai.common_utils import azure_ai_supports_native_responses
+from litellm.llms.azure_ai.common_utils import (
+ azure_ai_supports_native_responses,
+ foundry_chat_rejects_function_tools_while_reasoning,
+)
from litellm.llms.base_llm import BaseConfig, BaseImageGenerationConfig
from litellm.llms.base_llm.base_model_iterator import (
convert_model_response_to_streaming,
@@ -1107,6 +1110,10 @@ def responses_api_bridge_check(
# provider with a custom api_base and gpt-5.4+ model names serve tools without
# reasoning fine and have no /responses route, so they keep pre-existing
# behavior (bridge only on an explicit reasoning_effort).
+ # - Azure AI Foundry's OpenAI v1 hosts (azure_ai provider) enforce it later in the series:
+ # an explicit effort with function tools is rejected from gpt-5.6 on, and the unset
+ # effort only from gpt-6 on (gpt-5.6 serves tools with reasoning silently off), so the
+ # azure_ai gate keys on those measured boundaries instead of gpt-5.4+.
# - Older GPT-5 names (e.g. ``gpt-5``, ``gpt-5.1``): bridge only when a reasoning
# summary alias is present with ``reasoning_effort`` (tools alone stay on chat).
has_function_tool: Final = any(
@@ -1119,35 +1126,35 @@ def responses_api_bridge_check(
reasoning_active = reasoning_effort != "none"
# The reasoning+tools constraint is enforced by the real OpenAI backend behind any api.openai.com
# host (the default URL or a PrivateLink hostname such as .privatelink.api.openai.com) and
- # by Azure OpenAI, whether reached through the azure provider or as a Foundry OpenAI v1 host through
- # the azure_ai provider. Resolve the effective OpenAI base arg>global>env>default exactly as the chat
- # handler does, so a custom base set via litellm.api_base or OPENAI_BASE_URL/OPENAI_API_BASE isn't
- # misread as the default and bridged to a /responses route it lacks. A whitespace-only base
- # collapses to the default too.
+ # by Azure OpenAI through the azure provider. Resolve the effective OpenAI base arg>global>env>default
+ # exactly as the chat handler does, so a custom base set via litellm.api_base or
+ # OPENAI_BASE_URL/OPENAI_API_BASE isn't misread as the default and bridged to a /responses route it
+ # lacks. A whitespace-only base collapses to the default too.
resolved_api_base: Final = _resolve_openai_api_base(api_base).strip()
on_foundry_openai_endpoint: Final = custom_llm_provider == "azure_ai" and azure_ai_supports_native_responses(
model, api_base
)
on_constraint_enforcing_endpoint: Final = (
- custom_llm_provider == "azure"
- or on_foundry_openai_endpoint
- or resolved_api_base == ""
- or _is_openai_backed_api_base(resolved_api_base)
+ custom_llm_provider == "azure" or resolved_api_base == "" or _is_openai_backed_api_base(resolved_api_base)
+ )
+ chat_rejects_function_tools: Final = (
+ has_function_tool
+ and reasoning_active
+ and (
+ foundry_chat_rejects_function_tools_while_reasoning(model, reasoning_effort)
+ if on_foundry_openai_endpoint
+ else (
+ OpenAIGPT5Config.is_model_gpt_5_4_plus_model(model)
+ and (reasoning_effort is not None or on_constraint_enforcing_endpoint)
+ )
+ )
)
if (
(custom_llm_provider in ("openai", "azure") or on_foundry_openai_endpoint)
and model_info.get("mode") != "responses"
and OpenAIGPT5Config.is_model_gpt_5_model(model)
and not OpenAIGPT5Config.is_model_gpt_5_search_model(model)
- and (
- (reasoning_effort is not None and reasoning_summary is not None)
- or (
- OpenAIGPT5Config.is_model_gpt_5_4_plus_model(model)
- and has_function_tool
- and reasoning_active
- and (reasoning_effort is not None or on_constraint_enforcing_endpoint)
- )
- )
+ and ((reasoning_effort is not None and reasoning_summary is not None) or chat_rejects_function_tools)
):
model_info["mode"] = "responses"
model = model.replace("responses/", "")
diff --git a/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py
index 107a1afb2c6..0bb8425d95e 100644
--- a/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py
+++ b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py
@@ -159,6 +159,58 @@ class TestOpenAIGPT5ConfigIsModelGpt54PlusModel:
), f"Expected '{model}' NOT to be classified as gpt-5.4-or-newer"
+GPT5_6_PLUS_MODELS = [
+ "gpt-6-astra",
+ "openai/gpt-6-astra",
+ "gpt-5.6",
+ "gpt-5.6-sol",
+ "gpt-5.6-terra",
+ "gpt-5.10-preview",
+]
+
+GPT5_PRE_5_6_MODELS = [
+ "gpt-5",
+ "gpt-5.4",
+ "gpt-5.4-mini",
+ "gpt-5.5",
+ "gpt-5.5-pro",
+ "gpt-4o",
+]
+
+GPT6_PLUS_MODELS = [
+ "gpt-6-astra",
+ "openai/gpt-6-astra",
+ "gpt-6",
+ "gpt-6.1-preview",
+]
+
+GPT_PRE_6_MODELS = [
+ "gpt-5.6-sol",
+ "gpt-5.5",
+ "gpt-5",
+ "gpt-4o",
+]
+
+
+class TestOpenAIGPT5ConfigSeriesBoundaries:
+
+ @pytest.mark.parametrize("model", GPT5_6_PLUS_MODELS)
+ def test_gpt5_6_plus_models_are_classified_as_5_6_plus(self, model: str):
+ assert OpenAIGPT5Config.is_model_gpt_5_6_plus_model(model)
+
+ @pytest.mark.parametrize("model", GPT5_PRE_5_6_MODELS)
+ def test_pre_5_6_models_are_not_classified_as_5_6_plus(self, model: str):
+ assert not OpenAIGPT5Config.is_model_gpt_5_6_plus_model(model)
+
+ @pytest.mark.parametrize("model", GPT6_PLUS_MODELS)
+ def test_gpt6_plus_models_are_classified_as_6_plus(self, model: str):
+ assert OpenAIGPT5Config.is_model_gpt_6_plus_model(model)
+
+ @pytest.mark.parametrize("model", GPT_PRE_6_MODELS)
+ def test_pre_6_models_are_not_classified_as_6_plus(self, model: str):
+ assert not OpenAIGPT5Config.is_model_gpt_6_plus_model(model)
+
+
# ---------------------------------------------------------------------------
# AzureOpenAIGPT5Config
# ---------------------------------------------------------------------------
diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py
index ab09d242e98..c2b45aac488 100644
--- a/tests/test_litellm/test_main.py
+++ b/tests/test_litellm/test_main.py
@@ -1313,25 +1313,29 @@ _FOUNDRY_FUNCTION_TOOL: Final = ({"type": "function", "function": {"name": "get_
@pytest.mark.parametrize(
- "api_base, reasoning_effort",
+ "model_name, api_base, reasoning_effort",
[
- pytest.param(_FOUNDRY_API_BASE, None, id="foundry-host-unset-effort"),
- pytest.param(_FOUNDRY_API_BASE, "low", id="foundry-host-explicit-effort"),
- pytest.param("https://myresource.openai.azure.com", None, id="azure-openai-host-unset-effort"),
+ pytest.param("gpt-6-astra", _FOUNDRY_API_BASE, None, id="gpt-6-unset-effort"),
+ pytest.param("gpt-6-astra", _FOUNDRY_API_BASE, "low", id="gpt-6-explicit-effort"),
+ pytest.param("gpt-6-astra", "https://myresource.openai.azure.com", None, id="gpt-6-azure-openai-host"),
+ pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, "low", id="gpt-5.6-explicit-effort"),
+ pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, {"effort": "high"}, id="gpt-5.6-explicit-effort-dict"),
],
)
-def test_responses_api_bridge_check_azure_ai_foundry_gpt_5_4_plus_tools_routes_to_responses(api_base, reasoning_effort):
+def test_responses_api_bridge_check_azure_ai_foundry_rejected_tools_route_to_responses(
+ model_name, api_base, reasoning_effort
+):
from litellm.main import responses_api_bridge_check
model_info, model = responses_api_bridge_check(
- model="gpt-6-astra",
+ model=model_name,
custom_llm_provider="azure_ai",
tools=_FOUNDRY_FUNCTION_TOOL,
reasoning_effort=reasoning_effort,
api_base=api_base,
)
- assert model == "gpt-6-astra"
+ assert model == model_name
assert model_info.get("mode") == "responses"
@@ -1339,6 +1343,11 @@ def test_responses_api_bridge_check_azure_ai_foundry_gpt_5_4_plus_tools_routes_t
"model_name, api_base, reasoning_effort",
[
pytest.param("gpt-6-astra", _FOUNDRY_API_BASE, "none", id="explicit-none-stays-chat"),
+ pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, None, id="gpt-5.6-unset-effort-stays-chat"),
+ pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, "none", id="gpt-5.6-explicit-none-stays-chat"),
+ pytest.param("gpt-5.5", _FOUNDRY_API_BASE, "high", id="gpt-5.5-explicit-effort-stays-chat"),
+ pytest.param("gpt-5.4-mini", _FOUNDRY_API_BASE, None, id="gpt-5.4-mini-unset-effort-stays-chat"),
+ pytest.param("gpt-5.4-mini", _FOUNDRY_API_BASE, "low", id="gpt-5.4-mini-explicit-effort-stays-chat"),
pytest.param("gpt-6-astra", "https://myproject.models.ai.azure.com", None, id="serverless-host-stays-chat"),
pytest.param("Mistral-large-2411", _FOUNDRY_API_BASE, None, id="non-gpt-5-model-stays-chat"),
pytest.param("claude-opus-4-1", _FOUNDRY_API_BASE, None, id="claude-on-foundry-stays-chat"),
From b0971ee0bac259d278313eedd9e43bd6835da671 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 18:22:41 -0700
Subject: [PATCH 095/317] fix: count extra_body tools and cache_control in
place of the direct ones
---
.../anthropic_cache_control_hook.py | 22 +++++-------
.../test_anthropic_cache_control_hook.py | 36 +++++++++++++++++++
2 files changed, 45 insertions(+), 13 deletions(-)
diff --git a/litellm/integrations/anthropic_cache_control_hook.py b/litellm/integrations/anthropic_cache_control_hook.py
index f9b9238b181..036b9d033cd 100644
--- a/litellm/integrations/anthropic_cache_control_hook.py
+++ b/litellm/integrations/anthropic_cache_control_hook.py
@@ -318,26 +318,22 @@ class AnthropicCacheControlHook(CustomPromptManagement):
A tool carries its mark at the top level (Anthropic shape) or under ``function``
(OpenAI shape). A top-level ``cache_control`` is Anthropic's automatic caching,
- which places one breakpoint of its own on top of the explicit ones. Marks the
- client sends through the ``extra_body`` envelope of ``request_kwargs`` reach the
- wire too and count the same way. Callers pass only the tools whose mark reaches
- the provider on their path.
+ which places one breakpoint of its own on top of the explicit ones. The
+ ``extra_body`` envelope of ``request_kwargs`` is merged over the request on the
+ wire, so a ``tools`` or ``cache_control`` it carries replaces the direct value
+ and is counted in its place. Callers pass only the tools whose mark reaches the
+ provider on their path.
"""
extra_body: Final = (
_validated_object_mapping(AnthropicCacheControlHook._request_value(request_kwargs, "extra_body")) or {}
)
- automatic_blocks: Final = sum(
- 1 for control in (cache_control, extra_body.get("cache_control")) if control is not None
- )
- tool_blocks: Final = sum(
- 1
- for tool in (*(tools or ()), *(_validated_object_list(extra_body.get("tools")) or ()))
- if _tool_carries_cache_breakpoint(tool)
- )
+ wire_cache_control: Final = extra_body.get("cache_control", cache_control)
+ wire_tools: Final = _validated_object_list(extra_body["tools"]) if "tools" in extra_body else tools
+ tool_blocks: Final = sum(1 for tool in wire_tools or () if _tool_carries_cache_breakpoint(tool))
envelope_blocks: Final = AnthropicCacheControlHook.count_request_cache_breakpoints(
_validated_object_list(extra_body.get("messages")) or (), extra_body.get("system")
)
- return automatic_blocks + tool_blocks + envelope_blocks
+ return int(wire_cache_control is not None) + tool_blocks + envelope_blocks
@staticmethod
def _blocks_reserved_outside_messages(
diff --git a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
index 78aee3048ca..041b00c6c70 100644
--- a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
+++ b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
@@ -2596,6 +2596,42 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
_, result_sys = self._inject(self._marked_user_turns(3), kwargs)
assert result_sys == expected_system
+ @pytest.mark.parametrize(
+ "params,tools,marked_turns,injected",
+ [
+ ({"extra_body": {"tools": [MARKED_TOOL_TOP_LEVEL]}}, [MARKED_TOOL_TOP_LEVEL], 2, 1),
+ ({"extra_body": {"tools": [UNMARKED_TOOL]}}, [MARKED_TOOL_TOP_LEVEL], 3, 1),
+ ({"extra_body": {"tools": [MARKED_TOOL_TOP_LEVEL]}}, [UNMARKED_TOOL], 3, 0),
+ ({"extra_body": {"cache_control": EPHEMERAL}, "cache_control": EPHEMERAL}, None, 2, 1),
+ ],
+ ids=["same_marked_tool_both_ways", "extra_body_unmarks", "extra_body_marks", "root_cache_control_both_ways"],
+ )
+ def test_chat_cap_counts_extra_body_fields_in_place_of_the_direct_ones(self, params, tools, marked_turns, injected):
+ """``extra_body`` is merged over the request on the wire, so its ``tools`` and
+ ``cache_control`` replace the direct ones rather than adding to them."""
+ messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(marked_turns)]
+ params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), **copy.deepcopy(params)}
+ self._seed(params, copy.deepcopy(messages), tools=tools)
+ processed = self._chat(params, copy.deepcopy(messages))
+ assert _count_cache_control(processed) == marked_turns + injected
+
+ @pytest.mark.parametrize(
+ "kwargs,tools,marked_turns,expected_system",
+ [
+ ({"extra_body": {"tools": [MARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 2, [{"type": "text", "text": "sys", "cache_control": EPHEMERAL}]),
+ ({"extra_body": {"tools": [UNMARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 3, [{"type": "text", "text": "sys", "cache_control": EPHEMERAL}]),
+ ({"extra_body": {"tools": [MARKED_V1_TOOL]}}, [UNMARKED_V1_TOOL], 3, "sys"),
+ ({"extra_body": {"cache_control": EPHEMERAL}, "cache_control": EPHEMERAL}, None, 2, [{"type": "text", "text": "sys", "cache_control": EPHEMERAL}]),
+ ],
+ ids=["same_marked_tool_both_ways", "extra_body_unmarks", "extra_body_marks", "root_cache_control_both_ways"],
+ )
+ def test_v1_messages_cap_counts_extra_body_fields_in_place_of_the_direct_ones(
+ self, kwargs, tools, marked_turns, expected_system
+ ):
+ kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), **copy.deepcopy(kwargs)}
+ _, result_sys = self._inject(self._marked_user_turns(marked_turns), kwargs, tools=tools)
+ assert result_sys == expected_system
+
def test_v1_messages_automatic_defaults_stand_down_for_root_cache_control(self, monkeypatch):
monkeypatch.setattr(litellm, "enable_anthropic_prompt_caching", True)
root_cache_control = {"type": "ephemeral"}
From 94b2fd827b4ea2678fa88ac0788e948f3b899348 Mon Sep 17 00:00:00 2001
From: Tin Chi Lo
Date: Sat, 19 Sep 2026 17:02:56 -0700
Subject: [PATCH 096/317] feat(ui): show prompt caching requests and net
savings
---
backend/routes/allowlist.py | 1 +
.../prompt_caching_requests.py | 184 ++++++++++
litellm/proxy/proxy_server.py | 4 +
litellm/proxy/spend_tracking/savings.py | 75 ++--
.../prompt_caching_requests.py | 35 ++
.../test_prompt_caching_requests.py | 321 ++++++++++++++++++
.../proxy/spend_tracking/test_savings.py | 37 ++
.../_components/CacheLeakageCard.tsx | 6 +-
.../CostOptimizationView.activity.test.tsx | 1 +
...tCachingRequestsTable.integration.test.tsx | 248 ++++++++++++++
.../PromptCachingRequestsTable.tsx | 186 ++++++++++
.../_components/PromptCachingTab.test.tsx | 23 +-
.../_components/PromptCachingTab.tsx | 7 +
ui/litellm-dashboard/src/lib/http/schema.d.ts | 95 ++++++
14 files changed, 1195 insertions(+), 28 deletions(-)
create mode 100644 litellm/proxy/management_endpoints/prompt_caching_requests.py
create mode 100644 litellm/types/management_endpoints/prompt_caching_requests.py
create mode 100644 tests/test_litellm/proxy/management_endpoints/test_prompt_caching_requests.py
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingRequestsTable.integration.test.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingRequestsTable.tsx
diff --git a/backend/routes/allowlist.py b/backend/routes/allowlist.py
index 00c4e0070e6..c7f389c36a4 100644
--- a/backend/routes/allowlist.py
+++ b/backend/routes/allowlist.py
@@ -51,6 +51,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
"/cache_settings",
"/coordination_redis/",
"/cost_tracking",
+ "/cost_optimization/",
"/cost/",
"/credentials",
"/credential",
diff --git a/litellm/proxy/management_endpoints/prompt_caching_requests.py b/litellm/proxy/management_endpoints/prompt_caching_requests.py
new file mode 100644
index 00000000000..41255bd49b8
--- /dev/null
+++ b/litellm/proxy/management_endpoints/prompt_caching_requests.py
@@ -0,0 +1,184 @@
+from collections.abc import Callable, Mapping
+from datetime import datetime, timezone
+from types import MappingProxyType
+from typing import TYPE_CHECKING, Annotated, Final
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel, Json, TypeAdapter
+
+from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth, user_api_key_has_admin_view
+from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+from litellm.proxy.spend_tracking.savings import (
+ extract_cache_creation_tokens,
+ extract_cache_read_tokens,
+ marks_gateway_injection,
+ prompt_caching_savings_for_request,
+)
+from litellm.proxy.spend_tracking.spend_tracking_utils import (
+ _query_raw_rows, # pyright: ignore[reportPrivateUsage] # existing typed spend-query adapter; rows validated below
+)
+from litellm.types.integrations.anthropic_cache_control_hook import GATEWAY_INJECTED_CACHE_METADATA_KEY
+from litellm.types.management_endpoints.prompt_caching_requests import (
+ PromptCachingRequest,
+ PromptCachingRequestCursor,
+ PromptCachingRequestFilter,
+ PromptCachingRequestsResponse,
+)
+
+if TYPE_CHECKING:
+ from litellm.router import Router
+
+router: Final = APIRouter()
+
+
+def _numeric_token_sql(path: str) -> str:
+ value: Final = f"metadata #> '{{usage_object,{path}}}'"
+ return (
+ f"CASE WHEN jsonb_typeof({value}) = 'number' THEN ({value} #>> '{{}}')::numeric "
+ f"WHEN {value} = 'true'::jsonb THEN 1 WHEN {value} = 'false'::jsonb THEN 0 END"
+ )
+
+
+def _cache_tokens_sql(*paths: str) -> str:
+ candidates: Final = ", ".join(f"NULLIF(({_numeric_token_sql(path)}), 0)" for path in paths)
+ return f"TRUNC(COALESCE({candidates}, 0))"
+
+
+_CACHE_READ_SQL: Final = _cache_tokens_sql("cache_read_input_tokens", "prompt_tokens_details,cached_tokens")
+_CACHE_CREATION_SQL: Final = _cache_tokens_sql(
+ "cache_creation_input_tokens",
+ "prompt_tokens_details,cache_write_tokens",
+ "prompt_tokens_details,cache_creation_tokens",
+)
+_GATEWAY_INJECTED_SQL: Final = (
+ f"(jsonb_typeof(metadata->'{GATEWAY_INJECTED_CACHE_METADATA_KEY}') = 'string' "
+ f"AND (metadata->>'{GATEWAY_INJECTED_CACHE_METADATA_KEY}' = '' "
+ f"OR metadata->>'{GATEWAY_INJECTED_CACHE_METADATA_KEY}' = model_id))"
+)
+_FILTER_SQL: Final = MappingProxyType(
+ {
+ "all": f"({_GATEWAY_INJECTED_SQL} OR {_CACHE_READ_SQL} > 0 OR {_CACHE_CREATION_SQL} > 0)",
+ "injected": _GATEWAY_INJECTED_SQL,
+ "hits": f"{_CACHE_READ_SQL} > 0",
+ }
+)
+
+
+def prompt_caching_requests_sql(filter: PromptCachingRequestFilter) -> str:
+ return f"""
+ SELECT request_id, "startTime" AS start_time, "endTime" AS end_time,
+ model, model_id, custom_llm_provider, spend,
+ CASE WHEN jsonb_typeof(metadata->'usage_object') = 'object'
+ THEN metadata->'usage_object' END AS usage_object,
+ CASE WHEN jsonb_typeof(metadata->'cost_breakdown') = 'object'
+ THEN metadata->'cost_breakdown' END AS cost_breakdown,
+ CASE WHEN jsonb_typeof(metadata->'{GATEWAY_INJECTED_CACHE_METADATA_KEY}') = 'string'
+ THEN metadata->>'{GATEWAY_INJECTED_CACHE_METADATA_KEY}' END AS gateway_marker
+ FROM "LiteLLM_SpendLogs"
+ WHERE "startTime" >= ($1::text::timestamptz AT TIME ZONE 'UTC')
+ AND "startTime" <= ($2::text::timestamptz AT TIME ZONE 'UTC')
+ AND COALESCE(LOWER(cache_hit), 'false') != 'true'
+ AND {_FILTER_SQL[filter]}
+ AND ($4::text::timestamptz IS NULL OR
+ ("startTime", request_id) < (($4::text::timestamptz AT TIME ZONE 'UTC'), $5::text))
+ ORDER BY "startTime" DESC, request_id DESC
+ LIMIT $3::integer
+ """
+
+
+class _PromptCachingRow(BaseModel):
+ request_id: str
+ start_time: datetime
+ end_time: datetime
+ model: str
+ model_id: str | None
+ custom_llm_provider: str | None
+ spend: float
+ usage_object: Json[Mapping[str, object]] | Mapping[str, object] | None
+ cost_breakdown: Json[Mapping[str, object]] | Mapping[str, object] | None
+ gateway_marker: str | None
+
+
+_REQUEST_ROWS: Final = TypeAdapter(tuple[_PromptCachingRow, ...])
+
+
+def _request_result(row: _PromptCachingRow, llm_router: "Callable[[], Router | None]") -> PromptCachingRequest:
+ return PromptCachingRequest(
+ request_id=row.request_id,
+ start_time=row.start_time.replace(tzinfo=timezone.utc) if row.start_time.tzinfo is None else row.start_time,
+ model=row.model,
+ gateway_injected=marks_gateway_injection(
+ MappingProxyType({GATEWAY_INJECTED_CACHE_METADATA_KEY: row.gateway_marker}), row.model_id
+ ),
+ cache_read_tokens=extract_cache_read_tokens(row.usage_object),
+ cache_creation_tokens=extract_cache_creation_tokens(row.usage_object),
+ spend=row.spend,
+ net_savings=prompt_caching_savings_for_request(
+ model=row.model,
+ custom_llm_provider=row.custom_llm_provider,
+ usage_object=row.usage_object,
+ model_id=row.model_id,
+ llm_router=llm_router,
+ cost_breakdown=row.cost_breakdown,
+ billed_at=row.end_time,
+ ),
+ )
+
+
+@router.get(
+ "/cost_optimization/prompt_caching/requests",
+ tags=["Cost Optimization"], # mutable-ok: FastAPI's route API requires a list
+ response_model=PromptCachingRequestsResponse,
+)
+async def get_prompt_caching_requests(
+ user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
+ start_date: datetime,
+ end_date: datetime,
+ page_size: Annotated[int, Query(ge=1, le=100)] = 50,
+ filter: PromptCachingRequestFilter = "all",
+ cursor_start_time: datetime | None = None,
+ cursor_request_id: Annotated[str | None, Query(min_length=1)] = None,
+) -> PromptCachingRequestsResponse:
+ from litellm.proxy.proxy_server import llm_router, prisma_client
+
+ if not user_api_key_has_admin_view(user_api_key_dict):
+ raise HTTPException(status_code=403, detail="Only proxy admin roles can view prompt caching requests")
+ if (cursor_start_time is None) != (cursor_request_id is None):
+ raise HTTPException(status_code=400, detail="cursor_start_time and cursor_request_id must be provided together")
+ if prisma_client is None:
+ raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
+ start: Final = start_date.replace(tzinfo=timezone.utc) if start_date.tzinfo is None else start_date
+ end: Final = end_date.replace(tzinfo=timezone.utc) if end_date.tzinfo is None else end_date
+ if end < start:
+ raise HTTPException(status_code=400, detail="end_date must not be earlier than start_date")
+ cursor_time: Final = (
+ cursor_start_time.replace(tzinfo=timezone.utc)
+ if cursor_start_time is not None and cursor_start_time.tzinfo is None
+ else cursor_start_time
+ )
+ rows: Final = _REQUEST_ROWS.validate_python(
+ await _query_raw_rows(
+ prisma_client,
+ prompt_caching_requests_sql(filter),
+ start.isoformat(),
+ end.isoformat(),
+ page_size + 1,
+ cursor_time.isoformat() if cursor_time is not None else None,
+ cursor_request_id,
+ )
+ or ()
+ )
+
+ def current_router() -> "Router | None":
+ return llm_router
+
+ requests: Final = tuple(_request_result(row, current_router) for row in rows[:page_size])
+ has_more: Final = len(rows) > page_size
+ return PromptCachingRequestsResponse(
+ requests=requests,
+ page_size=page_size,
+ has_more=has_more,
+ next_cursor=PromptCachingRequestCursor(start_time=requests[-1].start_time, request_id=requests[-1].request_id)
+ if has_more
+ else None,
+ )
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index af25d418a63..f4a56e225cc 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -587,6 +587,9 @@ from litellm.proxy.management_endpoints.model_management_endpoints import (
from litellm.proxy.management_endpoints.organization_endpoints import (
router as organization_router,
)
+from litellm.proxy.management_endpoints.prompt_caching_requests import (
+ router as prompt_caching_requests_router,
+)
from litellm.proxy.management_endpoints.router_settings_endpoints import (
router as router_settings_router,
)
@@ -19183,6 +19186,7 @@ app.include_router(workflow_management_router)
app.include_router(memory_router)
app.include_router(plugin_router)
app.include_router(cost_tracking_settings_router)
+app.include_router(prompt_caching_requests_router)
app.include_router(router_settings_router)
app.include_router(fallback_management_router)
app.include_router(cache_settings_router)
diff --git a/litellm/proxy/spend_tracking/savings.py b/litellm/proxy/spend_tracking/savings.py
index b7a2ac62844..fbcf9c78d3e 100644
--- a/litellm/proxy/spend_tracking/savings.py
+++ b/litellm/proxy/spend_tracking/savings.py
@@ -578,6 +578,56 @@ def autorouter_savings_for_logging_payload(
)
+def _request_savings_pricing(
+ model: str | None,
+ custom_llm_provider: str | None,
+ model_id: str | None,
+ llm_router: "Callable[[], Router | None] | None",
+) -> tuple[str | None, ModelInfo | None]:
+ router_instance: Final = llm_router() if llm_router else None
+ identity: Final = _resolve_model(model, custom_llm_provider)
+ pricing: Final = _effective_model_info(router_instance, model_id, model or "") or (
+ _model_info(identity) if identity else None
+ )
+ return identity.provider if identity else custom_llm_provider, pricing
+
+
+def _prompt_caching_savings(
+ pricing: ModelInfo | None,
+ provider: str | None,
+ usage_object: Mapping[str, object] | None,
+ cost_breakdown: Mapping[str, object] | None,
+ billed_at: datetime | str | None,
+) -> float | None:
+ usage: Final = _usage_from_spend_log(usage_object)
+ if pricing is None or usage is None:
+ return None
+ basis: Final = _pricing_basis(cost_breakdown)
+ result: Final = calculate_prompt_caching_savings(
+ model_info=pricing,
+ usage=usage,
+ custom_llm_provider=provider,
+ service_tier=basis.service_tier,
+ data_residency=basis.data_residency,
+ vertex_location=basis.vertex_location,
+ billed_at=_coerce_billed_at(billed_at),
+ )
+ return result if isfinite(result) else None
+
+
+def prompt_caching_savings_for_request(
+ model: str | None,
+ custom_llm_provider: str | None,
+ usage_object: Mapping[str, object] | None,
+ model_id: str | None = None,
+ llm_router: "Callable[[], Router | None] | None" = None,
+ cost_breakdown: Mapping[str, object] | None = None,
+ billed_at: datetime | str | None = None,
+) -> float | None:
+ request_pricing: Final = _request_savings_pricing(model, custom_llm_provider, model_id, llm_router)
+ return _prompt_caching_savings(request_pricing[1], request_pricing[0], usage_object, cost_breakdown, billed_at)
+
+
def compute_savings_spend(
model: str | None,
custom_llm_provider: str | None,
@@ -639,29 +689,12 @@ def compute_savings_spend(
# Deployment rates when the request came through one, public rates otherwise --
# `_effective_model_info` merges a deployment's configured prices over the built-in
# map, so a negotiated price is not silently replaced by the list rate.
- router_instance: Router | None = llm_router() if llm_router else None
- identity: Final = _resolve_model(model, custom_llm_provider)
- pricing: Final = _effective_model_info(router_instance, model_id, model or "") or (
- _model_info(identity) if identity else None
- )
+ request_pricing: Final = _request_savings_pricing(model, custom_llm_provider, model_id, llm_router)
+ provider: Final = request_pricing[0]
+ pricing: Final = request_pricing[1]
input_cost: Final = (_get_cost_per_unit(pricing, "input_cost_per_token") or 0.0) if pricing else 0.0
compression: Final = max(compression_saved_tokens, 0) * input_cost
- usage: Final = _usage_from_spend_log(usage_object)
- basis: Final = _pricing_basis(cost_breakdown)
- billed_at_datetime: Final = _coerce_billed_at(billed_at)
- prompt_caching: Final = (
- calculate_prompt_caching_savings(
- model_info=pricing,
- usage=usage,
- custom_llm_provider=identity.provider if identity else custom_llm_provider,
- service_tier=basis.service_tier,
- data_residency=basis.data_residency,
- vertex_location=basis.vertex_location,
- billed_at=billed_at_datetime,
- )
- if pricing is not None and usage is not None
- else 0.0
- )
+ prompt_caching: Final = _prompt_caching_savings(pricing, provider, usage_object, cost_breakdown, billed_at) or 0.0
gateway_injected_caching: Final = prompt_caching if gateway_injected_cache else 0.0
# The figure the logging path recorded wins, before the usage gate on purpose: a row
diff --git a/litellm/types/management_endpoints/prompt_caching_requests.py b/litellm/types/management_endpoints/prompt_caching_requests.py
new file mode 100644
index 00000000000..e72183a113b
--- /dev/null
+++ b/litellm/types/management_endpoints/prompt_caching_requests.py
@@ -0,0 +1,35 @@
+from datetime import datetime
+from typing import Literal, TypeAlias
+
+from pydantic import BaseModel, ConfigDict
+
+PromptCachingRequestFilter: TypeAlias = Literal["all", "injected", "hits"]
+
+
+class PromptCachingRequest(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ request_id: str
+ start_time: datetime
+ model: str
+ gateway_injected: bool
+ cache_read_tokens: int
+ cache_creation_tokens: int
+ spend: float
+ net_savings: float | None
+
+
+class PromptCachingRequestCursor(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ start_time: datetime
+ request_id: str
+
+
+class PromptCachingRequestsResponse(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ requests: tuple[PromptCachingRequest, ...]
+ page_size: int
+ has_more: bool
+ next_cursor: PromptCachingRequestCursor | None
diff --git a/tests/test_litellm/proxy/management_endpoints/test_prompt_caching_requests.py b/tests/test_litellm/proxy/management_endpoints/test_prompt_caching_requests.py
new file mode 100644
index 00000000000..0995de6c39d
--- /dev/null
+++ b/tests/test_litellm/proxy/management_endpoints/test_prompt_caching_requests.py
@@ -0,0 +1,321 @@
+import json
+from collections.abc import AsyncIterator, Mapping
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+from types import SimpleNamespace
+from typing import Final
+
+import httpx
+import psycopg
+import pytest
+import pytest_asyncio
+from fastapi import FastAPI
+from prisma import Prisma
+from pydantic import TypeAdapter
+from pytest_postgresql import factories
+
+from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
+from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+from litellm.proxy.management_endpoints.prompt_caching_requests import router
+from litellm.proxy.spend_tracking.savings import (
+ extract_cache_creation_tokens,
+ extract_cache_read_tokens,
+ marks_gateway_injection,
+)
+from litellm.types.management_endpoints.prompt_caching_requests import (
+ PromptCachingRequestFilter,
+ PromptCachingRequestsResponse,
+)
+
+pytestmark = pytest.mark.usefixtures("local_model_cost_map")
+
+_cache_postgresql_proc: Final = factories.postgresql_proc() # pyright: ignore[reportUnknownMemberType] # third-party fixture factory has incomplete callable types
+_cache_postgresql: Final = factories.postgresql("_cache_postgresql_proc")
+_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object])
+_JSON_ROWS: Final = TypeAdapter(tuple[Mapping[str, object], ...])
+_START: Final = "2026-09-01T00:00:00Z"
+_END: Final = "2026-09-02T00:00:00Z"
+_URL: Final = "/cost_optimization/prompt_caching/requests"
+_MODEL: Final = "claude-sonnet-5"
+_MARKER: Final = "litellm_gateway_injected_cache"
+_DDL: Final = """
+ CREATE TABLE "LiteLLM_SpendLogs" (
+ request_id TEXT PRIMARY KEY, "startTime" TIMESTAMP, "endTime" TIMESTAMP,
+ model TEXT, model_id TEXT, custom_llm_provider TEXT, spend DOUBLE PRECISION,
+ metadata JSONB, cache_hit TEXT
+ )
+"""
+
+
+@dataclass(frozen=True)
+class _Case:
+ request_id: str
+ metadata: Mapping[str, object]
+ cache_hit: str | None = None
+ start_time: datetime = datetime(2026, 9, 1, 12, 0, 0, 123456)
+
+ def matches(self, filter: PromptCachingRequestFilter) -> bool:
+ if self.cache_hit is not None and self.cache_hit.lower() == "true":
+ return False
+ if not datetime(2026, 9, 1) <= self.start_time <= datetime(2026, 9, 2):
+ return False
+ usage: Final = self.metadata.get("usage_object")
+ normalized: Final = _JSON_OBJECT.validate_python(usage) if isinstance(usage, Mapping) else None
+ injected: Final = marks_gateway_injection(self.metadata, "dep-a")
+ reads: Final = extract_cache_read_tokens(normalized)
+ writes: Final = extract_cache_creation_tokens(normalized)
+ match filter:
+ case "injected":
+ return injected
+ case "hits":
+ return reads > 0
+ case "all":
+ return injected or reads > 0 or writes > 0
+
+
+_CASES: Final = (
+ _Case("injected-empty", {_MARKER: ""}),
+ _Case("injected-deployment", {_MARKER: "dep-a"}),
+ _Case("wrong-deployment", {_MARKER: "dep-b"}),
+ _Case("legacy-read", {"usage_object": {"cache_read_input_tokens": 100}}),
+ _Case("nested-read", {"usage_object": {"prompt_tokens_details": {"cached_tokens": 100}}}),
+ _Case("write", {"usage_object": {"cache_creation_input_tokens": 100}}),
+ _Case("nested-write", {"usage_object": {"prompt_tokens_details": {"cache_write_tokens": 100}}}),
+ _Case("nested-creation", {"usage_object": {"prompt_tokens_details": {"cache_creation_tokens": 100}}}),
+ _Case(
+ "top-precedence",
+ {"usage_object": {"cache_read_input_tokens": -2, "prompt_tokens_details": {"cached_tokens": 100}}},
+ ),
+ _Case(
+ "zero-fallback",
+ {"usage_object": {"cache_read_input_tokens": 0, "prompt_tokens_details": {"cached_tokens": 100}}},
+ ),
+ _Case(
+ "fractional-precedence",
+ {"usage_object": {"cache_read_input_tokens": 0.5, "prompt_tokens_details": {"cached_tokens": 100}}},
+ ),
+ _Case("malformed-number", {"usage_object": {"cache_read_input_tokens": "100"}}),
+ _Case("malformed-container", {"usage_object": [100]}),
+ _Case("boolean-number", {"usage_object": {"cache_read_input_tokens": True}}),
+ _Case("boolean-marker", {_MARKER: True}),
+ _Case("response-cache", {_MARKER: "", "usage_object": {"cache_read_input_tokens": 100}}, "True"),
+ _Case("outside-before", {_MARKER: ""}, start_time=datetime(2026, 8, 31, 23, 59, 59)),
+ _Case(
+ "outside-after", {"usage_object": {"cache_read_input_tokens": 100}}, start_time=datetime(2026, 9, 2, 0, 0, 1)
+ ),
+)
+
+
+@pytest_asyncio.fixture(loop_scope="function")
+async def _cache_prisma(
+ _cache_postgresql: psycopg.Connection[tuple[object, ...]],
+) -> AsyncIterator[Prisma]:
+ info: Final = _cache_postgresql.info
+ database: Final = Prisma(datasource={
+ "url": f"postgresql://{info.user}@{info.host}:{info.port}/{info.dbname}?connection_limit=1",
+ })
+ await database.connect()
+ try:
+ yield database
+ finally:
+ await database.disconnect()
+
+
+def _seed(connection: psycopg.Connection[tuple[object, ...]], cases: tuple[_Case, ...] = _CASES) -> None:
+ with connection.cursor() as cursor:
+ cursor.execute(_DDL)
+ cursor.executemany(
+ """INSERT INTO "LiteLLM_SpendLogs"
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s)""",
+ tuple(
+ (
+ case.request_id,
+ case.start_time,
+ datetime(2026, 9, 1, 12, 0, 1),
+ _MODEL,
+ "dep-a",
+ "anthropic",
+ 0.01,
+ json.dumps(dict(case.metadata)),
+ case.cache_hit,
+ )
+ for case in cases
+ ),
+ )
+ connection.commit()
+
+
+def _app(role: LitellmUserRoles | None) -> FastAPI:
+ application: Final = FastAPI()
+ application.include_router(router)
+
+ def caller() -> UserAPIKeyAuth:
+ return UserAPIKeyAuth(user_role=role)
+
+ application.dependency_overrides[user_api_key_auth] = caller
+ return application
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("filter", ["all", "injected", "hits"])
+@pytest.mark.parametrize("role", [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY])
+async def test_request_filters_match_accounting_and_paginate_before_projection(
+ _cache_postgresql: psycopg.Connection[tuple[object, ...]],
+ _cache_prisma: Prisma,
+ monkeypatch: pytest.MonkeyPatch,
+ filter: PromptCachingRequestFilter,
+ role: LitellmUserRoles,
+) -> None:
+ from litellm.proxy import proxy_server
+
+ _seed(_cache_postgresql)
+ monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace(db=_cache_prisma))
+ monkeypatch.setattr(proxy_server, "llm_router", None)
+ expected: Final = tuple(sorted((case.request_id for case in _CASES if case.matches(filter)), reverse=True))
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_app(role)), base_url="http://test") as client:
+ first: Final = await client.get(
+ _URL, params={"start_date": _START, "end_date": _END, "filter": filter, "page_size": 2}
+ )
+ assert first.status_code == 200
+ first_page: Final = PromptCachingRequestsResponse.model_validate_json(first.content)
+ assert tuple(row.request_id for row in first_page.requests) == expected[:2]
+ assert first_page.has_more is (len(expected) > 2)
+ assert (first_page.next_cursor is not None) is first_page.has_more
+ if first_page.next_cursor is not None:
+ assert first_page.next_cursor.request_id == expected[1]
+ assert first_page.next_cursor.start_time == first_page.requests[-1].start_time
+ next_response: Final = await client.get(
+ _URL, params={
+ "start_date": _START, "end_date": _END, "filter": filter, "page_size": 2,
+ "cursor_start_time": first_page.next_cursor.start_time.astimezone(
+ timezone(timedelta(hours=-7))
+ ).isoformat(),
+ "cursor_request_id": first_page.next_cursor.request_id,
+ }
+ )
+ assert next_response.status_code == 200
+ next_page: Final = PromptCachingRequestsResponse.model_validate_json(next_response.content)
+ assert tuple(row.request_id for row in next_page.requests) == expected[2:4]
+ assert next_page.has_more is (len(expected) > 4)
+ assert (next_page.next_cursor is not None) is next_page.has_more
+ second: Final = await client.get(
+ _URL, params={"start_date": _START, "end_date": _END, "filter": filter, "page_size": 100}
+ )
+ assert second.status_code == 200
+ complete: Final = PromptCachingRequestsResponse.model_validate_json(second.content)
+ assert tuple(row.request_id for row in complete.requests) == expected
+ assert complete.has_more is False
+ assert complete.next_cursor is None
+ assert all(row.start_time.tzinfo == timezone.utc for row in complete.requests)
+ payload: Final = _JSON_OBJECT.validate_json(second.content)
+ assert set(payload) == {"requests", "page_size", "has_more", "next_cursor"}
+ serialized_rows: Final = _JSON_ROWS.validate_python(payload["requests"])
+ assert set(serialized_rows[0]) == {
+ "request_id",
+ "start_time",
+ "model",
+ "gateway_injected",
+ "cache_read_tokens",
+ "cache_creation_tokens",
+ "spend",
+ "net_savings",
+ }
+ by_id: Final = {row.request_id: row for row in complete.requests}
+ if filter == "all":
+ assert by_id["injected-empty"].gateway_injected is True
+ assert by_id["injected-empty"].net_savings is None
+ assert by_id["legacy-read"].gateway_injected is False
+ assert by_id["legacy-read"].net_savings is not None and by_id["legacy-read"].net_savings > 0
+ assert by_id["write"].net_savings is not None and by_id["write"].net_savings < 0
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("role", [None, LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY])
+async def test_non_admin_is_denied_before_database_access(
+ role: LitellmUserRoles | None, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ from litellm.proxy import proxy_server
+
+ monkeypatch.setattr(proxy_server, "prisma_client", None)
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_app(role)), base_url="http://test") as client:
+ response: Final = await client.get(_URL, params={"start_date": _START, "end_date": _END})
+ assert response.status_code == 403
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("params", [
+ {"filter": "savings"}, {"page_size": 0}, {"page_size": 101}, {"start_date": "invalid"},
+ {"cursor_start_time": "invalid", "cursor_request_id": "request"},
+ {"cursor_start_time": _START, "cursor_request_id": ""},
+])
+async def test_invalid_request_is_rejected(params: Mapping[str, str | int]) -> None:
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=_app(LitellmUserRoles.PROXY_ADMIN)), base_url="http://test"
+ ) as client:
+ response: Final = await client.get(_URL, params={"start_date": _START, "end_date": _END, **params})
+ assert response.status_code == 422
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("params", [{"cursor_start_time": _START}, {"cursor_request_id": "request"}])
+async def test_incomplete_cursor_is_rejected(
+ params: Mapping[str, str], monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from litellm.proxy import proxy_server
+
+ monkeypatch.setattr(proxy_server, "prisma_client", None)
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=_app(LitellmUserRoles.PROXY_ADMIN)), base_url="http://test"
+ ) as client:
+ response: Final = await client.get(_URL, params={"start_date": _START, "end_date": _END, **params})
+ assert response.status_code == 400
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("delete_before_cursor", [False, True])
+async def test_cursor_keeps_remaining_requests_once_during_insertions_and_deletions(
+ _cache_postgresql: psycopg.Connection[tuple[object, ...]],
+ _cache_prisma: Prisma,
+ monkeypatch: pytest.MonkeyPatch,
+ delete_before_cursor: bool,
+) -> None:
+ from litellm.proxy import proxy_server
+
+ cases: Final = (*_CASES, _Case(
+ "older-cache-read", {"usage_object": {"cache_read_input_tokens": 100}}, start_time=datetime(2026, 9, 1, 11),
+ ))
+ _seed(_cache_postgresql, cases)
+ monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace(db=_cache_prisma))
+ monkeypatch.setattr(proxy_server, "llm_router", None)
+ expected: Final = (*sorted((case.request_id for case in _CASES if case.matches("all")), reverse=True), "older-cache-read")
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=_app(LitellmUserRoles.PROXY_ADMIN)), base_url="http://test"
+ ) as client:
+ first: Final = await client.get(_URL, params={"start_date": _START, "end_date": _END, "page_size": 2})
+ assert first.status_code == 200
+ first_page: Final = PromptCachingRequestsResponse.model_validate_json(first.content)
+ assert tuple(row.request_id for row in first_page.requests) == expected[:2]
+ assert first_page.next_cursor is not None
+ with _cache_postgresql.cursor() as cursor:
+ cursor.executemany(
+ """INSERT INTO "LiteLLM_SpendLogs"
+ SELECT %s, %s, "endTime", model, model_id, custom_llm_provider, spend, metadata, cache_hit
+ FROM "LiteLLM_SpendLogs" WHERE request_id = %s""",
+ (
+ ("newer-request", datetime(2026, 9, 1, 13), expected[0]),
+ ("zz-higher-id", cases[0].start_time, expected[0]),
+ ),
+ )
+ if delete_before_cursor:
+ cursor.execute('DELETE FROM "LiteLLM_SpendLogs" WHERE request_id = %s', (expected[0],))
+ _cache_postgresql.commit()
+ following: Final = await client.get(_URL, params={
+ "start_date": _START, "end_date": _END, "page_size": 100,
+ "cursor_start_time": first_page.next_cursor.start_time.isoformat(),
+ "cursor_request_id": first_page.next_cursor.request_id,
+ })
+ assert following.status_code == 200
+ following_page: Final = PromptCachingRequestsResponse.model_validate_json(following.content)
+ assert tuple(row.request_id for row in following_page.requests) == expected[2:]
+ assert following_page.has_more is False
+ assert following_page.next_cursor is None
diff --git a/tests/test_litellm/proxy/spend_tracking/test_savings.py b/tests/test_litellm/proxy/spend_tracking/test_savings.py
index aae966022e3..004f07da431 100644
--- a/tests/test_litellm/proxy/spend_tracking/test_savings.py
+++ b/tests/test_litellm/proxy/spend_tracking/test_savings.py
@@ -11,6 +11,7 @@ from litellm.proxy.spend_tracking.savings import (
compute_autorouter_savings,
compute_savings_spend,
marks_gateway_injection,
+ prompt_caching_savings_for_request,
)
from litellm.router import Router
from litellm.types.utils import Usage
@@ -18,6 +19,42 @@ from litellm.types.utils import Usage
pytestmark = pytest.mark.usefixtures("local_model_cost_map")
+@pytest.mark.parametrize("model,usage", [
+ (None, {"cache_read_input_tokens": 100}),
+ ("claude-sonnet-5", None),
+ ("claude-sonnet-5", {"prompt_tokens": "invalid"}),
+])
+def test_prompt_cache_estimate_distinguishes_unknown_from_zero(model: str | None, usage: dict[str, object] | None) -> None:
+ assert prompt_caching_savings_for_request(model, "anthropic", usage) is None
+ assert compute_savings_spend(model, "anthropic", 0, False, usage_object=usage).prompt_caching == 0
+ assert prompt_caching_savings_for_request("claude-sonnet-5", "anthropic", {"prompt_tokens": 100}) == 0
+
+
+def test_prompt_cache_estimate_uses_the_rollup_pricing_and_retains_write_premiums() -> None:
+ router: Final = Router(model_list=[{
+ "model_name": "negotiated",
+ "litellm_params": {
+ "model": "anthropic/claude-sonnet-5", "input_cost_per_token": 1e-6,
+ "cache_creation_input_token_cost": 1.25e-6, "cache_read_input_token_cost": 1e-7,
+ },
+ "model_info": {"id": "negotiated-cache-prices"},
+ }])
+
+ def current_router() -> Router:
+ return router
+
+ usage: Final = {"cache_read_input_tokens": 1000, "cache_creation_input_tokens": 20000}
+ estimate: Final = prompt_caching_savings_for_request(
+ "claude-sonnet-5", "anthropic", usage, model_id="negotiated-cache-prices", llm_router=current_router,
+ )
+ rollup: Final = compute_savings_spend(
+ "claude-sonnet-5", "anthropic", 0, True, usage_object=usage,
+ model_id="negotiated-cache-prices", llm_router=current_router,
+ )
+ assert estimate == pytest.approx(1000 * (1e-6 - 1e-7) - 20000 * (1.25e-6 - 1e-6))
+ assert estimate == rollup.prompt_caching == rollup.gateway_injected_caching
+
+
@pytest.mark.parametrize("modifier", [{"speed": "fast"}, {"inference_geo": "us"}])
@pytest.mark.parametrize("continuing", [False, True])
def test_baseline_preserves_anthropic_pricing_fields(modifier: dict[str, str], continuing: bool) -> None:
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx
index a0877b04648..f5b71a00061 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx
@@ -3,7 +3,6 @@
import React, { useMemo, useState } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown, Info } from "lucide-react";
-import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -81,7 +80,7 @@ const SortableHead = ({
};
const CacheLeakageCard: React.FC = ({ activity }) => {
- const { dateValue, onDateChange, results, loading, isFetchingMore, apiKeyTruncation } = activity;
+ const { results, loading, isFetchingMore, apiKeyTruncation } = activity;
const [dimension, setDimension] = useState("key");
const [sort, setSort] = useState({ column: "potentialSavings", dir: "desc" });
const leakage = useMemo(() => computeCacheLeakage(results, dimension), [results, dimension]);
@@ -111,9 +110,6 @@ const CacheLeakageCard: React.FC = ({ activity }) => {
cached token, after cache-write premiums.
-
setDimension(value === "model" ? "model" : "key")}>
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx
index 03250e3e53b..f8336f5ab56 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx
@@ -42,6 +42,7 @@ vi.mock("@/app/(dashboard)/router-settings/_components/general_settings", () =>
}));
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () =>
}));
+vi.mock("./PromptCachingRequestsTable", () => ({ default: () =>
}));
import CostOptimizationView from "./CostOptimizationView";
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingRequestsTable.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingRequestsTable.integration.test.tsx
new file mode 100644
index 00000000000..833a46ce16f
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingRequestsTable.integration.test.tsx
@@ -0,0 +1,248 @@
+import { Profiler } from "react";
+import { act, fireEvent, renderWithProviders, screen, testQueryClient, waitFor, within } from "@/../tests/test-utils";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { components } from "@/lib/http/schema";
+import PromptCachingRequestsTable from "./PromptCachingRequestsTable";
+import type { DateRange } from "./useDailyActivityRange";
+
+type CacheRequest = components["schemas"]["PromptCachingRequest"];
+type RequestsResponse = components["schemas"]["PromptCachingRequestsResponse"];
+const firstCursor = { start_time: "2026-09-01T11:59:59.123456Z", request_id: "first-boundary?&" };
+const secondCursor = { start_time: firstCursor.start_time, request_id: "second-boundary" };
+const fetchMock = vi.fn();
+const dates = { from: new Date(2026, 8, 1, 12), to: new Date(2026, 8, 2, 12) };
+const request = (overrides: Partial = {}): CacheRequest => ({
+ request_id: "request-default",
+ start_time: "2026-09-01T12:00:00Z",
+ model: "cache-test-model",
+ gateway_injected: true,
+ cache_read_tokens: 0,
+ cache_creation_tokens: 1000,
+ spend: 0.0375,
+ net_savings: -0.0075,
+ ...overrides,
+});
+const response = (requests: CacheRequest[], nextCursor: RequestsResponse["next_cursor"] = null) => {
+ const body: RequestsResponse = { requests, has_more: nextCursor !== null, next_cursor: nextCursor, page_size: 50 };
+ return Response.json(body);
+};
+const lastQuery = () => new URL(String(fetchMock.mock.calls.at(-1)?.[0]), "http://localhost").searchParams;
+
+describe("PromptCachingRequestsTable", () => {
+ beforeEach(() => {
+ fetchMock.mockReset();
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ testQueryClient.clear();
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
+ vi.useRealTimers();
+ });
+
+ it("separates recorded injection from cache hits, retains write premiums and unknown savings, and links each request", async () => {
+ const clientHit = {
+ request_id: "client-hit",
+ gateway_injected: false,
+ cache_read_tokens: 10000,
+ cache_creation_tokens: 0,
+ net_savings: 0.27,
+ };
+ fetchMock.mockResolvedValue(
+ response([
+ request({ request_id: "injected/write?&", net_savings: -0.0075 }),
+ request(clientHit),
+ request({ request_id: "unknown-price", net_savings: null }),
+ request({ request_id: "no-benefit", net_savings: 0 }),
+ ]),
+ );
+ renderWithProviders( );
+
+ const table = await screen.findByRole("table", { name: "Prompt caching requests" });
+ const write = within(table).getByRole("row", { name: /injected\/write/ });
+ expect(within(write).getByText("Recorded")).toBeInTheDocument();
+ expect(within(write).getByText("1,000")).toBeInTheDocument();
+ expect(within(write).getByText("$0.0375")).toBeInTheDocument();
+ expect(within(write).getByText("-$0.0075")).toBeInTheDocument();
+ expect(within(write).getByText(new Date("2026-09-01T12:00:00Z").toLocaleString())).toBeInTheDocument();
+ expect(within(write).getByText("cache-test-model")).toHaveAttribute("title", "cache-test-model");
+ expect(within(write).getByRole("link")).toHaveAttribute("href", "/ui/logs?log_id=injected%2Fwrite%3F%26");
+
+ const hit = within(table).getByRole("row", { name: /client-hit/ });
+ expect(within(hit).getByText("Not recorded")).toBeInTheDocument();
+ expect(within(hit).getByText("10,000")).toBeInTheDocument();
+ expect(within(hit).getByText("$0.2700")).toBeInTheDocument();
+ expect(within(table).getByRole("row", { name: /unknown-price/ })).toHaveTextContent("Unavailable");
+ expect(within(table).getByRole("row", { name: /no-benefit/ })).toHaveTextContent("$0.00");
+ expect(screen.getByText(/after cache-write premiums/)).toBeInTheDocument();
+ expect(lastQuery().get("start_date")).toBe("2026-09-01T00:00:00.000Z");
+ expect(lastQuery().get("end_date")).toBe("2026-09-02T23:59:59.999Z");
+ expect(fetchMock.mock.calls[0][1]?.headers).toEqual(expect.objectContaining({ Authorization: "Bearer token-a" }));
+ });
+
+ it("forwards complete server cursors, goes back to prior cursors, and clears them for each caching filter", async () => {
+ fetchMock.mockImplementation(async (input) => {
+ const query = new URL(String(input), "http://localhost").searchParams;
+ const pages = new Map([
+ [null, 1],
+ [firstCursor.request_id, 2],
+ [secondCursor.request_id, 3],
+ ]);
+ const page = pages.get(query.get("cursor_request_id"));
+ const nextCursor =
+ new Map([
+ [1, firstCursor],
+ [2, secondCursor],
+ ]).get(page ?? 0) ?? null;
+ return response([request({ request_id: `${query.get("filter")}-${page}` })], nextCursor);
+ });
+ renderWithProviders( );
+ await screen.findByRole("link", { name: "all-1" });
+ expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled();
+ expect(lastQuery().has("page")).toBe(false);
+ expect(lastQuery().has("cursor_request_id")).toBe(false);
+
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
+ await screen.findByRole("link", { name: "all-2" });
+ expect(screen.getByText("Page 2")).toBeInTheDocument();
+ expect(lastQuery().get("cursor_start_time")).toBe(firstCursor.start_time);
+ expect(lastQuery().get("cursor_request_id")).toBe(firstCursor.request_id);
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
+ await screen.findByRole("link", { name: "all-3" });
+ expect(screen.getByText("Page 3")).toBeInTheDocument();
+ expect(lastQuery().get("cursor_start_time")).toBe(secondCursor.start_time);
+ expect(lastQuery().get("cursor_request_id")).toBe(secondCursor.request_id);
+ expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
+
+ await testQueryClient.invalidateQueries({ refetchType: "none" });
+ fireEvent.click(screen.getByRole("button", { name: "Previous" }));
+ await screen.findByRole("link", { name: "all-2" });
+ await waitFor(() => expect(lastQuery().get("cursor_request_id")).toBe(firstCursor.request_id));
+ expect(lastQuery().get("cursor_start_time")).toBe(firstCursor.start_time);
+ expect(screen.getByText("Page 2")).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Previous" }));
+ await screen.findByRole("link", { name: "all-1" });
+ await waitFor(() => expect(lastQuery().has("cursor_request_id")).toBe(false));
+ expect(lastQuery().has("cursor_start_time")).toBe(false);
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
+ await screen.findByRole("link", { name: "all-2" });
+
+ fireEvent.click(screen.getByRole("tab", { name: "LiteLLM injected" }));
+ await screen.findByRole("link", { name: "injected-1" });
+ expect(screen.queryByRole("link", { name: "all-2" })).not.toBeInTheDocument();
+ expect(lastQuery().get("filter")).toBe("injected");
+ expect(lastQuery().has("cursor_request_id")).toBe(false);
+ expect(lastQuery().has("cursor_start_time")).toBe(false);
+
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
+ await screen.findByRole("link", { name: "injected-2" });
+ fireEvent.click(screen.getByRole("tab", { name: "Cache hits" }));
+ await screen.findByRole("link", { name: "hits-1" });
+ expect(lastQuery().get("filter")).toBe("hits");
+ expect(lastQuery().get("page_size")).toBe("50");
+ expect(screen.getByText("Page 1")).toBeInTheDocument();
+ });
+
+ it("includes the current UTC day for a range ending today, matching the activity totals", async () => {
+ vi.stubEnv("TZ", "America/Los_Angeles");
+ vi.setSystemTime(new Date("2026-09-20T03:00:00Z"));
+ fetchMock.mockResolvedValue(response([]));
+ const today = { from: new Date(2026, 8, 19), to: new Date() };
+ renderWithProviders( );
+
+ await screen.findByText("No matching prompt caching requests in this range");
+ expect(lastQuery().get("start_date")).toBe("2026-09-19T00:00:00.000Z");
+ expect(lastQuery().get("end_date")).toBe("2026-09-20T23:59:59.999Z");
+ });
+
+ it.each(["date", "authentication"])(
+ "hides every old-scope frame and resets pagination when %s changes",
+ async (change) => {
+ fetchMock.mockResolvedValueOnce(response([request({ request_id: "old-first" })], firstCursor));
+ fetchMock.mockResolvedValueOnce(response([request({ request_id: "old-second" })]));
+ const committedOldRows: boolean[] = [];
+ const snapshot = () => {
+ committedOldRows.push(screen.queryByRole("link", { name: "old-second" }) !== null);
+ };
+ const tree = (accessToken: string, dateValue: DateRange) => (
+
+
+
+ );
+ const { rerender } = renderWithProviders(tree("token-a", dates));
+ await screen.findByRole("link", { name: "old-first" });
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
+ await screen.findByRole("link", { name: "old-second" });
+
+ const pending = Promise.withResolvers();
+ fetchMock.mockReturnValueOnce(pending.promise);
+ committedOldRows.length = 0;
+ rerender(
+ tree(
+ change === "authentication" ? "token-b" : "token-a",
+ change === "date" ? { ...dates, to: new Date(2026, 8, 3) } : dates,
+ ),
+ );
+
+ expect(screen.getByRole("status")).toHaveTextContent("Loading requests");
+ expect(committedOldRows.length).toBeGreaterThan(0);
+ expect(committedOldRows.every((visible) => !visible)).toBe(true);
+ expect(lastQuery().has("cursor_request_id")).toBe(false);
+ expect(lastQuery().has("cursor_start_time")).toBe(false);
+ if (change === "date") {
+ expect(lastQuery().get("end_date")).toBe("2026-09-03T23:59:59.999Z");
+ } else {
+ expect(fetchMock.mock.calls.at(-1)?.[1]?.headers).toEqual(
+ expect.objectContaining({ Authorization: "Bearer token-b" }),
+ );
+ }
+
+ pending.resolve(response([request({ request_id: "new-first" })]));
+ await screen.findByRole("link", { name: "new-first" });
+ expect(screen.getByText("Page 1")).toBeInTheDocument();
+ expect(committedOldRows.every((visible) => !visible)).toBe(true);
+ },
+ );
+
+ it("ignores a delayed response from the previous caching filter", async () => {
+ const stale = Promise.withResolvers();
+ const current = Promise.withResolvers();
+ fetchMock.mockReturnValueOnce(stale.promise).mockReturnValueOnce(current.promise);
+ renderWithProviders( );
+ fireEvent.click(screen.getByRole("tab", { name: "Cache hits" }));
+ expect(lastQuery().get("filter")).toBe("hits");
+
+ current.resolve(response([request({ request_id: "current-hit" })]));
+ await screen.findByRole("link", { name: "current-hit" });
+ await act(async () => {
+ stale.resolve(response([request({ request_id: "stale-all" })], firstCursor));
+ await stale.promise;
+ });
+
+ expect(screen.getByRole("link", { name: "current-hit" })).toBeInTheDocument();
+ expect(screen.queryByRole("link", { name: "stale-all" })).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
+ });
+
+ it("offers retry after a failed read and shows the empty state after it succeeds", async () => {
+ fetchMock.mockRejectedValueOnce(new Error("offline"));
+ fetchMock.mockResolvedValueOnce(response([]));
+ renderWithProviders( );
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Could not load prompt caching requests");
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(await screen.findByText("No matching prompt caching requests in this range")).toBeInTheDocument();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it("does not request data for an incomplete date range", async () => {
+ renderWithProviders( );
+ expect(screen.getByText("Select a date range to view requests")).toBeInTheDocument();
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingRequestsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingRequestsTable.tsx
new file mode 100644
index 00000000000..29aa9252e7b
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingRequestsTable.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
+import Link from "next/link";
+import { useState } from "react";
+
+import { apiClient } from "@/components/networking";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { LOG_ID_QUERY_PARAM } from "@/components/view_logs/logDetailRouting";
+import type { paths } from "@/lib/http/schema";
+import { formatNumberWithCommas } from "@/utils/dataUtils";
+import { uiHref } from "@/utils/uiHref";
+import { usd } from "./costOptimizationUtils";
+import { benchmarksWindow as activityWindow } from "./useAutoRouterBenchmarks";
+import type { DateRange } from "./useDailyActivityRange";
+
+const REQUESTS_PATH = "/cost_optimization/prompt_caching/requests";
+type RequestsEndpoint = paths[typeof REQUESTS_PATH]["get"];
+type RequestsResponse = RequestsEndpoint["responses"][200]["content"]["application/json"];
+type RequestsQuery = NonNullable;
+type RequestFilter = NonNullable;
+type RequestCursor = RequestsResponse["next_cursor"];
+
+interface PromptCachingRequestsTableProps {
+ accessToken: string;
+ dateValue: DateRange;
+}
+
+export default function PromptCachingRequestsTable({ accessToken, dateValue }: PromptCachingRequestsTableProps) {
+ const [filter, setFilter] = useState("all");
+ const window = activityWindow(dateValue, new Date());
+ const startDate = window.start_date ? `${window.start_date}T00:00:00.000Z` : "";
+ const endDate = window.end_date ? `${window.end_date}T23:59:59.999Z` : "";
+ const scope = JSON.stringify([accessToken, startDate, endDate, filter]);
+ const [pagination, setPagination] = useState<{ scope: string; cursors: readonly RequestCursor[] }>({
+ scope,
+ cursors: [null],
+ });
+ const cursors = pagination.scope === scope ? pagination.cursors : [null];
+ const cursor = cursors.at(-1);
+ const page = cursors.length;
+
+ if (pagination.scope !== scope) {
+ setPagination({ scope, cursors: [null] });
+ }
+
+ const enabled = Boolean(accessToken && startDate && endDate);
+ const query: RequestsQuery = {
+ start_date: startDate,
+ end_date: endDate,
+ filter,
+ page_size: 50,
+ cursor_start_time: cursor?.start_time,
+ cursor_request_id: cursor?.request_id,
+ };
+ const queryOptions: UseQueryOptions = {
+ queryKey: [REQUESTS_PATH, accessToken, query],
+ queryFn: ({ signal }) => apiClient.get(REQUESTS_PATH, { accessToken, query, signal }),
+ enabled,
+ retry: false,
+ };
+ const requests = useQuery(queryOptions);
+ const nextCursor = requests.data?.next_cursor;
+
+ const changeFilter = (value: unknown) => {
+ if (value === "all" || value === "injected" || value === "hits") {
+ setFilter(value);
+ }
+ };
+
+ return (
+
+
+
+
Prompt caching requests
+
+ Requests with recorded LiteLLM injection or provider cache reads or writes. A cache hit alone does not
+ establish LiteLLM injection; older logs may not record it.
+
+
+ Net savings are estimated from logged usage and current configured pricing, after cache-write premiums.
+ Negative values mean caching cost more; unavailable means the request could not be priced.
+
+
+
+
+ All caching
+ LiteLLM injected
+ Cache hits
+
+
+
+
+ {!enabled && Select a date range to view requests
}
+ {enabled && requests.isPending && (
+
+ Loading requests...
+
+ )}
+ {enabled && requests.isError && (
+
+
Could not load prompt caching requests
+
void requests.refetch()} disabled={requests.isFetching}>
+ Retry
+
+
+ )}
+ {enabled && requests.isSuccess && (
+ <>
+ {requests.data.requests.length === 0 ? (
+
+ No matching prompt caching requests in this range
+
+ ) : (
+
+
+
+ Request
+ Model
+ LiteLLM injection
+ Cache reads
+ Cache writes
+ Actual cost
+ Net savings
+
+
+
+ {requests.data.requests.map((request) => (
+
+
+
+ {request.request_id}
+
+
+ {new Date(request.start_time).toLocaleString()}
+
+
+
+
+ {request.model}
+
+
+ {request.gateway_injected ? "Recorded" : "Not recorded"}
+ {formatNumberWithCommas(request.cache_read_tokens)}
+
+ {formatNumberWithCommas(request.cache_creation_tokens)}
+
+ {usd(request.spend)}
+
+ {request.net_savings === null ? "Unavailable" : usd(request.net_savings)}
+
+
+ ))}
+
+
+ )}
+
+ setPagination({ scope, cursors: cursors.slice(0, -1) })}
+ >
+ Previous
+
+ Page {page}
+ nextCursor && setPagination({ scope, cursors: [...cursors, nextCursor] })}
+ >
+ Next
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx
index 66db347e70f..35464c5852e 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx
@@ -1,4 +1,4 @@
-import { render, waitFor, screen } from "@testing-library/react";
+import { fireEvent, render, waitFor, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const mockGetGeneralSettingsCall = vi.fn();
@@ -12,6 +12,21 @@ vi.mock("@/app/(dashboard)/router-settings/_components/general_settings", () =>
}));
const mockCacheLeakageCard = vi.fn();
+const mockRequestsTable = vi.fn();
+const nextDateRange = { from: new Date(2026, 8, 1), to: new Date(2026, 8, 2) };
+
+vi.mock("./PromptCachingRequestsTable", () => ({
+ default: (props: unknown) => {
+ mockRequestsTable(props);
+ return
;
+ },
+}));
+
+vi.mock("@/components/shared/advanced_date_picker", () => ({
+ default: ({ onValueChange }: { onValueChange: (range: typeof nextDateRange) => void }) => (
+ onValueChange(nextDateRange)}>Change caching dates
+ ),
+}));
vi.mock("./CacheLeakageCard", () => ({
__esModule: true,
@@ -24,7 +39,7 @@ vi.mock("./CacheLeakageCard", () => ({
import PromptCachingTab from "./PromptCachingTab";
describe("PromptCachingTab", () => {
- it("renders the cache leakage table alongside the caching settings", async () => {
+ it("shares the selected dates between requests and cache leakage alongside caching settings", async () => {
mockGetGeneralSettingsCall.mockResolvedValue([]);
const activity = {
@@ -42,6 +57,10 @@ describe("PromptCachingTab", () => {
expect(screen.getByTestId("caching-settings")).toBeInTheDocument();
expect(screen.getByTestId("cache-leakage-card")).toBeInTheDocument();
+ expect(screen.getByTestId("caching-requests")).toBeInTheDocument();
+ expect(mockRequestsTable).toHaveBeenCalledWith({ accessToken: "test-token", dateValue: activity.dateValue });
+ fireEvent.click(screen.getByRole("button", { name: "Change caching dates" }));
+ expect(activity.onDateChange).toHaveBeenCalledWith(nextDateRange);
await waitFor(() => expect(mockCacheLeakageCard).toHaveBeenCalledWith(expect.objectContaining({ activity })));
});
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.tsx
index 59b38f272e0..4e43317998e 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.tsx
@@ -3,12 +3,14 @@
import React, { useCallback, useEffect, useState } from "react";
import { getGeneralSettingsCall } from "@/components/networking";
+import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { toast } from "@/lib/toast";
import {
PromptCachingPanel,
generalSettingsItem,
} from "@/app/(dashboard)/router-settings/_components/general_settings";
import CacheLeakageCard from "./CacheLeakageCard";
+import PromptCachingRequestsTable from "./PromptCachingRequestsTable";
import { DailyActivityRange } from "./useDailyActivityRange";
interface PromptCachingTabProps {
@@ -48,6 +50,11 @@ const PromptCachingTab: React.FC = ({ accessToken, activi
return (
+
+
Date range for requests and cache leakage
+
+
+
);
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 81580c8bfb1..d916509c06f 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -3534,6 +3534,23 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/cost_optimization/prompt_caching/requests": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get Prompt Caching Requests */
+ get: operations["get_prompt_caching_requests_cost_optimization_prompt_caching_requests_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/credentials": {
parameters: {
query?: never;
@@ -35814,6 +35831,48 @@ export interface components {
prompt_id: string;
prompt_info?: components["schemas"]["PromptInfo"] | null;
};
+ /** PromptCachingRequest */
+ PromptCachingRequest: {
+ /** Cache Creation Tokens */
+ cache_creation_tokens: number;
+ /** Cache Read Tokens */
+ cache_read_tokens: number;
+ /** Gateway Injected */
+ gateway_injected: boolean;
+ /** Model */
+ model: string;
+ /** Net Savings */
+ net_savings: number | null;
+ /** Request Id */
+ request_id: string;
+ /** Spend */
+ spend: number;
+ /**
+ * Start Time
+ * Format: date-time
+ */
+ start_time: string;
+ };
+ /** PromptCachingRequestCursor */
+ PromptCachingRequestCursor: {
+ /** Request Id */
+ request_id: string;
+ /**
+ * Start Time
+ * Format: date-time
+ */
+ start_time: string;
+ };
+ /** PromptCachingRequestsResponse */
+ PromptCachingRequestsResponse: {
+ /** Has More */
+ has_more: boolean;
+ next_cursor: components["schemas"]["PromptCachingRequestCursor"] | null;
+ /** Page Size */
+ page_size: number;
+ /** Requests */
+ requests: components["schemas"]["PromptCachingRequest"][];
+ };
/** PromptInfo */
PromptInfo: {
/**
@@ -47238,6 +47297,42 @@ export interface operations {
};
};
};
+ get_prompt_caching_requests_cost_optimization_prompt_caching_requests_get: {
+ parameters: {
+ query: {
+ start_date: string;
+ end_date: string;
+ page_size?: number;
+ filter?: "all" | "injected" | "hits";
+ cursor_start_time?: string | null;
+ cursor_request_id?: string | null;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PromptCachingRequestsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
get_credentials_credentials_get: {
parameters: {
query?: never;
From 875f015e24219110dbad35691d80acd1c1a3c375 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 18:33:56 -0700
Subject: [PATCH 097/317] fix(token_counter): count replayed redacted_thinking
blocks so prompt_caching keeps pinning
A conversation that replays a redacted_thinking block (Anthropic redacted reasoning, or the
/v1/messages bridge's stand-in for a reasoning item that carries no summary) made
_count_content_list raise, is_prompt_caching_valid_prompt swallowed that to False, and the
prompt_caching pre-call check neither recorded nor pinned the serving deployment, so the
conversation bounced across the group and paid a cache write on every deployment. The block
now counts like a thinking block with no text: zero tokens for the encrypted payload.
---
litellm/litellm_core_utils/token_counter.py | 11 ++--
.../litellm_core_utils/test_token_counter.py | 19 +++++++
.../test_prompt_caching_deployment_check.py | 52 +++++++++++++++++++
3 files changed, 79 insertions(+), 3 deletions(-)
diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py
index 6c1b7946394..bf37b1be2e4 100644
--- a/litellm/litellm_core_utils/token_counter.py
+++ b/litellm/litellm_core_utils/token_counter.py
@@ -46,6 +46,8 @@ from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionDocumentObject,
ChatCompletionNamedToolChoiceParam,
+ ChatCompletionRedactedThinkingBlock,
+ ChatCompletionThinkingBlock,
ChatCompletionToolParam,
OpenAIMessageContentListBlock,
)
@@ -854,6 +856,8 @@ def _count_content_list(
content_list: str
| Iterable[
OpenAIMessageContentListBlock
+ | ChatCompletionThinkingBlock
+ | ChatCompletionRedactedThinkingBlock
| AnthropicMessagesTextParam
| AnthropicMessagesImageParam
| AnthropicMessagesDocumentParam
@@ -898,9 +902,9 @@ def _count_content_list(
use_default_image_token_count,
default_token_count,
)
- elif c["type"] == "thinking":
+ elif c["type"] in ("thinking", "redacted_thinking"):
# Claude extended thinking content block
- # Count the thinking text and skip signature (opaque signature blob)
+ # Count the thinking text and skip the opaque blobs (signature, redacted data)
thinking_text = str(c.get("thinking", ""))
if thinking_text:
num_tokens += count_function(thinking_text)
@@ -920,7 +924,8 @@ def _count_content_list(
raise ValueError(
f"Invalid content item type: {content_type}. "
f"Expected str or dict with 'type' field "
- f"(text, image_url, image, document, file, tool_use, tool_result, thinking, tool_reference)."
+ f"(text, image_url, image, document, file, tool_use, tool_result, thinking, redacted_thinking, "
+ f"tool_reference)."
)
return num_tokens
except Exception as e:
diff --git a/tests/test_litellm/litellm_core_utils/test_token_counter.py b/tests/test_litellm/litellm_core_utils/test_token_counter.py
index ba3a6be609f..f19a8891609 100644
--- a/tests/test_litellm/litellm_core_utils/test_token_counter.py
+++ b/tests/test_litellm/litellm_core_utils/test_token_counter.py
@@ -1257,6 +1257,25 @@ def test_token_counter_with_thinking_content():
), f"Expected minimal token count for empty thinking block, got {tokens_no_thinking}"
+
+def test_token_counter_with_redacted_thinking_content():
+ """
+ A replayed redacted_thinking block (Anthropic redacted reasoning, or the /v1/messages bridge's stand-in
+ for a reasoning item with no summary) counts zero tokens for its encrypted payload, like a thinking
+ block with no text. It used to raise, which made is_prompt_caching_valid_prompt return False and the
+ prompt_caching pre-call check stop pinning the deployment that held the cached prefix.
+ """
+ model = "anthropic/claude-sonnet-4-5-20250929"
+ reply = {"type": "text", "text": "Draw from the box labeled Mixed, because that label must be wrong."}
+ redacted_block = {"type": "redacted_thinking", "data": "EqQBCkYIBRgCKkBjZ2xhc3M" * 30}
+ user_turn = {"role": "user", "content": [{"type": "text", "text": "Which box do you draw from?"}]}
+ follow_up = {"role": "user", "content": [{"type": "text", "text": "Restate that in one sentence."}]}
+
+ without_block = [user_turn, {"role": "assistant", "content": [reply]}, follow_up]
+ with_block = [user_turn, {"role": "assistant", "content": [redacted_block, reply]}, follow_up]
+
+ assert token_counter(model=model, messages=with_block) == token_counter(model=model, messages=without_block)
+
def test_token_counter_with_tool_reference_block():
"""
Regression test: a message containing an Anthropic tool-search
diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py
index 333e7b2ff31..267109c9164 100644
--- a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py
+++ b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py
@@ -197,6 +197,58 @@ async def test_async_filter_deployments_narrows_for_group_whose_model_minimum_is
AUTO_CACHING_MODEL = "anthropic/claude-sonnet-4-5"
+@pytest.mark.asyncio
+async def test_replayed_redacted_thinking_block_still_records_and_pins():
+ """
+ A model that returns no reasoning summary (gpt-5.x through the /v1/messages bridge, Anthropic with
+ redacted reasoning) hands the client a `redacted_thinking` block, and the client replays it on every
+ later turn. The token count behind `is_prompt_caching_valid_prompt` raised on that block, the helper
+ swallowed it to False, and the check neither recorded the serving deployment nor pinned it, so the
+ conversation bounced across the group and paid a cache write on each deployment.
+ """
+ cache = DualCache()
+ check = PromptCachingDeploymentCheck(cache=cache)
+ model = "openai/gpt-5.6-sol"
+ deployments = _deployments(model, model, model)
+ messages = cast(
+ List[AllMessageValues],
+ [
+ *_messages(word_count=3000),
+ {
+ "role": "assistant",
+ "content": [
+ {"type": "redacted_thinking", "data": "litellm_encrypted_reasoning:" + "Z" * 400},
+ {"type": "text", "text": "Draw from the box labeled Mixed."},
+ ],
+ },
+ {"role": "user", "content": "Restate that in one sentence."},
+ ],
+ )
+
+ assert is_prompt_caching_valid_prompt(model=model, messages=messages) is True
+
+ await check.async_log_success_event(
+ kwargs={
+ "standard_logging_object": {
+ "call_type": "anthropic_messages",
+ "model": model,
+ "messages": messages,
+ "model_id": "dep-2",
+ }
+ },
+ response_obj=None,
+ start_time=None,
+ end_time=None,
+ )
+ filtered = await check.async_filter_deployments(
+ model=MODEL_GROUP_ALIAS,
+ healthy_deployments=deployments,
+ messages=messages,
+ )
+
+ assert filtered == [deployments[1]]
+
+
def _auto_caching_messages() -> List[AllMessageValues]:
"""A prompt over the model minimum that carries no client cache_control."""
return cast(
From 2c3fc4cbff831a389baa76019c1380d1827f9b11 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 18:34:23 -0700
Subject: [PATCH 098/317] test: drop narrating docstrings and wrap long lines
in the cache hook tests
---
.../test_anthropic_cache_control_hook.py | 58 ++++++-------------
1 file changed, 17 insertions(+), 41 deletions(-)
diff --git a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
index 041b00c6c70..5f9d9e5bd9f 100644
--- a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
+++ b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
@@ -1366,8 +1366,6 @@ def _count_converse_cache_points(request_body: dict) -> int:
async def test_cache_control_hook_bedrock_tool_config_point_stands_down_when_client_marks_fill_the_cap(
monkeypatch: pytest.MonkeyPatch,
):
- """The client's own four marks fill the cap, so the configured tool_config point must
- not land as a fifth cachePoint in the converse payload."""
with patch.dict(
os.environ,
{
@@ -2331,13 +2329,6 @@ class TestPerKeyEnablePromptCaching:
class TestConfiguredInjectionPointsSurviveClientMarks:
- """Configured cache_control_injection_points are an explicit instruction, so they
- apply alongside the client's own cache_control marks (LIT-7586, #40675) instead of
- standing down on them. What bounds them is Anthropic's four-block cap, which has to
- count the client's marks on messages, system, tools and the root ``cache_control``
- (LIT-4582: a client-marked tool the cap could not see produced "Found 5" 400s).
- Only the automatic defaults stand down on client marks."""
-
CONFIGURED = [{"location": "message", "role": "system"}]
TAIL_POINT = [{"location": "message", "index": -1}]
TOOL_CONFIG_POINT = [{"location": "tool_config"}]
@@ -2360,10 +2351,14 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
"function": {"name": "t", "parameters": {}},
"cache_control": {"type": "ephemeral"},
}
- MARKED_TOOL_NESTED = {"type": "function", "function": {"name": "t", "parameters": {}, "cache_control": {"type": "ephemeral"}}}
+ MARKED_TOOL_NESTED = {
+ "type": "function",
+ "function": {"name": "t", "parameters": {}, "cache_control": {"type": "ephemeral"}},
+ }
UNMARKED_TOOL = {"type": "function", "function": {"name": "t", "parameters": {}}}
MARKED_V1_TOOL = {"name": "t", "input_schema": {}, "cache_control": {"type": "ephemeral"}}
UNMARKED_V1_TOOL = {"name": "t", "input_schema": {}}
+ MARKED_SYSTEM = [{"type": "text", "text": "sys", "cache_control": EPHEMERAL}]
MARKED_TOOL_SEARCH_REGEX = {
"type": "tool_search_tool_regex_20251119",
"name": "tool_search",
@@ -2413,8 +2408,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
)
def test_chat_tail_point_applies_when_client_marked_the_system_block(self):
- """The issue's shape: the client caches its system prompt, the deployment is
- configured to cache the trailing turn, and both marks must reach the provider."""
messages: List[AllMessageValues] = [
{"role": "system", "content": [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]},
{"role": "user", "content": "history"},
@@ -2450,9 +2443,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
ids=["marked_top_level", "marked_nested_in_function", "unmarked"],
)
def test_chat_cap_counts_client_marked_tools(self, tool, injected):
- """LIT-4582 regression: the prompt-management hook never sees the tools, so the
- seeding pass has to carry the client's tool marks into the cap or a configured
- point lands as a fifth block."""
messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(3)]
params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)}
self._seed(params, copy.deepcopy(messages), tools=[tool])
@@ -2461,9 +2451,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
@pytest.mark.parametrize("tool", [MARKED_TOOL_SEARCH_REGEX, MARKED_TOOL_SEARCH_BM25], ids=["regex", "bm25"])
def test_chat_cap_ignores_marked_tool_search_tools(self, tool):
- """The chat transform strips cache_control from tool-search tools before the
- request leaves, so a client mark there never reaches the provider's cap and
- must not cost the configured point its fourth slot."""
messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(3)]
params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)}
self._seed(params, copy.deepcopy(messages), tools=[tool])
@@ -2472,8 +2459,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
@pytest.mark.parametrize("marked_turns,forwarded", [(3, ["tool_config"]), (4, [])], ids=["slot_left", "cap_full"])
def test_chat_forwards_tool_config_point_only_while_a_slot_is_left(self, marked_turns, forwarded):
- """A forwarded tool_config point becomes a Bedrock cachePoint unconditionally, so
- it stands down once the client's own marks fill the cap."""
messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(marked_turns)]
params = {"cache_control_injection_points": copy.deepcopy(self.TOOL_CONFIG_POINT)}
self._seed(params, copy.deepcopy(messages), tools=[self.UNMARKED_TOOL])
@@ -2488,8 +2473,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
@pytest.mark.parametrize("marked_turns,injected", [(2, 1), (3, 0)])
def test_chat_root_cache_control_reserves_a_slot(self, marked_turns, injected):
- """Anthropic's automatic caching (a top-level ``cache_control``) places one
- breakpoint of its own, so it counts toward the cap like a client mark."""
messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(marked_turns)]
root_cache_control = {"type": "ephemeral"}
params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), "cache_control": root_cache_control}
@@ -2505,9 +2488,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
assert params["cache_control_injection_points"] is configured
def test_chat_reentry_over_injected_messages_adds_no_duplicate_marks(self):
- """acompletion() re-enters completion() and interceptor sub-calls reuse the
- request kwargs, so the same configured points meet messages that already carry
- litellm's own marks; the second pass must leave them as they are."""
points = [{"location": "message", "role": "system"}, {"location": "tool_config"}]
first_params = {"cache_control_injection_points": copy.deepcopy(points)}
self._seed(first_params, copy.deepcopy(self.MARKED_MESSAGES))
@@ -2535,7 +2515,9 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
system = [{"type": "text", "text": "s", "cache_control": {"type": "ephemeral"}}]
kwargs = {"cache_control_injection_points": copy.deepcopy(self.TAIL_POINT)}
result_msgs, result_sys = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs, system=system)
- assert result_msgs == [{"role": "user", "content": [{"type": "text", "text": "hi", "cache_control": self.EPHEMERAL}]}]
+ assert result_msgs == [
+ {"role": "user", "content": [{"type": "text", "text": "hi", "cache_control": self.EPHEMERAL}]}
+ ]
assert result_sys == system
def test_v1_messages_configured_point_applies_when_tools_marked(self):
@@ -2574,8 +2556,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
ids=["marked_tool", "root_cache_control", "unmarked_tool"],
)
def test_chat_cap_counts_client_marks_sent_through_extra_body(self, extra_body, injected):
- """Marks a client sends inside ``extra_body`` reach the wire like any other, so
- the seeding pass has to count them or a configured point lands as a fifth block."""
messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(3)]
params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), "extra_body": extra_body}
self._seed(params, copy.deepcopy(messages))
@@ -2607,8 +2587,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
ids=["same_marked_tool_both_ways", "extra_body_unmarks", "extra_body_marks", "root_cache_control_both_ways"],
)
def test_chat_cap_counts_extra_body_fields_in_place_of_the_direct_ones(self, params, tools, marked_turns, injected):
- """``extra_body`` is merged over the request on the wire, so its ``tools`` and
- ``cache_control`` replace the direct ones rather than adding to them."""
messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(marked_turns)]
params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), **copy.deepcopy(params)}
self._seed(params, copy.deepcopy(messages), tools=tools)
@@ -2618,10 +2596,10 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
@pytest.mark.parametrize(
"kwargs,tools,marked_turns,expected_system",
[
- ({"extra_body": {"tools": [MARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 2, [{"type": "text", "text": "sys", "cache_control": EPHEMERAL}]),
- ({"extra_body": {"tools": [UNMARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 3, [{"type": "text", "text": "sys", "cache_control": EPHEMERAL}]),
+ ({"extra_body": {"tools": [MARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 2, MARKED_SYSTEM),
+ ({"extra_body": {"tools": [UNMARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 3, MARKED_SYSTEM),
({"extra_body": {"tools": [MARKED_V1_TOOL]}}, [UNMARKED_V1_TOOL], 3, "sys"),
- ({"extra_body": {"cache_control": EPHEMERAL}, "cache_control": EPHEMERAL}, None, 2, [{"type": "text", "text": "sys", "cache_control": EPHEMERAL}]),
+ ({"extra_body": {"cache_control": EPHEMERAL}, "cache_control": EPHEMERAL}, None, 2, MARKED_SYSTEM),
],
ids=["same_marked_tool_both_ways", "extra_body_unmarks", "extra_body_marks", "root_cache_control_both_ways"],
)
@@ -2661,11 +2639,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
assert kwargs["cache_control"] is root_cache_control
def test_v1_messages_reentry_flow_preserves_tool_config_remainder(self):
- """The advisor interceptor re-enters anthropic_messages() with the outer
- request's kwargs and post-injection messages. The first pass applies the
- message point and writes back the tool_config remainder; the re-entry must
- keep that remainder and add no mark even though the messages and system
- now carry litellm's own."""
points = [{"location": "message", "role": "system"}, {"location": "tool_config"}]
kwargs = {"cache_control_injection_points": copy.deepcopy(points)}
msgs1, sys1 = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs)
@@ -2910,7 +2883,9 @@ class TestOpenAIPromptCacheBreakpoint:
assert kwargs == {}
def test_v1_messages_configured_points_apply_beside_client_content_breakpoint(self):
- messages = [{"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]}]
+ messages = [
+ {"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]}
+ ]
kwargs = {"cache_control_injection_points": copy.deepcopy(self.SYSTEM_POINT)}
result, system = self._inject(messages, "sys", kwargs)
assert result == messages
@@ -2922,7 +2897,9 @@ class TestOpenAIPromptCacheBreakpoint:
messages = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
kwargs = {"cache_control_injection_points": [{"location": "message", "index": -1}]}
result, result_system = self._inject(messages, system, kwargs)
- assert result == [{"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]}]
+ assert result == [
+ {"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]}
+ ]
assert result_system == system
assert kwargs == {"prompt_cache_options": self.EXPLICIT}
@@ -3600,7 +3577,6 @@ class TestRecordGatewayInjection:
assert kwargs["litellm_metadata"][self.KEY] == self.DEPLOYMENT
def test_configured_points_skipping_a_marked_target_record_nothing(self):
- """A configured point whose target the client already marked places nothing, so no marker lands."""
kwargs: dict = {
"litellm_metadata": {},
"cache_control_injection_points": [{"location": "message", "role": "system", "index": None}],
From 3772993032e93d283c9c0b0cf5a80909feae52f3 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 18:42:05 -0700
Subject: [PATCH 099/317] fix(anthropic_messages): only Mantle consumes
get_llm_provider's api_base
The /v1/messages handler passed the api_base get_llm_provider resolved to every
provider's native messages config, which shadowed DEEPSEEK_ANTHROPIC_API_BASE and
TENCENT_ANTHROPIC_API_BASE with the chat default and changed the azure_ai
precedence. Messages configs now opt in through uses_get_llm_provider_api_base(),
true only for Bedrock Mantle, whose region-prefixed model must resolve to a
region host before the prefix is stripped. Also registers
BedrockMantleAnthropicMessagesConfig in the lazy import registry.
---
litellm/__init__.py | 3 ++
litellm/_lazy_imports_registry.py | 5 +++
.../messages/handler.py | 6 ++-
.../anthropic_messages/transformation.py | 3 ++
.../bedrock_mantle/messages/transformation.py | 3 ++
...erimental_pass_through_messages_handler.py | 42 +++++++++++++++++++
6 files changed, 61 insertions(+), 1 deletion(-)
diff --git a/litellm/__init__.py b/litellm/__init__.py
index e17ab613dac..d2bbc107205 100644
--- a/litellm/__init__.py
+++ b/litellm/__init__.py
@@ -1684,6 +1684,9 @@ if TYPE_CHECKING:
from .llms.bedrock.messages.mantle_transformation import (
AmazonMantleMessagesConfig as AmazonMantleMessagesConfig,
)
+ from .llms.bedrock_mantle.messages.transformation import (
+ BedrockMantleAnthropicMessagesConfig as BedrockMantleAnthropicMessagesConfig,
+ )
from .llms.together_ai.chat import TogetherAIConfig as TogetherAIConfig
from .llms.together_ai.chat.transformation import (
TogetherAIChatConfig as TogetherAIChatConfig,
diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py
index 9cfcb9e41f7..bca04a17250 100644
--- a/litellm/_lazy_imports_registry.py
+++ b/litellm/_lazy_imports_registry.py
@@ -176,6 +176,7 @@ LLM_CONFIG_NAMES: Final = (
"BedrockClaudePlatformMessagesConfig",
"AmazonAnthropicClaudeMessagesConfig",
"AmazonMantleMessagesConfig",
+ "BedrockMantleAnthropicMessagesConfig",
"TogetherAIConfig",
"TogetherAIChatConfig",
"NLPCloudConfig",
@@ -746,6 +747,10 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
".llms.bedrock.messages.mantle_transformation",
"AmazonMantleMessagesConfig",
),
+ "BedrockMantleAnthropicMessagesConfig": (
+ ".llms.bedrock_mantle.messages.transformation",
+ "BedrockMantleAnthropicMessagesConfig",
+ ),
"TogetherAIConfig": (".llms.together_ai.chat", "TogetherAIConfig"),
"TogetherAIChatConfig": (
".llms.together_ai.chat.transformation",
diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py
index e1309ea4063..d87cb0a64f5 100644
--- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py
+++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py
@@ -501,7 +501,6 @@ def anthropic_messages_handler(
api_base=litellm_params.api_base,
api_key=litellm_params.api_key,
)
- resolved_api_base: Final = dynamic_api_base if dynamic_api_base is not None else api_base
# Store agentic loop params in logging object for agentic hooks
# This provides original request context needed for follow-up calls
@@ -652,6 +651,11 @@ def anthropic_messages_handler(
"display": "summarized",
}
+ resolved_api_base: Final = (
+ dynamic_api_base
+ if dynamic_api_base is not None and anthropic_messages_provider_config.uses_get_llm_provider_api_base()
+ else api_base
+ )
return base_llm_http_handler.anthropic_messages_handler(
model=model,
messages=strip_provider_specific_fields_from_anthropic_messages(messages),
diff --git a/litellm/llms/base_llm/anthropic_messages/transformation.py b/litellm/llms/base_llm/anthropic_messages/transformation.py
index 8e7c22930fa..101a5e6c58c 100644
--- a/litellm/llms/base_llm/anthropic_messages/transformation.py
+++ b/litellm/llms/base_llm/anthropic_messages/transformation.py
@@ -128,6 +128,9 @@ class BaseAnthropicMessagesConfig(ABC):
"""
return True
+ def uses_get_llm_provider_api_base(self) -> bool:
+ return False
+
def get_async_streaming_response_iterator(
self,
model: str,
diff --git a/litellm/llms/bedrock_mantle/messages/transformation.py b/litellm/llms/bedrock_mantle/messages/transformation.py
index 480c09a0476..480fe82ef4c 100644
--- a/litellm/llms/bedrock_mantle/messages/transformation.py
+++ b/litellm/llms/bedrock_mantle/messages/transformation.py
@@ -61,6 +61,9 @@ class BedrockMantleAnthropicMessagesConfig(BedrockMantleAuthMixin, AmazonMantleM
def custom_llm_provider(self) -> str | None:
return "bedrock_mantle"
+ def uses_get_llm_provider_api_base(self) -> bool:
+ return True
+
def get_complete_url(
self,
api_base: str | None,
diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py
index 997a97c6fd3..9fa3ef153be 100644
--- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py
+++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py
@@ -1438,3 +1438,45 @@ async def test_anthropic_messages_leaves_non_provider_failures_unmapped():
)
assert "Traceback" not in str(excinfo.value)
+
+
+def _recording_client(seen_urls: list[str]) -> AsyncHTTPHandler:
+ def record_and_answer(request: httpx.Request) -> httpx.Response:
+ seen_urls.append(str(request.url))
+ return httpx.Response(
+ 200,
+ json={
+ "id": "msg_test",
+ "type": "message",
+ "role": "assistant",
+ "model": "deepseek-chat",
+ "content": [{"type": "text", "text": "pong"}],
+ "stop_reason": "end_turn",
+ "stop_sequence": None,
+ "usage": {"input_tokens": 3, "output_tokens": 1},
+ },
+ )
+
+ upstream = AsyncHTTPHandler()
+ upstream.client = httpx.AsyncClient(transport=httpx.MockTransport(record_and_answer))
+ return upstream
+
+
+@pytest.mark.asyncio
+async def test_provider_messages_api_base_env_is_not_shadowed_by_the_chat_default(monkeypatch):
+ from litellm.llms.anthropic.experimental_pass_through.messages import handler
+
+ monkeypatch.delenv("DEEPSEEK_API_BASE", raising=False)
+ monkeypatch.setenv("DEEPSEEK_ANTHROPIC_API_BASE", "https://deepseek.internal.example/anthropic")
+ seen_urls: list[str] = []
+
+ await handler.anthropic_messages(
+ max_tokens=16,
+ messages=[{"role": "user", "content": "ping"}],
+ model="deepseek/deepseek-chat",
+ api_key="sk-test",
+ client=_recording_client(seen_urls),
+ )
+
+ assert seen_urls == ["https://deepseek.internal.example/anthropic/v1/messages"]
+
From 827d1c99a08d4809ddbea9047cbaa1191d0730e4 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 18:48:39 -0700
Subject: [PATCH 100/317] test: type the cache hook test helpers
---
.../test_anthropic_cache_control_hook.py | 55 +++++++++++--------
1 file changed, 33 insertions(+), 22 deletions(-)
diff --git a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
index 5f9d9e5bd9f..fd62a26c354 100644
--- a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
+++ b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py
@@ -4,10 +4,11 @@ import os
import subprocess
import sys
import textwrap
-from typing import List, Optional, Tuple
+from typing import Final, List, Optional, Tuple
from unittest.mock import MagicMock, patch
import pytest
+from pydantic import BaseModel, ConfigDict
import litellm
from litellm.integrations.anthropic_cache_control_hook import (
@@ -1334,7 +1335,7 @@ async def test_cache_control_hook_bedrock_payload_caps_with_tool_config_point(mo
client=client,
)
- request_body = json.loads(mock_post.call_args.kwargs["data"])
+ request_body = _ConverseBody.model_validate_json(mock_post.call_args.kwargs["data"])
cache_points = _count_converse_cache_points(request_body)
assert cache_points <= 4, (
@@ -1343,23 +1344,33 @@ async def test_cache_control_hook_bedrock_payload_caps_with_tool_config_point(mo
)
-def _count_converse_cache_points(request_body: dict) -> int:
- system_points = sum(
- 1 for block in request_body.get("system", []) if isinstance(block, dict) and "cachePoint" in block
+class _ConverseMessage(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ content: tuple[dict[str, object], ...] = ()
+
+
+class _ConverseToolConfig(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ tools: tuple[dict[str, object], ...] = ()
+
+
+class _ConverseBody(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ system: tuple[dict[str, object], ...] = ()
+ messages: tuple[_ConverseMessage, ...] = ()
+ toolConfig: _ConverseToolConfig = _ConverseToolConfig()
+
+
+def _count_converse_cache_points(request_body: _ConverseBody) -> int:
+ blocks: Final = (
+ *request_body.system,
+ *(block for message in request_body.messages for block in message.content),
+ *request_body.toolConfig.tools,
)
- message_points = sum(
- 1
- for msg in request_body.get("messages", [])
- if isinstance(msg.get("content"), list)
- for block in msg["content"]
- if isinstance(block, dict) and "cachePoint" in block
- )
- tool_points = sum(
- 1
- for tool in request_body.get("toolConfig", {}).get("tools", [])
- if isinstance(tool, dict) and "cachePoint" in tool
- )
- return system_points + message_points + tool_points
+ return sum(1 for block in blocks if "cachePoint" in block)
@pytest.mark.asyncio
@@ -1418,10 +1429,10 @@ async def test_cache_control_hook_bedrock_tool_config_point_stands_down_when_cli
client=client,
)
- request_body = json.loads(mock_post.call_args.kwargs["data"])
+ request_body = _ConverseBody.model_validate_json(mock_post.call_args.kwargs["data"])
assert _count_converse_cache_points(request_body) == 4
- assert not any("cachePoint" in tool for tool in request_body["toolConfig"]["tools"])
+ assert not any("cachePoint" in tool for tool in request_body.toolConfig.tools)
class TestApplyToAnthropicMessagesRequest:
@@ -2371,7 +2382,7 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
}
@staticmethod
- def _marked_user_turns(count):
+ def _marked_user_turns(count: int) -> List[AllMessageValues]:
return [
{"role": "user", "content": [{"type": "text", "text": f"turn {i}", "cache_control": {"type": "ephemeral"}}]}
for i in range(count)
@@ -2386,7 +2397,7 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
tools=tools,
)
- def _chat(self, params, messages):
+ def _chat(self, params: dict[str, object], messages: List[AllMessageValues]) -> List[AllMessageValues]:
_, processed, _ = AnthropicCacheControlHook().get_chat_completion_prompt(
model="claude-sonnet-4-5",
messages=messages,
From 325d17aca947c441b7a1ce0df892611d03efd84f Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 18:58:08 -0700
Subject: [PATCH 101/317] fix(litellm): keep a function tool without a body on
the chat route
A tools entry of only {"type": "function"} has nothing for the Responses
bridge to convert, and the bridge raised a 500 for it where the chat
route returns the provider's own 400. The gate now counts a tool as a
function tool only when it carries a function body or a top-level name,
on every provider the gate serves
---
litellm/main.py | 6 +++++-
tests/test_litellm/test_main.py | 29 +++++++++++++++++++++++++++++
2 files changed, 34 insertions(+), 1 deletion(-)
diff --git a/litellm/main.py b/litellm/main.py
index 93b6c730d86..24de7204a04 100644
--- a/litellm/main.py
+++ b/litellm/main.py
@@ -1117,7 +1117,11 @@ def responses_api_bridge_check(
# - Older GPT-5 names (e.g. ``gpt-5``, ``gpt-5.1``): bridge only when a reasoning
# summary alias is present with ``reasoning_effort`` (tools alone stay on chat).
has_function_tool: Final = any(
- (tool.get("type") == "function" if isinstance(tool, dict) else getattr(tool, "type", None) == "function")
+ (
+ tool.get("type") == "function" and (isinstance(tool.get("function"), dict) or "name" in tool)
+ if isinstance(tool, dict)
+ else getattr(tool, "type", None) == "function"
+ )
for tool in (tools or ())
)
if isinstance(reasoning_effort, dict):
diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py
index c2b45aac488..1990b96a51b 100644
--- a/tests/test_litellm/test_main.py
+++ b/tests/test_litellm/test_main.py
@@ -1049,6 +1049,35 @@ def test_responses_api_bridge_check_gpt_5_4_flat_function_tool_routes_to_respons
assert model_info.get("mode") == "responses"
+@pytest.mark.parametrize(
+ "custom_llm_provider, model_name, api_base",
+ [
+ pytest.param("openai", "gpt-5.6", None, id="openai"),
+ pytest.param("azure_ai", "gpt-6-astra", "https://myproject.services.ai.azure.com", id="azure-ai-foundry"),
+ ],
+)
+def test_responses_api_bridge_check_function_tool_without_body_stays_chat(
+ monkeypatch, custom_llm_provider, model_name, api_base
+):
+ import litellm
+ from litellm.main import responses_api_bridge_check
+
+ monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
+ monkeypatch.delenv("OPENAI_API_BASE", raising=False)
+ monkeypatch.setattr(litellm, "api_base", None)
+
+ model_info, model = responses_api_bridge_check(
+ model=model_name,
+ custom_llm_provider=custom_llm_provider,
+ tools=[{"type": "function"}],
+ reasoning_effort=None,
+ api_base=api_base,
+ )
+
+ assert model == model_name
+ assert model_info.get("mode") != "responses"
+
+
def test_responses_api_bridge_check_dict_effort_none_stays_chat():
"""The escape hatch must honor litellm's dict form: {"effort": "none"} means reasoning off."""
from litellm.main import responses_api_bridge_check
From 6ea74d70f1b67ccf49311271228d1fd9bcd86b7c Mon Sep 17 00:00:00 2001
From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 19:33:47 -0700
Subject: [PATCH 102/317] fix(auth): enforce SCIM status for admin JWTs and
refresh SCIM caches
---
litellm/proxy/auth/handle_jwt.py | 2 +-
litellm/proxy/auth/user_api_key_auth.py | 20 +++----
.../management_endpoints/scim/scim_v2.py | 7 +++
.../proxy/auth/test_handle_jwt.py | 43 +++++++++++++
.../proxy/auth/test_user_api_key_auth.py | 11 ++--
.../scim/test_scim_key_deactivation.py | 60 +++++++++++++++++++
6 files changed, 128 insertions(+), 15 deletions(-)
diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py
index 803093ff93a..d621bc634e6 100644
--- a/litellm/proxy/auth/handle_jwt.py
+++ b/litellm/proxy/auth/handle_jwt.py
@@ -2463,7 +2463,7 @@ class JWTAuthManager:
proxy_logging_obj=proxy_logging_obj,
team_id_upsert=team_id_upsert,
)
- if provisioning is None:
+ if provisioning is None or prisma_client is not None:
identity: Final = await JWTAuthManager._resolve_claim_identity(
jwt_valid_token, handler, prisma_client, user_api_key_cache, parent_otel_span, proxy_logging_obj
)
diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py
index 49aab20e461..4371ce4fda8 100644
--- a/litellm/proxy/auth/user_api_key_auth.py
+++ b/litellm/proxy/auth/user_api_key_auth.py
@@ -1714,6 +1714,16 @@ async def _user_api_key_auth_builder(
jwt_claims = result.get("jwt_claims", None)
agent_id: Final[str | None] = result.get("agent_id")
+ if (
+ user_object is not None
+ and isinstance(user_object.metadata, dict)
+ and user_object.metadata.get("scim_active") is False
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=f"User={user_id} has been deactivated via SCIM. Keys owned by this user cannot be used.",
+ )
+
if is_proxy_admin:
# Proxy admins authenticate via auth_builder (full
# access), not via a mapped virtual key. If
@@ -1730,16 +1740,6 @@ async def _user_api_key_auth_builder(
)
return JWTAuthManager.user_api_key_auth_from_result(result, parent_otel_span)
- if (
- user_object is not None
- and isinstance(user_object.metadata, dict)
- and user_object.metadata.get("scim_active") is False
- ):
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail=f"User={user_id} has been deactivated via SCIM. Keys owned by this user cannot be used.",
- )
-
valid_token = JWTAuthManager.user_api_key_auth_from_result(result, parent_otel_span)
# AUTO_REGISTER deferred from _resolve_jwt_to_virtual_key.
diff --git a/litellm/proxy/management_endpoints/scim/scim_v2.py b/litellm/proxy/management_endpoints/scim/scim_v2.py
index 2b74dc1e838..b676c0ddb82 100644
--- a/litellm/proxy/management_endpoints/scim/scim_v2.py
+++ b/litellm/proxy/management_endpoints/scim/scim_v2.py
@@ -46,6 +46,7 @@ from litellm.proxy._types import (
)
from litellm.proxy.auth.auth_checks import _delete_cache_key_object
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast
from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers
from litellm.proxy.management_endpoints.internal_user_endpoints import new_user
from litellm.proxy.management_endpoints.scim.scim_transformations import (
@@ -1804,6 +1805,9 @@ async def update_user(
where={"user_id": user_id},
data=update_data,
)
+ from litellm.proxy.proxy_server import user_api_key_cache
+
+ await evict_and_broadcast(cache_keys=(user_id,), user_api_key_cache=user_api_key_cache)
if client_set_active:
new_active: Final = _scim_active_value(metadata)
@@ -2375,6 +2379,9 @@ async def patch_user(
where={"user_id": user_id},
data=update_data,
)
+ from litellm.proxy.proxy_server import user_api_key_cache
+
+ await evict_and_broadcast(cache_keys=(user_id,), user_api_key_cache=user_api_key_cache)
if new_active is not None and new_active != (True if prev_active is None else prev_active):
await _set_user_keys_blocked(user_id=user_id, blocked=not new_active)
diff --git a/tests/test_litellm/proxy/auth/test_handle_jwt.py b/tests/test_litellm/proxy/auth/test_handle_jwt.py
index 15defb196af..232884d89fa 100644
--- a/tests/test_litellm/proxy/auth/test_handle_jwt.py
+++ b/tests/test_litellm/proxy/auth/test_handle_jwt.py
@@ -7144,3 +7144,46 @@ async def test_admin_jwt_team_header_only_provisions_during_admission(monkeypatc
else:
create_team.assert_not_awaited()
assert result["team_id"] is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("existing_user", [False, True])
+@pytest.mark.parametrize("warm_cache", [False, True])
+async def test_scope_admin_admission_resolves_existing_user_without_provisioning(
+ monkeypatch: pytest.MonkeyPatch, existing_user: bool, warm_cache: bool
+) -> None:
+ from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
+
+ private_key, jwk = _get_rsa_key_and_jwk("admin-status")
+ cache: Final = UserApiKeyCache()
+ cache.set_cache("litellm_jwt_auth_keys_https://admin.example/jwks", [jwk])
+ user_id: Final = f"admin-status-{existing_user}-{warm_cache}"
+ user: Final = LiteLLM_UserTable(user_id=user_id, metadata={"scim_active": False}, organization_memberships=[])
+ if existing_user and warm_cache:
+ cache.set_cache(user_id, user)
+ database: Final = MagicMock()
+ users: Final = database.db.litellm_usertable
+ users.find_unique = AsyncMock(return_value=user if existing_user else None)
+ users.find_first = AsyncMock(return_value=None)
+ users.create = AsyncMock()
+ handler: Final = JWTHandler()
+ handler.update_environment(
+ prisma_client=database,
+ user_api_key_cache=cache,
+ litellm_jwtauth=LiteLLM_JWTAuth(user_id_jwt_field="sub", user_id_upsert=True),
+ )
+ monkeypatch.setenv("JWT_PUBLIC_KEY_URL", "https://admin.example/jwks")
+ monkeypatch.setenv("JWT_ISSUER", "https://admin.example")
+ monkeypatch.setenv("JWT_AUDIENCE", "gateway")
+ token: Final = _encode_rsa_jwt(
+ private_key, "https://admin.example", "gateway", "admin-status",
+ {"sub": user_id, "scope": "litellm_proxy_admin"},
+ )
+ result: Final = await JWTAuthManager.auth_builder(
+ api_key=token, jwt_handler=handler, prisma_client=database, user_api_key_cache=cache,
+ parent_otel_span=None, proxy_logging_obj=MagicMock(), request_data={}, general_settings={}, route="/user/info",
+ )
+ assert result["is_proxy_admin"] is True
+ assert result["user_id"] == user_id
+ assert result["user_object"] == (user if existing_user else None)
+ users.create.assert_not_awaited()
diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py
index dcbc0713404..da36071a5b4 100644
--- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py
+++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py
@@ -7393,18 +7393,21 @@ class TestJWTAuthUserEmail:
assert result.user_email == "resolved@example.com"
@pytest.mark.asyncio
- @pytest.mark.parametrize("route", ["/mcp-rest/tools/list", "/mcp-rest/tools/call", "/v1/chat/completions"])
+ @pytest.mark.parametrize("route", ["/mcp-rest/tools/list", "/mcp-rest/tools/call", "/v1/chat/completions", "/user/info"])
@pytest.mark.parametrize("active", [False, True, None, "false", 0])
- async def test_jwt_auth_rejects_deactivated_user(self, route: str, active: bool | str | int | None) -> None:
+ @pytest.mark.parametrize("is_admin", [False, True])
+ async def test_jwt_auth_rejects_deactivated_user(
+ self, route: str, active: bool | str | int | None, is_admin: bool
+ ) -> None:
from typing import Final
jwt_token: Final = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature"
result: Final = {
- "is_proxy_admin": False,
+ "is_proxy_admin": is_admin,
"team_object": None,
"user_object": LiteLLM_UserTable(
user_id="jwt-human-user",
- user_role=LitellmUserRoles.INTERNAL_USER.value,
+ user_role=LitellmUserRoles.PROXY_ADMIN.value if is_admin else LitellmUserRoles.INTERNAL_USER.value,
metadata={} if active is None else {"scim_active": active},
),
"end_user_object": None,
diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_key_deactivation.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_key_deactivation.py
index 0a9cf8b84cb..2cfbfbbb3cb 100644
--- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_key_deactivation.py
+++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_key_deactivation.py
@@ -541,3 +541,63 @@ async def test_scim_put_user_explicit_active_false_blocks_keys():
assert update_kwargs["where"] == {"token": "hash-block-me"}
assert update_kwargs["data"]["blocked"] is True
assert '"scim_blocked": true' in update_kwargs["data"]["metadata"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("method", ["PUT", "PATCH"])
+@pytest.mark.parametrize("active", [False, True])
+@pytest.mark.parametrize("failure", [None, "write", "keys"])
+@pytest.mark.parametrize("status_change", [False, True])
+async def test_scim_status_write_refreshes_user_cache(
+ method: str, active: bool, failure: str | None, status_change: bool
+) -> None:
+ import json
+ from typing import Final
+
+ from litellm.proxy._types import ProxyException
+ from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
+
+ user_id: Final = "scim-cache-user"
+ saved: Final = LiteLLM_UserTable(
+ user_id=user_id, user_email="x@example.com", teams=[], metadata={"scim_active": not active if status_change else active},
+ )
+ updated: Final = LiteLLM_UserTable(
+ user_id=user_id, user_email="x@example.com", teams=[], metadata={"scim_active": active},
+ )
+ client, db = _build_prisma_with_keys([], mock_user=saved.model_copy(deep=True), updated_user=updated)
+ if failure == "write":
+ db.litellm_usertable.update.side_effect = RuntimeError("status write failed")
+ if failure == "keys":
+ db.litellm_verificationtoken.find_many.side_effect = RuntimeError("key update failed")
+ cache: Final = UserApiKeyCache()
+ await cache.async_set_cache(key=user_id, value=saved, model_type=LiteLLM_UserTable)
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", client), # test-quality-ok: substitute the database dependency
+ patch("litellm.proxy.proxy_server.user_api_key_cache", cache), # test-quality-ok: exercise a real isolated cache
+ patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()), # test-quality-ok: isolate the logging dependency
+ patch( # test-quality-ok: observe the Redis publication boundary
+ "litellm.proxy.common_utils.auth_cache_invalidation_pubsub.publish_auth_cache_invalidation",
+ new_callable=AsyncMock,
+ ) as broadcast,
+ ):
+ request: Final = (
+ update_user(user_id=user_id, user=SCIMUser.model_validate(_build_put_user_payload(user_id, active=active)))
+ if method == "PUT" else
+ patch_user(user_id=user_id, patch_ops=SCIMPatchOp(
+ Operations=[SCIMPatchOperation(op="replace", path="active", value=active)]
+ ))
+ )
+ if failure == "write" or (failure == "keys" and status_change):
+ with pytest.raises(ProxyException, match="status write failed" if failure == "write" else "key update failed"):
+ await request
+ else:
+ response: Final = await request
+ assert response.active is active
+ assert json.loads(db.litellm_usertable.update.await_args.kwargs["data"]["metadata"])["scim_active"] is active
+ cached: Final = await cache.async_get_cache(key=user_id, model_type=LiteLLM_UserTable)
+ if failure == "write":
+ assert cached == saved
+ broadcast.assert_not_awaited()
+ else:
+ assert cached is None
+ broadcast.assert_awaited_once_with(cache_key=user_id)
From 124196cbaa68832894e1491e5d0f28e0b38a86dd Mon Sep 17 00:00:00 2001
From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 19:48:53 -0700
Subject: [PATCH 103/317] fix(auth): preserve scope-admin email policy during
status lookup
---
litellm/proxy/auth/handle_jwt.py | 19 +++++++++++++++++--
.../proxy/auth/test_handle_jwt.py | 16 +++++++++++-----
2 files changed, 28 insertions(+), 7 deletions(-)
diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py
index d621bc634e6..996911cdfaa 100644
--- a/litellm/proxy/auth/handle_jwt.py
+++ b/litellm/proxy/auth/handle_jwt.py
@@ -2463,12 +2463,27 @@ class JWTAuthManager:
proxy_logging_obj=proxy_logging_obj,
team_id_upsert=team_id_upsert,
)
- if provisioning is None or prisma_client is not None:
+ if provisioning is None:
identity: Final = await JWTAuthManager._resolve_claim_identity(
jwt_valid_token, handler, prisma_client, user_api_key_cache, parent_otel_span, proxy_logging_obj
)
return {**admin_result, "user_object": identity.user_object}
- return admin_result
+ if prisma_client is None:
+ return admin_result
+ try:
+ admin_user: Final = await get_user_object(
+ user_id=user_id,
+ user_email=user_email,
+ sso_user_id=user_id,
+ prisma_client=prisma_client,
+ user_api_key_cache=user_api_key_cache,
+ user_id_upsert=False,
+ parent_otel_span=parent_otel_span,
+ proxy_logging_obj=proxy_logging_obj,
+ )
+ except UserNotFoundError:
+ return admin_result
+ return {**admin_result, "user_object": admin_user}
# Get team with model access
## Check if team_id is specified via x-litellm-team-id header
diff --git a/tests/test_litellm/proxy/auth/test_handle_jwt.py b/tests/test_litellm/proxy/auth/test_handle_jwt.py
index 232884d89fa..e768139f04a 100644
--- a/tests/test_litellm/proxy/auth/test_handle_jwt.py
+++ b/tests/test_litellm/proxy/auth/test_handle_jwt.py
@@ -7149,16 +7149,17 @@ async def test_admin_jwt_team_header_only_provisions_during_admission(monkeypatc
@pytest.mark.asyncio
@pytest.mark.parametrize("existing_user", [False, True])
@pytest.mark.parametrize("warm_cache", [False, True])
+@pytest.mark.parametrize("email", [None, "admin@external.example", "admin@allowed.example"])
async def test_scope_admin_admission_resolves_existing_user_without_provisioning(
- monkeypatch: pytest.MonkeyPatch, existing_user: bool, warm_cache: bool
+ monkeypatch: pytest.MonkeyPatch, existing_user: bool, warm_cache: bool, email: str | None
) -> None:
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
private_key, jwk = _get_rsa_key_and_jwk("admin-status")
cache: Final = UserApiKeyCache()
cache.set_cache("litellm_jwt_auth_keys_https://admin.example/jwks", [jwk])
- user_id: Final = f"admin-status-{existing_user}-{warm_cache}"
- user: Final = LiteLLM_UserTable(user_id=user_id, metadata={"scim_active": False}, organization_memberships=[])
+ user_id: Final = f"admin-status-{existing_user}-{warm_cache}-{email}"
+ user: Final = LiteLLM_UserTable(user_id=user_id, user_email="admin@allowed.example", metadata={"scim_active": False}, organization_memberships=[])
if existing_user and warm_cache:
cache.set_cache(user_id, user)
database: Final = MagicMock()
@@ -7170,14 +7171,17 @@ async def test_scope_admin_admission_resolves_existing_user_without_provisioning
handler.update_environment(
prisma_client=database,
user_api_key_cache=cache,
- litellm_jwtauth=LiteLLM_JWTAuth(user_id_jwt_field="sub", user_id_upsert=True),
+ litellm_jwtauth=LiteLLM_JWTAuth(
+ user_id_jwt_field="sub", user_id_upsert=True, user_email_jwt_field="email",
+ user_allowed_email_domain="allowed.example",
+ ),
)
monkeypatch.setenv("JWT_PUBLIC_KEY_URL", "https://admin.example/jwks")
monkeypatch.setenv("JWT_ISSUER", "https://admin.example")
monkeypatch.setenv("JWT_AUDIENCE", "gateway")
token: Final = _encode_rsa_jwt(
private_key, "https://admin.example", "gateway", "admin-status",
- {"sub": user_id, "scope": "litellm_proxy_admin"},
+ {"sub": user_id, "scope": "litellm_proxy_admin", **({"email": email} if email else {})},
)
result: Final = await JWTAuthManager.auth_builder(
api_key=token, jwt_handler=handler, prisma_client=database, user_api_key_cache=cache,
@@ -7187,3 +7191,5 @@ async def test_scope_admin_admission_resolves_existing_user_without_provisioning
assert result["user_id"] == user_id
assert result["user_object"] == (user if existing_user else None)
users.create.assert_not_awaited()
+ if existing_user:
+ assert users.find_unique.await_count == (0 if warm_cache else 1)
From 4fda0092d3aa21df1da30689c9c7660ac0bdcd80 Mon Sep 17 00:00:00 2001
From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 19:52:13 -0700
Subject: [PATCH 104/317] fix(mcp): explain missing public client dependencies
---
litellm/experimental_mcp_client/Readme.md | 17 +++++++++
litellm/experimental_mcp_client/__init__.py | 7 +++-
.../base_sdk_tests/check_base_sdk_install.py | 12 ++++++
.../test_mcp_client.py | 37 +++++++++++++++++++
4 files changed, 72 insertions(+), 1 deletion(-)
diff --git a/litellm/experimental_mcp_client/Readme.md b/litellm/experimental_mcp_client/Readme.md
index 14decce0256..3385a37cf69 100644
--- a/litellm/experimental_mcp_client/Readme.md
+++ b/litellm/experimental_mcp_client/Readme.md
@@ -2,6 +2,17 @@
LiteLLM MCP Client allows you to use MCP tools with LiteLLM
+Install the optional dependencies with `pip install 'litellm[mcp]'`, then use the existing public imports:
+
+```python
+from litellm.experimental_mcp_client import call_openai_tool, load_mcp_tools
+from litellm.experimental_mcp_client.client import MCPClient
+
+client = MCPClient(server_url="https://mcp.example.com/mcp")
+```
+
+Core `import litellm` works without the MCP extra. Importing the experimental MCP client without its MCP or HTTPX2 dependency raises an error with this installation command
+
## MCP Python SDK compatibility
The `mcp` and `proxy` extras require MCP Python SDK 2.2 or newer within the 2.x release line. Installing core LiteLLM without these extras does not require MCP
@@ -16,6 +27,12 @@ The shared unit-test workflow runs the MCP integration suite once, with SDK2 in
See the official [SDK migration guide](https://py.sdk.modelcontextprotocol.io/migration/) for Python API changes
+## Custom HTTP clients and authentication
+
+MCP HTTP and SSE transports now use `httpx2`. Custom authentication passed through `aws_auth` or `resolved_auth` must implement `httpx2.Auth`. Integrations that override the client's HTTP client factory or customize its event hooks must use `httpx2.AsyncClient`, request, response, timeout and transport types
+
+HTTPX1 clients, auth objects and hooks are not adapted by a compatibility shim. Migrate those integrations to HTTPX2 before upgrading. Ordinary `MCPClient` construction and LiteLLM's existing helper imports remain supported; this does not restore SDK1 Python imports or camelCase SDK model attributes in the shared Python environment
+
## HTTP redirects
For streamable HTTP POST requests, the MCP SDK follows method-preserving redirects such as HTTP 307/308 within the configured endpoint's origin. Redirects to another path on the same scheme, host and port work. The SDK also permits an HTTP-to-HTTPS upgrade on the same host using the default ports
diff --git a/litellm/experimental_mcp_client/__init__.py b/litellm/experimental_mcp_client/__init__.py
index 5399968ff74..7a3917a50ea 100644
--- a/litellm/experimental_mcp_client/__init__.py
+++ b/litellm/experimental_mcp_client/__init__.py
@@ -1,3 +1,8 @@
-from .tools import call_openai_tool, load_mcp_tools
+try:
+ from .tools import call_openai_tool, load_mcp_tools
+except ModuleNotFoundError as exc:
+ if exc.name not in ("mcp", "httpx2"):
+ raise
+ raise ImportError("MCP client dependencies are missing. Install them with: pip install 'litellm[mcp]'") from exc
__all__ = ["call_openai_tool", "load_mcp_tools"]
diff --git a/tests/base_sdk_tests/check_base_sdk_install.py b/tests/base_sdk_tests/check_base_sdk_install.py
index 190a900faf9..f680ba92645 100644
--- a/tests/base_sdk_tests/check_base_sdk_install.py
+++ b/tests/base_sdk_tests/check_base_sdk_install.py
@@ -50,6 +50,17 @@ def check_completion() -> str:
return "mock completion round-trips"
+def check_mcp_install_guidance() -> str:
+ try:
+ import litellm.experimental_mcp_client
+ except ImportError as error:
+ _require("pip install 'litellm[mcp]'" in str(error), f"missing MCP installation guidance: {error}")
+ _require(isinstance(error.__cause__, ModuleNotFoundError), "original missing-dependency cause was lost")
+ _require(error.__cause__.name == "mcp", f"unexpected missing dependency: {error.__cause__}")
+ return "optional MCP client explains how to install litellm[mcp]"
+ raise AssertionError("MCP client imported without the MCP extra")
+
+
def check_embedding() -> str:
import litellm
@@ -109,6 +120,7 @@ def check_bedrock_credential_resolution() -> str:
CHECKS: tuple[tuple[str, Callable[[], str]], ...] = (
("environment is base-only", check_environment_is_base_only),
("import litellm", check_import),
+ ("optional MCP installation guidance", check_mcp_install_guidance),
("chat completion", check_completion),
("embedding", check_embedding),
("bundled model metadata", check_bundled_model_metadata),
diff --git a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py
index 7e4598c2e58..4b698f1258d 100644
--- a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py
+++ b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py
@@ -1,10 +1,12 @@
import asyncio
import base64
+import importlib
import json
import os
import sys
from collections.abc import AsyncIterator
from pathlib import Path
+from types import ModuleType
from typing import Final
from unittest.mock import AsyncMock, MagicMock, Mock, patch
@@ -2160,3 +2162,38 @@ async def test_404_before_session_initialization_preserves_method_not_found() ->
)
assert caught.value.error.code == METHOD_NOT_FOUND
assert caught.value.error.message == "Not Found"
+
+
+@pytest.mark.parametrize("missing_module", ("mcp", "httpx2", "mcp.types", "openai.types.chat"))
+def test_public_mcp_import_missing_dependency(missing_module: str) -> None:
+ with patch.dict(sys.modules):
+ for name in tuple(sys.modules):
+ if name.startswith(("litellm.experimental_mcp_client", "mcp.", "mcp_types.")) or name == "mcp":
+ del sys.modules[name]
+ with patch.dict(sys.modules, {missing_module: None}):
+ with pytest.raises(ImportError) as caught:
+ importlib.import_module("litellm.experimental_mcp_client.client")
+
+ if missing_module in ("mcp", "httpx2"):
+ assert "pip install 'litellm[mcp]'" in str(caught.value)
+ assert isinstance(caught.value.__cause__, ModuleNotFoundError)
+ assert caught.value.__cause__.name == missing_module
+ else:
+ assert isinstance(caught.value, ModuleNotFoundError)
+ assert caught.value.name == missing_module
+ assert caught.value.__cause__ is None
+ assert "litellm[mcp]" not in str(caught.value)
+
+
+def test_public_mcp_import_preserves_incompatible_sdk_error() -> None:
+ with patch.dict(sys.modules):
+ for name in tuple(sys.modules):
+ if name.startswith("litellm.experimental_mcp_client"):
+ del sys.modules[name]
+ with patch.dict(sys.modules, {"mcp": ModuleType("mcp")}):
+ with pytest.raises(ImportError, match="cannot import name 'ClientSession'") as caught:
+ importlib.import_module("litellm.experimental_mcp_client.client")
+
+ assert not isinstance(caught.value, ModuleNotFoundError)
+ assert caught.value.__cause__ is None
+ assert "litellm[mcp]" not in str(caught.value)
From 517fff5bb7bbbd397ad1942cba5a3a1b35e0640a Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 19:52:22 -0700
Subject: [PATCH 105/317] fix(router): keep prompt caching affinity when the
breakpoint moves
The prompt_caching pre-call check keyed a deployment pin on a hash of the
whole cacheable prefix, cache_control markers included. Agent clients
such as Claude Code move the marker to the newest user turn on every
request, so the key changed every turn, the pin never matched, and a
multi-turn session drifted across deployments and lost its provider
cache.
Hash the prefix per content block with the markers stripped, chained so
every block position has a key, and write the pin at the breakpoint
block. Lookup walks back over the last PROMPT_CACHE_LOOKBACK_POSITIONS
positions (a run of tool_use or tool_result blocks counting as one), the
same window the provider probes for a cached prefix, in one batch cache
read. Both sides hash the prefix after base64 truncation so a request
carrying raw image bytes derives the keys the success event stored.
---
litellm/constants.py | 3 +
litellm/router_utils/prompt_caching_cache.py | 250 +++++++++++-----
.../test_prompt_caching_deployment_check.py | 273 +++++++++++++++++-
3 files changed, 450 insertions(+), 76 deletions(-)
diff --git a/litellm/constants.py b/litellm/constants.py
index bbeb4846e27..e4576ad4d5c 100644
--- a/litellm/constants.py
+++ b/litellm/constants.py
@@ -399,6 +399,9 @@ MINIMUM_PROMPT_CACHE_TOKEN_COUNT: Final = (
if MINIMUM_PROMPT_CACHE_TOKEN_COUNT_OVERRIDE is not None
else DEFAULT_MINIMUM_PROMPT_CACHE_TOKEN_COUNT
)
+# Anthropic checks at most 20 block positions behind a breakpoint for a cached prefix, a run of tool_use
+# or tool_result blocks counting as one position, so deployment affinity probes the same window
+PROMPT_CACHE_LOOKBACK_POSITIONS: Final = 20
DEFAULT_TRIM_RATIO: Final = float(
os.getenv("DEFAULT_TRIM_RATIO", 0.75)
) # default ratio of tokens to trim from the end of a prompt
diff --git a/litellm/router_utils/prompt_caching_cache.py b/litellm/router_utils/prompt_caching_cache.py
index 39708e168f5..0b784e1fa91 100644
--- a/litellm/router_utils/prompt_caching_cache.py
+++ b/litellm/router_utils/prompt_caching_cache.py
@@ -4,12 +4,21 @@ Wrapper around router cache. Meant to store model id when prompt caching support
import hashlib
import json
+from collections.abc import Iterable, Mapping, Sequence
+from dataclasses import dataclass
+from itertools import accumulate
from typing import TYPE_CHECKING, Any, Final, cast
+from pydantic import JsonValue, TypeAdapter
+from pydantic_core import to_jsonable_python
from typing_extensions import TypedDict
from litellm.caching.caching import DualCache
-from litellm.caching.in_memory_cache import InMemoryCache
+from litellm.constants import PROMPT_CACHE_LOOKBACK_POSITIONS
+from litellm.litellm_core_utils.logging_utils import (
+ truncate_base64_in_messages,
+ truncate_base64_in_messages_async,
+)
from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam
if TYPE_CHECKING:
@@ -28,10 +37,100 @@ class PromptCachingCacheValue(TypedDict):
model_id: str
+PROMPT_CACHE_PIN_TTL_SECONDS: Final = 300
+_TOOL_RUN_BLOCK_TYPES: Final = frozenset({"tool_use", "tool_result"})
+_PREFIX_ADAPTER: Final = TypeAdapter(tuple[Mapping[str, JsonValue], ...])
+_TOOLS_ADAPTER: Final = TypeAdapter(tuple[JsonValue, ...])
+_PINS_ADAPTER: Final[TypeAdapter[tuple[JsonValue, ...] | None]] = TypeAdapter(tuple[JsonValue, ...] | None)
+
+
+@dataclass(frozen=True, slots=True)
+class PrefixPosition:
+ cache_key: str
+ position: int
+
+
+def _sorted_pairs(pairs: Iterable[tuple[str, JsonValue]]) -> tuple[tuple[str, JsonValue], ...]:
+ return tuple(sorted(pairs, key=lambda pair: pair[0]))
+
+
+def _canonical_bytes(value: object) -> bytes:
+ return json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
+
+
+def _block_unit(
+ envelope: tuple[tuple[str, JsonValue], ...], message_run_type: str | None, block: JsonValue
+) -> tuple[bytes, str | None]:
+ if not isinstance(block, dict):
+ return _canonical_bytes((envelope, block)), message_run_type
+ block_type: Final = block.get("type")
+ block_run_type: Final = block_type if isinstance(block_type, str) and block_type in _TOOL_RUN_BLOCK_TYPES else None
+ stripped: Final = _sorted_pairs(item for item in block.items() if item[0] != "cache_control")
+ return _canonical_bytes((envelope, stripped)), message_run_type or block_run_type
+
+
+def _message_units(message: Mapping[str, JsonValue]) -> tuple[tuple[bytes, str | None], ...]:
+ envelope: Final = _sorted_pairs(item for item in message.items() if item[0] not in ("content", "cache_control"))
+ message_run_type: Final = "tool_result" if message.get("role") == "tool" else None
+ content: Final = message.get("content")
+ if isinstance(content, list) and content:
+ return tuple(_block_unit(envelope, message_run_type, block) for block in content)
+ if isinstance(content, str) and content:
+ return ((_canonical_bytes((envelope, (("text", content), ("type", "text")))), message_run_type),)
+ return ((_canonical_bytes((envelope, None)), message_run_type),)
+
+
+def _chain_digest(digest: bytes, unit: bytes) -> bytes:
+ return hashlib.sha256(digest + unit).digest()
+
+
+def _seed(tools: Sequence[ChatCompletionToolParam] | None) -> bytes:
+ if tools is None:
+ return hashlib.sha256(b"").digest()
+ return hashlib.sha256(
+ _canonical_bytes(_TOOLS_ADAPTER.validate_python(to_jsonable_python(tools, serialize_unknown=True)))
+ ).digest()
+
+
+def _positions_of(
+ prefix: tuple[Mapping[str, JsonValue], ...], tools: Sequence[ChatCompletionToolParam] | None
+) -> tuple[PrefixPosition, ...]:
+ units: Final = tuple(unit for message in prefix for unit in _message_units(message))
+ digests: Final = tuple(accumulate((unit_bytes for unit_bytes, _ in units), _chain_digest, initial=_seed(tools)))[1:]
+ run_types: Final = tuple(run_type for _, run_type in units)
+ positions: Final = accumulate(
+ 0 if run_type is not None and run_type == previous else 1
+ for run_type, previous in zip(run_types, (None, *run_types[:-1]))
+ )
+ return tuple(
+ PrefixPosition(cache_key=f"deployment:{digest.hex()}:prompt_caching", position=position)
+ for digest, position in zip(digests, positions)
+ )
+
+
+def _lookback_keys(positions: tuple[PrefixPosition, ...]) -> tuple[str, ...]:
+ if not positions:
+ return ()
+ oldest_probed_position: Final = positions[-1].position - PROMPT_CACHE_LOOKBACK_POSITIONS
+ return tuple(entry.cache_key for entry in reversed(positions) if entry.position > oldest_probed_position)
+
+
+def _pinned_value(value: JsonValue) -> PromptCachingCacheValue | None:
+ if not isinstance(value, dict):
+ return None
+ model_id: Final = value.get("model_id")
+ return PromptCachingCacheValue(model_id=model_id) if isinstance(model_id, str) else None
+
+
+def _first_pin(values: tuple[JsonValue, ...] | None) -> PromptCachingCacheValue | None:
+ if values is None:
+ return None
+ return next((pin for pin in map(_pinned_value, values) if pin is not None), None)
+
+
class PromptCachingCache:
def __init__(self, cache: DualCache):
self.cache = cache
- self.in_memory_cache = InMemoryCache()
@staticmethod
def serialize_object(obj: Any) -> object:
@@ -140,114 +239,123 @@ class PromptCachingCache:
return cacheable_prefix
@staticmethod
- def get_prompt_caching_cache_key(
+ def prefix_positions(
messages: list[AllMessageValues] | None,
- tools: list[ChatCompletionToolParam] | None,
- ) -> str | None:
- if messages is None and tools is None:
- return None
+ tools: Sequence[ChatCompletionToolParam] | None,
+ ) -> tuple[PrefixPosition, ...]:
+ """
+ One cache key per content block of the cacheable prefix, oldest block first.
- # Extract cacheable prefix from messages (only include up to last cache_control block)
- cacheable_messages = None
- if messages is not None:
- cacheable_messages = PromptCachingCache.extract_cacheable_prefix(messages)
- # If no cacheable prefix found, return None (can't cache)
- if not cacheable_messages:
- return None
+ Each key hashes the prefix content up to and including that block, with cache_control markers
+ left out, so the key of a block is the same whichever turn's breakpoint the prefix ends at.
+ String content hashes like a single text block, which is how the provider treats it and how
+ Claude Code re-sends a previously marked message. `position` counts a run of consecutive
+ tool_use (or tool_result) blocks as one, matching the provider's lookback window.
- # Use serialize_object for consistent and stable serialization
- data_to_hash: Final = {}
- if cacheable_messages is not None:
- serialized_messages: Final = PromptCachingCache.serialize_object(cacheable_messages)
- data_to_hash["messages"] = serialized_messages
- if tools is not None:
- serialized_tools: Final = PromptCachingCache.serialize_object(tools)
- data_to_hash["tools"] = serialized_tools
-
- # Combine serialized data into a single string
- data_to_hash_str: Final = json.dumps(
- data_to_hash,
- sort_keys=True,
- separators=(",", ":"),
+ The prefix is hashed in the shape the success event sees it, with long base64 data URIs
+ already replaced by their size placeholder, so a request carrying the raw image bytes
+ derives the same keys the write side stored.
+ """
+ if not messages:
+ return ()
+ return _positions_of(
+ _PREFIX_ADAPTER.validate_python(
+ to_jsonable_python(
+ truncate_base64_in_messages(PromptCachingCache.extract_cacheable_prefix(messages)),
+ serialize_unknown=True,
+ )
+ ),
+ tools,
)
- # Create a hash of the serialized data for a stable cache key
- hashed_data: Final = hashlib.sha256(data_to_hash_str.encode()).hexdigest()
- return f"deployment:{hashed_data}:prompt_caching"
+ @staticmethod
+ async def async_prefix_positions(
+ messages: list[AllMessageValues] | None,
+ tools: Sequence[ChatCompletionToolParam] | None,
+ ) -> tuple[PrefixPosition, ...]:
+ if not messages:
+ return ()
+ return _positions_of(
+ _PREFIX_ADAPTER.validate_python(
+ to_jsonable_python(
+ await truncate_base64_in_messages_async(PromptCachingCache.extract_cacheable_prefix(messages)),
+ serialize_unknown=True,
+ )
+ ),
+ tools,
+ )
+
+ @staticmethod
+ def get_prompt_caching_cache_key(
+ messages: list[AllMessageValues] | None,
+ tools: Sequence[ChatCompletionToolParam] | None,
+ ) -> str | None:
+ positions: Final = PromptCachingCache.prefix_positions(messages, tools)
+ return positions[-1].cache_key if positions else None
def add_model_id(
self,
model_id: str,
messages: list[AllMessageValues] | None,
- tools: list[ChatCompletionToolParam] | None,
+ tools: Sequence[ChatCompletionToolParam] | None,
) -> None:
- if messages is None and tools is None:
- return
-
cache_key: Final = PromptCachingCache.get_prompt_caching_cache_key(messages, tools)
- # If no cacheable prefix found, don't cache (can't generate cache key)
if cache_key is None:
return
- self.cache.set_cache(cache_key, PromptCachingCacheValue(model_id=model_id), ttl=300)
- return
+ self.cache.set_cache(cache_key, PromptCachingCacheValue(model_id=model_id), ttl=PROMPT_CACHE_PIN_TTL_SECONDS)
async def async_add_model_id(
self,
model_id: str,
messages: list[AllMessageValues] | None,
- tools: list[ChatCompletionToolParam] | None,
+ tools: Sequence[ChatCompletionToolParam] | None,
) -> None:
- if messages is None and tools is None:
- return
-
- cache_key: Final = PromptCachingCache.get_prompt_caching_cache_key(messages, tools)
- # If no cacheable prefix found, don't cache (can't generate cache key)
- if cache_key is None:
+ positions: Final = await PromptCachingCache.async_prefix_positions(messages, tools)
+ if not positions:
return
await self.cache.async_set_cache(
- cache_key,
+ positions[-1].cache_key,
PromptCachingCacheValue(model_id=model_id),
- ttl=300, # store for 5 minutes
+ ttl=PROMPT_CACHE_PIN_TTL_SECONDS,
)
- return
async def async_get_model_id(
self,
messages: list[AllMessageValues] | None,
- tools: list[ChatCompletionToolParam] | None,
+ tools: Sequence[ChatCompletionToolParam] | None,
) -> PromptCachingCacheValue | None:
"""
- Get model ID from cache using the cacheable prefix.
-
- The cache key is based on the cacheable prefix (everything up to and including
- the last cache_control block), so requests with the same cacheable prefix but
- different user messages will have the same cache key.
+ Find the deployment that last served this prefix, walking back from the breakpoint the
+ same way the provider cache does, so a breakpoint that moved forward since the last
+ turn still lands on the deployment whose cache holds the earlier prefix.
"""
- if messages is None and tools is None:
+ cache_keys: Final = _lookback_keys(await PromptCachingCache.async_prefix_positions(messages, tools))
+ if not cache_keys:
return None
- # Generate cache key using cacheable prefix
- cache_key: Final = PromptCachingCache.get_prompt_caching_cache_key(messages, tools)
- if cache_key is None:
- return None
-
- # Perform cache lookup
- cache_result: Final = await self.cache.async_get_cache(key=cache_key)
- return cache_result
+ return _first_pin(
+ _PINS_ADAPTER.validate_python(
+ await self.cache.async_batch_get_cache(
+ keys=list(cache_keys), # mutable-ok: DualCache.async_batch_get_cache only takes a list
+ )
+ )
+ )
def get_model_id(
self,
messages: list[AllMessageValues] | None,
- tools: list[ChatCompletionToolParam] | None,
+ tools: Sequence[ChatCompletionToolParam] | None,
) -> PromptCachingCacheValue | None:
- if messages is None and tools is None:
+ cache_keys: Final = _lookback_keys(PromptCachingCache.prefix_positions(messages, tools))
+ if not cache_keys:
return None
- cache_key: Final = PromptCachingCache.get_prompt_caching_cache_key(messages, tools)
- # If no cacheable prefix found, return None (can't cache)
- if cache_key is None:
- return None
-
- return self.cache.get_cache(cache_key)
+ return _first_pin(
+ _PINS_ADAPTER.validate_python(
+ self.cache.batch_get_cache(
+ keys=list(cache_keys), # mutable-ok: DualCache.batch_get_cache only takes a list
+ )
+ )
+ )
diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py
index 333e7b2ff31..d0a9223dfa7 100644
--- a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py
+++ b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py
@@ -1,5 +1,6 @@
import asyncio
import copy
+import functools
from typing import List, cast
import pytest
@@ -7,7 +8,7 @@ import pytest
import litellm
from litellm.caching.dual_cache import DualCache
-from litellm.constants import DEFAULT_MINIMUM_PROMPT_CACHE_TOKEN_COUNT
+from litellm.constants import DEFAULT_MINIMUM_PROMPT_CACHE_TOKEN_COUNT, PROMPT_CACHE_LOOKBACK_POSITIONS
from litellm.integrations.anthropic_cache_control_hook import AnthropicCacheControlHook
from litellm.integrations.custom_logger import CustomLogger
from litellm.router_utils.pre_call_checks.prompt_caching_deployment_check import (
@@ -30,7 +31,6 @@ def _local_model_cost_map_autouse(local_model_cost_map):
yield
-
def _deployments(*models: str) -> List[dict]:
return [
{
@@ -84,7 +84,9 @@ def test_write_gate_is_what_prevents_a_pin_below_the_model_minimum():
"""
messages = _messages(word_count=1400)
- token_count = token_counter(messages=messages, model="anthropic/claude-opus-4-5", use_default_image_token_count=True)
+ token_count = token_counter(
+ messages=messages, model="anthropic/claude-opus-4-5", use_default_image_token_count=True
+ )
assert 1024 < token_count < 4096
assert is_prompt_caching_valid_prompt(model="anthropic/claude-opus-4-5", messages=messages) is False
@@ -110,7 +112,9 @@ async def test_async_filter_deployments_does_not_narrow_prompt_below_model_minim
deployments = _deployments("anthropic/claude-opus-4-6", "anthropic/claude-opus-4-6")
messages = _messages(word_count=1400)
- token_count = token_counter(messages=messages, model="anthropic/claude-opus-4-6", use_default_image_token_count=True)
+ token_count = token_counter(
+ messages=messages, model="anthropic/claude-opus-4-6", use_default_image_token_count=True
+ )
assert DEFAULT_MINIMUM_PROMPT_CACHE_TOKEN_COUNT < token_count < OPUS_4_6_MIN_TOKENS
await PromptCachingCache(cache=cache).async_add_model_id(model_id="dep-2", messages=messages, tools=None)
@@ -136,7 +140,9 @@ async def test_async_filter_deployments_narrows_prompt_above_model_minimum():
deployments = _deployments("anthropic/claude-opus-4-6", "anthropic/claude-opus-4-6")
messages = _messages(word_count=5000)
- token_count = token_counter(messages=messages, model="anthropic/claude-opus-4-6", use_default_image_token_count=True)
+ token_count = token_counter(
+ messages=messages, model="anthropic/claude-opus-4-6", use_default_image_token_count=True
+ )
assert token_count > OPUS_4_6_MIN_TOKENS
await PromptCachingCache(cache=cache).async_add_model_id(model_id="dep-2", messages=messages, tools=None)
@@ -539,3 +545,260 @@ async def test_async_log_success_event_counts_the_prompt_off_the_event_loop():
"model_id": "dep-1"
}
assert_loop_stayed_free(took, lags)
+
+
+LONG_PROMPT = "word " * 3000
+ONE_PIXEL_PNG = (
+ "data:image/png;base64,"
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
+)
+
+
+def _turn(*messages: dict) -> List[AllMessageValues]:
+ return cast(List[AllMessageValues], list(messages))
+
+
+def _text(text: str) -> dict:
+ return {"type": "text", "text": text}
+
+
+def _marked(text: str) -> dict:
+ return {"type": "text", "text": text, "cache_control": {"type": "ephemeral"}}
+
+
+@pytest.mark.asyncio
+async def test_pin_survives_the_breakpoint_moving_to_the_next_turn():
+ """
+ The regression. Claude Code marks only the newest user message each turn, so the last breakpoint
+ moves forward every turn. The key hashed the prefix up to that moving breakpoint, markers
+ included, so no turn after the first ever found the pin the previous turn wrote, and a
+ multi-deployment group re-rolled the deployment mid-session, paying a cache write on a
+ deployment whose provider cache held nothing of the conversation.
+ """
+ cache = DualCache()
+ check = PromptCachingDeploymentCheck(cache=cache)
+ deployments = _deployments(AUTO_CACHING_MODEL, AUTO_CACHING_MODEL)
+ turn_one = _turn({"role": "user", "content": [_marked(LONG_PROMPT)]})
+ turn_two = _turn(
+ {"role": "user", "content": [_text(LONG_PROMPT)]},
+ {"role": "assistant", "content": "ok"},
+ {"role": "user", "content": [_marked("next")]},
+ )
+
+ await PromptCachingCache(cache=cache).async_add_model_id(model_id="dep-2", messages=turn_one, tools=None)
+
+ filtered = await check.async_filter_deployments(
+ model=MODEL_GROUP_ALIAS, healthy_deployments=deployments, messages=turn_two
+ )
+
+ assert filtered == [deployments[1]]
+
+
+@pytest.mark.asyncio
+async def test_pin_survives_the_marked_message_coming_back_as_string_content():
+ """
+ Claude Code sends the message that carries a breakpoint as a one-block content list and re-sends
+ it next turn as plain string content once the marker has moved on. The provider caches both
+ shapes identically, so the key has to as well, or the walk-back never lands on the turn-one write.
+ """
+ cache = DualCache()
+ check = PromptCachingDeploymentCheck(cache=cache)
+ deployments = _deployments(AUTO_CACHING_MODEL, AUTO_CACHING_MODEL)
+ turn_one = _turn(
+ {"role": "system", "content": [_marked(LONG_PROMPT)]},
+ {"role": "user", "content": [_marked("hello")]},
+ )
+ turn_two = _turn(
+ {"role": "system", "content": LONG_PROMPT},
+ {"role": "user", "content": "hello"},
+ {"role": "assistant", "content": "hi"},
+ {"role": "user", "content": [_marked("again")]},
+ )
+
+ await PromptCachingCache(cache=cache).async_add_model_id(model_id="dep-1", messages=turn_one, tools=None)
+
+ filtered = await check.async_filter_deployments(
+ model=MODEL_GROUP_ALIAS, healthy_deployments=deployments, messages=turn_two
+ )
+
+ assert filtered == [deployments[0]]
+
+
+@pytest.mark.asyncio
+async def test_lookback_stops_where_the_provider_cache_stops():
+ """
+ Anthropic finds a cached prefix at most PROMPT_CACHE_LOOKBACK_POSITIONS block positions behind a
+ breakpoint, the breakpoint block included. Probing further would pin to a deployment whose cache
+ the provider will not consult, and probing less would drop pins the provider still honors.
+ """
+ prompt_cache = PromptCachingCache(cache=DualCache())
+ await prompt_cache.async_add_model_id(
+ model_id="dep-1", messages=_turn({"role": "user", "content": [_marked("block 0")]}), tools=None
+ )
+
+ def turn_with_blocks_after(count: int) -> List[AllMessageValues]:
+ later = [_text(f"block {index}") for index in range(1, count)] + [_marked(f"block {count}")]
+ return _turn({"role": "user", "content": [_text("block 0"), *later]})
+
+ inside_window = turn_with_blocks_after(PROMPT_CACHE_LOOKBACK_POSITIONS - 1)
+ past_window = turn_with_blocks_after(PROMPT_CACHE_LOOKBACK_POSITIONS)
+
+ assert await prompt_cache.async_get_model_id(messages=inside_window, tools=None) == {"model_id": "dep-1"}
+ assert prompt_cache.get_model_id(messages=inside_window, tools=None) == {"model_id": "dep-1"}
+ assert await prompt_cache.async_get_model_id(messages=past_window, tools=None) is None
+ assert prompt_cache.get_model_id(messages=past_window, tools=None) is None
+
+
+@pytest.mark.asyncio
+async def test_a_run_of_tool_blocks_counts_as_one_lookback_position():
+ """
+ The provider counts consecutive tool_use blocks as one lookback position, and consecutive
+ tool_result blocks as one, in both the Anthropic and the OpenAI message shapes. An agent turn that
+ fans out into many tool calls would otherwise push the previous breakpoint out of the window
+ after a single turn, which is exactly when the conversation is longest and the cache matters most.
+ """
+ prompt_cache = PromptCachingCache(cache=DualCache())
+ await prompt_cache.async_add_model_id(
+ model_id="dep-1", messages=_turn({"role": "user", "content": [_marked("task")]}), tools=None
+ )
+ fan_out = PROMPT_CACHE_LOOKBACK_POSITIONS + 5
+
+ def anthropic_shaped(tool_use_type: str, tool_result_type: str) -> List[AllMessageValues]:
+ return _turn(
+ {"role": "user", "content": [_text("task")]},
+ {
+ "role": "assistant",
+ "content": [
+ {"type": tool_use_type, "id": f"call-{index}", "name": "read", "input": {"index": index}}
+ for index in range(fan_out)
+ ],
+ },
+ {
+ "role": "user",
+ "content": [
+ *(
+ {"type": tool_result_type, "tool_use_id": f"call-{index}", "content": "ok"}
+ for index in range(fan_out)
+ ),
+ _marked("continue"),
+ ],
+ },
+ )
+
+ openai_shaped = _turn(
+ {"role": "user", "content": [_text("task")]},
+ {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [
+ {"id": f"call-{index}", "type": "function", "function": {"name": "read", "arguments": "{}"}}
+ for index in range(fan_out)
+ ],
+ },
+ *({"role": "tool", "tool_call_id": f"call-{index}", "content": "ok"} for index in range(fan_out)),
+ {"role": "user", "content": [_marked("continue")]},
+ )
+
+ assert await prompt_cache.async_get_model_id(messages=anthropic_shaped("tool_use", "tool_result"), tools=None) == {
+ "model_id": "dep-1"
+ }
+ assert await prompt_cache.async_get_model_id(messages=openai_shaped, tools=None) == {"model_id": "dep-1"}
+ assert await prompt_cache.async_get_model_id(messages=anthropic_shaped("text", "text"), tools=None) is None
+
+
+@pytest.mark.asyncio
+async def test_an_edited_earlier_block_does_not_inherit_the_pin():
+ """Walking back must still bind every block's content, or an edited conversation pins to a stale cache."""
+ prompt_cache = PromptCachingCache(cache=DualCache())
+ await prompt_cache.async_add_model_id(
+ model_id="dep-1", messages=_turn({"role": "user", "content": [_marked("original")]}), tools=None
+ )
+ edited = _turn(
+ {"role": "user", "content": [_text("edited")]},
+ {"role": "assistant", "content": "ok"},
+ {"role": "user", "content": [_marked("next")]},
+ )
+
+ assert await prompt_cache.async_get_model_id(messages=edited, tools=None) is None
+
+
+class _BrokenBatchReadCache(DualCache):
+ async def async_batch_get_cache(self, keys, parent_otel_span=None, local_only=False, **kwargs):
+ return None
+
+
+@pytest.mark.asyncio
+async def test_a_failed_batch_read_pins_nothing():
+ """DualCache answers None rather than a list when the batch read raises, and routing must fall through."""
+ prompt_cache = PromptCachingCache(cache=_BrokenBatchReadCache())
+
+ assert (
+ await prompt_cache.async_get_model_id(messages=_turn({"role": "user", "content": [_marked("x")]}), tools=None)
+ is None
+ )
+
+
+@pytest.mark.asyncio
+async def test_pin_matches_when_the_success_event_truncated_an_image_payload(monkeypatch, local_model_cost_map):
+ """
+ The success event only ever sees the standard logging payload, whose long base64 data URIs are
+ replaced by size placeholders, while routing sees the raw request. Hashing the raw bytes on the
+ read side would key every image-carrying session past its own pin.
+ """
+ capture = _SentMessagesCapture()
+ monkeypatch.setattr(litellm, "callbacks", [capture])
+ image = {"type": "image_url", "image_url": {"url": ONE_PIXEL_PNG}}
+ turn_one = _turn({"role": "user", "content": [image, _marked(LONG_PROMPT)]})
+
+ await litellm.acompletion(
+ model=AUTO_CACHING_MODEL, messages=copy.deepcopy(turn_one), mock_response="ok", api_key="sk-fake"
+ )
+ logged = await _eventually(lambda: capture.messages)
+ assert logged is not None
+ assert logged != turn_one
+
+ cache = DualCache()
+ await PromptCachingCache(cache=cache).async_add_model_id(model_id="dep-2", messages=logged, tools=None)
+ turn_two = _turn(
+ {"role": "user", "content": [image, _text(LONG_PROMPT)]},
+ {"role": "assistant", "content": "ok"},
+ {"role": "user", "content": [_marked("next")]},
+ )
+ deployments = _deployments(AUTO_CACHING_MODEL, AUTO_CACHING_MODEL)
+
+ filtered = await PromptCachingDeploymentCheck(cache=cache).async_filter_deployments(
+ model=MODEL_GROUP_ALIAS, healthy_deployments=deployments, messages=turn_two
+ )
+
+ assert filtered == [deployments[1]]
+
+
+@pytest.mark.asyncio
+async def test_claude_code_style_session_stays_on_one_deployment_across_turns(local_model_cost_map):
+ """
+ End to end over the router with a client that marks only the newest user message each turn, the
+ way Claude Code does. Every turn has to land on the deployment that served the first one.
+ """
+ router = litellm.Router(
+ model_list=[
+ {
+ "model_name": MODEL_GROUP_ALIAS,
+ "litellm_params": {"model": AUTO_CACHING_MODEL, "api_key": "sk-fake"},
+ "model_info": {"id": model_id},
+ }
+ for model_id in ("dep-1", "dep-2", "dep-3")
+ ],
+ optional_pre_call_checks=["prompt_caching"],
+ )
+ user_turns = [LONG_PROMPT, *(f"follow-up {number}" for number in range(1, 6))]
+ history: List[AllMessageValues] = []
+ served: List[str] = []
+ for text in user_turns:
+ request = cast(List[AllMessageValues], [*history, {"role": "user", "content": [_marked(text)]}])
+ response = await router.acompletion(model=MODEL_GROUP_ALIAS, messages=request, mock_response="ok")
+ served.append(response._hidden_params["model_id"])
+ pin_key = PromptCachingCache.get_prompt_caching_cache_key(request, None)
+ assert await _eventually(functools.partial(router.cache.get_cache, key=pin_key)) is not None
+ history = [*history, {"role": "user", "content": [_text(text)]}, {"role": "assistant", "content": "ok"}]
+
+ assert served == [served[0]] * len(user_turns)
From c13dcb0abfe3de7b6722e18d7acf0f59eaa39fc8 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 20:11:14 -0700
Subject: [PATCH 106/317] fix(proxy): forward a client's anthropic-beta and
anthropic-version headers to bedrock_mantle
---
litellm/proxy/litellm_pre_call_utils.py | 7 +++++-
..._bedrock_mantle_messages_transformation.py | 19 +++++++++++++++
.../proxy/test_litellm_pre_call_utils.py | 24 ++++++++++++++++++-
3 files changed, 48 insertions(+), 2 deletions(-)
diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py
index 9a973755894..e415a78f412 100644
--- a/litellm/proxy/litellm_pre_call_utils.py
+++ b/litellm/proxy/litellm_pre_call_utils.py
@@ -3418,7 +3418,12 @@ async def add_guardrails_from_policy_engine(
_ANTHROPIC_API_HEADER_PROVIDERS: Final = ",".join(
- (LlmProviders.ANTHROPIC.value, LlmProviders.BEDROCK.value, LlmProviders.VERTEX_AI.value)
+ (
+ LlmProviders.ANTHROPIC.value,
+ LlmProviders.BEDROCK.value,
+ LlmProviders.BEDROCK_MANTLE.value,
+ LlmProviders.VERTEX_AI.value,
+ )
)
_ANTHROPIC_OAUTH_CREDENTIAL_PROVIDERS: Final = LlmProviders.ANTHROPIC.value
diff --git a/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py
index 3544262996c..6bacf8f3d94 100644
--- a/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py
+++ b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py
@@ -385,6 +385,25 @@ class TestBetaHeadersOnTheWire:
"interleaved-thinking-2025-05-14",
]
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_betas_a_proxy_client_sends_reach_mantle_filtered(self):
+ from litellm.proxy.litellm_pre_call_utils import add_provider_specific_headers_to_request
+
+ proxy_request_data: dict = {}
+ add_provider_specific_headers_to_request(
+ data=proxy_request_data,
+ headers={
+ "anthropic-beta": "claude-code-20250219,fast-mode-2026-02-01,interleaved-thinking-2025-05-14",
+ "anthropic-version": "2023-06-01",
+ "user-agent": "claude-cli/2.1.239",
+ },
+ )
+
+ route = await self._send(**proxy_request_data)
+
+ assert _sent_betas(route) == ["claude-code-20250219", "interleaved-thinking-2025-05-14"]
+
@pytest.mark.asyncio
@respx.mock
async def test_betas_mantle_rejects_are_dropped_before_the_request(self):
diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py
index 88d38d74f49..9257a2dd23d 100644
--- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py
+++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py
@@ -7249,7 +7249,7 @@ CROSS_ACCOUNT_AUTHORIZATION = "Bearer deliberately-configured-pass-through-token
SIGV4_PREFIX = "AWS4-HMAC-SHA256"
AUTHORIZATION_HEADER_CASINGS = ["authorization", "Authorization", "AUTHORIZATION"]
-LEAK_TARGET_PROVIDERS = ["bedrock", "bedrock_converse", "vertex_ai"]
+LEAK_TARGET_PROVIDERS = ["bedrock", "bedrock_converse", "bedrock_mantle", "vertex_ai"]
BEDROCK_ENDPOINT = (
"https://bedrock-runtime.us-west-2.amazonaws.com/model/us.anthropic.claude-sonnet-4-5-20250929-v1:0/invoke"
@@ -7342,6 +7342,28 @@ def test_oauth_credential_entry_is_scoped_to_anthropic_alone():
assert [entry["custom_llm_provider"] for entry in credential_entries] == ["anthropic"]
+@pytest.mark.parametrize("custom_llm_provider", ["anthropic", "bedrock", "bedrock_mantle", "vertex_ai"])
+def test_client_anthropic_api_headers_reach_every_anthropic_messages_provider(custom_llm_provider):
+ client_headers = {
+ "anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
+ "anthropic-version": "2023-06-01",
+ "user-agent": "claude-cli/2.1.239",
+ }
+
+ forwarded = _headers_forwarded_to(client_headers, custom_llm_provider)
+
+ assert forwarded == {
+ "anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
+ "anthropic-version": "2023-06-01",
+ }
+
+
+def test_client_anthropic_api_headers_stay_off_openai_compatible_providers():
+ forwarded = _headers_forwarded_to({"anthropic-beta": "claude-code-20250219"}, "openai")
+
+ assert forwarded == {}
+
+
def test_no_provider_specific_header_when_client_sends_nothing_anthropic():
data: dict = {}
add_provider_specific_headers_to_request(
From 0f0c0fe499fc12856273f6094e622a8f9dc72311 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 20:13:59 -0700
Subject: [PATCH 107/317] fix: drop a blank anthropic-beta header before it
reaches the provider
---
litellm/anthropic_beta_headers_manager.py | 2 +-
.../test_anthropic_beta_headers_filtering.py | 18 ++++++++++++++++++
2 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/litellm/anthropic_beta_headers_manager.py b/litellm/anthropic_beta_headers_manager.py
index abce47c191e..7e7099a53b0 100644
--- a/litellm/anthropic_beta_headers_manager.py
+++ b/litellm/anthropic_beta_headers_manager.py
@@ -334,7 +334,7 @@ def update_headers_with_filtered_beta(
Updated headers dict
"""
existing_beta: Final = headers.get("anthropic-beta")
- if not existing_beta:
+ if existing_beta is None:
return headers
# Parse existing beta headers
diff --git a/tests/test_litellm/test_anthropic_beta_headers_filtering.py b/tests/test_litellm/test_anthropic_beta_headers_filtering.py
index 3c967283abf..d404edb1281 100644
--- a/tests/test_litellm/test_anthropic_beta_headers_filtering.py
+++ b/tests/test_litellm/test_anthropic_beta_headers_filtering.py
@@ -18,6 +18,7 @@ import pytest
import litellm
from litellm.anthropic_beta_headers_manager import (
filter_and_transform_beta_headers,
+ update_headers_with_filtered_beta,
update_request_with_filtered_beta,
)
@@ -511,3 +512,20 @@ class TestAnthropicBetaHeadersFiltering:
assert (
"unknown-header-123" not in filtered
), f"Unknown header should not be in result for {provider}"
+
+ @pytest.mark.parametrize("provider", ["anthropic", "bedrock", "bedrock_mantle", "vertex_ai"])
+ def test_blank_anthropic_beta_header_is_removed(self, provider):
+ headers = {"anthropic-beta": "", "anthropic-version": "2023-06-01"}
+
+ assert update_headers_with_filtered_beta(headers, provider) == {"anthropic-version": "2023-06-01"}
+
+ @pytest.mark.parametrize("provider", ["anthropic", "bedrock", "bedrock_mantle", "vertex_ai"])
+ def test_whitespace_only_anthropic_beta_header_is_removed(self, provider):
+ headers = {"anthropic-beta": " , ", "anthropic-version": "2023-06-01"}
+
+ assert update_headers_with_filtered_beta(headers, provider) == {"anthropic-version": "2023-06-01"}
+
+ def test_absent_anthropic_beta_header_is_left_alone(self):
+ headers = {"anthropic-version": "2023-06-01"}
+
+ assert update_headers_with_filtered_beta(headers, "bedrock_mantle") == {"anthropic-version": "2023-06-01"}
From f24208f9ca8c0c5842e92eba09d6bc9b35b8a66f Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 20:17:55 -0700
Subject: [PATCH 108/317] fix(bedrock_mantle): price region-prefixed Claude
responses from the bare Bedrock row
---
litellm/utils.py | 17 ++++++++++++---
tests/test_litellm/test_cost_calculator.py | 25 ++++++++++++++++++++++
2 files changed, 39 insertions(+), 3 deletions(-)
diff --git a/litellm/utils.py b/litellm/utils.py
index 3439a21b560..f3b9fcfd1ed 100644
--- a/litellm/utils.py
+++ b/litellm/utils.py
@@ -5624,6 +5624,12 @@ def _get_model_info_from_generalization(
return None
+def _strip_mantle_region_prefix(model: str) -> str:
+ from litellm.llms.bedrock_mantle.common_utils import split_mantle_region_prefix
+
+ return split_mantle_region_prefix(model)[1]
+
+
def _get_potential_model_names(model: str, custom_llm_provider: str | None) -> PotentialModelNamesAndCustomLLMProvider:
if custom_llm_provider is None:
# Get custom_llm_provider
@@ -5656,17 +5662,22 @@ def _get_potential_model_names(model: str, custom_llm_provider: str | None) -> P
split_model = strip_bedrock_routing_prefix(split_model)
+ region_free_split_model: Final = (
+ _strip_mantle_region_prefix(split_model) if custom_llm_provider == "bedrock_mantle" else split_model
+ )
provider_model_info: Final = (
- ProviderConfigManager.get_provider_model_info(model=split_model, provider=LlmProviders(custom_llm_provider))
+ ProviderConfigManager.get_provider_model_info(
+ model=region_free_split_model, provider=LlmProviders(custom_llm_provider)
+ )
if custom_llm_provider in LlmProvidersSet
else None
)
provider_cost_key: Final = (
- provider_model_info.get_model_cost_key(split_model) if provider_model_info is not None else None
+ provider_model_info.get_model_cost_key(region_free_split_model) if provider_model_info is not None else None
)
return PotentialModelNamesAndCustomLLMProvider(
- split_model=split_model,
+ split_model=region_free_split_model,
combined_model_name=combined_model_name,
stripped_model_name=stripped_model_name,
combined_stripped_model_name=combined_stripped_model_name,
diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py
index aef17f3d5d0..fe52b9993f2 100644
--- a/tests/test_litellm/test_cost_calculator.py
+++ b/tests/test_litellm/test_cost_calculator.py
@@ -3522,6 +3522,31 @@ def test_cost_per_token_region_name_applies_to_provider_prefixed_model(_local_mo
)
+def test_completion_cost_mantle_native_messages_prices_claude_from_the_bedrock_row(_local_model_cost_map):
+ """Mantle's native Messages API answers with Anthropic's canonical model name and the proxy
+ resolves a Mantle region for every call, so the first cost candidate is
+ bedrock_mantle//claude-sonnet-5. That name has no row of its own and must fall through to
+ the deployment's bare Bedrock row instead of stopping on an unpriced capability rule at $0."""
+
+ response = litellm.ModelResponse(
+ id="msg_x",
+ choices=[{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}],
+ model="claude-sonnet-5",
+ usage={"prompt_tokens": 100, "completion_tokens": 10, "total_tokens": 110},
+ )
+ row = litellm.model_cost["anthropic.claude-sonnet-5"]
+ expected = 100 * row["input_cost_per_token"] + 10 * row["output_cost_per_token"]
+ assert expected > 0
+
+ for region_name in ("us-east-1", None):
+ assert litellm.completion_cost(
+ completion_response=response,
+ model="bedrock_mantle/anthropic.claude-sonnet-5",
+ custom_llm_provider="bedrock_mantle",
+ region_name=region_name,
+ ) == pytest.approx(expected)
+
+
def test_select_model_name_keeps_base_model_free_of_region(_local_model_cost_map):
"""An explicit base_model keeps pricing on that model's own key even when the request carries a
region with different regional rates, so the private provider model never widens region pricing."""
From 3ffe6272c96c08f54f972ef43a2541d73222f2ba Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 20:22:28 -0700
Subject: [PATCH 109/317] fix(router): hash the prompt caching affinity prefix
off the event loop
Offload the per-block hashing through offload_token_count on both the pre-call
read and the success-event write, hash raw bytes as base64 instead of raising,
drop the unused serialize_object helper, and bind the chained digest, the
message envelope, and the bytes path in the regression tests
---
litellm/constants.py | 2 -
litellm/router_utils/prompt_caching_cache.py | 38 +++------------
.../test_router_prompt_caching.py | 48 -------------------
.../test_prompt_caching_deployment_check.py | 40 ++++++++++++++--
4 files changed, 43 insertions(+), 85 deletions(-)
diff --git a/litellm/constants.py b/litellm/constants.py
index e4576ad4d5c..215f25bccd1 100644
--- a/litellm/constants.py
+++ b/litellm/constants.py
@@ -399,8 +399,6 @@ MINIMUM_PROMPT_CACHE_TOKEN_COUNT: Final = (
if MINIMUM_PROMPT_CACHE_TOKEN_COUNT_OVERRIDE is not None
else DEFAULT_MINIMUM_PROMPT_CACHE_TOKEN_COUNT
)
-# Anthropic checks at most 20 block positions behind a breakpoint for a cached prefix, a run of tool_use
-# or tool_result blocks counting as one position, so deployment affinity probes the same window
PROMPT_CACHE_LOOKBACK_POSITIONS: Final = 20
DEFAULT_TRIM_RATIO: Final = float(
os.getenv("DEFAULT_TRIM_RATIO", 0.75)
diff --git a/litellm/router_utils/prompt_caching_cache.py b/litellm/router_utils/prompt_caching_cache.py
index 0b784e1fa91..78fc5e3fe6d 100644
--- a/litellm/router_utils/prompt_caching_cache.py
+++ b/litellm/router_utils/prompt_caching_cache.py
@@ -15,10 +15,8 @@ from typing_extensions import TypedDict
from litellm.caching.caching import DualCache
from litellm.constants import PROMPT_CACHE_LOOKBACK_POSITIONS
-from litellm.litellm_core_utils.logging_utils import (
- truncate_base64_in_messages,
- truncate_base64_in_messages_async,
-)
+from litellm.litellm_core_utils.logging_utils import truncate_base64_in_messages
+from litellm.litellm_core_utils.token_counter import offload_token_count
from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam
if TYPE_CHECKING:
@@ -88,7 +86,9 @@ def _seed(tools: Sequence[ChatCompletionToolParam] | None) -> bytes:
if tools is None:
return hashlib.sha256(b"").digest()
return hashlib.sha256(
- _canonical_bytes(_TOOLS_ADAPTER.validate_python(to_jsonable_python(tools, serialize_unknown=True)))
+ _canonical_bytes(
+ _TOOLS_ADAPTER.validate_python(to_jsonable_python(tools, serialize_unknown=True, bytes_mode="base64"))
+ )
).digest()
@@ -132,23 +132,6 @@ class PromptCachingCache:
def __init__(self, cache: DualCache):
self.cache = cache
- @staticmethod
- def serialize_object(obj: Any) -> object:
- """Helper function to serialize Pydantic objects, dictionaries, or fallback to string."""
- if hasattr(obj, "dict"):
- # If the object is a Pydantic model, use its `dict()` method
- return obj.dict()
- elif isinstance(obj, dict):
- # If the object is a dictionary, serialize it with sorted keys
- return json.dumps(obj, sort_keys=True, separators=(",", ":")) # Standardize serialization
-
- elif isinstance(obj, list):
- # Serialize lists by ensuring each element is handled properly
- return [PromptCachingCache.serialize_object(item) for item in obj]
- elif isinstance(obj, (int, float, bool)):
- return obj # Keep primitive types as-is
- return str(obj)
-
@staticmethod
def extract_cacheable_prefix(
messages: list[AllMessageValues],
@@ -263,6 +246,7 @@ class PromptCachingCache:
to_jsonable_python(
truncate_base64_in_messages(PromptCachingCache.extract_cacheable_prefix(messages)),
serialize_unknown=True,
+ bytes_mode="base64",
)
),
tools,
@@ -275,15 +259,7 @@ class PromptCachingCache:
) -> tuple[PrefixPosition, ...]:
if not messages:
return ()
- return _positions_of(
- _PREFIX_ADAPTER.validate_python(
- to_jsonable_python(
- await truncate_base64_in_messages_async(PromptCachingCache.extract_cacheable_prefix(messages)),
- serialize_unknown=True,
- )
- ),
- tools,
- )
+ return await offload_token_count(PromptCachingCache.prefix_positions)(messages, tools)
@staticmethod
def get_prompt_caching_cache_key(
diff --git a/tests/router_unit_tests/test_router_prompt_caching.py b/tests/router_unit_tests/test_router_prompt_caching.py
index 5c36c30e818..879264ca502 100644
--- a/tests/router_unit_tests/test_router_prompt_caching.py
+++ b/tests/router_unit_tests/test_router_prompt_caching.py
@@ -11,57 +11,9 @@ from unittest.mock import patch, MagicMock, AsyncMock
from create_mock_standard_logging_payload import create_standard_logging_payload
from litellm.types.utils import StandardLoggingPayload
import unittest
-from pydantic import BaseModel
from litellm.router_utils.prompt_caching_cache import PromptCachingCache
-class ExampleModel(BaseModel):
- field1: str
- field2: int
-
-
-def test_serialize_pydantic_object():
- model = ExampleModel(field1="value", field2=42)
- serialized = PromptCachingCache.serialize_object(model)
- assert serialized == {"field1": "value", "field2": 42}
-
-
-def test_serialize_dict():
- obj = {"b": 2, "a": 1}
- serialized = PromptCachingCache.serialize_object(obj)
- assert serialized == '{"a":1,"b":2}' # JSON string with sorted keys
-
-
-def test_serialize_nested_dict():
- obj = {"z": {"b": 2, "a": 1}, "x": [1, 2, {"c": 3}]}
- serialized = PromptCachingCache.serialize_object(obj)
- expected = '{"x":[1,2,{"c":3}],"z":{"a":1,"b":2}}' # JSON string with sorted keys
- assert serialized == expected
-
-
-def test_serialize_list():
- obj = ["item1", {"a": 1, "b": 2}, 42]
- serialized = PromptCachingCache.serialize_object(obj)
- expected = ["item1", '{"a":1,"b":2}', 42]
- assert serialized == expected
-
-
-def test_serialize_fallback():
- obj = 12345 # Simple non-serializable object
- serialized = PromptCachingCache.serialize_object(obj)
- assert serialized == 12345
-
-
-def test_serialize_non_serializable():
- class CustomClass:
- def __str__(self):
- return "custom_object"
-
- obj = CustomClass()
- serialized = PromptCachingCache.serialize_object(obj)
- assert serialized == "custom_object" # Fallback to string conversion
-
-
@pytest.mark.asyncio
async def test_router_prompt_caching_same_cacheable_prefix_routes_to_same_deployment():
"""
diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py
index d0a9223dfa7..ad92f442a6e 100644
--- a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py
+++ b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py
@@ -708,7 +708,10 @@ async def test_a_run_of_tool_blocks_counts_as_one_lookback_position():
@pytest.mark.asyncio
async def test_an_edited_earlier_block_does_not_inherit_the_pin():
- """Walking back must still bind every block's content, or an edited conversation pins to a stale cache."""
+ """
+ Every key must bind the whole prefix before its block, not the block alone, or a conversation
+ that repeats a pinned block after an edit walks back onto a cache the provider no longer holds.
+ """
prompt_cache = PromptCachingCache(cache=DualCache())
await prompt_cache.async_add_model_id(
model_id="dep-1", messages=_turn({"role": "user", "content": [_marked("original")]}), tools=None
@@ -716,12 +719,41 @@ async def test_an_edited_earlier_block_does_not_inherit_the_pin():
edited = _turn(
{"role": "user", "content": [_text("edited")]},
{"role": "assistant", "content": "ok"},
- {"role": "user", "content": [_marked("next")]},
+ {"role": "user", "content": [_marked("original")]},
)
assert await prompt_cache.async_get_model_id(messages=edited, tools=None) is None
+@pytest.mark.asyncio
+async def test_swapped_roles_do_not_inherit_the_pin():
+ """The message envelope is part of what the provider caches, so the same blocks under other roles key apart."""
+ prompt_cache = PromptCachingCache(cache=DualCache())
+ pinned = _turn(
+ {"role": "user", "content": [_text("question")]},
+ {"role": "assistant", "content": [_marked("answer")]},
+ )
+ swapped = _turn(
+ {"role": "assistant", "content": [_text("question")]},
+ {"role": "user", "content": [_marked("answer")]},
+ )
+ await prompt_cache.async_add_model_id(model_id="dep-1", messages=pinned, tools=None)
+
+ assert await prompt_cache.async_get_model_id(messages=pinned, tools=None) == {"model_id": "dep-1"}
+ assert await prompt_cache.async_get_model_id(messages=swapped, tools=None) is None
+
+
+@pytest.mark.asyncio
+async def test_raw_bytes_in_a_block_hash_instead_of_failing_the_request():
+ """A block carrying raw bytes must key like any other block rather than raising out of the router filter."""
+ prompt_cache = PromptCachingCache(cache=DualCache())
+ binary_block = {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": b"\xff\xfe"}}
+ turn = _turn({"role": "user", "content": [binary_block, _marked("describe")]})
+ await prompt_cache.async_add_model_id(model_id="dep-1", messages=turn, tools=None)
+
+ assert await prompt_cache.async_get_model_id(messages=turn, tools=None) == {"model_id": "dep-1"}
+
+
class _BrokenBatchReadCache(DualCache):
async def async_batch_get_cache(self, keys, parent_otel_span=None, local_only=False, **kwargs):
return None
@@ -786,11 +818,11 @@ async def test_claude_code_style_session_stays_on_one_deployment_across_turns(lo
"litellm_params": {"model": AUTO_CACHING_MODEL, "api_key": "sk-fake"},
"model_info": {"id": model_id},
}
- for model_id in ("dep-1", "dep-2", "dep-3")
+ for model_id in (f"dep-{number}" for number in range(1, 7))
],
optional_pre_call_checks=["prompt_caching"],
)
- user_turns = [LONG_PROMPT, *(f"follow-up {number}" for number in range(1, 6))]
+ user_turns = [LONG_PROMPT, *(f"follow-up {number}" for number in range(1, 9))]
history: List[AllMessageValues] = []
served: List[str] = []
for text in user_turns:
From 0c68c58eb1d63f0d857bba7ebdd8c4c5dbea992a Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 20:39:53 -0700
Subject: [PATCH 110/317] test(proxy): expect bedrock_mantle in the anthropic
header provider list
---
tests/proxy_unit_tests/test_proxy_utils.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/proxy_unit_tests/test_proxy_utils.py b/tests/proxy_unit_tests/test_proxy_utils.py
index 160753e3442..c62aab11930 100644
--- a/tests/proxy_unit_tests/test_proxy_utils.py
+++ b/tests/proxy_unit_tests/test_proxy_utils.py
@@ -2004,7 +2004,7 @@ def test_provider_specific_header():
)
# Verify multi-provider support: anthropic headers work across multiple providers
assert data["provider_specific_header"] == {
- "custom_llm_provider": "anthropic,bedrock,vertex_ai",
+ "custom_llm_provider": "anthropic,bedrock,bedrock_mantle,vertex_ai",
"extra_headers": {
"anthropic-beta": "prompt-caching-2024-07-31",
},
@@ -2076,7 +2076,7 @@ def test_provider_specific_header_multi_provider():
assert "provider_specific_header" in data
assert (
data["provider_specific_header"]["custom_llm_provider"]
- == "anthropic,bedrock,vertex_ai"
+ == "anthropic,bedrock,bedrock_mantle,vertex_ai"
)
assert data["provider_specific_header"]["extra_headers"] == {
"anthropic-beta": "context-1m-2025-08-07",
From 365dc9a3b5fe7b622555ded21b76026cc49d0c50 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sun, 20 Sep 2026 05:14:15 +0000
Subject: [PATCH 111/317] feat(fal_ai): add gpt-image-2.5 flare/sunburst,
flux/dev and image edits
Route openai/gpt-image-2.5/{flare,sunburst}/text-to-image through the existing GPT Image config with the xhigh and max quality tiers, add a dedicated fal-ai/flux/dev config, and add a Fal image-edit config so /v1/images/edits works for the gpt-image-2.5 and gpt-image-2 edit endpoints. Add flat and quality-by-size keyed pricing rows so spend is non-zero
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/llms/fal_ai/cost_calculator.py | 8 +-
litellm/llms/fal_ai/image_edit/__init__.py | 3 +
.../llms/fal_ai/image_edit/transformation.py | 165 ++
.../llms/fal_ai/image_generation/__init__.py | 4 +
.../flux_dev_transformation.py | 12 +
.../gpt_image_2_transformation.py | 53 +-
.../fal_ai/image_generation/transformation.py | 33 +-
...odel_prices_and_context_window_backup.json | 1326 +++++++++++++++++
litellm/utils.py | 4 +
model_prices_and_context_window.json | 1326 +++++++++++++++++
.../test_fal_ai_image_edit_transformation.py | 106 ++
.../test_fal_ai_flux_dev_transformation.py | 61 +
.../test_fal_ai_gpt_image_2_transformation.py | 33 +
.../llms/fal_ai/test_cost_calculator.py | 71 +
14 files changed, 3159 insertions(+), 46 deletions(-)
create mode 100644 litellm/llms/fal_ai/image_edit/__init__.py
create mode 100644 litellm/llms/fal_ai/image_edit/transformation.py
create mode 100644 litellm/llms/fal_ai/image_generation/flux_dev_transformation.py
create mode 100644 tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py
create mode 100644 tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_flux_dev_transformation.py
diff --git a/litellm/llms/fal_ai/cost_calculator.py b/litellm/llms/fal_ai/cost_calculator.py
index 74848784c5b..f23bd1b46bc 100644
--- a/litellm/llms/fal_ai/cost_calculator.py
+++ b/litellm/llms/fal_ai/cost_calculator.py
@@ -19,10 +19,10 @@ FAL_NAMED_IMAGE_SIZES: Final[Mapping[str, str]] = MappingProxyType(
)
-def _keyed_size(model: str, optional_params: Mapping[str, object]) -> str | None:
+def _keyed_size(optional_params: Mapping[str, object]) -> str | None:
image_size: Final = optional_params.get("image_size")
- if image_size is None:
- return None if model.endswith("/edit") else FAL_TEXT_TO_IMAGE_DEFAULT_SIZE
+ if image_size is None or image_size == "auto":
+ return FAL_TEXT_TO_IMAGE_DEFAULT_SIZE
if isinstance(image_size, Mapping):
width: Final = image_size.get("width")
height: Final = image_size.get("height")
@@ -37,7 +37,7 @@ def _keyed_size(model: str, optional_params: Mapping[str, object]) -> str | None
def _keyed_cost_per_image(model: str, optional_params: Mapping[str, object] | None) -> float | None:
if optional_params is None:
return None
- size: Final = _keyed_size(model=model, optional_params=optional_params)
+ size: Final = _keyed_size(optional_params)
if size is None:
return None
raw_quality: Final = optional_params.get("quality")
diff --git a/litellm/llms/fal_ai/image_edit/__init__.py b/litellm/llms/fal_ai/image_edit/__init__.py
new file mode 100644
index 00000000000..c2f0f311f8c
--- /dev/null
+++ b/litellm/llms/fal_ai/image_edit/__init__.py
@@ -0,0 +1,3 @@
+from .transformation import FalAIImageEditConfig
+
+__all__ = ("FalAIImageEditConfig",)
diff --git a/litellm/llms/fal_ai/image_edit/transformation.py b/litellm/llms/fal_ai/image_edit/transformation.py
new file mode 100644
index 00000000000..f0bb2820d7e
--- /dev/null
+++ b/litellm/llms/fal_ai/image_edit/transformation.py
@@ -0,0 +1,165 @@
+import base64
+from collections.abc import Mapping
+from io import BufferedReader, BytesIO
+from types import MappingProxyType
+from typing import TYPE_CHECKING, Final
+
+import httpx
+from httpx._types import RequestFiles
+
+from litellm.images.utils import ImageEditRequestUtils
+from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
+from litellm.llms.fal_ai.image_generation.gpt_image_2_transformation import (
+ map_gpt_image_quality,
+ map_gpt_image_size,
+)
+from litellm.llms.fal_ai.image_generation.transformation import fal_images_to_image_objects
+from litellm.secret_managers.main import get_secret_str
+from litellm.types.images.main import ImageEditOptionalRequestParams
+from litellm.types.router import GenericLiteLLMParams
+from litellm.types.utils import FileTypes, ImageResponse
+
+if TYPE_CHECKING:
+ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
+
+DEFAULT_BASE_URL: Final[str] = "https://fal.run"
+EDIT_SUFFIX: Final[str] = "/edit"
+SUPPORTED_OPENAI_PARAMS: Final[tuple[str, ...]] = ("background", "mask", "n", "quality", "size")
+PARAM_TRANSLATION: Final[Mapping[str, str]] = MappingProxyType(
+ {
+ "background": "background",
+ "n": "num_images",
+ "quality": "quality",
+ "size": "image_size",
+ }
+)
+
+
+def _read_image_bytes(image: object) -> bytes:
+ if isinstance(image, bytes):
+ return image
+ if isinstance(image, (BytesIO, BufferedReader)):
+ position: Final = image.tell()
+ image.seek(0)
+ data: Final = image.read()
+ image.seek(position)
+ return data
+ raise ValueError(f"Unsupported image type for Fal AI image edit: {type(image).__name__}")
+
+
+def _to_data_url(image: object) -> str:
+ if isinstance(image, str):
+ return image
+ image_bytes: Final = _read_image_bytes(image)
+ mime_type: Final = ImageEditRequestUtils.get_image_content_type(image_bytes)
+ return f"data:{mime_type};base64,{base64.b64encode(image_bytes).decode('utf-8')}"
+
+
+def _first(value: object) -> object:
+ return value[0] if isinstance(value, list) and value else value
+
+
+class FalAIImageEditConfig(BaseImageEditConfig):
+ """
+ Image edits served through Fal AI's ``/edit`` endpoints, e.g. openai/gpt-image-2.5/flare/edit.
+
+ Fal expects a JSON body with ``image_urls`` (and an optional ``mask_url``) rather than multipart
+ uploads, so local files are sent inline as base64 data URLs.
+ """
+
+ def get_supported_openai_params(self, model: str) -> list: # mutable-ok: base class contract returns a list
+ return list(SUPPORTED_OPENAI_PARAMS) # mutable-ok: base class contract returns a list
+
+ def map_openai_params( # mutable-ok: base class contract returns a dict
+ self,
+ image_edit_optional_params: ImageEditOptionalRequestParams,
+ model: str,
+ drop_params: bool,
+ ) -> dict:
+ return { # mutable-ok: base class contract returns a dict
+ PARAM_TRANSLATION.get(key, key): self._translate_value(key, value, model)
+ for key, value in image_edit_optional_params.items()
+ if value is not None
+ }
+
+ def _translate_value(self, key: str, value: object, model: str) -> object:
+ if key == "size":
+ return map_gpt_image_size(value)
+ if key == "quality":
+ return map_gpt_image_quality(value, model)
+ return value
+
+ def validate_environment(
+ self,
+ headers: dict,
+ model: str,
+ api_key: str | None = None,
+ litellm_params: dict | None = None,
+ api_base: str | None = None,
+ ) -> dict:
+ final_api_key: Final = api_key or get_secret_str("FAL_AI_API_KEY")
+ if not final_api_key:
+ raise ValueError("FAL_AI_API_KEY is not set")
+ return {**headers, "Authorization": f"Key {final_api_key}"} # mutable-ok: base class contract returns a dict
+
+ def use_multipart_form_data(self) -> bool:
+ return False
+
+ def get_complete_url(
+ self,
+ model: str,
+ api_base: str | None,
+ litellm_params: dict,
+ ) -> str:
+ base_url: Final = (api_base or get_secret_str("FAL_AI_API_BASE") or DEFAULT_BASE_URL).rstrip("/")
+ endpoint: Final = model if model.endswith(EDIT_SUFFIX) else f"{model}{EDIT_SUFFIX}"
+ return f"{base_url}/{endpoint}"
+
+ def transform_image_edit_request(
+ self,
+ model: str,
+ prompt: str | None,
+ image: FileTypes | None,
+ image_edit_optional_request_params: dict,
+ litellm_params: GenericLiteLLMParams,
+ headers: dict,
+ ) -> tuple[dict, RequestFiles]:
+ if image is None:
+ raise ValueError("Fal AI image edit requires at least one input image")
+ images: Final = tuple(image) if isinstance(image, list) else (image,)
+ mask: Final = _first(image_edit_optional_request_params.get("mask"))
+ mask_field: Final[Mapping[str, str]] = (
+ MappingProxyType({"mask_url": _to_data_url(mask)}) if mask is not None else MappingProxyType({})
+ )
+ provider_params: Final[Mapping[str, object]] = MappingProxyType(
+ {
+ key: value for key, value in image_edit_optional_request_params.items() if key != "mask"
+ } # mutable-ok: frozen by MappingProxyType
+ )
+ request_body: Final[dict[str, object]] = { # mutable-ok: base class contract returns a dict
+ "prompt": prompt,
+ "image_urls": tuple(_to_data_url(img) for img in images if img is not None),
+ **mask_field,
+ **provider_params,
+ }
+ return request_body, ()
+
+ def transform_image_edit_response(
+ self,
+ model: str,
+ raw_response: httpx.Response,
+ logging_obj: "LiteLLMLoggingObj",
+ ) -> ImageResponse:
+ try:
+ response_json: Final = raw_response.json()
+ except Exception as e:
+ raise self.get_error_class(
+ error_message=f"Error parsing Fal AI image edit response: {e}",
+ status_code=raw_response.status_code,
+ headers=raw_response.headers,
+ )
+ model_response: Final = ImageResponse()
+ model_response.data = list( # mutable-ok: ImageResponse.data is typed as a list
+ fal_images_to_image_objects(response_json.get("images", ()))
+ )
+ return model_response
diff --git a/litellm/llms/fal_ai/image_generation/__init__.py b/litellm/llms/fal_ai/image_generation/__init__.py
index 2b305c8f234..cdd491cd300 100644
--- a/litellm/llms/fal_ai/image_generation/__init__.py
+++ b/litellm/llms/fal_ai/image_generation/__init__.py
@@ -9,6 +9,7 @@ from .bytedance_transformation import (
FalAIBytedanceDreaminaV31Config,
FalAIBytedanceSeedreamV3Config,
)
+from .flux_dev_transformation import FalAIFluxDevConfig
from .flux_pro_v11_transformation import FalAIFluxProV11Config
from .flux_pro_v11_ultra_transformation import FalAIFluxProV11UltraConfig
from .flux_schnell_transformation import FalAIFluxSchnellConfig
@@ -25,6 +26,7 @@ __all__ = [
"FalAIBriaConfig",
"FalAIBytedanceDreaminaV31Config",
"FalAIBytedanceSeedreamV3Config",
+ "FalAIFluxDevConfig",
"FalAIFluxProV11Config",
"FalAIFluxProV11UltraConfig",
"FalAIFluxSchnellConfig",
@@ -65,6 +67,8 @@ def get_fal_ai_image_generation_config(model: str) -> BaseImageGenerationConfig:
if "ultra" in model_lower:
return FalAIFluxProV11UltraConfig()
return FalAIFluxProV11Config()
+ elif "flux/dev" in model_lower or "flux-dev" in model_lower:
+ return FalAIFluxDevConfig()
elif "flux/schnell" in model_lower or "flux-schnell" in model_lower or "schnell" in model_lower:
return FalAIFluxSchnellConfig()
elif "bytedance/seedream" in model_lower:
diff --git a/litellm/llms/fal_ai/image_generation/flux_dev_transformation.py b/litellm/llms/fal_ai/image_generation/flux_dev_transformation.py
new file mode 100644
index 00000000000..f9976d519e4
--- /dev/null
+++ b/litellm/llms/fal_ai/image_generation/flux_dev_transformation.py
@@ -0,0 +1,12 @@
+from .flux_schnell_transformation import FalAIFluxSchnellConfig
+
+
+class FalAIFluxDevConfig(FalAIFluxSchnellConfig):
+ """
+ Configuration for Fal AI Flux Dev model.
+
+ Model endpoint: fal-ai/flux/dev
+ Documentation: https://fal.ai/models/fal-ai/flux/dev
+ """
+
+ IMAGE_GENERATION_ENDPOINT: str = "fal-ai/flux/dev"
diff --git a/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py b/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py
index b91ae8ce2b0..ce016b350d8 100644
--- a/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py
+++ b/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py
@@ -22,6 +22,32 @@ SUPPORTED_OPENAI_PARAMS: Final[tuple[OpenAIImageGenerationOptionalParams, ...]]
"response_format",
"size",
)
+SUPPORTED_QUALITIES: Final[frozenset[str]] = frozenset({"auto", "low", "medium", "high"})
+GPT_IMAGE_25_QUALITIES: Final[frozenset[str]] = SUPPORTED_QUALITIES | frozenset(("xhigh", "max"))
+GPT_IMAGE_25_MARKER: Final[str] = "gpt-image-2.5"
+OPENAI_QUALITY_ALIASES: Final[Mapping[str, str]] = MappingProxyType({"hd": "high", "standard": "medium"})
+
+
+def map_gpt_image_size(size: object) -> object:
+ if not isinstance(size, str) or size == "auto":
+ return size
+ try:
+ width, height = (int(part) for part in size.lower().split("x"))
+ except ValueError:
+ return size
+ image_size: Final[FalAIImageSize] = {"width": width, "height": height}
+ return image_size
+
+
+def supported_gpt_image_qualities(model: str) -> frozenset[str]:
+ return GPT_IMAGE_25_QUALITIES if GPT_IMAGE_25_MARKER in model.lower() else SUPPORTED_QUALITIES
+
+
+def map_gpt_image_quality(quality: object, model: str) -> object:
+ if not isinstance(quality, str):
+ return quality
+ normalized: Final[str] = OPENAI_QUALITY_ALIASES.get(quality, quality)
+ return normalized if normalized in supported_gpt_image_qualities(model) else "auto"
class FalAIGPTImage2Config(FalAIBaseConfig):
@@ -31,13 +57,12 @@ class FalAIGPTImage2Config(FalAIBaseConfig):
Model endpoints:
- openai/gpt-image-2 (text-to-image)
- openai/gpt-image-2/edit (editing, with optional mask)
+ - openai/gpt-image-2.5/flare/text-to-image, openai/gpt-image-2.5/sunburst/text-to-image
Documentation: https://fal.ai/models/openai/gpt-image-2/api
"""
MODEL_PREFIX: Final[str] = "openai/"
- SUPPORTED_QUALITIES: Final[frozenset[str]] = frozenset({"auto", "low", "medium", "high"})
- OPENAI_QUALITY_ALIASES: Final[Mapping[str, str]] = MappingProxyType({"hd": "high", "standard": "medium"})
PARAM_TRANSLATION: Final[Mapping[str, str]] = MappingProxyType(
{
"n": "num_images",
@@ -83,36 +108,20 @@ class FalAIGPTImage2Config(FalAIBaseConfig):
)
translated_params: Final[Mapping[str, object]] = MappingProxyType(
{
- self.PARAM_TRANSLATION[key]: self._translate_value(key, value)
+ self.PARAM_TRANSLATION[key]: self._translate_value(key, value, model)
for key, value in non_default_params.items()
if key in self.PARAM_TRANSLATION and self.PARAM_TRANSLATION[key] not in optional_params
}
)
return {**optional_params, **translated_params} # mutable-ok: base class contract returns a dict
- def _translate_value(self, key: str, value: object) -> object:
+ def _translate_value(self, key: str, value: object, model: str) -> object:
if key == "size":
- return self._map_image_size(value)
+ return map_gpt_image_size(value)
if key == "quality":
- return self._map_quality(value)
+ return map_gpt_image_quality(value, model)
return value
- def _map_image_size(self, size: object) -> object:
- if not isinstance(size, str) or size == "auto":
- return size
- try:
- width, height = (int(part) for part in size.lower().split("x"))
- except ValueError:
- return size
- image_size: Final[FalAIImageSize] = {"width": width, "height": height}
- return image_size
-
- def _map_quality(self, quality: object) -> object:
- if not isinstance(quality, str):
- return quality
- normalized: Final[str] = self.OPENAI_QUALITY_ALIASES.get(quality, quality)
- return normalized if normalized in self.SUPPORTED_QUALITIES else "auto"
-
def transform_image_generation_request( # mutable-ok: base class contract returns a dict
self,
model: str,
diff --git a/litellm/llms/fal_ai/image_generation/transformation.py b/litellm/llms/fal_ai/image_generation/transformation.py
index 7a114677b2d..7f6a417e8a1 100644
--- a/litellm/llms/fal_ai/image_generation/transformation.py
+++ b/litellm/llms/fal_ai/image_generation/transformation.py
@@ -22,6 +22,18 @@ else:
LiteLLMLoggingObj = Any
+def fal_images_to_image_objects(images: object) -> tuple[ImageObject, ...]:
+ if not isinstance(images, list):
+ return ()
+ return tuple(
+ ImageObject(url=image_data.get("url", None), b64_json=image_data.get("b64_json", None))
+ if isinstance(image_data, dict)
+ else ImageObject(url=image_data, b64_json=None)
+ for image_data in images
+ if isinstance(image_data, (dict, str))
+ )
+
+
class FalAIBaseConfig(BaseImageGenerationConfig):
"""
Base configuration for Fal AI image generation models.
@@ -96,26 +108,7 @@ class FalAIBaseConfig(BaseImageGenerationConfig):
if not model_response.data:
model_response.data = []
- # Handle fal.ai response format
- images: Final = response_data.get("images", [])
- if isinstance(images, list):
- for image_data in images:
- if isinstance(image_data, dict):
- model_response.data.append(
- ImageObject(
- url=image_data.get("url", None),
- b64_json=image_data.get("b64_json", None),
- )
- )
- elif isinstance(image_data, str):
- # If images is just a list of URLs
- model_response.data.append(
- ImageObject(
- url=image_data,
- b64_json=None,
- )
- )
-
+ model_response.data.extend(fal_images_to_image_objects(response_data.get("images", ())))
return model_response
diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json
index 6f8db4d2215..76839e3cec3 100644
--- a/litellm/model_prices_and_context_window_backup.json
+++ b/litellm/model_prices_and_context_window_backup.json
@@ -23444,6 +23444,1332 @@
],
"supports_vision": true
},
+ "fal_ai/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "metadata": {
+ "notes": "OpenAI gpt-image-2.5 (flare) served through fal.ai. fal publishes deterministic per-image prices per size and quality, mirrored as keyed entries fal_ai/{quality}/{width}-x-{height}/openai/gpt-image-2.5/flare/text-to-image that the fal_ai cost calculator picks from the request params. This flat entry is the fallback for the default request (quality=high, image_size=landscape_4_3 at 1024x768). quality=auto is priced as high"
+ },
+ "mode": "image_generation",
+ "output_cost_per_image": 0.03612,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-768/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00402,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-1024/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00588,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-1536/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00474,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1920-x-1080/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00441,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/2560-x-1440/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00615,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/3840-x-2160/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01113,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-768/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00903,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-1024/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01317,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-1536/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01029,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1920-x-1080/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01029,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/2560-x-1440/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01434,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/3840-x-2160/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.02595,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-768/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.03612,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-1024/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05268,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-1536/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.04116,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1920-x-1080/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.0396,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/2560-x-1440/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05529,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/3840-x-2160/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.10008,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-768/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.0642,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-1024/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.09366,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-1536/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.07377,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1920-x-1080/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.07041,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/2560-x-1440/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.09828,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/3840-x-2160/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1779,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-768/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.14445,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-1024/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.21072,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-1536/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.16464,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1920-x-1080/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1584,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/2560-x-1440/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.2211,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/3840-x-2160/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.40026,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "metadata": {
+ "notes": "Editing endpoint of gpt-image-2.5 (flare) on fal.ai, reachable through /v1/images/edits or the image generation path with fal's image_urls param. Prices include one input image and live in keyed entries fal_ai/{quality}/{width}-x-{height}/openai/gpt-image-2.5/flare/edit. This flat entry is the fallback for the default edit request (quality=high, image_size=auto, inferred from the input image, priced as 1024x768 high)"
+ },
+ "mode": "image_generation",
+ "output_cost_per_image": 0.03612,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-768/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00402,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-1024/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00588,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-1536/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00474,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1920-x-1080/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00441,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/2560-x-1440/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00615,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/3840-x-2160/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01113,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-768/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00903,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-1024/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01317,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-1536/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01029,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1920-x-1080/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01029,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/2560-x-1440/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01434,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/3840-x-2160/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.02595,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-768/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.03612,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-1024/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05268,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-1536/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.04116,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1920-x-1080/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.0396,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/2560-x-1440/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05529,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/3840-x-2160/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.10008,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-768/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.0642,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-1024/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.09366,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-1536/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.07377,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1920-x-1080/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.07041,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/2560-x-1440/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.09828,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/3840-x-2160/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1779,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-768/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.14445,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-1024/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.21072,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-1536/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.16464,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1920-x-1080/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1584,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/2560-x-1440/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.2211,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/3840-x-2160/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.40026,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "metadata": {
+ "notes": "OpenAI gpt-image-2.5 (sunburst) served through fal.ai. fal publishes deterministic per-image prices per size and quality, mirrored as keyed entries fal_ai/{quality}/{width}-x-{height}/openai/gpt-image-2.5/sunburst/text-to-image that the fal_ai cost calculator picks from the request params. This flat entry is the fallback for the default request (quality=high, image_size=landscape_4_3 at 1024x768). quality=auto is priced as high"
+ },
+ "mode": "image_generation",
+ "output_cost_per_image": 0.03612,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-768/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00402,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-1024/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00588,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-1536/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00474,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1920-x-1080/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00441,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/2560-x-1440/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00615,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/3840-x-2160/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01113,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-768/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00903,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-1024/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01317,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-1536/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01029,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1920-x-1080/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01029,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/2560-x-1440/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01434,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/3840-x-2160/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.02595,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-768/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.03612,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-1024/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05268,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-1536/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.04116,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1920-x-1080/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.0396,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/2560-x-1440/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05529,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/3840-x-2160/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.10008,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-768/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.0642,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-1024/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.09366,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-1536/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.07377,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1920-x-1080/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.07041,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/2560-x-1440/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.09828,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/3840-x-2160/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1779,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-768/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.14445,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-1024/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.21072,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-1536/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.16464,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1920-x-1080/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1584,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/2560-x-1440/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.2211,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/3840-x-2160/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.40026,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "metadata": {
+ "notes": "Editing endpoint of gpt-image-2.5 (sunburst) on fal.ai, reachable through /v1/images/edits or the image generation path with fal's image_urls param. Prices include one input image and live in keyed entries fal_ai/{quality}/{width}-x-{height}/openai/gpt-image-2.5/sunburst/edit. This flat entry is the fallback for the default edit request (quality=high, image_size=auto, inferred from the input image, priced as 1024x768 high)"
+ },
+ "mode": "image_generation",
+ "output_cost_per_image": 0.03612,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-768/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00402,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-1024/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00588,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-1536/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00474,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1920-x-1080/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00441,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/2560-x-1440/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00615,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/3840-x-2160/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01113,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-768/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00903,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-1024/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01317,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-1536/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01029,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1920-x-1080/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01029,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/2560-x-1440/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01434,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/3840-x-2160/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.02595,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-768/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.03612,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-1024/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05268,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-1536/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.04116,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1920-x-1080/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.0396,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/2560-x-1440/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05529,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/3840-x-2160/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.10008,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-768/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.0642,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-1024/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.09366,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-1536/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.07377,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1920-x-1080/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.07041,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/2560-x-1440/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.09828,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/3840-x-2160/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1779,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-768/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.14445,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-1024/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.21072,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-1536/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.16464,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1920-x-1080/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1584,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/2560-x-1440/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.2211,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/3840-x-2160/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.40026,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/fal-ai/flux/dev": {
+ "litellm_provider": "fal_ai",
+ "metadata": {
+ "notes": "fal bills FLUX.1 [dev] at $0.025 per megapixel, rounding each image up to the nearest megapixel. Every named fal image_size (including the landscape_4_3 default) rounds up to 1 megapixel, so this flat per-image price is exact for them"
+ },
+ "mode": "image_generation",
+ "output_cost_per_image": 0.025,
+ "source": "https://fal.ai/models/fal-ai/flux/dev",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ]
+ },
"featherless_ai/featherless-ai/Qwerky-72B": {
"litellm_provider": "featherless_ai",
"max_input_tokens": 32768,
diff --git a/litellm/utils.py b/litellm/utils.py
index b724313641f..b2a84a4815d 100644
--- a/litellm/utils.py
+++ b/litellm/utils.py
@@ -9508,6 +9508,10 @@ class ProviderConfigManager:
)
return BlackForestLabsImageEditConfig()
+ elif LlmProviders.FAL_AI == provider:
+ from litellm.llms.fal_ai.image_edit import FalAIImageEditConfig
+
+ return FalAIImageEditConfig()
elif LlmProviders.AZURE_AI == provider:
from litellm.llms.azure_ai.image_edit import get_azure_ai_image_edit_config
diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json
index 6f8db4d2215..76839e3cec3 100644
--- a/model_prices_and_context_window.json
+++ b/model_prices_and_context_window.json
@@ -23444,6 +23444,1332 @@
],
"supports_vision": true
},
+ "fal_ai/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "metadata": {
+ "notes": "OpenAI gpt-image-2.5 (flare) served through fal.ai. fal publishes deterministic per-image prices per size and quality, mirrored as keyed entries fal_ai/{quality}/{width}-x-{height}/openai/gpt-image-2.5/flare/text-to-image that the fal_ai cost calculator picks from the request params. This flat entry is the fallback for the default request (quality=high, image_size=landscape_4_3 at 1024x768). quality=auto is priced as high"
+ },
+ "mode": "image_generation",
+ "output_cost_per_image": 0.03612,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-768/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00402,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-1024/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00588,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-1536/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00474,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1920-x-1080/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00441,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/2560-x-1440/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00615,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/3840-x-2160/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01113,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-768/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00903,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-1024/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01317,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-1536/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01029,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1920-x-1080/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01029,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/2560-x-1440/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01434,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/3840-x-2160/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.02595,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-768/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.03612,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-1024/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05268,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-1536/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.04116,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1920-x-1080/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.0396,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/2560-x-1440/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05529,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/3840-x-2160/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.10008,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-768/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.0642,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-1024/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.09366,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-1536/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.07377,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1920-x-1080/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.07041,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/2560-x-1440/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.09828,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/3840-x-2160/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1779,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-768/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.14445,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-1024/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.21072,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-1536/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.16464,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1920-x-1080/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1584,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/2560-x-1440/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.2211,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/3840-x-2160/openai/gpt-image-2.5/flare/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.40026,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "metadata": {
+ "notes": "Editing endpoint of gpt-image-2.5 (flare) on fal.ai, reachable through /v1/images/edits or the image generation path with fal's image_urls param. Prices include one input image and live in keyed entries fal_ai/{quality}/{width}-x-{height}/openai/gpt-image-2.5/flare/edit. This flat entry is the fallback for the default edit request (quality=high, image_size=auto, inferred from the input image, priced as 1024x768 high)"
+ },
+ "mode": "image_generation",
+ "output_cost_per_image": 0.03612,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-768/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00402,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-1024/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00588,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-1536/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00474,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1920-x-1080/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00441,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/2560-x-1440/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00615,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/3840-x-2160/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01113,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-768/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00903,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-1024/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01317,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-1536/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01029,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1920-x-1080/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01029,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/2560-x-1440/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01434,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/3840-x-2160/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.02595,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-768/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.03612,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-1024/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05268,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-1536/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.04116,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1920-x-1080/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.0396,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/2560-x-1440/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05529,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/3840-x-2160/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.10008,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-768/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.0642,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-1024/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.09366,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-1536/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.07377,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1920-x-1080/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.07041,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/2560-x-1440/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.09828,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/3840-x-2160/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1779,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-768/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.14445,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-1024/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.21072,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-1536/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.16464,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1920-x-1080/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1584,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/2560-x-1440/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.2211,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/3840-x-2160/openai/gpt-image-2.5/flare/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.40026,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/flare/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "metadata": {
+ "notes": "OpenAI gpt-image-2.5 (sunburst) served through fal.ai. fal publishes deterministic per-image prices per size and quality, mirrored as keyed entries fal_ai/{quality}/{width}-x-{height}/openai/gpt-image-2.5/sunburst/text-to-image that the fal_ai cost calculator picks from the request params. This flat entry is the fallback for the default request (quality=high, image_size=landscape_4_3 at 1024x768). quality=auto is priced as high"
+ },
+ "mode": "image_generation",
+ "output_cost_per_image": 0.03612,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-768/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00402,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-1024/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00588,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-1536/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00474,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1920-x-1080/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00441,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/2560-x-1440/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00615,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/3840-x-2160/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01113,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-768/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00903,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-1024/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01317,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-1536/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01029,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1920-x-1080/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01029,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/2560-x-1440/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01434,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/3840-x-2160/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.02595,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-768/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.03612,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-1024/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05268,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-1536/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.04116,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1920-x-1080/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.0396,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/2560-x-1440/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05529,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/3840-x-2160/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.10008,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-768/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.0642,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-1024/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.09366,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-1536/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.07377,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1920-x-1080/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.07041,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/2560-x-1440/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.09828,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/3840-x-2160/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1779,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-768/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.14445,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-1024/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.21072,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-1536/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.16464,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1920-x-1080/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1584,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/2560-x-1440/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.2211,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/3840-x-2160/openai/gpt-image-2.5/sunburst/text-to-image": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.40026,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "metadata": {
+ "notes": "Editing endpoint of gpt-image-2.5 (sunburst) on fal.ai, reachable through /v1/images/edits or the image generation path with fal's image_urls param. Prices include one input image and live in keyed entries fal_ai/{quality}/{width}-x-{height}/openai/gpt-image-2.5/sunburst/edit. This flat entry is the fallback for the default edit request (quality=high, image_size=auto, inferred from the input image, priced as 1024x768 high)"
+ },
+ "mode": "image_generation",
+ "output_cost_per_image": 0.03612,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-768/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00402,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-1024/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00588,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1024-x-1536/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00474,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/1920-x-1080/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00441,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/2560-x-1440/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00615,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/low/3840-x-2160/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01113,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-768/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.00903,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-1024/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01317,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1024-x-1536/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01029,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/1920-x-1080/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01029,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/2560-x-1440/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.01434,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/medium/3840-x-2160/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.02595,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-768/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.03612,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-1024/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05268,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1024-x-1536/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.04116,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/1920-x-1080/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.0396,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/2560-x-1440/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.05529,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/high/3840-x-2160/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.10008,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-768/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.0642,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-1024/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.09366,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1024-x-1536/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.07377,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/1920-x-1080/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.07041,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/2560-x-1440/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.09828,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/xhigh/3840-x-2160/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1779,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-768/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.14445,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-1024/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.21072,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1024-x-1536/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.16464,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/1920-x-1080/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.1584,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/2560-x-1440/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.2211,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/max/3840-x-2160/openai/gpt-image-2.5/sunburst/edit": {
+ "litellm_provider": "fal_ai",
+ "mode": "image_generation",
+ "output_cost_per_image": 0.40026,
+ "source": "https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit",
+ "supported_endpoints": [
+ "/v1/images/edits",
+ "/v1/images/generations"
+ ],
+ "supports_vision": true
+ },
+ "fal_ai/fal-ai/flux/dev": {
+ "litellm_provider": "fal_ai",
+ "metadata": {
+ "notes": "fal bills FLUX.1 [dev] at $0.025 per megapixel, rounding each image up to the nearest megapixel. Every named fal image_size (including the landscape_4_3 default) rounds up to 1 megapixel, so this flat per-image price is exact for them"
+ },
+ "mode": "image_generation",
+ "output_cost_per_image": 0.025,
+ "source": "https://fal.ai/models/fal-ai/flux/dev",
+ "supported_endpoints": [
+ "/v1/images/generations"
+ ]
+ },
"featherless_ai/featherless-ai/Qwerky-72B": {
"litellm_provider": "featherless_ai",
"max_input_tokens": 32768,
diff --git a/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py b/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py
new file mode 100644
index 00000000000..d8df467f0f4
--- /dev/null
+++ b/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py
@@ -0,0 +1,106 @@
+import base64
+import io
+import json
+
+import httpx
+import pytest
+
+from litellm.llms.fal_ai.image_edit import FalAIImageEditConfig
+from litellm.types.images.main import ImageEditOptionalRequestParams
+from litellm.types.router import GenericLiteLLMParams
+from litellm.types.utils import ImageResponse, LlmProviders
+from litellm.utils import ProviderConfigManager
+
+PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
+
+
+def test_fal_ai_resolves_to_image_edit_config():
+ config = ProviderConfigManager.get_provider_image_edit_config(
+ model="openai/gpt-image-2.5/flare/edit", provider=LlmProviders.FAL_AI
+ )
+ assert isinstance(config, FalAIImageEditConfig)
+
+
+@pytest.mark.parametrize(
+ "model,expected",
+ [
+ ("openai/gpt-image-2.5/flare", "https://fal.run/openai/gpt-image-2.5/flare/edit"),
+ ("openai/gpt-image-2.5/sunburst/edit", "https://fal.run/openai/gpt-image-2.5/sunburst/edit"),
+ ("openai/gpt-image-2", "https://fal.run/openai/gpt-image-2/edit"),
+ ],
+)
+def test_get_complete_url_appends_edit_suffix_once(model, expected):
+ assert FalAIImageEditConfig().get_complete_url(model=model, api_base=None, litellm_params={}) == expected
+
+
+def test_get_complete_url_respects_api_base():
+ url = FalAIImageEditConfig().get_complete_url(
+ model="openai/gpt-image-2.5/flare", api_base="https://proxy.internal/", litellm_params={}
+ )
+ assert url == "https://proxy.internal/openai/gpt-image-2.5/flare/edit"
+
+
+def test_validate_environment_uses_fal_key_scheme():
+ headers = FalAIImageEditConfig().validate_environment(headers={}, model="m", api_key="secret")
+ assert headers["Authorization"] == "Key secret"
+
+
+def test_validate_environment_requires_key(monkeypatch):
+ monkeypatch.delenv("FAL_AI_API_KEY", raising=False)
+ with pytest.raises(ValueError, match="FAL_AI_API_KEY"):
+ FalAIImageEditConfig().validate_environment(headers={}, model="m", api_key=None)
+
+
+def test_map_openai_params_translates_to_fal_names():
+ mapped = FalAIImageEditConfig().map_openai_params(
+ image_edit_optional_params=ImageEditOptionalRequestParams(
+ n=2, size="1024x1536", quality="xhigh", background="transparent"
+ ),
+ model="openai/gpt-image-2.5/flare/edit",
+ drop_params=False,
+ )
+ assert mapped == {
+ "num_images": 2,
+ "image_size": {"width": 1024, "height": 1536},
+ "quality": "xhigh",
+ "background": "transparent",
+ }
+
+
+def test_transform_request_inlines_local_images_as_data_urls_and_keeps_remote_urls():
+ body, files = FalAIImageEditConfig().transform_image_edit_request(
+ model="openai/gpt-image-2.5/flare/edit",
+ prompt="make it blue",
+ image=[io.BytesIO(PNG_BYTES), "https://example.com/in.png"],
+ image_edit_optional_request_params={"num_images": 1, "mask": io.BytesIO(PNG_BYTES)},
+ litellm_params=GenericLiteLLMParams(),
+ headers={},
+ )
+ expected_data_url = "data:image/png;base64," + base64.b64encode(PNG_BYTES).decode()
+ assert files == ()
+ assert body["prompt"] == "make it blue"
+ assert json.loads(json.dumps(body))["image_urls"] == [expected_data_url, "https://example.com/in.png"]
+ assert body["mask_url"] == expected_data_url
+ assert body["num_images"] == 1
+ assert "mask" not in body
+
+
+def test_transform_response_maps_fal_images():
+ raw = httpx.Response(200, json={"images": [{"url": "https://fal.media/out.png"}]})
+ response = FalAIImageEditConfig().transform_image_edit_response(
+ model="openai/gpt-image-2.5/flare/edit", raw_response=raw, logging_obj=None
+ )
+ assert isinstance(response, ImageResponse)
+ assert [image.url for image in response.data] == ["https://fal.media/out.png"]
+
+
+def test_transform_request_requires_an_image():
+ with pytest.raises(ValueError, match="input image"):
+ FalAIImageEditConfig().transform_image_edit_request(
+ model="openai/gpt-image-2.5/flare/edit",
+ prompt="make it blue",
+ image=None,
+ image_edit_optional_request_params={},
+ litellm_params=GenericLiteLLMParams(),
+ headers={},
+ )
diff --git a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_flux_dev_transformation.py b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_flux_dev_transformation.py
new file mode 100644
index 00000000000..09c9bc4b5f7
--- /dev/null
+++ b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_flux_dev_transformation.py
@@ -0,0 +1,61 @@
+import httpx
+import pytest
+
+from litellm.llms.fal_ai.image_generation import (
+ FalAIFluxDevConfig,
+ FalAIFluxSchnellConfig,
+ FalAIImageGenerationConfig,
+ get_fal_ai_image_generation_config,
+)
+from litellm.types.utils import ImageResponse
+
+
+@pytest.mark.parametrize("model", ["fal-ai/flux/dev", "flux/dev", "flux-dev"])
+def test_flux_dev_config_selected(model):
+ config = get_fal_ai_image_generation_config(model)
+ assert isinstance(config, FalAIFluxDevConfig)
+ assert not isinstance(config, FalAIImageGenerationConfig)
+
+
+def test_flux_schnell_still_routes_to_schnell():
+ config = get_fal_ai_image_generation_config("fal-ai/flux/schnell")
+ assert isinstance(config, FalAIFluxSchnellConfig)
+ assert not isinstance(config, FalAIFluxDevConfig)
+
+
+def test_flux_dev_url_targets_dev_endpoint():
+ url = FalAIFluxDevConfig().get_complete_url(
+ api_base=None, api_key="k", model="fal-ai/flux/dev", optional_params={}, litellm_params={}
+ )
+ assert url == "https://fal.run/fal-ai/flux/dev"
+
+
+def test_flux_dev_maps_openai_params_and_builds_request():
+ config = FalAIFluxDevConfig()
+ optional_params = config.map_openai_params(
+ non_default_params={"n": 2, "size": "1024x1024", "response_format": "b64_json"},
+ optional_params={},
+ model="fal-ai/flux/dev",
+ drop_params=False,
+ )
+ body = config.transform_image_generation_request(
+ model="fal-ai/flux/dev", prompt="a cat", optional_params=optional_params, litellm_params={}, headers={}
+ )
+ assert body["prompt"] == "a cat"
+ assert body["num_images"] == 2
+ assert body["image_size"] == "square_hd"
+
+
+def test_flux_dev_response_yields_one_image_object_per_fal_image():
+ raw = httpx.Response(200, json={"images": [{"url": "https://fal.media/a.png"}, {"url": "https://fal.media/b.png"}]})
+ response = FalAIFluxDevConfig().transform_image_generation_response(
+ model="fal-ai/flux/dev",
+ raw_response=raw,
+ model_response=ImageResponse(),
+ logging_obj=None,
+ request_data={},
+ optional_params={},
+ litellm_params={},
+ encoding=None,
+ )
+ assert [image.url for image in response.data] == ["https://fal.media/a.png", "https://fal.media/b.png"]
diff --git a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py
index 18a7e0161db..5445c0cc1b4 100644
--- a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py
+++ b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py
@@ -127,3 +127,36 @@ def test_transform_image_generation_request():
) == {"prompt": "a red bicycle", "quality": "high", "num_images": 2}
+@pytest.mark.parametrize(
+ "model",
+ [
+ "openai/gpt-image-2.5/flare/text-to-image",
+ "openai/gpt-image-2.5/sunburst/text-to-image",
+ ],
+)
+def test_gpt_image_25_routes_to_its_own_fal_endpoint(model):
+ config = get_fal_ai_image_generation_config(model)
+ assert isinstance(config, FalAIGPTImage2Config)
+ assert (
+ config.get_complete_url(api_base=None, api_key="k", model=model, optional_params={}, litellm_params={})
+ == f"https://fal.run/{model}"
+ )
+
+
+@pytest.mark.parametrize(
+ "model,quality,expected",
+ [
+ ("openai/gpt-image-2.5/flare/text-to-image", "xhigh", "xhigh"),
+ ("openai/gpt-image-2.5/sunburst/text-to-image", "max", "max"),
+ ("openai/gpt-image-2.5/flare/text-to-image", "hd", "high"),
+ ("openai/gpt-image-2", "xhigh", "auto"),
+ ("openai/gpt-image-2", "max", "auto"),
+ ],
+)
+def test_map_openai_params_quality_tiers_follow_model(model, quality, expected):
+ assert FalAIGPTImage2Config().map_openai_params(
+ non_default_params={"quality": quality},
+ optional_params={},
+ model=model,
+ drop_params=False,
+ ) == {"quality": expected}
diff --git a/tests/test_litellm/llms/fal_ai/test_cost_calculator.py b/tests/test_litellm/llms/fal_ai/test_cost_calculator.py
index 419aff42059..989b5855803 100644
--- a/tests/test_litellm/llms/fal_ai/test_cost_calculator.py
+++ b/tests/test_litellm/llms/fal_ai/test_cost_calculator.py
@@ -17,3 +17,74 @@ def _use_local_model_cost_map(monkeypatch):
def _image_response(num_images: int = 1) -> ImageResponse:
return ImageResponse(data=[ImageObject(url="https://example.com/img.png") for _ in range(num_images)])
+
+
+GPT_IMAGE_25_MODELS = (
+ "openai/gpt-image-2.5/flare/text-to-image",
+ "openai/gpt-image-2.5/flare/edit",
+ "openai/gpt-image-2.5/sunburst/text-to-image",
+ "openai/gpt-image-2.5/sunburst/edit",
+)
+
+
+@pytest.mark.parametrize("model", GPT_IMAGE_25_MODELS)
+def test_gpt_image_25_default_request_matches_high_1024x768_keyed_row(model):
+ default_cost = cost_calculator(model=f"fal_ai/{model}", image_response=_image_response(), optional_params={})
+ keyed_cost = litellm.model_cost[f"fal_ai/high/1024-x-768/{model}"]["output_cost_per_image"]
+ assert default_cost == keyed_cost > 0
+
+
+@pytest.mark.parametrize("model", GPT_IMAGE_25_MODELS)
+def test_gpt_image_25_quality_and_size_pick_keyed_row(model):
+ cost = cost_calculator(
+ model=f"fal_ai/{model}",
+ image_response=_image_response(num_images=2),
+ optional_params={"quality": "max", "image_size": {"width": 3840, "height": 2160}},
+ )
+ assert cost == 2 * litellm.model_cost[f"fal_ai/max/3840-x-2160/{model}"]["output_cost_per_image"] > 0
+
+
+def test_gpt_image_25_edit_auto_size_still_honors_quality():
+ model = "fal_ai/openai/gpt-image-2.5/flare/edit"
+ low = cost_calculator(
+ model=model, image_response=_image_response(), optional_params={"quality": "low", "image_size": "auto"}
+ )
+ high = cost_calculator(
+ model=model, image_response=_image_response(), optional_params={"quality": "high", "image_size": "auto"}
+ )
+ assert 0 < low < high
+
+
+def test_gpt_image_25_quality_tiers_are_monotonic():
+ costs = tuple(
+ cost_calculator(
+ model="fal_ai/openai/gpt-image-2.5/sunburst/text-to-image",
+ image_response=_image_response(),
+ optional_params={"quality": quality, "image_size": "square_hd"},
+ )
+ for quality in ("low", "medium", "high", "xhigh", "max")
+ )
+ assert costs == tuple(sorted(costs)) and len(set(costs)) == len(costs)
+
+
+def test_flux_dev_cost_is_nonzero_and_distinct_from_schnell():
+ dev = cost_calculator(
+ model="fal_ai/fal-ai/flux/dev", image_response=_image_response(num_images=3), optional_params={}
+ )
+ schnell = cost_calculator(
+ model="fal_ai/fal-ai/flux/schnell", image_response=_image_response(num_images=3), optional_params={}
+ )
+ assert dev > schnell > 0
+ assert dev == 3 * litellm.model_cost["fal_ai/fal-ai/flux/dev"]["output_cost_per_image"]
+
+
+def test_image_edit_call_type_routes_to_fal_keyed_pricing():
+ model = "openai/gpt-image-2.5/flare/edit"
+ cost = CostCalculatorUtils.route_image_generation_cost_calculator(
+ model=model,
+ completion_response=_image_response(),
+ custom_llm_provider="fal_ai",
+ optional_params={"quality": "medium", "image_size": {"width": 1024, "height": 1024}},
+ call_type="aimage_edit",
+ )
+ assert cost == litellm.model_cost[f"fal_ai/medium/1024-x-1024/{model}"]["output_cost_per_image"] > 0
From 62c215be19a7b26cd8646c55f658fcadde724150 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sun, 20 Sep 2026 05:31:27 +0000
Subject: [PATCH 112/317] fix(fal_ai): accept every FileTypes image input and
derive gpt-image qualities from pricing metadata
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../llms/fal_ai/image_edit/transformation.py | 38 ++++++++++++++----
.../gpt_image_2_transformation.py | 13 +++++--
.../test_fal_ai_image_edit_transformation.py | 39 +++++++++++++++++++
.../test_fal_ai_gpt_image_2_transformation.py | 8 ++++
4 files changed, 88 insertions(+), 10 deletions(-)
diff --git a/litellm/llms/fal_ai/image_edit/transformation.py b/litellm/llms/fal_ai/image_edit/transformation.py
index f0bb2820d7e..274e166e9f6 100644
--- a/litellm/llms/fal_ai/image_edit/transformation.py
+++ b/litellm/llms/fal_ai/image_edit/transformation.py
@@ -1,8 +1,9 @@
import base64
+import os
from collections.abc import Mapping
-from io import BufferedReader, BytesIO
+from pathlib import Path
from types import MappingProxyType
-from typing import TYPE_CHECKING, Final
+from typing import TYPE_CHECKING, Final, Protocol, runtime_checkable
import httpx
from httpx._types import RequestFiles
@@ -35,16 +36,39 @@ PARAM_TRANSLATION: Final[Mapping[str, str]] = MappingProxyType(
)
+@runtime_checkable
+class _Readable(Protocol):
+ def read(self) -> bytes: ...
+
+
+@runtime_checkable
+class _Tellable(Protocol):
+ def tell(self) -> int: ...
+
+
+@runtime_checkable
+class _Seekable(Protocol):
+ def seek(self, position: int) -> int: ...
+
+
def _read_image_bytes(image: object) -> bytes:
if isinstance(image, bytes):
return image
- if isinstance(image, (BytesIO, BufferedReader)):
- position: Final = image.tell()
+ if isinstance(image, tuple) and len(image) >= 2:
+ return _read_image_bytes(image[1])
+ if isinstance(image, os.PathLike):
+ return Path(image).read_bytes()
+ if isinstance(image, str):
+ raise ValueError(f"Unsupported image type for Fal AI image edit: {type(image).__name__}")
+ if not hasattr(image, "read") or not isinstance(image, _Readable):
+ raise ValueError(f"Unsupported image type for Fal AI image edit: {type(image).__name__}")
+ position: Final = image.tell() if hasattr(image, "tell") and isinstance(image, _Tellable) else 0
+ if hasattr(image, "seek") and isinstance(image, _Seekable):
image.seek(0)
- data: Final = image.read()
+ data: Final = image.read()
+ if hasattr(image, "seek") and isinstance(image, _Seekable):
image.seek(position)
- return data
- raise ValueError(f"Unsupported image type for Fal AI image edit: {type(image).__name__}")
+ return data
def _to_data_url(image: object) -> str:
diff --git a/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py b/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py
index ce016b350d8..cfa36a65a6f 100644
--- a/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py
+++ b/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py
@@ -4,6 +4,7 @@ from typing import Final
from typing_extensions import ReadOnly, TypedDict
+import litellm
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import OpenAIImageGenerationOptionalParams
@@ -23,8 +24,6 @@ SUPPORTED_OPENAI_PARAMS: Final[tuple[OpenAIImageGenerationOptionalParams, ...]]
"size",
)
SUPPORTED_QUALITIES: Final[frozenset[str]] = frozenset({"auto", "low", "medium", "high"})
-GPT_IMAGE_25_QUALITIES: Final[frozenset[str]] = SUPPORTED_QUALITIES | frozenset(("xhigh", "max"))
-GPT_IMAGE_25_MARKER: Final[str] = "gpt-image-2.5"
OPENAI_QUALITY_ALIASES: Final[Mapping[str, str]] = MappingProxyType({"hd": "high", "standard": "medium"})
@@ -40,7 +39,15 @@ def map_gpt_image_size(size: object) -> object:
def supported_gpt_image_qualities(model: str) -> frozenset[str]:
- return GPT_IMAGE_25_QUALITIES if GPT_IMAGE_25_MARKER in model.lower() else SUPPORTED_QUALITIES
+ suffix: Final = f"/{model}"
+ keyed: Final = frozenset(
+ key.removeprefix("fal_ai/").split("/")[0]
+ for key in litellm.model_cost
+ if key.startswith("fal_ai/")
+ and key.endswith(suffix)
+ and key.removeprefix("fal_ai/").removesuffix(suffix).count("/") == 1
+ )
+ return keyed | frozenset(("auto",)) if keyed else SUPPORTED_QUALITIES
def map_gpt_image_quality(quality: object, model: str) -> object:
diff --git a/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py b/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py
index d8df467f0f4..ce23f54a48e 100644
--- a/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py
+++ b/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py
@@ -1,6 +1,8 @@
import base64
import io
import json
+from pathlib import Path
+from typing import Final
import httpx
import pytest
@@ -14,6 +16,20 @@ from litellm.utils import ProviderConfigManager
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
+class GenericFileLike:
+ def __init__(self, data: bytes):
+ self._buffer = io.BytesIO(data)
+
+ def read(self) -> bytes:
+ return self._buffer.read()
+
+ def seek(self, position: int) -> int:
+ return self._buffer.seek(position)
+
+ def tell(self) -> int:
+ return self._buffer.tell()
+
+
def test_fal_ai_resolves_to_image_edit_config():
config = ProviderConfigManager.get_provider_image_edit_config(
model="openai/gpt-image-2.5/flare/edit", provider=LlmProviders.FAL_AI
@@ -85,6 +101,29 @@ def test_transform_request_inlines_local_images_as_data_urls_and_keeps_remote_ur
assert "mask" not in body
+@pytest.mark.parametrize("input_kind", ("path", "tuple_bytes", "tuple_file_like", "file_like"))
+def test_transform_request_accepts_openai_file_types(tmp_path, input_kind):
+ image_path: Final[Path] = tmp_path / "in.png"
+ image_path.write_bytes(PNG_BYTES)
+ image: Final[object] = {
+ "path": image_path,
+ "tuple_bytes": ("in.png", PNG_BYTES),
+ "tuple_file_like": ("in.png", io.BytesIO(PNG_BYTES), "image/png"),
+ "file_like": GenericFileLike(PNG_BYTES),
+ }[input_kind]
+ expected_data_url: Final = "data:image/png;base64," + base64.b64encode(PNG_BYTES).decode()
+ body, files = FalAIImageEditConfig().transform_image_edit_request(
+ model="openai/gpt-image-2",
+ prompt="make it blue",
+ image=image,
+ image_edit_optional_request_params={},
+ litellm_params=GenericLiteLLMParams(),
+ headers={},
+ )
+ assert files == ()
+ assert body["image_urls"] == (expected_data_url,)
+
+
def test_transform_response_maps_fal_images():
raw = httpx.Response(200, json={"images": [{"url": "https://fal.media/out.png"}]})
response = FalAIImageEditConfig().transform_image_edit_response(
diff --git a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py
index 5445c0cc1b4..262b9de7631 100644
--- a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py
+++ b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py
@@ -7,6 +7,7 @@ from litellm.llms.fal_ai.image_generation import (
FalAINanoBananaConfig,
get_fal_ai_image_generation_config,
)
+from litellm.llms.fal_ai.image_generation.gpt_image_2_transformation import map_gpt_image_quality
from litellm.types.utils import ImageObject, ImageResponse
@@ -160,3 +161,10 @@ def test_map_openai_params_quality_tiers_follow_model(model, quality, expected):
model=model,
drop_params=False,
) == {"quality": expected}
+
+
+def test_map_gpt_image_quality_derives_supported_tiers_from_pricing_metadata():
+ assert map_gpt_image_quality("xhigh", "openai/gpt-image-2.5/flare/text-to-image") == "xhigh"
+ assert map_gpt_image_quality("xhigh", "openai/gpt-image-2") == "auto"
+ assert map_gpt_image_quality("xhigh", "openai/unknown-model") == "auto"
+ assert map_gpt_image_quality("high", "openai/unknown-model") == "high"
From cf581bf3277bd7822cc74182829d89bd2d3ddc62 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sun, 20 Sep 2026 05:32:24 +0000
Subject: [PATCH 113/317] refactor(fal_ai): drop redundant hasattr guards in
the image edit byte reader
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/llms/fal_ai/image_edit/transformation.py | 10 ++++------
1 file changed, 4 insertions(+), 6 deletions(-)
diff --git a/litellm/llms/fal_ai/image_edit/transformation.py b/litellm/llms/fal_ai/image_edit/transformation.py
index 274e166e9f6..3879ef0715c 100644
--- a/litellm/llms/fal_ai/image_edit/transformation.py
+++ b/litellm/llms/fal_ai/image_edit/transformation.py
@@ -58,15 +58,13 @@ def _read_image_bytes(image: object) -> bytes:
return _read_image_bytes(image[1])
if isinstance(image, os.PathLike):
return Path(image).read_bytes()
- if isinstance(image, str):
+ if isinstance(image, str) or not isinstance(image, _Readable):
raise ValueError(f"Unsupported image type for Fal AI image edit: {type(image).__name__}")
- if not hasattr(image, "read") or not isinstance(image, _Readable):
- raise ValueError(f"Unsupported image type for Fal AI image edit: {type(image).__name__}")
- position: Final = image.tell() if hasattr(image, "tell") and isinstance(image, _Tellable) else 0
- if hasattr(image, "seek") and isinstance(image, _Seekable):
+ position: Final = image.tell() if isinstance(image, _Tellable) else 0
+ if isinstance(image, _Seekable):
image.seek(0)
data: Final = image.read()
- if hasattr(image, "seek") and isinstance(image, _Seekable):
+ if isinstance(image, _Seekable):
image.seek(position)
return data
From cc7dce6a218f0be9b2e9026a87d0977b5136b34c Mon Sep 17 00:00:00 2001
From: kerry
Date: Sun, 20 Sep 2026 05:32:02 +0000
Subject: [PATCH 114/317] fix(fal_ai): accept every FileTypes image input and
derive gpt-image qualities from pricing rows
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../llms/fal_ai/image_edit/transformation.py | 26 +++------
.../gpt_image_2_transformation.py | 33 +++++++----
.../test_fal_ai_image_edit_transformation.py | 55 +++++++++----------
.../test_fal_ai_gpt_image_2_transformation.py | 29 ++++++++--
4 files changed, 78 insertions(+), 65 deletions(-)
diff --git a/litellm/llms/fal_ai/image_edit/transformation.py b/litellm/llms/fal_ai/image_edit/transformation.py
index 3879ef0715c..794d058bbd4 100644
--- a/litellm/llms/fal_ai/image_edit/transformation.py
+++ b/litellm/llms/fal_ai/image_edit/transformation.py
@@ -37,36 +37,28 @@ PARAM_TRANSLATION: Final[Mapping[str, str]] = MappingProxyType(
@runtime_checkable
-class _Readable(Protocol):
- def read(self) -> bytes: ...
-
-
-@runtime_checkable
-class _Tellable(Protocol):
+class _SeekableBinaryReader(Protocol):
def tell(self) -> int: ...
+ def seek(self, offset: int) -> int: ...
-@runtime_checkable
-class _Seekable(Protocol):
- def seek(self, position: int) -> int: ...
+ def read(self) -> bytes: ...
def _read_image_bytes(image: object) -> bytes:
if isinstance(image, bytes):
return image
- if isinstance(image, tuple) and len(image) >= 2:
+ if isinstance(image, tuple):
return _read_image_bytes(image[1])
if isinstance(image, os.PathLike):
return Path(image).read_bytes()
- if isinstance(image, str) or not isinstance(image, _Readable):
- raise ValueError(f"Unsupported image type for Fal AI image edit: {type(image).__name__}")
- position: Final = image.tell() if isinstance(image, _Tellable) else 0
- if isinstance(image, _Seekable):
+ if isinstance(image, _SeekableBinaryReader):
+ position: Final = image.tell()
image.seek(0)
- data: Final = image.read()
- if isinstance(image, _Seekable):
+ data: Final = image.read()
image.seek(position)
- return data
+ return data
+ raise ValueError(f"Unsupported image type for Fal AI image edit: {type(image).__name__}")
def _to_data_url(image: object) -> str:
diff --git a/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py b/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py
index cfa36a65a6f..3dfc26f8f46 100644
--- a/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py
+++ b/litellm/llms/fal_ai/image_generation/gpt_image_2_transformation.py
@@ -23,7 +23,6 @@ SUPPORTED_OPENAI_PARAMS: Final[tuple[OpenAIImageGenerationOptionalParams, ...]]
"response_format",
"size",
)
-SUPPORTED_QUALITIES: Final[frozenset[str]] = frozenset({"auto", "low", "medium", "high"})
OPENAI_QUALITY_ALIASES: Final[Mapping[str, str]] = MappingProxyType({"hd": "high", "standard": "medium"})
@@ -38,23 +37,33 @@ def map_gpt_image_size(size: object) -> object:
return image_size
-def supported_gpt_image_qualities(model: str) -> frozenset[str]:
- suffix: Final = f"/{model}"
- keyed: Final = frozenset(
- key.removeprefix("fal_ai/").split("/")[0]
- for key in litellm.model_cost
- if key.startswith("fal_ai/")
- and key.endswith(suffix)
- and key.removeprefix("fal_ai/").removesuffix(suffix).count("/") == 1
+def supported_gpt_image_qualities(
+ model: str, model_cost: Mapping[str, Mapping[str, object]] | None = None
+) -> frozenset[str]:
+ costs: Final = litellm.model_cost if model_cost is None else model_cost
+ endpoint: Final[str] = model.removeprefix("fal_ai/")
+ qualified_endpoint: Final[str] = endpoint if endpoint.startswith("openai/") else f"openai/{endpoint}"
+ qualities: Final[frozenset[str]] = frozenset(
+ parts[1]
+ for key in costs
+ if (parts := key.split("/"))[0] == "fal_ai"
+ and len(parts) > 3
+ and "-x-" in parts[2]
+ and "/".join(parts[3:]) == qualified_endpoint
)
- return keyed | frozenset(("auto",)) if keyed else SUPPORTED_QUALITIES
+ return qualities | {"auto"} if qualities else frozenset()
-def map_gpt_image_quality(quality: object, model: str) -> object:
+def map_gpt_image_quality(
+ quality: object, model: str, model_cost: Mapping[str, Mapping[str, object]] | None = None
+) -> object:
if not isinstance(quality, str):
return quality
normalized: Final[str] = OPENAI_QUALITY_ALIASES.get(quality, quality)
- return normalized if normalized in supported_gpt_image_qualities(model) else "auto"
+ supported: Final[frozenset[str]] = supported_gpt_image_qualities(model, model_cost)
+ if not supported:
+ return normalized
+ return normalized if normalized in supported else "auto"
class FalAIGPTImage2Config(FalAIBaseConfig):
diff --git a/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py b/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py
index ce23f54a48e..9f4637308e1 100644
--- a/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py
+++ b/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py
@@ -1,8 +1,8 @@
import base64
import io
import json
+import tempfile
from pathlib import Path
-from typing import Final
import httpx
import pytest
@@ -16,20 +16,6 @@ from litellm.utils import ProviderConfigManager
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
-class GenericFileLike:
- def __init__(self, data: bytes):
- self._buffer = io.BytesIO(data)
-
- def read(self) -> bytes:
- return self._buffer.read()
-
- def seek(self, position: int) -> int:
- return self._buffer.seek(position)
-
- def tell(self) -> int:
- return self._buffer.tell()
-
-
def test_fal_ai_resolves_to_image_edit_config():
config = ProviderConfigManager.get_provider_image_edit_config(
model="openai/gpt-image-2.5/flare/edit", provider=LlmProviders.FAL_AI
@@ -101,27 +87,36 @@ def test_transform_request_inlines_local_images_as_data_urls_and_keeps_remote_ur
assert "mask" not in body
-@pytest.mark.parametrize("input_kind", ("path", "tuple_bytes", "tuple_file_like", "file_like"))
-def test_transform_request_accepts_openai_file_types(tmp_path, input_kind):
- image_path: Final[Path] = tmp_path / "in.png"
- image_path.write_bytes(PNG_BYTES)
- image: Final[object] = {
- "path": image_path,
- "tuple_bytes": ("in.png", PNG_BYTES),
- "tuple_file_like": ("in.png", io.BytesIO(PNG_BYTES), "image/png"),
- "file_like": GenericFileLike(PNG_BYTES),
- }[input_kind]
- expected_data_url: Final = "data:image/png;base64," + base64.b64encode(PNG_BYTES).decode()
- body, files = FalAIImageEditConfig().transform_image_edit_request(
- model="openai/gpt-image-2",
+@pytest.mark.parametrize(
+ "image_factory",
+ [
+ pytest.param(lambda path: ("red.png", PNG_BYTES), id="filename-bytes-tuple"),
+ pytest.param(lambda path: ("red.png", PNG_BYTES, "image/png"), id="three-tuple-with-content-type"),
+ pytest.param(lambda path: path, id="path"),
+ pytest.param(lambda path: io.FileIO(str(path), "rb"), id="file-io"),
+ pytest.param(
+ lambda path: tempfile.SpooledTemporaryFile(suffix=".png"),
+ id="spooled-temp-file",
+ ),
+ ],
+)
+def test_transform_request_reads_every_file_types_input(tmp_path, image_factory):
+ path = Path(tmp_path) / "red.png"
+ path.write_bytes(PNG_BYTES)
+ image = image_factory(path)
+ if isinstance(image, tempfile.SpooledTemporaryFile):
+ image.write(PNG_BYTES)
+ image.seek(3)
+ body, _ = FalAIImageEditConfig().transform_image_edit_request(
+ model="openai/gpt-image-2.5/flare/edit",
prompt="make it blue",
image=image,
image_edit_optional_request_params={},
litellm_params=GenericLiteLLMParams(),
headers={},
)
- assert files == ()
- assert body["image_urls"] == (expected_data_url,)
+ expected_data_url = "data:image/png;base64," + base64.b64encode(PNG_BYTES).decode()
+ assert body["image_urls"][0] == expected_data_url
def test_transform_response_maps_fal_images():
diff --git a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py
index 262b9de7631..f9d5393f426 100644
--- a/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py
+++ b/tests/test_litellm/llms/fal_ai/image_generation/test_fal_ai_gpt_image_2_transformation.py
@@ -7,7 +7,10 @@ from litellm.llms.fal_ai.image_generation import (
FalAINanoBananaConfig,
get_fal_ai_image_generation_config,
)
-from litellm.llms.fal_ai.image_generation.gpt_image_2_transformation import map_gpt_image_quality
+from litellm.llms.fal_ai.image_generation.gpt_image_2_transformation import (
+ map_gpt_image_quality,
+ supported_gpt_image_qualities,
+)
from litellm.types.utils import ImageObject, ImageResponse
@@ -163,8 +166,22 @@ def test_map_openai_params_quality_tiers_follow_model(model, quality, expected):
) == {"quality": expected}
-def test_map_gpt_image_quality_derives_supported_tiers_from_pricing_metadata():
- assert map_gpt_image_quality("xhigh", "openai/gpt-image-2.5/flare/text-to-image") == "xhigh"
- assert map_gpt_image_quality("xhigh", "openai/gpt-image-2") == "auto"
- assert map_gpt_image_quality("xhigh", "openai/unknown-model") == "auto"
- assert map_gpt_image_quality("high", "openai/unknown-model") == "high"
+@pytest.mark.parametrize(
+ "model",
+ [
+ "some-new-model",
+ "openai/some-new-model",
+ "fal_ai/openai/some-new-model",
+ ],
+)
+def test_supported_qualities_derived_from_pricing_rows(model):
+ model_cost = {
+ "fal_ai/xhigh/1024-x-1024/openai/some-new-model": {},
+ "fal_ai/low/1024-x-1024/openai/some-new-model": {},
+ "fal_ai/max/1024-x-1024/openai/other-model": {},
+ }
+ assert supported_gpt_image_qualities(model, model_cost) == {"xhigh", "low", "auto"}
+
+
+def test_map_gpt_image_quality_passes_through_when_no_pricing_rows():
+ assert map_gpt_image_quality("xhigh", "some-new-model", {}) == "xhigh"
From 468f74c62824513aa6616e1fafb539f5e5076023 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Sat, 19 Sep 2026 22:37:07 -0700
Subject: [PATCH 115/317] ci(e2e): fix the stage-mirror batch reds and keep a
redacted pytest log
The changed-test gate booted its stage-mirror stack without files_settings
or finetune_settings, so every raw upload with a custom_llm_provider hit a
500, and it exported the whole provider env into the gateways, so the
AWS_ROLE_NAME the assume-role test needs made the GovCloud deployment run
an AssumeRole with its static keys. The gate also deleted its pytest output,
so a red run left nothing to read. The mirror config now carries the
openai, azure, and vertex_ai file settings, gateways start without
AWS_ROLE_NAME, and the workflow uploads the pass logs and junit files with
every secret value, every field of a JSON-valued secret, and their
XML-escaped forms replaced before the raw files are removed.
---
.github/e2e-stack/redact_output.py | 84 +++++++++++++++++++
.github/e2e-stack/up.sh | 2 +-
.github/workflows/test-e2e-changed.yml | 20 ++++-
.../test_e2e_changed_gate.py | 76 +++++++++++++++++
tests/e2e/gateway/stage_mirror_ci_config.yml | 17 ++++
5 files changed, 197 insertions(+), 2 deletions(-)
create mode 100644 .github/e2e-stack/redact_output.py
diff --git a/.github/e2e-stack/redact_output.py b/.github/e2e-stack/redact_output.py
new file mode 100644
index 00000000000..0dfea8aec7f
--- /dev/null
+++ b/.github/e2e-stack/redact_output.py
@@ -0,0 +1,84 @@
+import argparse
+import os
+import sys
+from functools import reduce
+from pathlib import Path
+from typing import Final
+from xml.sax.saxutils import escape
+
+from pydantic import JsonValue, TypeAdapter, ValidationError
+from secrets_to_env import MIN_MASKED_LENGTH
+
+REDACTED: Final = "***"
+json_adapter: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
+
+
+def string_leaves(node: JsonValue) -> tuple[str, ...]:
+ match node:
+ case str():
+ return (node,)
+ case list():
+ return tuple(leaf for child in node for leaf in string_leaves(child))
+ case dict():
+ return tuple(leaf for child in node.values() for leaf in string_leaves(child))
+ case _:
+ return ()
+
+
+def field_lines(value: str) -> tuple[str, ...]:
+ try:
+ return tuple(line for leaf in string_leaves(json_adapter.validate_json(value)) for line in leaf.splitlines())
+ except ValidationError:
+ return ()
+
+
+def masked_values(values_files: tuple[Path, ...]) -> tuple[str, ...]:
+ values: Final = frozenset(
+ line.split("=", 1)[1].strip().strip("'")
+ for path in values_files
+ for line in path.read_text().splitlines()
+ if "=" in line
+ )
+ texts: Final = frozenset(text for value in values for text in (value, *field_lines(value)))
+ renderings: Final = frozenset(
+ rendering
+ for text in texts
+ if len(text) >= MIN_MASKED_LENGTH
+ for rendering in (text, escape(text), escape(text, {'"': """}))
+ )
+ return tuple(sorted(renderings, key=lambda rendering: (-len(rendering), rendering)))
+
+
+def redact(text: str, values: tuple[str, ...]) -> str:
+ return reduce(lambda redacted, value: redacted.replace(value, REDACTED), values, text)
+
+
+def write_redacted(source: Path, out_dir: Path, values: tuple[str, ...]) -> None:
+ target: Final = out_dir / source.name
+ with os.fdopen(os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600), "w") as handle:
+ _ = handle.write(redact(source.read_text(errors="replace"), values))
+
+
+def main() -> int:
+ parser: Final = argparse.ArgumentParser()
+ _ = parser.add_argument("--values", action="append", type=Path, required=True)
+ _ = parser.add_argument("--out", type=Path, required=True)
+ _ = parser.add_argument("files", nargs="*", type=Path)
+ args: Final = parser.parse_args()
+ values_files: Final = tuple(args.values)
+ out_dir: Final[Path] = args.out
+ sources: Final = tuple(args.files)
+ try:
+ values: Final = masked_values(values_files)
+ out_dir.mkdir(mode=0o700, exist_ok=True)
+ for source in sources:
+ write_redacted(source, out_dir, values)
+ except OSError as error:
+ _ = sys.stderr.write(f"could not redact {error.filename}\n")
+ return 1
+ _ = sys.stdout.write(f"redacted {len(sources)} file(s) into {out_dir}\n")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.github/e2e-stack/up.sh b/.github/e2e-stack/up.sh
index a789a570483..928b58e93bb 100755
--- a/.github/e2e-stack/up.sh
+++ b/.github/e2e-stack/up.sh
@@ -143,7 +143,7 @@ env "${SERVER_ENV[@]}" uv run --no-sync python migrations/run.py >"${LOGS_DIR}/m
start_server() {
local name="$1"; shift
- env "${SERVER_ENV[@]}" "$@" >"${LOGS_DIR}/${name}.log" 2>&1 &
+ env -u AWS_ROLE_NAME "${SERVER_ENV[@]}" "$@" >"${LOGS_DIR}/${name}.log" 2>&1 &
echo $! > "${PIDS_DIR}/${name}.pid"
}
diff --git a/.github/workflows/test-e2e-changed.yml b/.github/workflows/test-e2e-changed.yml
index c9f08deb36e..fb7ddf53b2b 100644
--- a/.github/workflows/test-e2e-changed.yml
+++ b/.github/workflows/test-e2e-changed.yml
@@ -206,6 +206,24 @@ jobs:
echo "pass ${pass} of 3 passed"
done
+ - name: Redact the pytest output
+ if: always() && steps.boot.outcome == 'success'
+ run: |
+ umask 077
+ shopt -s nullglob
+ uv run --no-sync python .github/e2e-stack/redact_output.py \
+ --values tests/e2e/.env --values "${RUNNER_TEMP}/litellm-e2e-stack/stack.env" \
+ --out "${RUNNER_TEMP}/e2e-redacted" "${RUNNER_TEMP}"/e2e-pass-*.log "${RUNNER_TEMP}"/e2e-pass-*.xml
+
+ - name: Keep the redacted pytest output
+ if: always() && steps.boot.outcome == 'success'
+ uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
+ with:
+ name: e2e-changed-pytest-output-${{ github.run_attempt }}
+ path: ${{ runner.temp }}/e2e-redacted
+ retention-days: 14
+ if-no-files-found: ignore
+
- name: Stop the stack
if: always() && steps.boot.outcome != 'skipped'
run: bash .github/e2e-stack/down.sh
@@ -214,7 +232,7 @@ jobs:
if: always()
run: |
rm -f tests/e2e/.env "${RUNNER_TEMP}/e2e-boot.log" "${RUNNER_TEMP}"/e2e-pass-*.log "${RUNNER_TEMP}"/e2e-pass-*.xml
- rm -rf "${RUNNER_TEMP}/litellm-e2e-stack"
+ rm -rf "${RUNNER_TEMP}/litellm-e2e-stack" "${RUNNER_TEMP}/e2e-redacted"
gate:
name: e2e-changed-tests
diff --git a/tests/code_coverage_tests/test_e2e_changed_gate.py b/tests/code_coverage_tests/test_e2e_changed_gate.py
index 5ae0863baf0..9b6540bf8af 100644
--- a/tests/code_coverage_tests/test_e2e_changed_gate.py
+++ b/tests/code_coverage_tests/test_e2e_changed_gate.py
@@ -9,6 +9,7 @@ import pytest
GATE: Final = Path(__file__).resolve().parents[2] / ".github/e2e-stack/assert_tests_ran.py"
SECRETS_TO_ENV: Final = GATE.with_name("secrets_to_env.py")
SELECT_TESTS: Final = GATE.with_name("select_tests.py")
+REDACT_OUTPUT: Final = GATE.with_name("redact_output.py")
CANARY: Final = ("tests/e2e/access_control/test_a.py", "tests/e2e/access_control/test_b.py")
SELECTED: Final = ("tests/e2e/access_control/test_a.py", "tests/e2e/access_control/test_b.py")
@@ -115,6 +116,81 @@ def test_short_values_are_written_without_masking_every_digit_in_the_log(tmp_pat
assert env_path.read_text() == "FLAG='1'\nAPI_KEY='sk-0123456789abcdef'\n"
+def redact_output(tmp_path: Path, values: tuple[str, ...], text: str) -> tuple[subprocess.CompletedProcess[str], Path]:
+ env_path: Final = tmp_path / ".env"
+ _ = env_path.write_text("".join(f"{name}='{value}'\n" for name, value in zip(("A", "B", "C"), values)))
+ stack_env: Final = tmp_path / "stack.env"
+ _ = stack_env.write_text("LITELLM_MASTER_KEY=sk-e2e-master0123\nREDIS_PORT=6379\n")
+ log: Final = tmp_path / "e2e-pass-1.log"
+ _ = log.write_text(text)
+ out_dir: Final = tmp_path / "redacted"
+ result: Final = subprocess.run( # test-quality-ok: standalone script that imports its sibling by script directory
+ [
+ sys.executable,
+ str(REDACT_OUTPUT),
+ "--values",
+ str(env_path),
+ "--values",
+ str(stack_env),
+ "--out",
+ str(out_dir),
+ str(log),
+ ],
+ capture_output=True,
+ text=True,
+ )
+ return result, out_dir / log.name
+
+
+def test_redacted_output_hides_every_masked_value_and_keeps_the_rest(tmp_path: Path) -> None:
+ text: Final = (
+ "FAILED key=sk-0123456789abcdef master=sk-e2e-master0123 flag=1 port=6379 message=Missing credentials\n"
+ )
+
+ result, redacted = redact_output(tmp_path, ("sk-0123456789abcdef", "1"), text)
+
+ assert result.returncode == 0, result.stderr
+ assert redacted.read_text() == "FAILED key=*** master=*** flag=1 port=6379 message=Missing credentials\n"
+ assert (redacted.stat().st_mode & 0o777) == 0o600
+ assert (tmp_path / "e2e-pass-1.log").read_text() == text
+ assert "sk-" not in result.stdout + result.stderr
+
+
+def test_a_masked_value_that_prefixes_a_longer_one_leaves_no_tail(tmp_path: Path) -> None:
+ result, redacted = redact_output(tmp_path, ("sk-0123456789", "sk-0123456789abcdef"), "token sk-0123456789abcdef\n")
+
+ assert result.returncode == 0, result.stderr
+ assert redacted.read_text() == "token ***\n"
+
+
+def test_a_json_secret_is_hidden_field_by_field_however_it_is_escaped(tmp_path: Path) -> None:
+ credentials: Final = (
+ '{"type": "service_account", "signing_key": "MIIEvAIBADANBgkqhkiG9w0BAQEFAASC\\n'
+ 'c2VjcmV0LWtleS1ib2R5LWxpbmUtdHdv\\n", "client_id": "104857600000000000001"}'
+ )
+ text: Final = (
+ "decoded MIIEvAIBADANBgkqhkiG9w0BAQEFAASC\n"
+ "c2VjcmV0LWtleS1ib2R5LWxpbmUtdHdv\n"
+ "escaped MIIEvAIBADANBgkqhkiG9w0BAQEFAASC\\nc2VjcmV0LWtleS1ib2R5LWxpbmUtdHdv\\n\n"
+ "twice MIIEvAIBADANBgkqhkiG9w0BAQEFAASC\\\\nc2VjcmV0LWtleS1ib2R5LWxpbmUtdHdv\n"
+ "client 104857600000000000001 status 403\n"
+ )
+
+ result, redacted = redact_output(tmp_path, (credentials,), text)
+
+ assert result.returncode == 0, result.stderr
+ assert redacted.read_text() == "decoded ***\n***\nescaped ***\\n***\\n\ntwice ***\\\\n***\nclient *** status 403\n"
+
+
+def test_a_secret_with_xml_special_characters_is_hidden_in_the_junit_file(tmp_path: Path) -> None:
+ text: Final = 'body p&ss<w"rd-1 \n'
+
+ result, redacted = redact_output(tmp_path, ('p&ssbody ***\n'
+
+
def select_tests(changed: tuple[str, ...]) -> tuple[str, ...]:
result: Final = subprocess.run(
[sys.executable, str(SELECT_TESTS), *CANARY],
diff --git a/tests/e2e/gateway/stage_mirror_ci_config.yml b/tests/e2e/gateway/stage_mirror_ci_config.yml
index 8c8e64443cb..352caddf588 100644
--- a/tests/e2e/gateway/stage_mirror_ci_config.yml
+++ b/tests/e2e/gateway/stage_mirror_ci_config.yml
@@ -64,6 +64,23 @@ model_list:
model: openai/text-embedding-3-small
api_key: os.environ/OPENAI_API_KEY
+files_settings:
+ - custom_llm_provider: openai
+ api_key: os.environ/OPENAI_API_KEY
+ - custom_llm_provider: azure
+ api_base: os.environ/AZURE_API_BASE
+ api_key: os.environ/AZURE_API_KEY
+ api_version: 2025-04-01-preview
+ - custom_llm_provider: vertex_ai
+ vertex_project: os.environ/VERTEXAI_PROJECT
+ vertex_location: us-central1
+ vertex_credentials: os.environ/VERTEXAI_CREDENTIALS
+ bucket_name: os.environ/GCS_BUCKET_NAME
+
+finetune_settings:
+ - custom_llm_provider: openai
+ api_key: os.environ/OPENAI_API_KEY
+
mcp_servers:
devin:
url: "https://mcp.devin.ai/mcp"
From 9f24699e4c1e4cee507b5a5d6bd8b704962fc586 Mon Sep 17 00:00:00 2001
From: kerry
Date: Sun, 20 Sep 2026 05:44:56 +0000
Subject: [PATCH 116/317] fix(fal_ai): reject empty image lists in image edit
requests
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/llms/fal_ai/image_edit/transformation.py | 6 +++---
.../image_edit/test_fal_ai_image_edit_transformation.py | 5 +++--
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/litellm/llms/fal_ai/image_edit/transformation.py b/litellm/llms/fal_ai/image_edit/transformation.py
index 794d058bbd4..70b5d0612f2 100644
--- a/litellm/llms/fal_ai/image_edit/transformation.py
+++ b/litellm/llms/fal_ai/image_edit/transformation.py
@@ -138,9 +138,9 @@ class FalAIImageEditConfig(BaseImageEditConfig):
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> tuple[dict, RequestFiles]:
- if image is None:
+ images: Final = tuple(img for img in (image if isinstance(image, list) else (image,)) if img is not None)
+ if not images:
raise ValueError("Fal AI image edit requires at least one input image")
- images: Final = tuple(image) if isinstance(image, list) else (image,)
mask: Final = _first(image_edit_optional_request_params.get("mask"))
mask_field: Final[Mapping[str, str]] = (
MappingProxyType({"mask_url": _to_data_url(mask)}) if mask is not None else MappingProxyType({})
@@ -152,7 +152,7 @@ class FalAIImageEditConfig(BaseImageEditConfig):
)
request_body: Final[dict[str, object]] = { # mutable-ok: base class contract returns a dict
"prompt": prompt,
- "image_urls": tuple(_to_data_url(img) for img in images if img is not None),
+ "image_urls": tuple(_to_data_url(img) for img in images),
**mask_field,
**provider_params,
}
diff --git a/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py b/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py
index 9f4637308e1..65b04e1f1b8 100644
--- a/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py
+++ b/tests/test_litellm/llms/fal_ai/image_edit/test_fal_ai_image_edit_transformation.py
@@ -128,12 +128,13 @@ def test_transform_response_maps_fal_images():
assert [image.url for image in response.data] == ["https://fal.media/out.png"]
-def test_transform_request_requires_an_image():
+@pytest.mark.parametrize("image", [None, []])
+def test_transform_request_requires_an_image(image):
with pytest.raises(ValueError, match="input image"):
FalAIImageEditConfig().transform_image_edit_request(
model="openai/gpt-image-2.5/flare/edit",
prompt="make it blue",
- image=None,
+ image=image,
image_edit_optional_request_params={},
litellm_params=GenericLiteLLMParams(),
headers={},
From f9244749e089a1fb0fcd157f5d5d8b2895d65e19 Mon Sep 17 00:00:00 2001
From: yassin
Date: Sun, 20 Sep 2026 06:00:06 +0000
Subject: [PATCH 117/317] fix(proxy): return 422 instead of 429 for
BudgetExceededError
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/exceptions.py | 2 +-
.../_experimental/mcp_server/auth/user_api_key_auth_mcp.py | 2 +-
.../test_litellm/litellm_core_utils/test_litellm_logging.py | 6 +++---
.../mcp_server/auth/test_user_api_key_auth_mcp.py | 6 +++---
.../test_litellm/proxy/auth/test_auth_exception_handler.py | 6 +++---
tests/test_litellm/proxy/auth/test_multi_budget_windows.py | 4 ++--
.../management_endpoints/test_key_management_endpoints.py | 4 ++--
tests/test_litellm/proxy/test_common_request_processing.py | 4 ++--
8 files changed, 17 insertions(+), 17 deletions(-)
diff --git a/litellm/exceptions.py b/litellm/exceptions.py
index 14cc16452f0..4b236aec99c 100644
--- a/litellm/exceptions.py
+++ b/litellm/exceptions.py
@@ -1002,7 +1002,7 @@ class BudgetExceededError(Exception):
):
self.current_cost = current_cost
self.max_budget = max_budget
- self.status_code = 429
+ self.status_code = 422
self.llm_provider = llm_provider or ""
self.entity_type = entity_type
self.entity_id = entity_id
diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py
index b0d57cb6228..b0640e4f0dd 100644
--- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py
+++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py
@@ -1154,7 +1154,7 @@ class MCPRequestHandler:
Failures surface with the status the standard pipeline would give them, mirroring
``UserAPIKeyAuthExceptionHandler``: a disallowed route is the route gate's own 403, an
- over-budget identity is a 429, a sub-check that raised its own ``HTTPException``/
+ over-budget identity is a 422, a sub-check that raised its own ``HTTPException``/
``ProxyException`` keeps that status, a transient database outage is a retryable 503, and
only a genuinely unresolvable failure (a blocked team/project raises a bare ``Exception``,
same as the standard pipeline's fallback) becomes the fail-closed 401. Collapsing every
diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py
index 626a13c8061..325052ebda9 100644
--- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py
+++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py
@@ -3033,7 +3033,7 @@ def test_get_error_information_budget_exceeded_structured_fields():
assert result["error_budget_entity_id"] == "repro-user"
assert result["error_budget_limit"] == 1e-06
assert result["error_budget_spend"] == 3.4e-05
- assert result["error_code"] == "429"
+ assert result["error_code"] == "422"
assert result["error_class"] == "BudgetExceededError"
assert result["error_rate_limit_type"] == "budget"
@@ -6407,7 +6407,7 @@ def test_get_error_information_keeps_traceback_for_unmapped_provider_4xx():
def test_get_error_information_skips_traceback_for_budget_rejection_with_provider():
- """A key-over-budget 429 is the proxy's own rejection even after the auth
+ """A key-over-budget 422 is the proxy's own rejection even after the auth
handler stamps the requested model's provider onto it, so it stays cheap."""
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
@@ -6416,7 +6416,7 @@ def test_get_error_information_skips_traceback_for_budget_rejection_with_provide
litellm.BudgetExceededError(current_cost=0.01, max_budget=0.0, llm_provider="anthropic")
)
result = StandardLoggingPayloadSetup.get_error_information(over_budget)
- assert result["error_code"] == "429"
+ assert result["error_code"] == "422"
assert result["llm_provider"] == "anthropic"
assert result["traceback"] == ""
diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py
index 4380df194ed..087c5a03498 100644
--- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py
+++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py
@@ -6339,15 +6339,15 @@ class TestMCPDcrBridgeDelegateAdmission:
)
return exc_info.value
- async def test_over_budget_admission_surfaces_429_not_401(self):
- """A validly-authenticated but over-budget identity surfaces the standard pipeline's 429, not
+ async def test_over_budget_admission_surfaces_422_not_401(self):
+ """A validly-authenticated but over-budget identity surfaces the standard pipeline's 422, not
a misleading 401. Flattening budget to 401 told the caller their credential was invalid, which
on a DCR client reads as broken auth and triggers a re-authorize that cannot fix a budget
problem. Regression for the status-flattening finding on the live-policy gate."""
import litellm
mapped = await self._enforce_with_gate_error(litellm.BudgetExceededError(current_cost=10.0, max_budget=1.0))
- assert mapped.status_code == 429
+ assert mapped.status_code == 422
async def test_db_outage_during_policy_surfaces_503_not_401(self):
"""A transient database outage during the live-policy gate surfaces a retryable 503, not a 401
diff --git a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py
index 125b8862dfc..3edc57af124 100644
--- a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py
+++ b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py
@@ -448,7 +448,7 @@ async def test_handle_authentication_error_budget_exceeded():
)
assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
- assert int(exc_info.value.code) == status.HTTP_429_TOO_MANY_REQUESTS
+ assert int(exc_info.value.code) == status.HTTP_422_UNPROCESSABLE_CONTENT
@pytest.mark.asyncio
@@ -687,7 +687,7 @@ def _http_request(client_host: str | None = "10.1.2.3", headers: dict[str, str]
{"allow_requests_on_db_unavailable": False},
{},
"10.1.2.3",
- id="429_budget_exceeded",
+ id="422_budget_exceeded",
),
],
)
@@ -697,7 +697,7 @@ async def test_auth_failure_logs_requester_ip_address(
request_kwargs: dict[str, dict[str, str]],
expected_ip: str,
) -> None:
- """401s and budget 429s are rejected before `add_litellm_data_to_request` stamps
+ """401s and budget 422s are rejected before `add_litellm_data_to_request` stamps
the caller IP, so without this the failure logs (spend logs, prometheus client_ip)
had no IP, and a 401 rarely carries a key or user identity either."""
with (
diff --git a/tests/test_litellm/proxy/auth/test_multi_budget_windows.py b/tests/test_litellm/proxy/auth/test_multi_budget_windows.py
index 0f01391b2f5..1c928448bd8 100644
--- a/tests/test_litellm/proxy/auth/test_multi_budget_windows.py
+++ b/tests/test_litellm/proxy/auth/test_multi_budget_windows.py
@@ -75,7 +75,7 @@ async def test_over_first_window_raises():
await _virtual_key_multi_budget_check(valid_token=token)
err = exc_info.value
- assert err.status_code == 429
+ assert err.status_code == 422
assert "24h" in str(err)
assert "Key over" in str(err)
@@ -107,7 +107,7 @@ async def test_over_second_window_raises():
await _virtual_key_multi_budget_check(valid_token=token)
err = exc_info.value
- assert err.status_code == 429
+ assert err.status_code == 422
assert "30d" in str(err)
diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py
index e2a68988ee2..b02ff47ed52 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py
@@ -8156,7 +8156,7 @@ async def test_reset_key_spend_resets_budget_windows(monkeypatch):
counter without also advancing reset_at is not durable either: the very
next request would re-sum the unchanged historical spend and put the
counter right back above the window's max_budget, so
- _virtual_key_multi_budget_check kept raising BudgetExceededError (429) on
+ _virtual_key_multi_budget_check kept raising BudgetExceededError (422) on
every request even though the key's own reported spend read $0.
"""
mock_prisma_client = MagicMock()
@@ -16593,7 +16593,7 @@ async def test_info_key_fn_reads_the_configured_budget_model_key(monkeypatch):
It used to probe a second, provider-stripped key because the counter was
written under the request model instead, which is what let a key report zero
- usage while being blocked at 429.
+ usage while being blocked at 422.
"""
from unittest.mock import AsyncMock, MagicMock
diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py
index e4ca0b03d59..0b872400be0 100644
--- a/tests/test_litellm/proxy/test_common_request_processing.py
+++ b/tests/test_litellm/proxy/test_common_request_processing.py
@@ -495,7 +495,7 @@ class TestProxyBaseLLMRequestProcessing:
)
assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
- assert exc_info.value.code == "429"
+ assert exc_info.value.code == "422"
tag_budget_check.assert_awaited_once()
_, call_kwargs = tag_budget_check.call_args
assert call_kwargs["tags"] == ("guardrail-tag",)
@@ -702,7 +702,7 @@ class TestProxyBaseLLMRequestProcessing:
)
assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
- assert exc_info.value.code == "429"
+ assert exc_info.value.code == "422"
assert "guardrail-tag" in exc_info.value.message
@pytest.mark.asyncio
From 92d3a1d87de637efd57acfd31dd8068f5ee0848c Mon Sep 17 00:00:00 2001
From: yassin
Date: Sun, 20 Sep 2026 06:10:16 +0000
Subject: [PATCH 118/317] test(proxy): expect 422 for per-model budget
rejections on cursor route
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../proxy/response_api_endpoints/test_endpoints.py | 6 +++---
tests/test_litellm/proxy/test_proxy_server.py | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py b/tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py
index f7abb209015..4153bf7d7ee 100644
--- a/tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py
+++ b/tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py
@@ -2099,7 +2099,7 @@ class TestCursorVariantPerModelBudgetEnforcement:
response = _post_cursor_with_real_auth(valid_token, attrs, request_model="claude-opus-5-thinking-high")
- assert response.status_code == 429, response.text
+ assert response.status_code == 422, response.text
error = response.json()["error"]
assert error["type"] == "budget_exceeded"
assert "exceeded budget for model=claude-opus-5" in error["message"]
@@ -2110,8 +2110,8 @@ class TestCursorVariantPerModelBudgetEnforcement:
base_response = _post_cursor_with_real_auth(valid_token, attrs, request_model="claude-opus-5")
alias_response = _post_cursor_with_real_auth(valid_token, attrs, request_model="claude-opus-5-fast")
- assert base_response.status_code == 429, base_response.text
- assert alias_response.status_code == 429, alias_response.text
+ assert base_response.status_code == 422, base_response.text
+ assert alias_response.status_code == 422, alias_response.text
assert alias_response.json() == base_response.json()
diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py
index f71f9c20f3b..950a6cc3c40 100644
--- a/tests/test_litellm/proxy/test_proxy_server.py
+++ b/tests/test_litellm/proxy/test_proxy_server.py
@@ -10872,7 +10872,7 @@ async def test_realtime_session_rejected_in_pre_call_releases_the_budget_reserva
"""A rate-limit or guardrail rejection happens before route_request, so the
relay never runs and no success log can own the reservation. The endpoint
must release it on that exit too, or the key stays pinned at the reserved
- amount and its next requests 429 with budget_exceeded while /key/info shows
+ amount and its next requests 422 with budget_exceeded while /key/info shows
spend 0 (reproduced live with rpm_limit=1). The client still gets the
pre-call error event and the 1011 close it got before."""
reservation: Final = {"reserved_cost": 0.55, "input_cost": 0.0, "finalized": False, "entries": []}
From b7e11546b569786bd6733ba7a9c90c54f90984d2 Mon Sep 17 00:00:00 2001
From: yassin
Date: Sun, 20 Sep 2026 06:33:57 +0000
Subject: [PATCH 119/317] test: expect 422 for budget refusals in unification,
e2e and integration suites
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/e2e/e2e_http.py | 2 +-
.../e2e/management/test_key_management_e2e.py | 4 +--
.../budgets/test_budget_enforcement_e2e.py | 30 +++++++++----------
.../budgets/test_multi_window_budget_e2e.py | 2 +-
.../test_team_multi_window_budget_e2e.py | 2 +-
.../test_partial_update_sequences.py | 4 +--
.../integration/spend/test_cache_and_quota.py | 4 +--
.../test_rate_limit_error_unification.py | 6 ++--
8 files changed, 27 insertions(+), 27 deletions(-)
diff --git a/tests/e2e/e2e_http.py b/tests/e2e/e2e_http.py
index 4184b6cbefc..d4978601b20 100644
--- a/tests/e2e/e2e_http.py
+++ b/tests/e2e/e2e_http.py
@@ -95,7 +95,7 @@ class UnauthorizedError(BaseModel):
class RateLimitedError(BaseModel):
kind: Literal["rate_limited"] = "rate_limited"
retry_after_seconds: int | None = None
- # litellm overloads 429 for budget_exceeded too, so keep the body to tell them apart.
+ # keep the body so callers can tell limiter kinds apart.
body: str = ""
diff --git a/tests/e2e/management/test_key_management_e2e.py b/tests/e2e/management/test_key_management_e2e.py
index 353b0f7cf09..39a9e657b8c 100644
--- a/tests/e2e/management/test_key_management_e2e.py
+++ b/tests/e2e/management/test_key_management_e2e.py
@@ -96,8 +96,8 @@ def _spend_until_budget_blocks(client: ManagementClient, key: str) -> None:
for _ in range(40):
outcome = client.chat_status(key, SPEND_MODEL, f"spend {unique_marker()}")
if _is_budget_block(outcome):
- assert outcome.status_code == 429, (
- f"budget refusal must be 429, got {outcome.status_code}: {outcome.body[:200]}"
+ assert outcome.status_code == 422, (
+ f"budget refusal must be 422, got {outcome.status_code}: {outcome.body[:200]}"
)
return
assert outcome.ok, f"paid call failed before the budget tripped ({outcome.status_code}): {outcome.body[:300]}"
diff --git a/tests/e2e/quota_management/budgets/test_budget_enforcement_e2e.py b/tests/e2e/quota_management/budgets/test_budget_enforcement_e2e.py
index 918739863ce..8a9be1d1385 100644
--- a/tests/e2e/quota_management/budgets/test_budget_enforcement_e2e.py
+++ b/tests/e2e/quota_management/budgets/test_budget_enforcement_e2e.py
@@ -46,10 +46,10 @@ def _assert_budget_blocks(client: BudgetClient, key: str, *, user: str = "") ->
pytest.fail("budget never enforced within the call budget")
-def _assert_blocked_429(client: BudgetClient, key: str) -> StreamingResponse:
+def _assert_blocked_422(client: BudgetClient, key: str) -> StreamingResponse:
blocked = _assert_budget_blocks(client, key)
- assert blocked.status_code == 429, (
- f"budget refusal must be 429, got {blocked.status_code}: {blocked.body[:200]}"
+ assert blocked.status_code == 422, (
+ f"budget refusal must be 422, got {blocked.status_code}: {blocked.body[:200]}"
)
return blocked
@@ -60,7 +60,7 @@ class TestBudgetBlocksPerLevel:
key = client.generate_key(max_budget=TINY_CAP)
resources.defer(lambda: client.delete_key(key))
- _assert_blocked_429(client, key)
+ _assert_blocked_422(client, key)
@pytest.mark.covers("quota_management.budget.team.blocks_over_limit")
def test_team_budget_blocks_every_team_key(self, client: BudgetClient, resources: ResourceManager) -> None:
@@ -71,10 +71,10 @@ class TestBudgetBlocksPerLevel:
sibling_key = client.generate_key(team_id=team_id)
resources.defer(lambda: client.delete_key(sibling_key))
- _assert_blocked_429(client, spender_key)
+ _assert_blocked_422(client, spender_key)
sibling = _chat(client, sibling_key)
- assert is_budget_block(sibling) and sibling.status_code == 429, (
- f"a sibling key on the capped team must get the same 429 budget_exceeded, "
+ assert is_budget_block(sibling) and sibling.status_code == 422, (
+ f"a sibling key on the capped team must get the same 422 budget_exceeded, "
f"got {sibling.status_code}: {sibling.body[:200]}"
)
@@ -99,10 +99,10 @@ class TestBudgetBlocksPerLevel:
team_key = client.generate_key(team_id=team_id, user_id=user_id)
resources.defer(lambda: client.delete_key(team_key))
- _assert_blocked_429(client, first_key)
+ _assert_blocked_422(client, first_key)
second = _chat(client, second_key)
- assert is_budget_block(second) and second.status_code == 429, (
- f"the second personal key of a user over budget must get the same 429 budget_exceeded, "
+ assert is_budget_block(second) and second.status_code == 422, (
+ f"the second personal key of a user over budget must get the same 422 budget_exceeded, "
f"got {second.status_code}: {second.body[:200]}"
)
team_result = _chat(client, team_key)
@@ -133,7 +133,7 @@ class TestBudgetBlocksPerLevel:
key = client.generate_key(team_id=team_id)
resources.defer(lambda: client.delete_key(key))
- blocked = _assert_blocked_429(client, key)
+ blocked = _assert_blocked_422(client, key)
assert f"Organization={org_id}" in blocked.body, (
f"refusal must name the org as the blocker, got: {blocked.body[:200]}"
)
@@ -155,7 +155,7 @@ class TestBudgetBlocksPerLevel:
teammate_key = client.generate_key(team_id=team_id, user_id=teammate_id)
resources.defer(lambda: client.delete_key(teammate_key))
- _assert_blocked_429(client, member_key)
+ _assert_blocked_422(client, member_key)
require_successful_call(_chat(client, teammate_key))
@@ -176,7 +176,7 @@ class TestKeyBudgetBlocksAcrossKeyKinds:
control_key = client.generate_key(user_id=user_id)
resources.defer(lambda: client.delete_key(control_key))
- _assert_blocked_429(client, capped_key)
+ _assert_blocked_422(client, capped_key)
require_successful_call(_chat(client, control_key))
@pytest.mark.covers("quota_management.budget.key.blocks_over_limit")
@@ -188,7 +188,7 @@ class TestKeyBudgetBlocksAcrossKeyKinds:
control_key = client.generate_key(team_id=team_id)
resources.defer(lambda: client.delete_key(control_key))
- _assert_blocked_429(client, capped_key)
+ _assert_blocked_422(client, capped_key)
require_successful_call(_chat(client, control_key))
@pytest.mark.covers("quota_management.budget.key.blocks_over_limit")
@@ -205,5 +205,5 @@ class TestKeyBudgetBlocksAcrossKeyKinds:
control_key = client.generate_key(team_id=team_id, user_id=member_id)
resources.defer(lambda: client.delete_key(control_key))
- _assert_blocked_429(client, capped_key)
+ _assert_blocked_422(client, capped_key)
require_successful_call(_chat(client, control_key))
diff --git a/tests/e2e/quota_management/budgets/test_multi_window_budget_e2e.py b/tests/e2e/quota_management/budgets/test_multi_window_budget_e2e.py
index e1cca0c0414..e04f857545d 100644
--- a/tests/e2e/quota_management/budgets/test_multi_window_budget_e2e.py
+++ b/tests/e2e/quota_management/budgets/test_multi_window_budget_e2e.py
@@ -102,7 +102,7 @@ def test_long_window_blocks_after_short_window_resets(client: BudgetClient, reso
# 1. drive the key to get blocked by SHORT_WINDOW, assert it's budget error
blocked = _drive_to_block(client, key)
- assert blocked.status_code == 429, f"budget block was not a 429: {blocked.status_code} {blocked.body[:200]}"
+ assert blocked.status_code == 422, f"budget block was not a 422: {blocked.status_code} {blocked.body[:200]}"
# 2. check the reset times of both budget windows after we drove to being blocked
blocked_reset_at = window_reset_at(client.key_budget_windows(key), SHORT_WINDOW)
diff --git a/tests/e2e/quota_management/budgets/test_team_multi_window_budget_e2e.py b/tests/e2e/quota_management/budgets/test_team_multi_window_budget_e2e.py
index 1db68e6afe9..7683132776b 100644
--- a/tests/e2e/quota_management/budgets/test_team_multi_window_budget_e2e.py
+++ b/tests/e2e/quota_management/budgets/test_team_multi_window_budget_e2e.py
@@ -101,7 +101,7 @@ def test_team_long_window_blocks_after_short_window_resets(client: BudgetClient,
# 1. drive the key to being blocked, assert its blocked by budget budget_exceeded
blocked = _drive_to_block(client, key)
- assert blocked.status_code == 429, f"budget block was not a 429: {blocked.status_code} {blocked.body[:200]}"
+ assert blocked.status_code == 422, f"budget block was not a 422: {blocked.status_code} {blocked.body[:200]}"
# 2. check the the teams budget windows
blocked_reset_at = window_reset_at(client.team_budget_windows(team_id), SHORT_WINDOW)
diff --git a/tests/integration/management/test_partial_update_sequences.py b/tests/integration/management/test_partial_update_sequences.py
index d79c145a685..64d807de39d 100644
--- a/tests/integration/management/test_partial_update_sequences.py
+++ b/tests/integration/management/test_partial_update_sequences.py
@@ -97,7 +97,7 @@ def test_zero_false_and_empty_values_are_not_treated_as_omission(gateway: Gatewa
"POST", "/v1/chat/completions",
{"model": models[0], "messages": [{"role": "user", "content": "zero budget"}]}, key=key,
)
- assert denied.status_code == 429, denied.text
+ assert denied.status_code == 422, denied.text
assert denied.json()["error"]["type"] == "budget_exceeded"
gateway.post("/key/update", {"key": key, "max_budget": 1, "models": [], "metadata": {}})
info: Final = object_value(gateway.get("/key/info", {"key": key})["info"])
@@ -127,7 +127,7 @@ def test_zero_false_and_empty_values_are_not_treated_as_omission(gateway: Gatewa
"POST", "/v1/chat/completions",
{"model": models[0], "messages": [{"role": "user", "content": "updated zero budget"}]}, key=key,
)
- assert zero_after_update.status_code == 429, zero_after_update.text
+ assert zero_after_update.status_code == 422, zero_after_update.text
assert zero_after_update.json()["error"]["type"] == "budget_exceeded"
gateway.post("/key/update", {"key": key, "max_budget": None})
assert read_rows(
diff --git a/tests/integration/spend/test_cache_and_quota.py b/tests/integration/spend/test_cache_and_quota.py
index 840594c1a96..d32297765f6 100644
--- a/tests/integration/spend/test_cache_and_quota.py
+++ b/tests/integration/spend/test_cache_and_quota.py
@@ -185,7 +185,7 @@ def test_key_budget_at_boundary_blocks_provider_then_explicit_reset_restores(gat
{"model": model, "messages": [{"role": "user", "content": f"over budget {uuid.uuid4().hex}"}]},
key=key,
)
- assert denied.status_code == 429 and denied.json()["error"]["type"] == "budget_exceeded", denied.text
+ assert denied.status_code == 422 and denied.json()["error"]["type"] == "budget_exceeded", denied.text
assert upstream.get("/__observations").json()["requests"] == []
assert gateway.chat(model, key=control, text=f"control {uuid.uuid4().hex}")["usage"]["total_tokens"] == 40
gateway.post("/key/update", {"key": key, "spend": 0})
@@ -205,7 +205,7 @@ def test_key_budget_at_boundary_blocks_provider_then_explicit_reset_restores(gat
{"model": model, "messages": [{"role": "user", "content": f"boundary again {uuid.uuid4().hex}"}]},
key=key,
)
- assert denied_again.status_code == 429 and denied_again.json()["error"]["type"] == "budget_exceeded", (
+ assert denied_again.status_code == 422 and denied_again.json()["error"]["type"] == "budget_exceeded", (
denied_again.text
)
assert upstream.get("/__observations").json()["requests"] == []
diff --git a/tests/test_litellm/test_rate_limit_error_unification.py b/tests/test_litellm/test_rate_limit_error_unification.py
index 8241b29aff1..256d33845a5 100644
--- a/tests/test_litellm/test_rate_limit_error_unification.py
+++ b/tests/test_litellm/test_rate_limit_error_unification.py
@@ -1397,10 +1397,10 @@ class TestBudgetExceededErrorSurfacesUnifiedFields:
assert e.llm_provider == "anthropic"
def test_should_keep_existing_status_code_and_message(self):
- # Backward-compat guard: existing callers depend on `status_code=429`
+ # Backward-compat guard: existing callers depend on `status_code=422`
# and the canonical message format.
e = litellm.BudgetExceededError(current_cost=0.000109, max_budget=0.0001)
- assert e.status_code == 429
+ assert e.status_code == 422
assert "Current cost: 0.000109" in e.message
assert "Max budget: 0.0001" in e.message
@@ -1424,7 +1424,7 @@ class TestBudgetExceededErrorSurfacesUnifiedFields:
info = StandardLoggingPayloadSetup.get_error_information(e)
assert info["error_rate_limit_category"] == "litellm_rate_limit"
assert info["error_rate_limit_type"] == "budget"
- assert info["error_code"] == "429"
+ assert info["error_code"] == "422"
assert info["error_class"] == "BudgetExceededError"
def test_should_propagate_llm_provider_to_standard_logging_payload(self):
From bf804f51885820a6163ddcd2322ad4494f1caeb8 Mon Sep 17 00:00:00 2001
From: yassin
Date: Sun, 20 Sep 2026 07:00:10 +0000
Subject: [PATCH 120/317] feat(proxy): add budget_exceeded_status_code setting
to restore 429 for budget refusals
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/__init__.py | 1 +
litellm/exceptions.py | 3 ++-
tests/test_litellm/test_rate_limit_error_unification.py | 5 +++++
3 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/litellm/__init__.py b/litellm/__init__.py
index 738dd0cac76..be8f59d210b 100644
--- a/litellm/__init__.py
+++ b/litellm/__init__.py
@@ -400,6 +400,7 @@ default_redis_batch_cache_expiry: Optional[float] = None
model_alias_map: Dict[str, str] = {}
model_group_settings: Optional["ModelGroupSettings"] = None
max_budget: float = 0.0 # set the max budget across all providers
+budget_exceeded_status_code: int = 422 # set to 429 to restore the pre-422 budget_exceeded response code
budget_duration: Optional[str] = (
None # proxy only - resets budget after fixed duration. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d").
)
diff --git a/litellm/exceptions.py b/litellm/exceptions.py
index 4b236aec99c..c8de2ab12ed 100644
--- a/litellm/exceptions.py
+++ b/litellm/exceptions.py
@@ -16,6 +16,7 @@ from typing import Any, Final
import httpx
import openai
+import litellm
from litellm.types.utils import LiteLLMCommonStrings
from litellm.types.vector_stores import VectorStoreSearchFailure
@@ -1002,7 +1003,7 @@ class BudgetExceededError(Exception):
):
self.current_cost = current_cost
self.max_budget = max_budget
- self.status_code = 422
+ self.status_code = litellm.budget_exceeded_status_code
self.llm_provider = llm_provider or ""
self.entity_type = entity_type
self.entity_id = entity_id
diff --git a/tests/test_litellm/test_rate_limit_error_unification.py b/tests/test_litellm/test_rate_limit_error_unification.py
index 256d33845a5..e5acba938c7 100644
--- a/tests/test_litellm/test_rate_limit_error_unification.py
+++ b/tests/test_litellm/test_rate_limit_error_unification.py
@@ -1404,6 +1404,11 @@ class TestBudgetExceededErrorSurfacesUnifiedFields:
assert "Current cost: 0.000109" in e.message
assert "Max budget: 0.0001" in e.message
+ def test_should_honor_budget_exceeded_status_code_override(self, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setattr(litellm, "budget_exceeded_status_code", 429)
+ e = litellm.BudgetExceededError(current_cost=0.5, max_budget=0.1)
+ assert e.status_code == 429
+
def test_should_still_be_catchable_as_exception_not_rate_limit_error(self):
# Critical: we deliberately did NOT make BudgetExceededError a
# RateLimitError subclass. Existing `except BudgetExceededError:`
From fac518dbf7dd0449b6cbd8f941c822f695f11233 Mon Sep 17 00:00:00 2001
From: Yuneng Jiang
Date: Sun, 20 Sep 2026 00:08:06 -0700
Subject: [PATCH 121/317] feat(proxy): default to the v2 migration resolver
The migrations Job entrypoint (migrations/run.py) has defaulted to v2 with
USE_V2_MIGRATION_RESOLVER=false as the opt-out, and the Helm chart documents
that knob. Proxy startup still defaulted to v1, so the two paths disagreed
about which resolver a deployment runs.
Proxy startup now resolves the same way: v2 unless USE_V2_MIGRATION_RESOLVER
is false or --use_legacy_migration_resolver is passed.
- --use_v2_migration_resolver stays accepted as a no-op that warns, so
existing commands and Helm values do not fail on an unknown option.
- The dedicated Postgres smoke-test job is repointed at the legacy resolver
so v1 keeps real-DB proxy-boot coverage, and the two jobs that deselected
it by name are updated to match the rename.
#39178 reverted an earlier flip because two replicas sharing a database
deadlocked (40P01 / P3018) with neither answering /health/liveliness. That
contention is what #40932 coordinates, which is why this builds on it.
---
.circleci/config.yml | 8 +-
litellm/proxy/proxy_cli.py | 50 ++++++++--
.../test_basic_python_version.py | 10 +-
tests/test_litellm/proxy/test_proxy_cli.py | 92 +++++++++++++++++--
4 files changed, 135 insertions(+), 25 deletions(-)
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 2dcedbfac4a..6d5b9a2258e 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1508,7 +1508,7 @@ jobs:
- run:
name: Run tests
command: |
- uv run --no-sync python -m pytest -vv tests/local_testing/test_basic_python_version.py -k "not v2_resolver"
+ uv run --no-sync python -m pytest -vv tests/local_testing/test_basic_python_version.py -k "not legacy_resolver"
installing_litellm_on_python_3_13:
docker:
@@ -1532,7 +1532,7 @@ jobs:
- run:
name: Run tests
command: |
- uv run --no-sync python -m pytest -v tests/local_testing/test_basic_python_version.py -k "not v2_resolver"
+ uv run --no-sync python -m pytest -v tests/local_testing/test_basic_python_version.py -k "not legacy_resolver"
installing_litellm_on_python_v2_migration_resolver:
docker:
@@ -1561,10 +1561,10 @@ jobs:
url: tcp://localhost:5432
timeout: "60"
- run:
- name: Run v2 migration resolver proxy smoke test
+ name: Run legacy migration resolver proxy smoke test
command: |
uv run --no-sync python -m pytest -vv \
- tests/local_testing/test_basic_python_version.py::test_litellm_proxy_server_config_no_general_settings_v2_resolver
+ tests/local_testing/test_basic_python_version.py::test_litellm_proxy_server_config_no_general_settings_legacy_resolver
helm_chart_testing:
machine:
diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py
index 464d1141f8d..14d0331c0ff 100644
--- a/litellm/proxy/proxy_cli.py
+++ b/litellm/proxy/proxy_cli.py
@@ -181,6 +181,14 @@ def append_query_params(url: str | None, params: dict) -> str:
return modified_url
+def resolve_v2_migration_resolver(*, use_legacy_flag: bool) -> bool:
+ from litellm_proxy_extras.utils import str_to_bool
+
+ if use_legacy_flag:
+ return False
+ return bool(str_to_bool(os.getenv("USE_V2_MIGRATION_RESOLVER", "true")))
+
+
class ProxyInitializationHelpers:
@staticmethod
def _echo_litellm_version():
@@ -932,12 +940,24 @@ class ProxyInitializationHelpers:
is_flag=True,
default=False,
help=(
- "Opt into the v2 migration resolver. Avoids the diff-and-force recovery "
- "path that can cause schema thrashing during rolling deploys where two "
- "LiteLLM versions contend for the same DB. Default is the v1 resolver."
+ "Deprecated and ignored: the v2 migration resolver is now the default, "
+ "so this flag has no effect. It is still accepted so existing commands "
+ "keep working. Pass --use_legacy_migration_resolver, or set "
+ "USE_V2_MIGRATION_RESOLVER=false, to opt back into v1."
),
envvar="USE_V2_MIGRATION_RESOLVER",
)
+@click.option(
+ "--use_legacy_migration_resolver",
+ is_flag=True,
+ default=False,
+ help=(
+ "Fall back to the legacy v1 migration resolver. By default the proxy "
+ "uses the v2 resolver, which avoids the diff-and-force recovery path "
+ "that can cause schema thrashing during rolling deploys where two "
+ "LiteLLM versions contend for the same DB."
+ ),
+)
@click.option(
"--reload",
is_flag=True,
@@ -1005,6 +1025,7 @@ def run_server(
limit_concurrency: int | None,
enforce_prisma_migration_check: bool,
use_v2_migration_resolver: bool,
+ use_legacy_migration_resolver: bool,
reload: bool,
prometheus_metrics_port: int | None,
):
@@ -1346,17 +1367,28 @@ def run_server(
if should_update_prisma_schema(general_settings.get("disable_prisma_schema_update")) is False:
check_prisma_schema_diff(db_url=None)
else:
- if not use_v2_migration_resolver:
+ use_v2_resolver: Final = resolve_v2_migration_resolver(
+ use_legacy_flag=use_legacy_migration_resolver
+ )
+ if use_v2_migration_resolver and use_v2_resolver:
print(
- "\033[1;33mLiteLLM Proxy: Using default (v1) migration resolver. "
- "If your deployment has seen schema thrashing during rolling "
- "deploys, try --use_v2_migration_resolver (safer: avoids the "
- "diff-and-force recovery that caused the thrash).\033[0m"
+ "\033[1;33mLiteLLM Proxy: --use_v2_migration_resolver is "
+ "deprecated and has no effect \u2014 the v2 migration resolver "
+ "is now the default. You can safely remove it. To opt back "
+ "into the legacy v1 resolver, pass "
+ "--use_legacy_migration_resolver.\033[0m"
+ )
+ if not use_v2_resolver:
+ print(
+ "\033[1;33mLiteLLM Proxy: Using the legacy (v1) migration "
+ "resolver. It performs the diff-and-force recovery that can "
+ "cause schema thrashing during rolling deploys where two "
+ "LiteLLM versions contend for the same DB.\033[0m"
)
try:
setup_ok: Final = PrismaManager.setup_database(
use_migrate=not use_prisma_db_push,
- use_v2_resolver=use_v2_migration_resolver,
+ use_v2_resolver=use_v2_resolver,
)
except RuntimeError as e:
# Raised on unrecoverable migration errors: the v2
diff --git a/tests/local_testing/test_basic_python_version.py b/tests/local_testing/test_basic_python_version.py
index fb06ed6b69d..0ce59332417 100644
--- a/tests/local_testing/test_basic_python_version.py
+++ b/tests/local_testing/test_basic_python_version.py
@@ -305,14 +305,14 @@ def _run_proxy_server_smoke_test(extra_proxy_args=None):
def test_litellm_proxy_server_config_no_general_settings():
- """Exercises the default (v1) migration resolver."""
+ """Exercises the default (v2) migration resolver."""
_run_proxy_server_smoke_test()
-def test_litellm_proxy_server_config_no_general_settings_v2_resolver():
- """Exercises the opt-in v2 migration resolver.
+def test_litellm_proxy_server_config_no_general_settings_legacy_resolver():
+ """Exercises the opt-out legacy (v1) migration resolver.
Runs in a separate CI job against a local Postgres to avoid collisions
- with the v1 variant when they share a database.
+ with the default variant when they share a database.
"""
- _run_proxy_server_smoke_test(extra_proxy_args=["--use_v2_migration_resolver"])
+ _run_proxy_server_smoke_test(extra_proxy_args=["--use_legacy_migration_resolver"])
diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py
index 8cbae859b5c..4b83044b36d 100644
--- a/tests/test_litellm/proxy/test_proxy_cli.py
+++ b/tests/test_litellm/proxy/test_proxy_cli.py
@@ -1995,7 +1995,7 @@ class TestRunServerDbSetup:
# use_prisma_db_push should be False (default), so use_migrate should be True
run_server.main(["--local", "--skip_server_startup"], standalone_mode=False)
mock_setup_database.assert_called_with(
- use_migrate=True, use_v2_resolver=False
+ use_migrate=True, use_v2_resolver=True
)
# Reset mocks
@@ -2010,7 +2010,7 @@ class TestRunServerDbSetup:
standalone_mode=False,
)
mock_setup_database.assert_called_with(
- use_migrate=False, use_v2_resolver=False
+ use_migrate=False, use_v2_resolver=True
)
@patch("atexit.register")
@@ -2070,7 +2070,7 @@ class TestRunServerDbSetup:
assert "prisma CLI is neither on PATH" not in capsys.readouterr().out
mock_setup_database.assert_called_once_with(
- use_migrate=True, use_v2_resolver=False
+ use_migrate=True, use_v2_resolver=True
)
@patch("subprocess.run")
@@ -2137,7 +2137,7 @@ class TestRunServerDbSetup:
)
assert exc_info.value.code == 1
mock_setup_database.assert_called_once_with(
- use_migrate=True, use_v2_resolver=False
+ use_migrate=True, use_v2_resolver=True
)
@patch("subprocess.run")
@@ -2204,11 +2204,11 @@ class TestRunServerDbSetup:
mock_atexit_register,
mock_subprocess_run,
):
- """USE_V2_MIGRATION_RESOLVER must select the v2 resolver.
+ """USE_V2_MIGRATION_RESOLVER=true must select the v2 resolver.
The Helm migrations Job runs `python litellm/proxy/prisma_migration.py`,
- which calls run_server with a fixed argv, so a deployment has no way to
- pass --use_v2_migration_resolver and an env var is the only route in.
+ which calls run_server with a fixed argv, so a deployment reaches the
+ resolver through the env var rather than a CLI flag.
"""
from litellm.proxy.proxy_cli import run_server
@@ -2249,6 +2249,84 @@ class TestRunServerDbSetup:
use_migrate=True, use_v2_resolver=True
)
+ @pytest.mark.parametrize(
+ "argv_extra, env_extra, expected_v2",
+ [
+ ([], {}, True),
+ ([], {"USE_V2_MIGRATION_RESOLVER": "false"}, False),
+ (["--use_legacy_migration_resolver"], {}, False),
+ (
+ ["--use_legacy_migration_resolver"],
+ {"USE_V2_MIGRATION_RESOLVER": "true"},
+ False,
+ ),
+ (["--use_v2_migration_resolver"], {}, True),
+ ],
+ ids=[
+ "default-is-v2",
+ "env-false-opts-out",
+ "legacy-flag-opts-out",
+ "legacy-flag-beats-env-true",
+ "deprecated-v2-flag-still-accepted",
+ ],
+ )
+ @patch("subprocess.run")
+ @patch("atexit.register")
+ @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above
+ @patch("litellm.proxy.db.check_migration.check_prisma_schema_diff") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above
+ @patch("litellm.proxy.db.prisma_client.should_update_prisma_schema") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above
+ def test_migration_resolver_selection(
+ self,
+ mock_should_update_schema,
+ mock_check_schema_diff,
+ mock_setup_database,
+ mock_atexit_register,
+ mock_subprocess_run,
+ argv_extra,
+ env_extra,
+ expected_v2,
+ ):
+ from litellm.proxy.proxy_cli import run_server
+
+ mock_subprocess_run.return_value = MagicMock(returncode=0)
+ mock_should_update_schema.return_value = True
+ mock_setup_database.return_value = True
+
+ mock_proxy_module = MagicMock(
+ app=MagicMock(),
+ ProxyConfig=MagicMock(),
+ KeyManagementSettings=MagicMock(),
+ save_worker_config=MagicMock(),
+ )
+
+ clean_env = {
+ k: v
+ for k, v in os.environ.items()
+ if k
+ not in ("DATABASE_URL", "DIRECT_URL", "USE_V2_MIGRATION_RESOLVER")
+ }
+ clean_env["DATABASE_URL"] = "postgresql://test:test@localhost:5432/test"
+ clean_env.update(env_extra)
+
+ with (
+ patch.dict(os.environ, clean_env, clear=True),
+ patch.dict(
+ "sys.modules",
+ {
+ "proxy_server": mock_proxy_module,
+ "litellm.proxy.proxy_server": mock_proxy_module,
+ },
+ ),
+ ):
+ run_server.main(
+ ["--local", "--skip_server_startup", *argv_extra],
+ standalone_mode=False,
+ )
+
+ mock_setup_database.assert_called_once_with(
+ use_migrate=True, use_v2_resolver=expected_v2
+ )
+
# --- Module-level helpers for worker startup hook tests ---
From 556c7f6b68ec5e38bc13a3d9ad10b58a300ef7bc Mon Sep 17 00:00:00 2001
From: Yuneng Jiang
Date: Sun, 20 Sep 2026 00:50:05 -0700
Subject: [PATCH 122/317] ci: keep real-database coverage for both migration
resolvers
The Postgres-backed smoke job previously exercised one resolver. Running
the default and the legacy variants in it covers v2 now that it is the
default, without losing v1's coverage.
---
.circleci/config.yml | 3 ++-
tests/local_testing/test_basic_python_version.py | 4 ++--
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 6d5b9a2258e..ba107472b8d 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1561,9 +1561,10 @@ jobs:
url: tcp://localhost:5432
timeout: "60"
- run:
- name: Run legacy migration resolver proxy smoke test
+ name: Run both migration resolvers against Postgres
command: |
uv run --no-sync python -m pytest -vv \
+ tests/local_testing/test_basic_python_version.py::test_litellm_proxy_server_config_no_general_settings \
tests/local_testing/test_basic_python_version.py::test_litellm_proxy_server_config_no_general_settings_legacy_resolver
helm_chart_testing:
diff --git a/tests/local_testing/test_basic_python_version.py b/tests/local_testing/test_basic_python_version.py
index 0ce59332417..ef500fdff42 100644
--- a/tests/local_testing/test_basic_python_version.py
+++ b/tests/local_testing/test_basic_python_version.py
@@ -312,7 +312,7 @@ def test_litellm_proxy_server_config_no_general_settings():
def test_litellm_proxy_server_config_no_general_settings_legacy_resolver():
"""Exercises the opt-out legacy (v1) migration resolver.
- Runs in a separate CI job against a local Postgres to avoid collisions
- with the default variant when they share a database.
+ Runs after the default variant in the CI job that provides a local
+ Postgres, so both resolvers get real-database proxy-boot coverage.
"""
_run_proxy_server_smoke_test(extra_proxy_args=["--use_legacy_migration_resolver"])
From e1d2789d29650fc95533eae7b7b5a824ec5e87c4 Mon Sep 17 00:00:00 2001
From: yuneng
Date: Sun, 20 Sep 2026 07:35:23 +0000
Subject: [PATCH 123/317] test(llms): migrate phase 6 provider unit tests to
tests/unit
Migrate 18 provider test files from tests/test_litellm/llms to tests/unit/llms.
194 kept tests move as-is after mutation testing; 1 test deleted
(test_completion_datarobot_with_environment_variables, env-gated no-assert);
the fixture-only fal_ai cost calculator file is removed.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../llms/fal_ai/test_cost_calculator.py | 19 ---------------
.../llms/chatgpt/chat/test_streaming_utils.py | 0
.../test_chatgpt_responses_transformation.py | 9 +++++++
.../test_cloudflare_transformation.py | 0
.../cohere/chat/test_cohere_transformation.py | 0
.../cohere/embed/test_v1_transformation.py | 0
.../ocr/test_cohere_parse_transformation.py | 0
.../rerank/test_rerank_guardrail_handler.py | 0
.../llms/crusoe/test_crusoe.py | 0
.../test_databricks_chat_transformation.py | 0
...est_databricks_responses_transformation.py | 0
.../test_datarobot_chat_transformation.py | 0
.../llms/datarobot/test_datarobot.py | 24 -------------------
.../chat/test_deepseek_chat_transformation.py | 0
...pseek_anthropic_messages_transformation.py | 0
.../deepseek/test_deepseek_cost_calculator.py | 10 ++++++++
...docker_model_runner_chat_transformation.py | 0
...levenlabs_text_to_speech_transformation.py | 0
.../fastcrw/search/test_transformation.py | 0
19 files changed, 19 insertions(+), 43 deletions(-)
delete mode 100644 tests/test_litellm/llms/fal_ai/test_cost_calculator.py
rename tests/{test_litellm => unit}/llms/chatgpt/chat/test_streaming_utils.py (100%)
rename tests/{test_litellm => unit}/llms/chatgpt/responses/test_chatgpt_responses_transformation.py (97%)
rename tests/{test_litellm => unit}/llms/cloudflare/test_cloudflare_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/cohere/chat/test_cohere_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/cohere/embed/test_v1_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/cohere/ocr/test_cohere_parse_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/cohere/rerank/test_rerank_guardrail_handler.py (100%)
rename tests/{test_litellm => unit}/llms/crusoe/test_crusoe.py (100%)
rename tests/{test_litellm => unit}/llms/databricks/chat/test_databricks_chat_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/databricks/responses/test_databricks_responses_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/datarobot/chat/test_datarobot_chat_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/datarobot/test_datarobot.py (75%)
rename tests/{test_litellm => unit}/llms/deepseek/chat/test_deepseek_chat_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/deepseek/test_deepseek_cost_calculator.py (91%)
rename tests/{test_litellm => unit}/llms/docker_model_runner/test_docker_model_runner_chat_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/elevenlabs/test_elevenlabs_text_to_speech_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/fastcrw/search/test_transformation.py (100%)
diff --git a/tests/test_litellm/llms/fal_ai/test_cost_calculator.py b/tests/test_litellm/llms/fal_ai/test_cost_calculator.py
deleted file mode 100644
index 419aff42059..00000000000
--- a/tests/test_litellm/llms/fal_ai/test_cost_calculator.py
+++ /dev/null
@@ -1,19 +0,0 @@
-import pytest
-
-import litellm
-from litellm.litellm_core_utils.llm_cost_calc.utils import CostCalculatorUtils
-from litellm.llms.fal_ai.cost_calculator import cost_calculator
-from litellm.types.utils import ImageObject, ImageResponse
-
-
-@pytest.fixture(autouse=True)
-def _use_local_model_cost_map(monkeypatch):
- monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
- monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
- litellm.get_model_info.cache_clear()
- yield
- litellm.get_model_info.cache_clear()
-
-
-def _image_response(num_images: int = 1) -> ImageResponse:
- return ImageResponse(data=[ImageObject(url="https://example.com/img.png") for _ in range(num_images)])
diff --git a/tests/test_litellm/llms/chatgpt/chat/test_streaming_utils.py b/tests/unit/llms/chatgpt/chat/test_streaming_utils.py
similarity index 100%
rename from tests/test_litellm/llms/chatgpt/chat/test_streaming_utils.py
rename to tests/unit/llms/chatgpt/chat/test_streaming_utils.py
diff --git a/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py b/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py
similarity index 97%
rename from tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py
rename to tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py
index 9bf3eec61f9..c01ec312796 100644
--- a/tests/test_litellm/llms/chatgpt/responses/test_chatgpt_responses_transformation.py
+++ b/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py
@@ -19,6 +19,15 @@ from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
+@pytest.fixture
+def local_model_cost_map(monkeypatch):
+ monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
+ monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
+ litellm.get_model_info.cache_clear()
+ yield
+ litellm.get_model_info.cache_clear()
+
+
class TestChatGPTResponsesAPITransformation:
@pytest.mark.parametrize(
"model_name",
diff --git a/tests/test_litellm/llms/cloudflare/test_cloudflare_transformation.py b/tests/unit/llms/cloudflare/test_cloudflare_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/cloudflare/test_cloudflare_transformation.py
rename to tests/unit/llms/cloudflare/test_cloudflare_transformation.py
diff --git a/tests/test_litellm/llms/cohere/chat/test_cohere_transformation.py b/tests/unit/llms/cohere/chat/test_cohere_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/cohere/chat/test_cohere_transformation.py
rename to tests/unit/llms/cohere/chat/test_cohere_transformation.py
diff --git a/tests/test_litellm/llms/cohere/embed/test_v1_transformation.py b/tests/unit/llms/cohere/embed/test_v1_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/cohere/embed/test_v1_transformation.py
rename to tests/unit/llms/cohere/embed/test_v1_transformation.py
diff --git a/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_transformation.py b/tests/unit/llms/cohere/ocr/test_cohere_parse_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/cohere/ocr/test_cohere_parse_transformation.py
rename to tests/unit/llms/cohere/ocr/test_cohere_parse_transformation.py
diff --git a/tests/test_litellm/llms/cohere/rerank/test_rerank_guardrail_handler.py b/tests/unit/llms/cohere/rerank/test_rerank_guardrail_handler.py
similarity index 100%
rename from tests/test_litellm/llms/cohere/rerank/test_rerank_guardrail_handler.py
rename to tests/unit/llms/cohere/rerank/test_rerank_guardrail_handler.py
diff --git a/tests/test_litellm/llms/crusoe/test_crusoe.py b/tests/unit/llms/crusoe/test_crusoe.py
similarity index 100%
rename from tests/test_litellm/llms/crusoe/test_crusoe.py
rename to tests/unit/llms/crusoe/test_crusoe.py
diff --git a/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py b/tests/unit/llms/databricks/chat/test_databricks_chat_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py
rename to tests/unit/llms/databricks/chat/test_databricks_chat_transformation.py
diff --git a/tests/test_litellm/llms/databricks/responses/test_databricks_responses_transformation.py b/tests/unit/llms/databricks/responses/test_databricks_responses_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/databricks/responses/test_databricks_responses_transformation.py
rename to tests/unit/llms/databricks/responses/test_databricks_responses_transformation.py
diff --git a/tests/test_litellm/llms/datarobot/chat/test_datarobot_chat_transformation.py b/tests/unit/llms/datarobot/chat/test_datarobot_chat_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/datarobot/chat/test_datarobot_chat_transformation.py
rename to tests/unit/llms/datarobot/chat/test_datarobot_chat_transformation.py
diff --git a/tests/test_litellm/llms/datarobot/test_datarobot.py b/tests/unit/llms/datarobot/test_datarobot.py
similarity index 75%
rename from tests/test_litellm/llms/datarobot/test_datarobot.py
rename to tests/unit/llms/datarobot/test_datarobot.py
index d9f42960601..c98faf0151e 100644
--- a/tests/test_litellm/llms/datarobot/test_datarobot.py
+++ b/tests/unit/llms/datarobot/test_datarobot.py
@@ -78,27 +78,3 @@ def test_completion_datarobot_with_deployment():
except Exception as e:
pytest.fail(f"Error occurred: {e}")
-
-def test_completion_datarobot_with_environment_variables():
- """Allow the test to run with environment variables if they are set for integrations."""
- # If keys are not set, the test will be skipped
- if os.environ.get("DATAROBOT_API_TOKEN") is None:
- return
-
- messages = [
- {"role": "user", "content": "What's the weather like in San Francisco?"}
- ]
- try:
- response = completion(
- model="datarobot/vertex_ai/gemini-1.5-flash-002",
- messages=messages,
- max_tokens=5,
- clientId="custom-model",
- )
- print(response)
- assert response["object"] == "chat.completion"
- assert response["model"] == "gemini-1.5-flash-002"
- assert len(response["choices"]) == 1
- assert len(response["choices"][0]["message"]["content"]) > 0
- except Exception as e:
- pytest.fail(f"Error occurred: {e}")
diff --git a/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py b/tests/unit/llms/deepseek/chat/test_deepseek_chat_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py
rename to tests/unit/llms/deepseek/chat/test_deepseek_chat_transformation.py
diff --git a/tests/test_litellm/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py b/tests/unit/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py
rename to tests/unit/llms/deepseek/messages/test_deepseek_anthropic_messages_transformation.py
diff --git a/tests/test_litellm/llms/deepseek/test_deepseek_cost_calculator.py b/tests/unit/llms/deepseek/test_deepseek_cost_calculator.py
similarity index 91%
rename from tests/test_litellm/llms/deepseek/test_deepseek_cost_calculator.py
rename to tests/unit/llms/deepseek/test_deepseek_cost_calculator.py
index c3a4cdad0ac..cde4a8a4244 100644
--- a/tests/test_litellm/llms/deepseek/test_deepseek_cost_calculator.py
+++ b/tests/unit/llms/deepseek/test_deepseek_cost_calculator.py
@@ -7,6 +7,16 @@ import litellm
from litellm._internal_context import pinned_billing_time
from litellm.types.utils import ModelResponse, PromptTokensDetailsWrapper, Usage
+
+@pytest.fixture
+def local_model_cost_map(monkeypatch):
+ monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
+ monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
+ litellm.get_model_info.cache_clear()
+ yield
+ litellm.get_model_info.cache_clear()
+
+
PEAK_MOMENTS: Final = (
pytest.param(datetime(2026, 9, 22, 8, 0, tzinfo=timezone.utc), id="tuesday-08:00"),
pytest.param(datetime(2026, 9, 25, 9, 59, tzinfo=timezone.utc), id="friday-09:59"),
diff --git a/tests/test_litellm/llms/docker_model_runner/test_docker_model_runner_chat_transformation.py b/tests/unit/llms/docker_model_runner/test_docker_model_runner_chat_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/docker_model_runner/test_docker_model_runner_chat_transformation.py
rename to tests/unit/llms/docker_model_runner/test_docker_model_runner_chat_transformation.py
diff --git a/tests/test_litellm/llms/elevenlabs/test_elevenlabs_text_to_speech_transformation.py b/tests/unit/llms/elevenlabs/test_elevenlabs_text_to_speech_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/elevenlabs/test_elevenlabs_text_to_speech_transformation.py
rename to tests/unit/llms/elevenlabs/test_elevenlabs_text_to_speech_transformation.py
diff --git a/tests/test_litellm/llms/fastcrw/search/test_transformation.py b/tests/unit/llms/fastcrw/search/test_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/fastcrw/search/test_transformation.py
rename to tests/unit/llms/fastcrw/search/test_transformation.py
From 4048062e537329784c8197fc9b16ba77a08b7611 Mon Sep 17 00:00:00 2001
From: yuneng
Date: Sun, 20 Sep 2026 07:48:07 +0000
Subject: [PATCH 124/317] test(llms): annotate local_model_cost_map fixtures in
phase 6 tests
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../chatgpt/responses/test_chatgpt_responses_transformation.py | 3 ++-
tests/unit/llms/deepseek/test_deepseek_cost_calculator.py | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py b/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py
index c01ec312796..0b04dd0ed78 100644
--- a/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py
+++ b/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py
@@ -5,6 +5,7 @@ Source: litellm/llms/chatgpt/responses/transformation.py
"""
import json
+from collections.abc import Generator
from unittest.mock import MagicMock, patch
import httpx
@@ -20,7 +21,7 @@ from litellm.utils import ProviderConfigManager
@pytest.fixture
-def local_model_cost_map(monkeypatch):
+def local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]:
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
litellm.get_model_info.cache_clear()
diff --git a/tests/unit/llms/deepseek/test_deepseek_cost_calculator.py b/tests/unit/llms/deepseek/test_deepseek_cost_calculator.py
index cde4a8a4244..e61c15c3746 100644
--- a/tests/unit/llms/deepseek/test_deepseek_cost_calculator.py
+++ b/tests/unit/llms/deepseek/test_deepseek_cost_calculator.py
@@ -1,3 +1,4 @@
+from collections.abc import Generator
from datetime import datetime, timezone
from typing import Final
@@ -9,7 +10,7 @@ from litellm.types.utils import ModelResponse, PromptTokensDetailsWrapper, Usage
@pytest.fixture
-def local_model_cost_map(monkeypatch):
+def local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]:
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
litellm.get_model_info.cache_clear()
From b450baa402527c5be47a532508d7708fd1ca9a41 Mon Sep 17 00:00:00 2001
From: yuneng
Date: Sun, 20 Sep 2026 07:47:33 +0000
Subject: [PATCH 125/317] test(llms): migrate phase 5 provider unit tests to
tests/unit
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../image/test_bedrock_image_bearer_token.py | 158 ------------------
.../test_amazon_nova_canvas_transformation.py | 0
.../test_amazon_stability3_transformation.py | 0
.../image/test_bedrock_image_bearer_token.py | 21 +++
.../test_bedrock_image_prepare_request.py | 0
.../test_amazon_nova_canvas_image_edit.py | 0
.../test_bedrock_agent_transformation.py | 0
.../guardrail_translation/test_handler.py | 0
...test_bedrock_passthrough_transformation.py | 2 -
.../realtime/test_bedrock_realtime_handler.py | 0
.../test_bedrock_realtime_transformation.py | 0
.../test_bedrock_rerank_header_forwarding.py | 0
...est_bedrock_vector_store_transformation.py | 0
...drock_mantle_passthrough_transformation.py | 0
.../test_bfl_image_edit_transformation.py | 0
...est_bfl_image_generation_transformation.py | 0
.../test_bfl_common_utils.py | 0
.../chat/test_bytez_chat_transformation.py | 0
.../test_cerebras_chat_transformation.py | 0
.../llms/chat/test_converse_handler.py | 0
.../chatgpt/test_chatgpt_authenticator.py | 0
21 files changed, 21 insertions(+), 160 deletions(-)
delete mode 100644 tests/test_litellm/llms/bedrock/image/test_bedrock_image_bearer_token.py
rename tests/{test_litellm => unit}/llms/bedrock/image/test_amazon_nova_canvas_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/image/test_amazon_stability3_transformation.py (100%)
create mode 100644 tests/unit/llms/bedrock/image/test_bedrock_image_bearer_token.py
rename tests/{test_litellm => unit}/llms/bedrock/image/test_bedrock_image_prepare_request.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/invoke_agent/test_bedrock_agent_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/passthrough/guardrail_translation/test_handler.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/passthrough/test_bedrock_passthrough_transformation.py (99%)
rename tests/{test_litellm => unit}/llms/bedrock/realtime/test_bedrock_realtime_handler.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/realtime/test_bedrock_realtime_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/rerank/test_bedrock_rerank_header_forwarding.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock_mantle/passthrough/test_bedrock_mantle_passthrough_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/black_forest_labs/image_edit/test_bfl_image_edit_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/black_forest_labs/image_generation/test_bfl_image_generation_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/black_forest_labs/test_bfl_common_utils.py (100%)
rename tests/{test_litellm => unit}/llms/bytez/chat/test_bytez_chat_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/cerebras/test_cerebras_chat_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/chat/test_converse_handler.py (100%)
rename tests/{test_litellm => unit}/llms/chatgpt/test_chatgpt_authenticator.py (100%)
diff --git a/tests/test_litellm/llms/bedrock/image/test_bedrock_image_bearer_token.py b/tests/test_litellm/llms/bedrock/image/test_bedrock_image_bearer_token.py
deleted file mode 100644
index 0b11a66c100..00000000000
--- a/tests/test_litellm/llms/bedrock/image/test_bedrock_image_bearer_token.py
+++ /dev/null
@@ -1,158 +0,0 @@
-import json
-import os
-from unittest.mock import Mock, patch
-import pytest
-
-
-import litellm
-from litellm.llms.custom_httpx.http_handler import HTTPHandler, AsyncHTTPHandler
-
-# Mock response for Bedrock image generation
-mock_image_response = {"images": ["base64_encoded_image_data"], "error": None}
-
-
-class TestBedrockImageGeneration:
- def test_image_generation_with_api_key_bearer_token(self):
- """Test image generation with bearer token authentication"""
- test_api_key = "test-bearer-token-12345"
- model = "bedrock/stability.sd3-large-v1:0"
- prompt = "A cute baby sea otter"
-
- with patch(
- "litellm.llms.bedrock.image_generation.image_handler.BedrockImageGeneration.image_generation"
- ) as mock_bedrock_image_gen:
- # Setup mock response
- mock_image_response_obj = litellm.ImageResponse()
- mock_image_response_obj.data = [{"url": "https://example.com/image.jpg"}]
- mock_bedrock_image_gen.return_value = mock_image_response_obj
-
- response = litellm.image_generation(
- model=model,
- prompt=prompt,
- aws_region_name="us-west-2",
- api_key=test_api_key,
- )
-
- assert response is not None
- assert len(response.data) > 0
-
- mock_bedrock_image_gen.assert_called_once()
- for call in mock_bedrock_image_gen.call_args_list:
- if "headers" in call.kwargs:
- headers = call.kwargs["headers"]
- if (
- "Authorization" in headers
- and headers["Authorization"] == f"Bearer {test_api_key}"
- ):
- break
-
- def test_image_generation_with_env_variable_bearer_token(self, monkeypatch):
- """Test image generation with bearer token from environment variable"""
- test_api_key = "env-bearer-token-12345"
- model = "bedrock/stability.sd3-large-v1:0"
- prompt = "A cute baby sea otter"
-
- # Mock the environment variable
- with (
- patch.dict(os.environ, {"AWS_BEARER_TOKEN_BEDROCK": test_api_key}),
- patch(
- "litellm.llms.bedrock.image_generation.image_handler.BedrockImageGeneration.image_generation"
- ) as mock_bedrock_image_gen,
- ):
-
- mock_image_response_obj = litellm.ImageResponse()
- mock_image_response_obj.data = [{"url": "https://example.com/image.jpg"}]
- mock_bedrock_image_gen.return_value = mock_image_response_obj
-
- response = litellm.image_generation(
- model=model, prompt=prompt, aws_region_name="us-west-2"
- )
-
- assert response is not None
- assert len(response.data) > 0
-
- mock_bedrock_image_gen.assert_called_once()
- for call in mock_bedrock_image_gen.call_args_list:
- if "headers" in call.kwargs:
- headers = call.kwargs["headers"]
- if (
- "Authorization" in headers
- and headers["Authorization"] == f"Bearer {test_api_key}"
- ):
- break
-
- @pytest.mark.asyncio
- async def test_async_image_generation_with_bearer_token(self):
- """Test async image generation with bearer token authentication"""
- test_api_key = "async-bearer-token-12345"
- model = "bedrock/stability.sd3-large-v1:0"
- prompt = "A cute baby sea otter"
-
- with patch(
- "litellm.llms.bedrock.image_generation.image_handler.BedrockImageGeneration.async_image_generation"
- ) as mock_async_bedrock_image_gen:
- mock_image_response_obj = litellm.ImageResponse()
- mock_image_response_obj.data = [{"url": "https://example.com/image.jpg"}]
- mock_async_bedrock_image_gen.return_value = mock_image_response_obj
-
- # Call async image generation with api_key parameter
- response = await litellm.aimage_generation(
- model=model,
- prompt=prompt,
- aws_region_name="us-west-2",
- api_key=test_api_key,
- )
-
- assert response is not None
- assert len(response.data) > 0
-
- mock_async_bedrock_image_gen.assert_called_once()
- for call in mock_async_bedrock_image_gen.call_args_list:
- if "headers" in call.kwargs:
- headers = call.kwargs["headers"]
- if (
- "Authorization" in headers
- and headers["Authorization"] == f"Bearer {test_api_key}"
- ):
- break
-
- def test_image_generation_with_sigv4(self):
- """Test image generation falls back to SigV4 auth when no bearer token is provided"""
- model = "bedrock/stability.sd3-large-v1:0"
- prompt = "A cute baby sea otter"
-
- with patch(
- "litellm.llms.bedrock.image_generation.image_handler.BedrockImageGeneration.image_generation"
- ) as mock_bedrock_image_gen:
- mock_image_response_obj = litellm.ImageResponse()
- mock_image_response_obj.data = [{"url": "https://example.com/image.jpg"}]
- mock_bedrock_image_gen.return_value = mock_image_response_obj
-
- response = litellm.image_generation(
- model=model, prompt=prompt, aws_region_name="us-west-2"
- )
-
- assert response is not None
- assert len(response.data) > 0
- mock_bedrock_image_gen.assert_called_once()
-
-
-def test_image_generation_bearer_token_never_runs_the_sigv4_credential_chain(monkeypatch):
- """The deployment's AWS profile does not exist, so resolving SigV4 credentials
- raises; a bearer-token deployment must still sign the request with the
- bearer token alone."""
- from litellm.llms.bedrock.image_generation.image_handler import BedrockImageGeneration
-
- monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345")
-
- request = BedrockImageGeneration()._prepare_request(
- model="amazon.nova-canvas-v1:0",
- prompt="A cute baby sea otter",
- optional_params={"aws_region_name": "us-west-2", "aws_profile_name": "litellm-no-such-aws-profile"},
- api_base=None,
- extra_headers=None,
- api_key=None,
- logging_obj=Mock(),
- )
-
- assert request.prepped.headers["Authorization"] == "Bearer env-bearer-token-12345"
diff --git a/tests/test_litellm/llms/bedrock/image/test_amazon_nova_canvas_transformation.py b/tests/unit/llms/bedrock/image/test_amazon_nova_canvas_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/image/test_amazon_nova_canvas_transformation.py
rename to tests/unit/llms/bedrock/image/test_amazon_nova_canvas_transformation.py
diff --git a/tests/test_litellm/llms/bedrock/image/test_amazon_stability3_transformation.py b/tests/unit/llms/bedrock/image/test_amazon_stability3_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/image/test_amazon_stability3_transformation.py
rename to tests/unit/llms/bedrock/image/test_amazon_stability3_transformation.py
diff --git a/tests/unit/llms/bedrock/image/test_bedrock_image_bearer_token.py b/tests/unit/llms/bedrock/image/test_bedrock_image_bearer_token.py
new file mode 100644
index 00000000000..599507da03d
--- /dev/null
+++ b/tests/unit/llms/bedrock/image/test_bedrock_image_bearer_token.py
@@ -0,0 +1,21 @@
+from unittest.mock import Mock
+
+def test_image_generation_bearer_token_never_runs_the_sigv4_credential_chain(monkeypatch):
+ """The deployment's AWS profile does not exist, so resolving SigV4 credentials
+ raises; a bearer-token deployment must still sign the request with the
+ bearer token alone."""
+ from litellm.llms.bedrock.image_generation.image_handler import BedrockImageGeneration
+
+ monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345")
+
+ request = BedrockImageGeneration()._prepare_request(
+ model="amazon.nova-canvas-v1:0",
+ prompt="A cute baby sea otter",
+ optional_params={"aws_region_name": "us-west-2", "aws_profile_name": "litellm-no-such-aws-profile"},
+ api_base=None,
+ extra_headers=None,
+ api_key=None,
+ logging_obj=Mock(),
+ )
+
+ assert request.prepped.headers["Authorization"] == "Bearer env-bearer-token-12345"
diff --git a/tests/test_litellm/llms/bedrock/image/test_bedrock_image_prepare_request.py b/tests/unit/llms/bedrock/image/test_bedrock_image_prepare_request.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/image/test_bedrock_image_prepare_request.py
rename to tests/unit/llms/bedrock/image/test_bedrock_image_prepare_request.py
diff --git a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py b/tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py
rename to tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py
diff --git a/tests/test_litellm/llms/bedrock/invoke_agent/test_bedrock_agent_transformation.py b/tests/unit/llms/bedrock/invoke_agent/test_bedrock_agent_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/invoke_agent/test_bedrock_agent_transformation.py
rename to tests/unit/llms/bedrock/invoke_agent/test_bedrock_agent_transformation.py
diff --git a/tests/test_litellm/llms/bedrock/passthrough/guardrail_translation/test_handler.py b/tests/unit/llms/bedrock/passthrough/guardrail_translation/test_handler.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/passthrough/guardrail_translation/test_handler.py
rename to tests/unit/llms/bedrock/passthrough/guardrail_translation/test_handler.py
diff --git a/tests/test_litellm/llms/bedrock/passthrough/test_bedrock_passthrough_transformation.py b/tests/unit/llms/bedrock/passthrough/test_bedrock_passthrough_transformation.py
similarity index 99%
rename from tests/test_litellm/llms/bedrock/passthrough/test_bedrock_passthrough_transformation.py
rename to tests/unit/llms/bedrock/passthrough/test_bedrock_passthrough_transformation.py
index f2a9af11af7..854ef92fa4b 100644
--- a/tests/test_litellm/llms/bedrock/passthrough/test_bedrock_passthrough_transformation.py
+++ b/tests/unit/llms/bedrock/passthrough/test_bedrock_passthrough_transformation.py
@@ -367,8 +367,6 @@ def test_bedrock_passthrough_region_extraction_from_inference_profile_arn():
assert (
"us-west-2" in api_base
), f"Expected region 'us-west-2' from ARN in base URL, but got: {api_base}"
-
-
def test_bedrock_passthrough_model_id_arn_encoding():
"""
Test that model_id ARNs are properly URL-encoded when used in endpoints.
diff --git a/tests/test_litellm/llms/bedrock/realtime/test_bedrock_realtime_handler.py b/tests/unit/llms/bedrock/realtime/test_bedrock_realtime_handler.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/realtime/test_bedrock_realtime_handler.py
rename to tests/unit/llms/bedrock/realtime/test_bedrock_realtime_handler.py
diff --git a/tests/test_litellm/llms/bedrock/realtime/test_bedrock_realtime_transformation.py b/tests/unit/llms/bedrock/realtime/test_bedrock_realtime_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/realtime/test_bedrock_realtime_transformation.py
rename to tests/unit/llms/bedrock/realtime/test_bedrock_realtime_transformation.py
diff --git a/tests/test_litellm/llms/bedrock/rerank/test_bedrock_rerank_header_forwarding.py b/tests/unit/llms/bedrock/rerank/test_bedrock_rerank_header_forwarding.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/rerank/test_bedrock_rerank_header_forwarding.py
rename to tests/unit/llms/bedrock/rerank/test_bedrock_rerank_header_forwarding.py
diff --git a/tests/test_litellm/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py b/tests/unit/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py
rename to tests/unit/llms/bedrock/vector_stores/test_bedrock_vector_store_transformation.py
diff --git a/tests/test_litellm/llms/bedrock_mantle/passthrough/test_bedrock_mantle_passthrough_transformation.py b/tests/unit/llms/bedrock_mantle/passthrough/test_bedrock_mantle_passthrough_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock_mantle/passthrough/test_bedrock_mantle_passthrough_transformation.py
rename to tests/unit/llms/bedrock_mantle/passthrough/test_bedrock_mantle_passthrough_transformation.py
diff --git a/tests/test_litellm/llms/black_forest_labs/image_edit/test_bfl_image_edit_transformation.py b/tests/unit/llms/black_forest_labs/image_edit/test_bfl_image_edit_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/black_forest_labs/image_edit/test_bfl_image_edit_transformation.py
rename to tests/unit/llms/black_forest_labs/image_edit/test_bfl_image_edit_transformation.py
diff --git a/tests/test_litellm/llms/black_forest_labs/image_generation/test_bfl_image_generation_transformation.py b/tests/unit/llms/black_forest_labs/image_generation/test_bfl_image_generation_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/black_forest_labs/image_generation/test_bfl_image_generation_transformation.py
rename to tests/unit/llms/black_forest_labs/image_generation/test_bfl_image_generation_transformation.py
diff --git a/tests/test_litellm/llms/black_forest_labs/test_bfl_common_utils.py b/tests/unit/llms/black_forest_labs/test_bfl_common_utils.py
similarity index 100%
rename from tests/test_litellm/llms/black_forest_labs/test_bfl_common_utils.py
rename to tests/unit/llms/black_forest_labs/test_bfl_common_utils.py
diff --git a/tests/test_litellm/llms/bytez/chat/test_bytez_chat_transformation.py b/tests/unit/llms/bytez/chat/test_bytez_chat_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/bytez/chat/test_bytez_chat_transformation.py
rename to tests/unit/llms/bytez/chat/test_bytez_chat_transformation.py
diff --git a/tests/test_litellm/llms/cerebras/test_cerebras_chat_transformation.py b/tests/unit/llms/cerebras/test_cerebras_chat_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/cerebras/test_cerebras_chat_transformation.py
rename to tests/unit/llms/cerebras/test_cerebras_chat_transformation.py
diff --git a/tests/test_litellm/llms/chat/test_converse_handler.py b/tests/unit/llms/chat/test_converse_handler.py
similarity index 100%
rename from tests/test_litellm/llms/chat/test_converse_handler.py
rename to tests/unit/llms/chat/test_converse_handler.py
diff --git a/tests/test_litellm/llms/chatgpt/test_chatgpt_authenticator.py b/tests/unit/llms/chatgpt/test_chatgpt_authenticator.py
similarity index 100%
rename from tests/test_litellm/llms/chatgpt/test_chatgpt_authenticator.py
rename to tests/unit/llms/chatgpt/test_chatgpt_authenticator.py
From 522c3e3ed6a5ce39b742eed3cbea68303683aeda Mon Sep 17 00:00:00 2001
From: yuneng
Date: Sun, 20 Sep 2026 07:45:29 +0000
Subject: [PATCH 126/317] test: migrate wave 1 phase 2 legacy unit tests to
tests/unit
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../chat/test_amazon_nova_chat_completion.py | 195 ------------------
.../integrations/levo/test_levo.py | 42 ----
.../test_litellm_agent_model_resolver.py | 0
.../test_mavvrik_focus_logger.py | 0
.../integrations/opik/test_opik_extractors.py | 0
.../integrations/pointfive/test_logger.py | 0
.../integrations/pointfive/test_payload.py | 0
.../pointfive/test_upload_client.py | 0
.../test_vector_store_pre_call_hook.py | 0
.../audio_utils/test_subtitle_utils.py | 0
.../test_convert_dict_to_response.py | 0
.../test_convert_to_streaming_response.py | 0
.../test_get_formatted_prompt.py | 0
.../test_response_metadata.py | 0
.../test_a2a_guardrail_handler.py | 0
.../chat/test_a2a_chat_streaming_iterator.py | 0
.../a2a/chat/test_a2a_chat_transformation.py | 0
.../llms/a2a/test_common_utils.py | 0
.../llms/anthropic/batches/test_handler.py | 0
.../anthropic/batches/test_transformation.py | 0
20 files changed, 237 deletions(-)
delete mode 100644 tests/test_litellm/llms/amazon_nova/chat/test_amazon_nova_chat_completion.py
rename tests/{test_litellm => unit}/integrations/levo/test_levo.py (88%)
rename tests/{test_litellm => unit}/integrations/litellm_agent/test_litellm_agent_model_resolver.py (100%)
rename tests/{test_litellm => unit}/integrations/mavvrik_focus/test_mavvrik_focus_logger.py (100%)
rename tests/{test_litellm => unit}/integrations/opik/test_opik_extractors.py (100%)
rename tests/{test_litellm => unit}/integrations/pointfive/test_logger.py (100%)
rename tests/{test_litellm => unit}/integrations/pointfive/test_payload.py (100%)
rename tests/{test_litellm => unit}/integrations/pointfive/test_upload_client.py (100%)
rename tests/{test_litellm => unit}/integrations/vector_store_integrations/test_vector_store_pre_call_hook.py (100%)
rename tests/{test_litellm => unit}/litellm_core_utils/audio_utils/test_subtitle_utils.py (100%)
rename tests/{test_litellm => unit}/litellm_core_utils/llm_response_utils/test_convert_dict_to_response.py (100%)
rename tests/{test_litellm => unit}/litellm_core_utils/llm_response_utils/test_convert_to_streaming_response.py (100%)
rename tests/{test_litellm => unit}/litellm_core_utils/llm_response_utils/test_get_formatted_prompt.py (100%)
rename tests/{test_litellm => unit}/litellm_core_utils/llm_response_utils/test_response_metadata.py (100%)
rename tests/{test_litellm => unit}/llms/a2a/chat/guardrail_translation/test_a2a_guardrail_handler.py (100%)
rename tests/{test_litellm => unit}/llms/a2a/chat/test_a2a_chat_streaming_iterator.py (100%)
rename tests/{test_litellm => unit}/llms/a2a/chat/test_a2a_chat_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/a2a/test_common_utils.py (100%)
rename tests/{test_litellm => unit}/llms/anthropic/batches/test_handler.py (100%)
rename tests/{test_litellm => unit}/llms/anthropic/batches/test_transformation.py (100%)
diff --git a/tests/test_litellm/llms/amazon_nova/chat/test_amazon_nova_chat_completion.py b/tests/test_litellm/llms/amazon_nova/chat/test_amazon_nova_chat_completion.py
deleted file mode 100644
index ecdd1b36333..00000000000
--- a/tests/test_litellm/llms/amazon_nova/chat/test_amazon_nova_chat_completion.py
+++ /dev/null
@@ -1,195 +0,0 @@
-import os
-import pytest
-
-# Ensure the project root is on the import path
-
-from litellm import completion
-from litellm.types.utils import ModelResponse, Usage, Choices, Message
-
-
-def _has_api_key() -> bool:
- """Check if Amazon Nova API key is available"""
- return (
- "AMAZON_NOVA_API_KEY" in os.environ
- and os.environ["AMAZON_NOVA_API_KEY"] is not None
- )
-
-
-def _create_mock_nova_response():
- """Helper function to create mock Amazon Nova response for testing"""
- return ModelResponse(
- id="chatcmpl-test-nova-micro",
- choices=[
- Choices(
- finish_reason="stop",
- index=0,
- message=Message(
- content="I am Amazon Nova Micro. 777 times 9 equals 6993.",
- role="assistant",
- ),
- )
- ],
- created=1234567890,
- model="amazon-nova/nova-micro-v1",
- object="chat.completion",
- usage=Usage(prompt_tokens=25, completion_tokens=15, total_tokens=40),
- )
-
-
-def test_amazon_nova_chat_completion_nova_micro():
- if _has_api_key():
- response: ModelResponse = completion(
- model="amazon-nova/nova-micro-v1",
- messages=[
- {"role": "system", "content": "You are a helpful assistant"},
- {
- "role": "user",
- "content": "What model are you? Can you calculate 777 times 9?",
- },
- ],
- api_key=os.environ["AMAZON_NOVA_API_KEY"],
- )
- else:
- # Use mock response when API key is not available
- response = _create_mock_nova_response()
- # Additional mock-specific assertions for code review reference
- assert (
- response.choices[0].message.content
- == "I am Amazon Nova Micro. 777 times 9 equals 6993."
- )
- assert response.model == "amazon-nova/nova-micro-v1"
- assert response.usage.prompt_tokens == 25
- assert response.usage.completion_tokens == 15
- assert response.object == "chat.completion"
- assert response.choices[0].finish_reason == "stop"
- assert response.choices[0].message.role == "assistant"
-
- # Common assertions for both real and mock responses
- assert response is not None
- assert hasattr(response, "choices")
- assert len(response.choices) > 0
- assert response.choices[0].message.content is not None
- assert response.usage.total_tokens > 0
-
-
-@pytest.mark.skipif(not _has_api_key(), reason="Amazon Nova API key not available")
-def test_amazon_nova_chat_completion_nova_lite():
- response: ModelResponse = completion(
- model="amazon-nova/nova-lite-v1",
- messages=[
- {"role": "system", "content": "You are a helpful assistant"},
- {
- "role": "user",
- "content": "What model are you? Please tell me a poem on rain",
- },
- ],
- api_key=os.environ["AMAZON_NOVA_API_KEY"],
- )
-
- assert response is not None
- assert hasattr(response, "choices")
- assert len(response.choices) > 0
- assert response.choices[0].message.content is not None
- assert response.usage.total_tokens > 0
-
-
-@pytest.mark.skipif(not _has_api_key(), reason="Amazon Nova API key not available")
-def test_amazon_nova_chat_completion_nova_pro():
- response: ModelResponse = completion(
- model="amazon-nova/nova-pro-v1",
- messages=[
- {"role": "system", "content": "You are a helpful assistant"},
- {
- "role": "user",
- "content": "What model are you? What is MCP server and how does that help in building GenAI applications?",
- },
- ],
- timeout=30,
- api_key=os.environ["AMAZON_NOVA_API_KEY"],
- )
-
- assert response is not None
- assert hasattr(response, "choices")
- assert len(response.choices) > 0
- assert response.choices[0].message.content is not None
- assert response.usage.total_tokens > 0
-
-
-@pytest.mark.skipif(not _has_api_key(), reason="Amazon Nova API key not available")
-def test_amazon_nova_chat_completion_nova_premier():
- response: ModelResponse = completion(
- model="amazon-nova/nova-premier-v1",
- messages=[
- {"role": "system", "content": "You are a helpful assistant"},
- {
- "role": "user",
- "content": "What model are you? Can you help me understand what Trigonometry is?",
- },
- ],
- timeout=60,
- api_key=os.environ["AMAZON_NOVA_API_KEY"],
- )
-
- assert response is not None
- print(response.choices[0].message.content)
- assert hasattr(response, "choices")
- assert len(response.choices) > 0
- assert response.choices[0].message.content is not None
- assert response.usage.total_tokens > 0
-
-
-@pytest.mark.skipif(not _has_api_key(), reason="Amazon Nova API key not available")
-def test_amazon_nova_chat_completion_with_tool_usage():
- response: ModelResponse = completion(
- model="amazon-nova/nova-micro-v1",
- messages=[
- {"role": "system", "content": "You are a helpful assistant"},
- {"role": "user", "content": "What is the temperature in SFO?"},
- ],
- tools=[
- {
- "type": "function",
- "function": {
- "name": "getCurrentWeather",
- "description": "Get the current weather in a given city",
- "parameters": {
- "type": "object",
- "properties": {
- "location": {
- "type": "string",
- "description": "City and country e.g. Bogotá, Colombia",
- }
- },
- "required": ["location"],
- },
- },
- }
- ],
- api_key=os.environ["AMAZON_NOVA_API_KEY"],
- )
-
- assert response is not None
- assert hasattr(response, "choices")
- assert len(response.choices) > 0
- assert response.choices[0].message is not None
-
-
-@pytest.mark.skipif(not _has_api_key(), reason="Amazon Nova API key not available")
-def test_amazon_nova_chat_completion_with_stream_response():
- response = completion(
- model="amazon-nova/nova-micro-v1",
- stream=True,
- messages=[
- {"role": "system", "content": "You are a helpful assistant"},
- {
- "role": "user",
- "content": "What are MMO games? Can you give me some sample references?",
- },
- ],
- api_key=os.environ["AMAZON_NOVA_API_KEY"],
- )
-
- assert response is not None
- chunks = list(response)
- assert chunks is not None
- assert len(chunks) > 0
diff --git a/tests/test_litellm/integrations/levo/test_levo.py b/tests/unit/integrations/levo/test_levo.py
similarity index 88%
rename from tests/test_litellm/integrations/levo/test_levo.py
rename to tests/unit/integrations/levo/test_levo.py
index 903be644671..647bcb3154e 100644
--- a/tests/test_litellm/integrations/levo/test_levo.py
+++ b/tests/unit/integrations/levo/test_levo.py
@@ -151,48 +151,6 @@ class TestLevoConfig(unittest.TestCase):
class TestLevoIntegration(unittest.TestCase):
"""Integration tests for LevoLogger."""
- @patch.dict(
- "os.environ",
- {
- "LEVOAI_API_KEY": "test-api-key",
- "LEVOAI_ORG_ID": "test-org-id",
- "LEVOAI_WORKSPACE_ID": "test-workspace-id",
- "LEVOAI_COLLECTOR_URL": "https://collector.levo.ai",
- },
- )
- @pytest.mark.skipif(
- not OPENTELEMETRY_AVAILABLE, reason="OpenTelemetry packages not installed"
- )
- @patch(
- "litellm.integrations.opentelemetry.OpenTelemetry._init_otel_logger_on_litellm_proxy"
- )
- @pytest.mark.asyncio
- async def test_levo_logger_health_check_healthy(self, mock_init_proxy):
- """Test health check returns healthy status when config is valid."""
- # Mock the proxy initialization to avoid importing proxy code
- mock_init_proxy.return_value = None
-
- config = LevoLogger.get_levo_config()
- otel_config = OpenTelemetryConfig(
- exporter=config.protocol,
- endpoint=config.endpoint,
- headers=config.otlp_auth_headers,
- )
-
- # Create tracer provider with in-memory exporter
- tracer_provider = TracerProvider()
- tracer_provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
-
- levo_logger = LevoLogger(
- config=otel_config, callback_name="levo", tracer_provider=tracer_provider
- )
-
- # Run health check
- result = await levo_logger.async_health_check()
-
- self.assertEqual(result["status"], "healthy")
- self.assertIn("message", result)
-
@patch.dict("os.environ", {}, clear=True)
def test_levo_logger_health_check_unhealthy(self):
"""Test health check returns unhealthy status when required vars are missing."""
diff --git a/tests/test_litellm/integrations/litellm_agent/test_litellm_agent_model_resolver.py b/tests/unit/integrations/litellm_agent/test_litellm_agent_model_resolver.py
similarity index 100%
rename from tests/test_litellm/integrations/litellm_agent/test_litellm_agent_model_resolver.py
rename to tests/unit/integrations/litellm_agent/test_litellm_agent_model_resolver.py
diff --git a/tests/test_litellm/integrations/mavvrik_focus/test_mavvrik_focus_logger.py b/tests/unit/integrations/mavvrik_focus/test_mavvrik_focus_logger.py
similarity index 100%
rename from tests/test_litellm/integrations/mavvrik_focus/test_mavvrik_focus_logger.py
rename to tests/unit/integrations/mavvrik_focus/test_mavvrik_focus_logger.py
diff --git a/tests/test_litellm/integrations/opik/test_opik_extractors.py b/tests/unit/integrations/opik/test_opik_extractors.py
similarity index 100%
rename from tests/test_litellm/integrations/opik/test_opik_extractors.py
rename to tests/unit/integrations/opik/test_opik_extractors.py
diff --git a/tests/test_litellm/integrations/pointfive/test_logger.py b/tests/unit/integrations/pointfive/test_logger.py
similarity index 100%
rename from tests/test_litellm/integrations/pointfive/test_logger.py
rename to tests/unit/integrations/pointfive/test_logger.py
diff --git a/tests/test_litellm/integrations/pointfive/test_payload.py b/tests/unit/integrations/pointfive/test_payload.py
similarity index 100%
rename from tests/test_litellm/integrations/pointfive/test_payload.py
rename to tests/unit/integrations/pointfive/test_payload.py
diff --git a/tests/test_litellm/integrations/pointfive/test_upload_client.py b/tests/unit/integrations/pointfive/test_upload_client.py
similarity index 100%
rename from tests/test_litellm/integrations/pointfive/test_upload_client.py
rename to tests/unit/integrations/pointfive/test_upload_client.py
diff --git a/tests/test_litellm/integrations/vector_store_integrations/test_vector_store_pre_call_hook.py b/tests/unit/integrations/vector_store_integrations/test_vector_store_pre_call_hook.py
similarity index 100%
rename from tests/test_litellm/integrations/vector_store_integrations/test_vector_store_pre_call_hook.py
rename to tests/unit/integrations/vector_store_integrations/test_vector_store_pre_call_hook.py
diff --git a/tests/test_litellm/litellm_core_utils/audio_utils/test_subtitle_utils.py b/tests/unit/litellm_core_utils/audio_utils/test_subtitle_utils.py
similarity index 100%
rename from tests/test_litellm/litellm_core_utils/audio_utils/test_subtitle_utils.py
rename to tests/unit/litellm_core_utils/audio_utils/test_subtitle_utils.py
diff --git a/tests/test_litellm/litellm_core_utils/llm_response_utils/test_convert_dict_to_response.py b/tests/unit/litellm_core_utils/llm_response_utils/test_convert_dict_to_response.py
similarity index 100%
rename from tests/test_litellm/litellm_core_utils/llm_response_utils/test_convert_dict_to_response.py
rename to tests/unit/litellm_core_utils/llm_response_utils/test_convert_dict_to_response.py
diff --git a/tests/test_litellm/litellm_core_utils/llm_response_utils/test_convert_to_streaming_response.py b/tests/unit/litellm_core_utils/llm_response_utils/test_convert_to_streaming_response.py
similarity index 100%
rename from tests/test_litellm/litellm_core_utils/llm_response_utils/test_convert_to_streaming_response.py
rename to tests/unit/litellm_core_utils/llm_response_utils/test_convert_to_streaming_response.py
diff --git a/tests/test_litellm/litellm_core_utils/llm_response_utils/test_get_formatted_prompt.py b/tests/unit/litellm_core_utils/llm_response_utils/test_get_formatted_prompt.py
similarity index 100%
rename from tests/test_litellm/litellm_core_utils/llm_response_utils/test_get_formatted_prompt.py
rename to tests/unit/litellm_core_utils/llm_response_utils/test_get_formatted_prompt.py
diff --git a/tests/test_litellm/litellm_core_utils/llm_response_utils/test_response_metadata.py b/tests/unit/litellm_core_utils/llm_response_utils/test_response_metadata.py
similarity index 100%
rename from tests/test_litellm/litellm_core_utils/llm_response_utils/test_response_metadata.py
rename to tests/unit/litellm_core_utils/llm_response_utils/test_response_metadata.py
diff --git a/tests/test_litellm/llms/a2a/chat/guardrail_translation/test_a2a_guardrail_handler.py b/tests/unit/llms/a2a/chat/guardrail_translation/test_a2a_guardrail_handler.py
similarity index 100%
rename from tests/test_litellm/llms/a2a/chat/guardrail_translation/test_a2a_guardrail_handler.py
rename to tests/unit/llms/a2a/chat/guardrail_translation/test_a2a_guardrail_handler.py
diff --git a/tests/test_litellm/llms/a2a/chat/test_a2a_chat_streaming_iterator.py b/tests/unit/llms/a2a/chat/test_a2a_chat_streaming_iterator.py
similarity index 100%
rename from tests/test_litellm/llms/a2a/chat/test_a2a_chat_streaming_iterator.py
rename to tests/unit/llms/a2a/chat/test_a2a_chat_streaming_iterator.py
diff --git a/tests/test_litellm/llms/a2a/chat/test_a2a_chat_transformation.py b/tests/unit/llms/a2a/chat/test_a2a_chat_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/a2a/chat/test_a2a_chat_transformation.py
rename to tests/unit/llms/a2a/chat/test_a2a_chat_transformation.py
diff --git a/tests/test_litellm/llms/a2a/test_common_utils.py b/tests/unit/llms/a2a/test_common_utils.py
similarity index 100%
rename from tests/test_litellm/llms/a2a/test_common_utils.py
rename to tests/unit/llms/a2a/test_common_utils.py
diff --git a/tests/test_litellm/llms/anthropic/batches/test_handler.py b/tests/unit/llms/anthropic/batches/test_handler.py
similarity index 100%
rename from tests/test_litellm/llms/anthropic/batches/test_handler.py
rename to tests/unit/llms/anthropic/batches/test_handler.py
diff --git a/tests/test_litellm/llms/anthropic/batches/test_transformation.py b/tests/unit/llms/anthropic/batches/test_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/anthropic/batches/test_transformation.py
rename to tests/unit/llms/anthropic/batches/test_transformation.py
From 02ccdbae906dfc21edb29421d799692c62d7054d Mon Sep 17 00:00:00 2001
From: yuneng
Date: Sun, 20 Sep 2026 07:50:50 +0000
Subject: [PATCH 127/317] test(llms): migrate bedrock, baseten and base_llm
batch tests to tests/unit
---
.../files/test_bedrock_files_integration.py | 115 ------------------
.../base_llm/batches/test_transformation.py | 16 ---
.../realtime/test_transcription_protocol.py | 0
.../baseten/chat/test_baseten_completions.py | 0
.../test_agentcore_transformation.py | 0
.../test_amazon_moonshot_transformation.py | 0
.../test_amazon_nova_transformation.py | 22 ++++
.../test_amazon_qwen2_transformation.py | 0
.../test_amazon_qwen3_transformation.py | 0
.../test_base_invoke_transformation.py | 0
...ations_anthropic_claude3_transformation.py | 76 ++++++++++++
.../test_twelvelabs_pegasus_transformation.py | 0
...test_bedrock_chat_mantle_transformation.py | 43 +++++++
.../test_bedrock_count_tokens_handler.py | 0
...est_bedrock_count_tokens_transformation.py | 0
.../expected_bedrock_batch_completions.jsonl | 0
.../expected_bedrock_batch_embeddings.jsonl | 0
.../files/input_batch_completions.jsonl | 0
.../files/input_batch_embeddings.jsonl | 0
.../files/test_bedrock_files_handler.py | 0
.../test_bedrock_files_transformation.py | 0
21 files changed, 141 insertions(+), 131 deletions(-)
delete mode 100644 tests/test_litellm/llms/bedrock/files/test_bedrock_files_integration.py
rename tests/{test_litellm => unit}/llms/base_llm/batches/test_transformation.py (92%)
rename tests/{test_litellm => unit}/llms/base_llm/realtime/test_transcription_protocol.py (100%)
rename tests/{test_litellm => unit}/llms/baseten/chat/test_baseten_completions.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/chat/agentcore/test_agentcore_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/chat/invoke_transformations/test_amazon_moonshot_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/chat/invoke_transformations/test_amazon_nova_transformation.py (85%)
rename tests/{test_litellm => unit}/llms/bedrock/chat/invoke_transformations/test_amazon_qwen2_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/chat/invoke_transformations/test_amazon_qwen3_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/chat/invoke_transformations/test_base_invoke_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py (92%)
rename tests/{test_litellm => unit}/llms/bedrock/chat/invoke_transformations/test_twelvelabs_pegasus_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/chat/mantle/test_bedrock_chat_mantle_transformation.py (68%)
rename tests/{test_litellm => unit}/llms/bedrock/count_tokens/test_bedrock_count_tokens_handler.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/count_tokens/test_bedrock_count_tokens_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/files/expected_bedrock_batch_completions.jsonl (100%)
rename tests/{test_litellm => unit}/llms/bedrock/files/expected_bedrock_batch_embeddings.jsonl (100%)
rename tests/{test_litellm => unit}/llms/bedrock/files/input_batch_completions.jsonl (100%)
rename tests/{test_litellm => unit}/llms/bedrock/files/input_batch_embeddings.jsonl (100%)
rename tests/{test_litellm => unit}/llms/bedrock/files/test_bedrock_files_handler.py (100%)
rename tests/{test_litellm => unit}/llms/bedrock/files/test_bedrock_files_transformation.py (100%)
diff --git a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_integration.py b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_integration.py
deleted file mode 100644
index 6d37d43b028..00000000000
--- a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_integration.py
+++ /dev/null
@@ -1,115 +0,0 @@
-"""
-Test Bedrock files integration with main files API
-"""
-
-import base64
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-import litellm
-from litellm.types.llms.openai import HttpxBinaryResponseContent
-from litellm.types.utils import SpecialEnums
-
-
-class TestBedrockFilesIntegration:
- """Test integration of Bedrock files with main litellm API"""
-
- @pytest.mark.asyncio
- async def test_litellm_afile_content_bedrock_provider_with_s3_uri(self):
- """Test litellm.afile_content with bedrock provider using direct S3 URI"""
- file_id = "s3://test-bucket/test-file.jsonl"
- expected_content = (
- b'{"recordId": "request-1", "modelInput": {}, "modelOutput": {}}'
- )
-
- # Create a mock HttpxBinaryResponseContent response
- import httpx
-
- mock_response = httpx.Response(
- status_code=200,
- content=expected_content,
- headers={"content-type": "application/octet-stream"},
- request=httpx.Request(method="GET", url="s3://test-bucket/test-file.jsonl"),
- )
- mock_result = HttpxBinaryResponseContent(response=mock_response)
-
- # Mock the base_llm_http_handler.retrieve_file_content since the code
- # now routes through ProviderConfigManager -> base_llm_http_handler
- with patch(
- "litellm.files.main.base_llm_http_handler.retrieve_file_content",
- new_callable=MagicMock,
- ) as mock_retrieve:
- mock_retrieve.return_value = mock_result
-
- # Call litellm.afile_content
- result = await litellm.afile_content(
- file_id=file_id,
- custom_llm_provider="bedrock",
- aws_region_name="us-west-2",
- )
-
- # Verify the result
- assert isinstance(result, HttpxBinaryResponseContent)
- assert result.response.content == expected_content
- assert result.response.status_code == 200
-
- # Verify the mock was called with correct parameters
- mock_retrieve.assert_called_once()
- call_kwargs = mock_retrieve.call_args.kwargs
- assert call_kwargs["_is_async"] is True
- assert call_kwargs["file_content_request"]["file_id"] == file_id
-
- @pytest.mark.asyncio
- async def test_litellm_afile_content_bedrock_provider_with_unified_file_id(self):
- """Test litellm.afile_content with bedrock provider using unified file ID"""
- # Create a unified file ID
- s3_uri = "s3://test-bucket/batch-outputs/output.jsonl"
- unified_id = "test-unified-id-123"
- model_id = "test-model-id-456"
-
- unified_file_id_str = f"litellm_proxy:application/json;unified_id,{unified_id};target_model_names,;llm_output_file_id,{s3_uri};llm_output_file_model_id,{model_id}"
- encoded_file_id = (
- base64.urlsafe_b64encode(unified_file_id_str.encode()).decode().rstrip("=")
- )
-
- expected_content = (
- b'{"recordId": "request-1", "modelInput": {}, "modelOutput": {}}'
- )
-
- # Create a mock HttpxBinaryResponseContent response
- import httpx
-
- mock_response = httpx.Response(
- status_code=200,
- content=expected_content,
- headers={"content-type": "application/octet-stream"},
- request=httpx.Request(method="GET", url=s3_uri),
- )
- mock_result = HttpxBinaryResponseContent(response=mock_response)
-
- # Mock the base_llm_http_handler.retrieve_file_content
- with patch(
- "litellm.files.main.base_llm_http_handler.retrieve_file_content",
- new_callable=MagicMock,
- ) as mock_retrieve:
- mock_retrieve.return_value = mock_result
-
- # Call litellm.afile_content with unified file ID
- result = await litellm.afile_content(
- file_id=encoded_file_id,
- custom_llm_provider="bedrock",
- aws_region_name="us-west-2",
- )
-
- # Verify the result
- assert isinstance(result, HttpxBinaryResponseContent)
- assert result.response.content == expected_content
- assert result.response.status_code == 200
-
- # Verify the mock was called
- mock_retrieve.assert_called_once()
- call_kwargs = mock_retrieve.call_args.kwargs
- assert call_kwargs["_is_async"] is True
- # The handler passes the encoded file_id as-is
- assert call_kwargs["file_content_request"]["file_id"] == encoded_file_id
diff --git a/tests/test_litellm/llms/base_llm/batches/test_transformation.py b/tests/unit/llms/base_llm/batches/test_transformation.py
similarity index 92%
rename from tests/test_litellm/llms/base_llm/batches/test_transformation.py
rename to tests/unit/llms/base_llm/batches/test_transformation.py
index d84c820228f..0c360ce2ed9 100644
--- a/tests/test_litellm/llms/base_llm/batches/test_transformation.py
+++ b/tests/unit/llms/base_llm/batches/test_transformation.py
@@ -129,22 +129,6 @@ def test_subclass_missing_any_abstract_member_cannot_instantiate(missing_member)
Incomplete()
-def test_concrete_instance_methods_run():
- """Sanity: the trivial overrides actually execute through the base contract."""
- instance = _ConcreteBatchesConfig()
- assert instance.custom_llm_provider == LlmProviders.OPENAI
- assert instance.validate_environment(
- headers={"x": "1"},
- model="m",
- messages=[],
- optional_params={},
- litellm_params={},
- ) == {"x": "1"}
- assert instance.transform_retrieve_batch_request(
- batch_id="b-1", optional_params={}, litellm_params={}
- ) == {"batch_id": "b-1"}
-
-
# =========================================================================== #
# get_config()
# =========================================================================== #
diff --git a/tests/test_litellm/llms/base_llm/realtime/test_transcription_protocol.py b/tests/unit/llms/base_llm/realtime/test_transcription_protocol.py
similarity index 100%
rename from tests/test_litellm/llms/base_llm/realtime/test_transcription_protocol.py
rename to tests/unit/llms/base_llm/realtime/test_transcription_protocol.py
diff --git a/tests/test_litellm/llms/baseten/chat/test_baseten_completions.py b/tests/unit/llms/baseten/chat/test_baseten_completions.py
similarity index 100%
rename from tests/test_litellm/llms/baseten/chat/test_baseten_completions.py
rename to tests/unit/llms/baseten/chat/test_baseten_completions.py
diff --git a/tests/test_litellm/llms/bedrock/chat/agentcore/test_agentcore_transformation.py b/tests/unit/llms/bedrock/chat/agentcore/test_agentcore_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/chat/agentcore/test_agentcore_transformation.py
rename to tests/unit/llms/bedrock/chat/agentcore/test_agentcore_transformation.py
diff --git a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_amazon_moonshot_transformation.py b/tests/unit/llms/bedrock/chat/invoke_transformations/test_amazon_moonshot_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_amazon_moonshot_transformation.py
rename to tests/unit/llms/bedrock/chat/invoke_transformations/test_amazon_moonshot_transformation.py
diff --git a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_amazon_nova_transformation.py b/tests/unit/llms/bedrock/chat/invoke_transformations/test_amazon_nova_transformation.py
similarity index 85%
rename from tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_amazon_nova_transformation.py
rename to tests/unit/llms/bedrock/chat/invoke_transformations/test_amazon_nova_transformation.py
index 6c370344ae7..f0f0f9160fb 100644
--- a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_amazon_nova_transformation.py
+++ b/tests/unit/llms/bedrock/chat/invoke_transformations/test_amazon_nova_transformation.py
@@ -1,5 +1,8 @@
import json
+import pytest
+
+import litellm
from litellm.llms.bedrock.chat.invoke_transformations.amazon_nova_transformation import (
AmazonInvokeNovaConfig,
)
@@ -13,6 +16,25 @@ TOOL_CALL = {"id": "call_1", "type": "function", "function": {"name": "f", "argu
PNG_DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
+@pytest.fixture
+def local_model_cost_map(monkeypatch):
+ """Force the bundled in-repo cost map so capability and pricing assertions do not
+ depend on the network-fetched ``main`` copy, which lags this branch until merge.
+
+ ``get_model_info`` is lru_cached, so swapping ``model_cost`` is not enough on its
+ own; clear on the way in and out so entries warmed against either map never leak
+ across tests."""
+ original_model_cost = litellm.model_cost
+ monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
+ litellm.model_cost = litellm.get_model_cost_map(url="")
+ litellm.get_model_info.cache_clear()
+ try:
+ yield
+ finally:
+ litellm.model_cost = original_model_cost
+ litellm.get_model_info.cache_clear()
+
+
def _transform_request(messages, optional_params, litellm_params=None):
return AmazonInvokeNovaConfig().transform_request(
model=MODEL,
diff --git a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_amazon_qwen2_transformation.py b/tests/unit/llms/bedrock/chat/invoke_transformations/test_amazon_qwen2_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_amazon_qwen2_transformation.py
rename to tests/unit/llms/bedrock/chat/invoke_transformations/test_amazon_qwen2_transformation.py
diff --git a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_amazon_qwen3_transformation.py b/tests/unit/llms/bedrock/chat/invoke_transformations/test_amazon_qwen3_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_amazon_qwen3_transformation.py
rename to tests/unit/llms/bedrock/chat/invoke_transformations/test_amazon_qwen3_transformation.py
diff --git a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_base_invoke_transformation.py b/tests/unit/llms/bedrock/chat/invoke_transformations/test_base_invoke_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_base_invoke_transformation.py
rename to tests/unit/llms/bedrock/chat/invoke_transformations/test_base_invoke_transformation.py
diff --git a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py b/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py
similarity index 92%
rename from tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py
rename to tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py
index 84db0733227..2c74d23a6a2 100644
--- a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py
+++ b/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py
@@ -1,6 +1,8 @@
import asyncio
+import base64
import json
import uuid
+from types import SimpleNamespace
from typing import Final
from unittest.mock import patch
@@ -17,6 +19,80 @@ from litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transfor
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
+ONE_PIXEL_PNG = base64.b64decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
+)
+
+
+@pytest.fixture
+def async_only_image_fetch(monkeypatch):
+ from litellm.litellm_core_utils.prompt_templates import factory, image_handling
+ from litellm.llms.gemini.chat import transformation as gemini_chat_transformation
+
+ fetch = SimpleNamespace(
+ fetched=[],
+ base64_png=base64.b64encode(ONE_PIXEL_PNG).decode(),
+ data_url="data:image/png;base64," + base64.b64encode(ONE_PIXEL_PNG).decode(),
+ )
+
+ def forbid_sync_fetch(client, url, **kwargs):
+ raise litellm.ImageFetchError(f"sync image fetch ran on the event loop: {url}")
+
+ async def serve_png(client, url, **kwargs):
+ fetch.fetched.append(url)
+ return httpx.Response(
+ 200,
+ content=ONE_PIXEL_PNG,
+ headers={"content-type": "image/png"},
+ request=httpx.Request("GET", url),
+ )
+
+ def forbid_sync_convert(url, *args, **kwargs):
+ if url.startswith(("http://", "https://")):
+ raise litellm.ImageFetchError(f"sync convert_url_to_base64 ran on the request path: {url}")
+ return url
+
+ monkeypatch.setattr(image_handling, "safe_get", forbid_sync_fetch)
+ monkeypatch.setattr(image_handling, "async_safe_get", serve_png)
+ for module in (image_handling, factory, gemini_chat_transformation):
+ monkeypatch.setattr(module, "convert_url_to_base64", forbid_sync_convert)
+ return fetch
+
+
+@pytest.fixture
+def local_model_cost_map(monkeypatch):
+ """Force the bundled in-repo cost map so capability and pricing assertions do not
+ depend on the network-fetched ``main`` copy, which lags this branch until merge.
+
+ ``get_model_info`` is lru_cached, so swapping ``model_cost`` is not enough on its
+ own; clear on the way in and out so entries warmed against either map never leak
+ across tests."""
+ original_model_cost = litellm.model_cost
+ monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
+ litellm.model_cost = litellm.get_model_cost_map(url="")
+ litellm.get_model_info.cache_clear()
+ try:
+ yield
+ finally:
+ litellm.model_cost = original_model_cost
+ litellm.get_model_info.cache_clear()
+
+
+@pytest.fixture
+def local_beta_headers_config(monkeypatch):
+ """Pin the bundled ``anthropic_beta_headers_config.json`` so beta header assertions
+ do not depend on the network-fetched copy or on what earlier tests left cached."""
+ from litellm.anthropic_beta_headers_manager import reload_beta_headers_config
+
+ monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "True")
+ reload_beta_headers_config()
+ try:
+ yield
+ finally:
+ monkeypatch.delenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", raising=False)
+ reload_beta_headers_config()
+
+
def test_get_supported_params_thinking():
config = AmazonAnthropicClaudeConfig()
params = config.get_supported_openai_params(
diff --git a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_twelvelabs_pegasus_transformation.py b/tests/unit/llms/bedrock/chat/invoke_transformations/test_twelvelabs_pegasus_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_twelvelabs_pegasus_transformation.py
rename to tests/unit/llms/bedrock/chat/invoke_transformations/test_twelvelabs_pegasus_transformation.py
diff --git a/tests/test_litellm/llms/bedrock/chat/mantle/test_bedrock_chat_mantle_transformation.py b/tests/unit/llms/bedrock/chat/mantle/test_bedrock_chat_mantle_transformation.py
similarity index 68%
rename from tests/test_litellm/llms/bedrock/chat/mantle/test_bedrock_chat_mantle_transformation.py
rename to tests/unit/llms/bedrock/chat/mantle/test_bedrock_chat_mantle_transformation.py
index a8448f5fa7a..cb892b1ea11 100644
--- a/tests/test_litellm/llms/bedrock/chat/mantle/test_bedrock_chat_mantle_transformation.py
+++ b/tests/unit/llms/bedrock/chat/mantle/test_bedrock_chat_mantle_transformation.py
@@ -1,12 +1,55 @@
+import base64
import json
import uuid
+from types import SimpleNamespace
import httpx
+import pytest
import litellm
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
+ONE_PIXEL_PNG = base64.b64decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
+)
+
+
+@pytest.fixture
+def async_only_image_fetch(monkeypatch):
+ from litellm.litellm_core_utils.prompt_templates import factory, image_handling
+ from litellm.llms.gemini.chat import transformation as gemini_chat_transformation
+
+ fetch = SimpleNamespace(
+ fetched=[],
+ base64_png=base64.b64encode(ONE_PIXEL_PNG).decode(),
+ data_url="data:image/png;base64," + base64.b64encode(ONE_PIXEL_PNG).decode(),
+ )
+
+ def forbid_sync_fetch(client, url, **kwargs):
+ raise litellm.ImageFetchError(f"sync image fetch ran on the event loop: {url}")
+
+ async def serve_png(client, url, **kwargs):
+ fetch.fetched.append(url)
+ return httpx.Response(
+ 200,
+ content=ONE_PIXEL_PNG,
+ headers={"content-type": "image/png"},
+ request=httpx.Request("GET", url),
+ )
+
+ def forbid_sync_convert(url, *args, **kwargs):
+ if url.startswith(("http://", "https://")):
+ raise litellm.ImageFetchError(f"sync convert_url_to_base64 ran on the request path: {url}")
+ return url
+
+ monkeypatch.setattr(image_handling, "safe_get", forbid_sync_fetch)
+ monkeypatch.setattr(image_handling, "async_safe_get", serve_png)
+ for module in (image_handling, factory, gemini_chat_transformation):
+ monkeypatch.setattr(module, "convert_url_to_base64", forbid_sync_convert)
+ return fetch
+
+
async def test_bedrock_mantle_claude_async_completion_inlines_remote_images_off_the_event_loop(async_only_image_fetch):
image_url = f"http://img.example/{uuid.uuid4()}.png"
captured = {}
diff --git a/tests/test_litellm/llms/bedrock/count_tokens/test_bedrock_count_tokens_handler.py b/tests/unit/llms/bedrock/count_tokens/test_bedrock_count_tokens_handler.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/count_tokens/test_bedrock_count_tokens_handler.py
rename to tests/unit/llms/bedrock/count_tokens/test_bedrock_count_tokens_handler.py
diff --git a/tests/test_litellm/llms/bedrock/count_tokens/test_bedrock_count_tokens_transformation.py b/tests/unit/llms/bedrock/count_tokens/test_bedrock_count_tokens_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/count_tokens/test_bedrock_count_tokens_transformation.py
rename to tests/unit/llms/bedrock/count_tokens/test_bedrock_count_tokens_transformation.py
diff --git a/tests/test_litellm/llms/bedrock/files/expected_bedrock_batch_completions.jsonl b/tests/unit/llms/bedrock/files/expected_bedrock_batch_completions.jsonl
similarity index 100%
rename from tests/test_litellm/llms/bedrock/files/expected_bedrock_batch_completions.jsonl
rename to tests/unit/llms/bedrock/files/expected_bedrock_batch_completions.jsonl
diff --git a/tests/test_litellm/llms/bedrock/files/expected_bedrock_batch_embeddings.jsonl b/tests/unit/llms/bedrock/files/expected_bedrock_batch_embeddings.jsonl
similarity index 100%
rename from tests/test_litellm/llms/bedrock/files/expected_bedrock_batch_embeddings.jsonl
rename to tests/unit/llms/bedrock/files/expected_bedrock_batch_embeddings.jsonl
diff --git a/tests/test_litellm/llms/bedrock/files/input_batch_completions.jsonl b/tests/unit/llms/bedrock/files/input_batch_completions.jsonl
similarity index 100%
rename from tests/test_litellm/llms/bedrock/files/input_batch_completions.jsonl
rename to tests/unit/llms/bedrock/files/input_batch_completions.jsonl
diff --git a/tests/test_litellm/llms/bedrock/files/input_batch_embeddings.jsonl b/tests/unit/llms/bedrock/files/input_batch_embeddings.jsonl
similarity index 100%
rename from tests/test_litellm/llms/bedrock/files/input_batch_embeddings.jsonl
rename to tests/unit/llms/bedrock/files/input_batch_embeddings.jsonl
diff --git a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_handler.py b/tests/unit/llms/bedrock/files/test_bedrock_files_handler.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/files/test_bedrock_files_handler.py
rename to tests/unit/llms/bedrock/files/test_bedrock_files_handler.py
diff --git a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py
rename to tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py
From 9aec964bace897dd4713ee5820da357b35d19eaa Mon Sep 17 00:00:00 2001
From: Yuneng Jiang
Date: Sun, 20 Sep 2026 01:02:19 -0700
Subject: [PATCH 128/317] Merge remote-tracking branch 'origin/main' into
litellm_flip_v2_migration_resolver_default
Drops the TQ008 suppressions the new test carried; main removed that rule.
---
tests/test_litellm/proxy/test_proxy_cli.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py
index 4b83044b36d..d2835142194 100644
--- a/tests/test_litellm/proxy/test_proxy_cli.py
+++ b/tests/test_litellm/proxy/test_proxy_cli.py
@@ -2272,9 +2272,9 @@ class TestRunServerDbSetup:
)
@patch("subprocess.run")
@patch("atexit.register")
- @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above
- @patch("litellm.proxy.db.check_migration.check_prisma_schema_diff") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above
- @patch("litellm.proxy.db.prisma_client.should_update_prisma_schema") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above
+ @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database")
+ @patch("litellm.proxy.db.check_migration.check_prisma_schema_diff")
+ @patch("litellm.proxy.db.prisma_client.should_update_prisma_schema")
def test_migration_resolver_selection(
self,
mock_should_update_schema,
From 4defed7f2e7eaacdf8e130857eddc6018c7bc3f7 Mon Sep 17 00:00:00 2001
From: yuneng
Date: Sun, 20 Sep 2026 08:03:54 +0000
Subject: [PATCH 129/317] test: migrate wave 1 phase 8 legacy llm tests to
tests/unit
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../chat/test_hosted_vllm_ssl_verify.py | 147 ------------------
.../test_hosted_vllm_embedding_ssl_verify.py | 135 ----------------
..._github_copilot_messages_transformation.py | 7 -
...github_copilot_responses_transformation.py | 116 +++++---------
.../test_gradient_ai_chat_transformation.py | 0
.../chat/test_groq_chat_transformation.py | 2 -
.../llms/groq/test_groq_cost_calculator.py | 0
.../test_hosted_vllm_chat_transformation.py | 71 +--------
...st_hosted_vllm_embedding_transformation.py | 8 +-
...t_hosted_vllm_image_edit_transformation.py | 0
.../responses/test_hosted_vllm_responses.py | 9 +-
.../test_hosted_vllm_rerank_transformation.py | 0
.../test_hosted_vllm_video_transformation.py | 0
.../test_huggingface_rerank_transformation.py | 40 +----
.../test_inception_chat_transformation.py | 18 +--
...est_inception_completion_transformation.py | 18 +--
.../test_jina_embedding_transformation.py | 0
.../chat/test_langflow_chat_transformation.py | 31 +---
.../litellm_proxy/test_sandbox_executor.py | 25 +--
.../litellm_proxy/test_skills_ownership.py | 73 ++-------
20 files changed, 84 insertions(+), 616 deletions(-)
delete mode 100644 tests/test_litellm/llms/hosted_vllm/chat/test_hosted_vllm_ssl_verify.py
delete mode 100644 tests/test_litellm/llms/hosted_vllm/embedding/test_hosted_vllm_embedding_ssl_verify.py
rename tests/{test_litellm => unit}/llms/github_copilot/messages/test_github_copilot_messages_transformation.py (98%)
rename tests/{test_litellm => unit}/llms/github_copilot/responses/test_github_copilot_responses_transformation.py (89%)
rename tests/{test_litellm => unit}/llms/gradient_ai/chat/test_gradient_ai_chat_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/groq/chat/test_groq_chat_transformation.py (99%)
rename tests/{test_litellm => unit}/llms/groq/test_groq_cost_calculator.py (100%)
rename tests/{test_litellm => unit}/llms/hosted_vllm/chat/test_hosted_vllm_chat_transformation.py (82%)
rename tests/{test_litellm => unit}/llms/hosted_vllm/embedding/test_hosted_vllm_embedding_transformation.py (97%)
rename tests/{test_litellm => unit}/llms/hosted_vllm/image_edit/test_hosted_vllm_image_edit_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/hosted_vllm/responses/test_hosted_vllm_responses.py (96%)
rename tests/{test_litellm => unit}/llms/hosted_vllm/test_hosted_vllm_rerank_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/huggingface/rerank/test_huggingface_rerank_transformation.py (91%)
rename tests/{test_litellm => unit}/llms/inception/test_inception_chat_transformation.py (96%)
rename tests/{test_litellm => unit}/llms/inception/test_inception_completion_transformation.py (95%)
rename tests/{test_litellm => unit}/llms/jina_ai/embedding/test_jina_embedding_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/langflow/chat/test_langflow_chat_transformation.py (93%)
rename tests/{test_litellm => unit}/llms/litellm_proxy/test_sandbox_executor.py (84%)
rename tests/{test_litellm => unit}/llms/litellm_proxy/test_skills_ownership.py (88%)
diff --git a/tests/test_litellm/llms/hosted_vllm/chat/test_hosted_vllm_ssl_verify.py b/tests/test_litellm/llms/hosted_vllm/chat/test_hosted_vllm_ssl_verify.py
deleted file mode 100644
index 2364468efe1..00000000000
--- a/tests/test_litellm/llms/hosted_vllm/chat/test_hosted_vllm_ssl_verify.py
+++ /dev/null
@@ -1,147 +0,0 @@
-"""
-Test SSL verification for hosted_vllm provider.
-
-This test ensures that the ssl_verify parameter is properly passed through
-to the HTTP client when using the hosted_vllm provider.
-
-Issue: ssl_verify parameter was being ignored because hosted_vllm fell through
-to the OpenAI catch-all path in main.py, which doesn't pass ssl_verify to the HTTP client.
-"""
-
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-
-import litellm
-
-
-class TestHostedVLLMSSLVerify:
- """Test suite for SSL verification in hosted_vllm provider."""
-
- @patch("litellm.llms.custom_httpx.llm_http_handler._get_httpx_client")
- def test_hosted_vllm_ssl_verify_false_sync(self, mock_get_httpx_client):
- """Test that ssl_verify=False is passed to the HTTP client for sync calls."""
- # Setup mock client
- mock_client = MagicMock()
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.headers = {"content-type": "application/json"}
- mock_response.json.return_value = {
- "id": "chatcmpl-test",
- "object": "chat.completion",
- "created": 1234567890,
- "model": "test-model",
- "choices": [
- {
- "index": 0,
- "message": {
- "role": "assistant",
- "content": "Test response",
- },
- "finish_reason": "stop",
- }
- ],
- "usage": {
- "prompt_tokens": 10,
- "completion_tokens": 5,
- "total_tokens": 15,
- },
- }
- mock_response.text = '{"id": "chatcmpl-test", "object": "chat.completion", "created": 1234567890, "model": "test-model", "choices": [{"index": 0, "message": {"role": "assistant", "content": "Test response"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}'
- mock_client.post.return_value = mock_response
- mock_get_httpx_client.return_value = mock_client
-
- try:
- litellm.completion(
- model="hosted_vllm/test-model",
- messages=[{"role": "user", "content": "Hello"}],
- api_base="https://test-vllm.example.com/v1",
- ssl_verify=False,
- )
- except Exception:
- # Even if the response parsing fails, we just need to verify
- # that the mock was called with the correct ssl_verify parameter
- pass
-
- # Verify _get_httpx_client was called with ssl_verify=False
- mock_get_httpx_client.assert_called()
- call_args = mock_get_httpx_client.call_args
-
- # Check that params contains ssl_verify=False
- if call_args[0]:
- # Positional argument
- params = call_args[0][0]
- else:
- # Keyword argument
- params = call_args[1].get("params", {})
-
- assert (
- params.get("ssl_verify") is False
- ), f"Expected ssl_verify=False in params, got {params}"
-
- @patch("litellm.llms.custom_httpx.llm_http_handler.get_async_httpx_client")
- @pytest.mark.asyncio
- async def test_hosted_vllm_ssl_verify_false_async(
- self, mock_get_async_httpx_client
- ):
- """Test that ssl_verify=False is passed to the HTTP client for async calls."""
- # Setup mock async client
- mock_client = MagicMock()
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.headers = {"content-type": "application/json"}
- mock_response.json.return_value = {
- "id": "chatcmpl-test",
- "object": "chat.completion",
- "created": 1234567890,
- "model": "test-model",
- "choices": [
- {
- "index": 0,
- "message": {
- "role": "assistant",
- "content": "Test response",
- },
- "finish_reason": "stop",
- }
- ],
- "usage": {
- "prompt_tokens": 10,
- "completion_tokens": 5,
- "total_tokens": 15,
- },
- }
- mock_response.text = '{"id": "chatcmpl-test", "object": "chat.completion", "created": 1234567890, "model": "test-model", "choices": [{"index": 0, "message": {"role": "assistant", "content": "Test response"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}'
-
- async def mock_post(*args, **kwargs):
- return mock_response
-
- mock_client.post = mock_post
- mock_get_async_httpx_client.return_value = mock_client
-
- try:
- await litellm.acompletion(
- model="hosted_vllm/test-model",
- messages=[{"role": "user", "content": "Hello"}],
- api_base="https://test-vllm.example.com/v1",
- ssl_verify=False,
- )
- except Exception:
- # Even if the response parsing fails, we just need to verify
- # that the mock was called with the correct ssl_verify parameter
- pass
-
- # Verify get_async_httpx_client was called with ssl_verify=False
- mock_get_async_httpx_client.assert_called()
- call_kwargs = mock_get_async_httpx_client.call_args[1]
-
- # Check that params contains ssl_verify=False
- params = call_kwargs.get("params", {})
- assert (
- params.get("ssl_verify") is False
- ), f"Expected ssl_verify=False in params, got {params}"
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v", "-s"])
diff --git a/tests/test_litellm/llms/hosted_vllm/embedding/test_hosted_vllm_embedding_ssl_verify.py b/tests/test_litellm/llms/hosted_vllm/embedding/test_hosted_vllm_embedding_ssl_verify.py
deleted file mode 100644
index de94da49384..00000000000
--- a/tests/test_litellm/llms/hosted_vllm/embedding/test_hosted_vllm_embedding_ssl_verify.py
+++ /dev/null
@@ -1,135 +0,0 @@
-"""
-Test SSL verification for hosted_vllm provider embeddings.
-
-This test ensures that the ssl_verify parameter is properly passed through
-to the HTTP client when using the hosted_vllm provider for embeddings.
-
-Issue: ssl_verify parameter was being ignored because hosted_vllm fell through
-to the openai_like catch-all path in main.py, which doesn't pass ssl_verify to the HTTP client.
-"""
-
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-
-import litellm
-
-
-class TestHostedVLLMEmbeddingSSLVerify:
- """Test suite for SSL verification in hosted_vllm provider embeddings."""
-
- @patch("litellm.llms.custom_httpx.llm_http_handler._get_httpx_client")
- def test_hosted_vllm_embedding_ssl_verify_false_sync(self, mock_get_httpx_client):
- """Test that ssl_verify=False is passed to the HTTP client for sync embedding calls."""
- # Setup mock client
- mock_client = MagicMock()
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.headers = {"content-type": "application/json"}
- mock_response.json.return_value = {
- "object": "list",
- "data": [
- {
- "object": "embedding",
- "index": 0,
- "embedding": [0.1, 0.2, 0.3, 0.4, 0.5],
- }
- ],
- "model": "text-embedding-model",
- "usage": {
- "prompt_tokens": 5,
- "total_tokens": 5,
- },
- }
- mock_response.text = '{"object": "list", "data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2, 0.3, 0.4, 0.5]}], "model": "text-embedding-model", "usage": {"prompt_tokens": 5, "total_tokens": 5}}'
- mock_client.post.return_value = mock_response
- mock_get_httpx_client.return_value = mock_client
-
- try:
- litellm.embedding(
- model="hosted_vllm/text-embedding-model",
- input=["hello world"],
- api_base="https://test-vllm.example.com/v1",
- ssl_verify=False,
- )
- except Exception:
- # Even if the response parsing fails, we just need to verify
- # that the mock was called with the correct ssl_verify parameter
- pass
-
- # Verify _get_httpx_client was called with ssl_verify=False
- mock_get_httpx_client.assert_called()
- call_args = mock_get_httpx_client.call_args
-
- # Check that params contains ssl_verify=False
- if call_args[0]:
- # Positional argument
- params = call_args[0][0]
- else:
- # Keyword argument
- params = call_args[1].get("params", {})
-
- assert (
- params.get("ssl_verify") is False
- ), f"Expected ssl_verify=False in params, got {params}"
-
- @patch("litellm.llms.custom_httpx.llm_http_handler.get_async_httpx_client")
- @pytest.mark.asyncio
- async def test_hosted_vllm_embedding_ssl_verify_false_async(
- self, mock_get_async_httpx_client
- ):
- """Test that ssl_verify=False is passed to the HTTP client for async embedding calls."""
- # Setup mock async client
- mock_client = MagicMock()
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.headers = {"content-type": "application/json"}
- mock_response.json.return_value = {
- "object": "list",
- "data": [
- {
- "object": "embedding",
- "index": 0,
- "embedding": [0.1, 0.2, 0.3, 0.4, 0.5],
- }
- ],
- "model": "text-embedding-model",
- "usage": {
- "prompt_tokens": 5,
- "total_tokens": 5,
- },
- }
- mock_response.text = '{"object": "list", "data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2, 0.3, 0.4, 0.5]}], "model": "text-embedding-model", "usage": {"prompt_tokens": 5, "total_tokens": 5}}'
-
- async def mock_post(*args, **kwargs):
- return mock_response
-
- mock_client.post = mock_post
- mock_get_async_httpx_client.return_value = mock_client
-
- try:
- await litellm.aembedding(
- model="hosted_vllm/text-embedding-model",
- input=["hello world"],
- api_base="https://test-vllm.example.com/v1",
- ssl_verify=False,
- )
- except Exception:
- # Even if the response parsing fails, we just need to verify
- # that the mock was called with the correct ssl_verify parameter
- pass
-
- # Verify get_async_httpx_client was called with ssl_verify=False
- mock_get_async_httpx_client.assert_called()
- call_kwargs = mock_get_async_httpx_client.call_args[1]
-
- # Check that params contains ssl_verify=False
- params = call_kwargs.get("params", {})
- assert (
- params.get("ssl_verify") is False
- ), f"Expected ssl_verify=False in params, got {params}"
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v", "-s"])
diff --git a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py b/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py
similarity index 98%
rename from tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py
rename to tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py
index 8039e744f46..9e9760650cf 100644
--- a/tests/test_litellm/llms/github_copilot/messages/test_github_copilot_messages_transformation.py
+++ b/tests/unit/llms/github_copilot/messages/test_github_copilot_messages_transformation.py
@@ -10,13 +10,6 @@ from litellm.llms.github_copilot.messages.transformation import (
)
-def test_github_copilot_anthropic_messages_config_init():
- """Test GithubCopilotAnthropicMessagesConfig initialization."""
- config = GithubCopilotAnthropicMessagesConfig()
- assert config is not None
- assert hasattr(config, "authenticator")
-
-
def test_github_copilot_anthropic_messages_get_complete_url():
"""get_complete_url builds the /v1/messages URL from the base it is handed.
diff --git a/tests/test_litellm/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py
similarity index 89%
rename from tests/test_litellm/llms/github_copilot/responses/test_github_copilot_responses_transformation.py
rename to tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py
index 0174465b0cc..b8380b7adb4 100644
--- a/tests/test_litellm/llms/github_copilot/responses/test_github_copilot_responses_transformation.py
+++ b/tests/unit/llms/github_copilot/responses/test_github_copilot_responses_transformation.py
@@ -26,9 +26,7 @@ def use_local_model_cost_map(monkeypatch: pytest.MonkeyPatch):
"""Pin litellm.model_cost to the bundled local backup so tests don't depend
on remote catalog fetches (and don't change behavior across remote refreshes)."""
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
- monkeypatch.setattr(
- litellm, "model_cost", get_model_cost_map(url=litellm.model_cost_map_url)
- )
+ monkeypatch.setattr(litellm, "model_cost", get_model_cost_map(url=litellm.model_cost_map_url))
litellm.add_known_models(model_cost_map=litellm.model_cost)
@@ -44,49 +42,35 @@ class TestGithubCopilotResponsesAPITransformation:
provider=LlmProviders.GITHUB_COPILOT,
)
- assert (
- config is not None
- ), "Config should not be None for GitHub Copilot provider"
- assert isinstance(
- config, GithubCopilotResponsesAPIConfig
- ), f"Expected GithubCopilotResponsesAPIConfig, got {type(config)}"
- assert (
- config.custom_llm_provider == LlmProviders.GITHUB_COPILOT
- ), "custom_llm_provider should be GITHUB_COPILOT"
+ assert config is not None, "Config should not be None for GitHub Copilot provider"
+ assert isinstance(config, GithubCopilotResponsesAPIConfig), (
+ f"Expected GithubCopilotResponsesAPIConfig, got {type(config)}"
+ )
+ assert config.custom_llm_provider == LlmProviders.GITHUB_COPILOT, "custom_llm_provider should be GITHUB_COPILOT"
@patch("litellm.llms.github_copilot.responses.transformation.Authenticator")
def test_github_copilot_responses_endpoint_url(self, mock_authenticator_class):
"""Test that get_complete_url returns correct GitHub Copilot endpoint"""
# Mock authenticator to return default base
mock_auth_instance = MagicMock()
- mock_auth_instance.get_api_base.return_value = (
- "https://api.individual.githubcopilot.com"
- )
+ mock_auth_instance.get_api_base.return_value = "https://api.individual.githubcopilot.com"
mock_authenticator_class.return_value = mock_auth_instance
config = GithubCopilotResponsesAPIConfig()
# Test with default GitHub Copilot API base (from authenticator)
url = config.get_complete_url(api_base=None, litellm_params={})
- assert (
- url == "https://api.individual.githubcopilot.com/responses"
- ), f"Expected GitHub Copilot responses endpoint, got {url}"
+ assert url == "https://api.individual.githubcopilot.com/responses", (
+ f"Expected GitHub Copilot responses endpoint, got {url}"
+ )
# Test with custom api_base (overrides authenticator)
- custom_url = config.get_complete_url(
- api_base="https://custom.githubcopilot.com", litellm_params={}
- )
- assert (
- custom_url == "https://custom.githubcopilot.com/responses"
- ), f"Expected custom endpoint, got {custom_url}"
+ custom_url = config.get_complete_url(api_base="https://custom.githubcopilot.com", litellm_params={})
+ assert custom_url == "https://custom.githubcopilot.com/responses", f"Expected custom endpoint, got {custom_url}"
# Test with trailing slash
- url_with_slash = config.get_complete_url(
- api_base="https://api.githubcopilot.com/", litellm_params={}
- )
- assert (
- url_with_slash == "https://api.githubcopilot.com/responses"
- ), "Should handle trailing slash"
+ url_with_slash = config.get_complete_url(api_base="https://api.githubcopilot.com/", litellm_params={})
+ assert url_with_slash == "https://api.githubcopilot.com/responses", "Should handle trailing slash"
@patch("litellm.llms.github_copilot.responses.transformation.Authenticator")
def test_validate_environment_default_headers(self, mock_authenticator_class):
@@ -98,9 +82,7 @@ class TestGithubCopilotResponsesAPITransformation:
config = GithubCopilotResponsesAPIConfig()
- headers = config.validate_environment(
- headers={}, model="gpt-5.1-codex", litellm_params={}
- )
+ headers = config.validate_environment(headers={}, model="gpt-5.1-codex", litellm_params={})
# Check required headers
assert headers["Authorization"] == "Bearer test-api-key-123"
@@ -127,9 +109,7 @@ class TestGithubCopilotResponsesAPITransformation:
"custom-header": "custom-value",
}
- headers = config.validate_environment(
- headers=custom_headers, model="gpt-5.1-codex", litellm_params={}
- )
+ headers = config.validate_environment(headers=custom_headers, model="gpt-5.1-codex", litellm_params={})
# User header should override default
assert headers["editor-version"] == "custom/2.0.0"
@@ -182,9 +162,7 @@ class TestGithubCopilotResponsesAPITransformation:
"""Test _has_vision_input detects input_image type"""
config = GithubCopilotResponsesAPIConfig()
- input_with_vision = [
- {"role": "user", "content": [{"type": "input_image", "data": "base64..."}]}
- ]
+ input_with_vision = [{"role": "user", "content": [{"type": "input_image", "data": "base64..."}]}]
has_vision = config._has_vision_input(input_with_vision)
assert has_vision is True, "Should detect input_image type"
@@ -246,13 +224,11 @@ class TestGithubCopilotResponsesAPITransformation:
}
]
- headers = config.validate_environment(
- headers={}, model="gpt-5.1-codex", litellm_params=mock_litellm_params
- )
+ headers = config.validate_environment(headers={}, model="gpt-5.1-codex", litellm_params=mock_litellm_params)
- assert (
- headers.get("copilot-vision-request") == "true"
- ), "Should add copilot-vision-request header for vision input"
+ assert headers.get("copilot-vision-request") == "true", (
+ "Should add copilot-vision-request header for vision input"
+ )
@patch("litellm.llms.github_copilot.responses.transformation.Authenticator")
def test_validate_environment_with_x_initiator(self, mock_authenticator_class):
@@ -270,21 +246,15 @@ class TestGithubCopilotResponsesAPITransformation:
{"role": "assistant", "content": "Hi"},
]
- headers = config.validate_environment(
- headers={}, model="gpt-5.1-codex", litellm_params=mock_litellm_params
- )
+ headers = config.validate_environment(headers={}, model="gpt-5.1-codex", litellm_params=mock_litellm_params)
- assert (
- headers.get("X-Initiator") == "agent"
- ), "Should set X-Initiator to 'agent' for assistant role"
+ assert headers.get("X-Initiator") == "agent", "Should set X-Initiator to 'agent' for assistant role"
def test_map_openai_params_no_transformation(self):
"""Test that map_openai_params passes through parameters unchanged"""
config = GithubCopilotResponsesAPIConfig()
- params = ResponsesAPIOptionalRequestParams(
- temperature=0.7, max_output_tokens=1000, stream=False
- )
+ params = ResponsesAPIOptionalRequestParams(temperature=0.7, max_output_tokens=1000, stream=False)
result = config.map_openai_params(
response_api_optional_params=params,
@@ -338,9 +308,9 @@ class TestGithubCopilotResponsesAPITransformation:
result = config._handle_reasoning_item(reasoning_item)
# encrypted_content should be preserved
- assert (
- result.get("encrypted_content") == "encrypted-blob-abc123"
- ), "encrypted_content must be preserved for GitHub Copilot multi-turn conversations"
+ assert result.get("encrypted_content") == "encrypted-blob-abc123", (
+ "encrypted_content must be preserved for GitHub Copilot multi-turn conversations"
+ )
# status=None should be filtered out
assert "status" not in result, "status=None should be filtered out"
# content=None should be filtered out
@@ -393,9 +363,7 @@ class TestGithubCopilotResponsesAPIRouting:
in the (already-merged) model info; otherwise returns None so the dispatcher
routes through the chat-completions translation bridge."""
- @patch(
- "litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper"
- )
+ @patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper")
def test_returns_config_when_mode_is_responses(self, mock_get_info):
"""``mode=responses`` returns native config."""
mock_get_info.return_value = {"mode": "responses"}
@@ -405,9 +373,7 @@ class TestGithubCopilotResponsesAPIRouting:
)
assert isinstance(config, GithubCopilotResponsesAPIConfig)
- @patch(
- "litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper"
- )
+ @patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper")
def test_returns_none_when_mode_is_chat(self, mock_get_info):
"""``mode=chat`` returns None so dispatcher uses bridge."""
mock_get_info.return_value = {"mode": "chat"}
@@ -417,9 +383,7 @@ class TestGithubCopilotResponsesAPIRouting:
)
assert config is None
- @patch(
- "litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper"
- )
+ @patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper")
def test_returns_none_when_mode_is_unset_and_no_endpoints(self, mock_get_info):
"""Entry without ``mode`` and without ``supported_endpoints`` returns None
(conservative default)."""
@@ -499,9 +463,7 @@ class TestGithubCopilotResponsesAPIRouting:
)
assert isinstance(config, GithubCopilotResponsesAPIConfig)
- @patch(
- "litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper"
- )
+ @patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper")
def test_returns_none_when_get_model_info_raises(self, mock_get_info):
"""Catalog lookup failure (model not registered) returns None
(conservative default; bridge handles unknown models safely)."""
@@ -512,9 +474,7 @@ class TestGithubCopilotResponsesAPIRouting:
)
assert config is None
- @patch(
- "litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper"
- )
+ @patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper")
def test_user_override_via_register_model(self, mock_get_info):
"""User-supplied per-deployment ``model_info`` flows through
``litellm.register_model`` (called by the router) into the merged
@@ -528,9 +488,7 @@ class TestGithubCopilotResponsesAPIRouting:
)
assert isinstance(config, GithubCopilotResponsesAPIConfig)
- @patch(
- "litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper"
- )
+ @patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper")
def test_realistic_chat_only_entry_returns_none(self, mock_get_info):
"""Realistic ``model_prices_and_context_window.json`` shape for a
chat-only Copilot model (e.g. github_copilot/gemini-3.1-pro-preview)
@@ -554,9 +512,7 @@ class TestGithubCopilotResponsesAPIRouting:
)
assert config is None
- @patch(
- "litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper"
- )
+ @patch("litellm.llms.github_copilot.responses.transformation._cached_get_model_info_helper")
def test_realistic_responses_only_entry_returns_config(self, mock_get_info):
"""Realistic catalog entry for a Responses-only Copilot model
(e.g. github_copilot/gpt-5.5) returns the native config."""
@@ -592,9 +548,7 @@ class TestGithubCopilotReasoningStreamItemIdNormalization:
output_index group to the id from its output_item.added."""
def _config(self):
- with patch(
- "litellm.llms.github_copilot.responses.transformation.Authenticator"
- ):
+ with patch("litellm.llms.github_copilot.responses.transformation.Authenticator"):
return GithubCopilotResponsesAPIConfig()
def _transform(self, config, chunk):
diff --git a/tests/test_litellm/llms/gradient_ai/chat/test_gradient_ai_chat_transformation.py b/tests/unit/llms/gradient_ai/chat/test_gradient_ai_chat_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/gradient_ai/chat/test_gradient_ai_chat_transformation.py
rename to tests/unit/llms/gradient_ai/chat/test_gradient_ai_chat_transformation.py
diff --git a/tests/test_litellm/llms/groq/chat/test_groq_chat_transformation.py b/tests/unit/llms/groq/chat/test_groq_chat_transformation.py
similarity index 99%
rename from tests/test_litellm/llms/groq/chat/test_groq_chat_transformation.py
rename to tests/unit/llms/groq/chat/test_groq_chat_transformation.py
index f605958b979..f5a7a920124 100644
--- a/tests/test_litellm/llms/groq/chat/test_groq_chat_transformation.py
+++ b/tests/unit/llms/groq/chat/test_groq_chat_transformation.py
@@ -202,5 +202,3 @@ class TestGroqWebSearchUsageSignal:
model_response = litellm.ModelResponse()
GroqChatConfig()._add_web_search_usage(model_response=model_response)
assert getattr(model_response, "usage", None) is None
-
-
diff --git a/tests/test_litellm/llms/groq/test_groq_cost_calculator.py b/tests/unit/llms/groq/test_groq_cost_calculator.py
similarity index 100%
rename from tests/test_litellm/llms/groq/test_groq_cost_calculator.py
rename to tests/unit/llms/groq/test_groq_cost_calculator.py
diff --git a/tests/test_litellm/llms/hosted_vllm/chat/test_hosted_vllm_chat_transformation.py b/tests/unit/llms/hosted_vllm/chat/test_hosted_vllm_chat_transformation.py
similarity index 82%
rename from tests/test_litellm/llms/hosted_vllm/chat/test_hosted_vllm_chat_transformation.py
rename to tests/unit/llms/hosted_vllm/chat/test_hosted_vllm_chat_transformation.py
index 82b05601a85..1cc6a1457fc 100644
--- a/tests/test_litellm/llms/hosted_vllm/chat/test_hosted_vllm_chat_transformation.py
+++ b/tests/unit/llms/hosted_vllm/chat/test_hosted_vllm_chat_transformation.py
@@ -41,74 +41,9 @@ def test_hosted_vllm_chat_transformation_file_url():
]
-def test_hosted_vllm_chat_transformation_with_audio_url():
- from litellm import completion
-
- mock_client = MagicMock()
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.headers = {"content-type": "application/json"}
- mock_response.json.return_value = {
- "id": "chatcmpl-test",
- "object": "chat.completion",
- "created": 1234567890,
- "model": "llama-3.1-70b-instruct",
- "choices": [
- {
- "index": 0,
- "message": {"role": "assistant", "content": "Test response"},
- "finish_reason": "stop",
- }
- ],
- "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
- }
- mock_response.text = json.dumps(mock_response.json.return_value)
- mock_client.post.return_value = mock_response
-
- with patch(
- "litellm.llms.custom_httpx.llm_http_handler._get_httpx_client",
- return_value=mock_client,
- ):
- try:
- completion(
- model="hosted_vllm/llama-3.1-70b-instruct",
- messages=[
- {
- "role": "user",
- "content": [
- {
- "type": "audio_url",
- "audio_url": {"url": "https://example.com/audio.mp3"},
- },
- ],
- },
- ],
- api_base="https://test-vllm.example.com/v1",
- )
- except Exception:
- pass
-
- mock_client.post.assert_called_once()
- call_kwargs = mock_client.post.call_args[1]
- request_data = json.loads(call_kwargs["data"])
- assert request_data["messages"] == [
- {
- "role": "user",
- "content": [
- {
- "type": "audio_url",
- "audio_url": {"url": "https://example.com/audio.mp3"},
- }
- ],
- }
- ]
-
-
def test_hosted_vllm_supports_reasoning_effort():
config = HostedVLLMChatConfig()
- supported_params = config.get_supported_openai_params(
- model="hosted_vllm/gpt-oss-120b"
- )
+ supported_params = config.get_supported_openai_params(model="hosted_vllm/gpt-oss-120b")
assert "reasoning_effort" in supported_params
optional_params = config.map_openai_params(
non_default_params={"reasoning_effort": "high"},
@@ -129,9 +64,7 @@ def test_hosted_vllm_supports_thinking():
Related issue: https://github.com/BerriAI/litellm/issues/19761
"""
config = HostedVLLMChatConfig()
- supported_params = config.get_supported_openai_params(
- model="hosted_vllm/GLM-4.6-FP8"
- )
+ supported_params = config.get_supported_openai_params(model="hosted_vllm/GLM-4.6-FP8")
assert "thinking" in supported_params
# Test thinking below the low threshold -> "minimal"
diff --git a/tests/test_litellm/llms/hosted_vllm/embedding/test_hosted_vllm_embedding_transformation.py b/tests/unit/llms/hosted_vllm/embedding/test_hosted_vllm_embedding_transformation.py
similarity index 97%
rename from tests/test_litellm/llms/hosted_vllm/embedding/test_hosted_vllm_embedding_transformation.py
rename to tests/unit/llms/hosted_vllm/embedding/test_hosted_vllm_embedding_transformation.py
index 34be3e12abd..5854b1596b4 100644
--- a/tests/test_litellm/llms/hosted_vllm/embedding/test_hosted_vllm_embedding_transformation.py
+++ b/tests/unit/llms/hosted_vllm/embedding/test_hosted_vllm_embedding_transformation.py
@@ -87,9 +87,7 @@ class TestHostedVLLMEmbeddingTransformation:
headers={},
)
- assert (
- "encoding_format" not in result
- ), "encoding_format should not be in request when not provided"
+ assert "encoding_format" not in result, "encoding_format should not be in request when not provided"
def test_encoding_format_not_included_when_none(self):
"""
@@ -278,9 +276,7 @@ class TestHostedVLLMEmbeddingTransformation:
sent_data = json.loads(call_kwargs["data"])
# Assert that encoding_format is NOT in the sent data
- assert (
- "encoding_format" not in sent_data
- ), "encoding_format should not be in request when not provided"
+ assert "encoding_format" not in sent_data, "encoding_format should not be in request when not provided"
assert sent_data["model"] == "BAAI/bge-small-en-v1.5"
assert sent_data["input"] == ["Hello world"]
diff --git a/tests/test_litellm/llms/hosted_vllm/image_edit/test_hosted_vllm_image_edit_transformation.py b/tests/unit/llms/hosted_vllm/image_edit/test_hosted_vllm_image_edit_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/hosted_vllm/image_edit/test_hosted_vllm_image_edit_transformation.py
rename to tests/unit/llms/hosted_vllm/image_edit/test_hosted_vllm_image_edit_transformation.py
diff --git a/tests/test_litellm/llms/hosted_vllm/responses/test_hosted_vllm_responses.py b/tests/unit/llms/hosted_vllm/responses/test_hosted_vllm_responses.py
similarity index 96%
rename from tests/test_litellm/llms/hosted_vllm/responses/test_hosted_vllm_responses.py
rename to tests/unit/llms/hosted_vllm/responses/test_hosted_vllm_responses.py
index e81bf0c4f1f..55d0ce1e68e 100644
--- a/tests/test_litellm/llms/hosted_vllm/responses/test_hosted_vllm_responses.py
+++ b/tests/unit/llms/hosted_vllm/responses/test_hosted_vllm_responses.py
@@ -68,9 +68,7 @@ def test_hosted_vllm_responses_create_with_string_input():
Test that hosted_vllm routes directly to the native /v1/responses endpoint
when the Responses API config is registered, and correctly parses the response.
"""
- mock_client = _make_mock_http_client(
- _make_mock_responses_api_response("I'm doing well, thanks!")
- )
+ mock_client = _make_mock_http_client(_make_mock_responses_api_response("I'm doing well, thanks!"))
with patch(
"litellm.llms.custom_httpx.llm_http_handler._get_httpx_client",
@@ -109,10 +107,7 @@ def test_hosted_vllm_responses_create_with_explicit_none_extra_body():
)
# extra_body=None should be normalized to an empty dict (or absent)
- assert (
- optional_params.get("extra_body") is not None
- or "extra_body" not in optional_params
- )
+ assert optional_params.get("extra_body") is not None or "extra_body" not in optional_params
def test_hosted_vllm_provider_config_registration():
diff --git a/tests/test_litellm/llms/hosted_vllm/test_hosted_vllm_rerank_transformation.py b/tests/unit/llms/hosted_vllm/test_hosted_vllm_rerank_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/hosted_vllm/test_hosted_vllm_rerank_transformation.py
rename to tests/unit/llms/hosted_vllm/test_hosted_vllm_rerank_transformation.py
diff --git a/tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py b/tests/unit/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py
rename to tests/unit/llms/hosted_vllm/videos/test_hosted_vllm_video_transformation.py
diff --git a/tests/test_litellm/llms/huggingface/rerank/test_huggingface_rerank_transformation.py b/tests/unit/llms/huggingface/rerank/test_huggingface_rerank_transformation.py
similarity index 91%
rename from tests/test_litellm/llms/huggingface/rerank/test_huggingface_rerank_transformation.py
rename to tests/unit/llms/huggingface/rerank/test_huggingface_rerank_transformation.py
index 9d6b7290eb6..6fd2b006fef 100644
--- a/tests/test_litellm/llms/huggingface/rerank/test_huggingface_rerank_transformation.py
+++ b/tests/unit/llms/huggingface/rerank/test_huggingface_rerank_transformation.py
@@ -219,29 +219,6 @@ def test_huggingface_rerank_return_documents(mock_post):
assert "text" in result["document"]
-@patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post")
-def test_huggingface_rerank_error_handling(mock_post):
- """Test HuggingFace rerank error handling."""
-
- def return_val():
- return {"error": "Unauthorized"}
-
- mock_response = MagicMock()
- mock_response.status_code = 401
- mock_response.json = return_val
- mock_response.text = "Unauthorized"
- mock_post.return_value = mock_response
-
- with pytest.raises(litellm.APIConnectionError):
- litellm.rerank(
- model="huggingface/BAAI/bge-reranker-base",
- query="hello",
- documents=["hello", "world"],
- top_n=2,
- api_key="invalid_key",
- )
-
-
def test_huggingface_rerank_config():
"""Test HuggingFaceRerankConfig class functionality."""
from litellm.llms.huggingface.rerank.transformation import HuggingFaceRerankConfig
@@ -249,10 +226,7 @@ def test_huggingface_rerank_config():
config = HuggingFaceRerankConfig()
# Test complete URL generation
- assert (
- config.get_complete_url(None, "test")
- == "https://api-inference.huggingface.co/rerank"
- )
+ assert config.get_complete_url(None, "test") == "https://api-inference.huggingface.co/rerank"
# Test custom API base
custom_url = config.get_complete_url("https://custom.huggingface.co", "test")
@@ -292,13 +266,9 @@ def test_request_transformation():
config = HuggingFaceRerankConfig()
- optional_params = OptionalRerankParams(
- query="hello", texts=["hello", "world"], top_n=2, return_text=True
- )
+ optional_params = OptionalRerankParams(query="hello", texts=["hello", "world"], top_n=2, return_text=True)
- request_body = config.transform_rerank_request(
- model="test", optional_rerank_params=optional_params, headers={}
- )
+ request_body = config.transform_rerank_request(model="test", optional_rerank_params=optional_params, headers={})
assert request_body["query"] == "hello"
assert request_body["texts"] == ["hello", "world"]
@@ -368,9 +338,7 @@ def test_validate_environment():
# Test headers override
custom_headers = {"custom": "header"}
- headers = config.validate_environment(
- headers=custom_headers, model="test", api_key="test_key"
- )
+ headers = config.validate_environment(headers=custom_headers, model="test", api_key="test_key")
assert "custom" in headers
assert headers["custom"] == "header"
diff --git a/tests/test_litellm/llms/inception/test_inception_chat_transformation.py b/tests/unit/llms/inception/test_inception_chat_transformation.py
similarity index 96%
rename from tests/test_litellm/llms/inception/test_inception_chat_transformation.py
rename to tests/unit/llms/inception/test_inception_chat_transformation.py
index 1d12be2adee..c4c023077fc 100644
--- a/tests/test_litellm/llms/inception/test_inception_chat_transformation.py
+++ b/tests/unit/llms/inception/test_inception_chat_transformation.py
@@ -188,21 +188,15 @@ def test_inception_does_not_leak_key_to_caller_api_base():
caller also supplies their own key.
"""
config = InceptionChatConfig()
- with mock.patch.dict(
- os.environ, {"INCEPTION_API_KEY": "server-secret"}, clear=True
- ):
+ with mock.patch.dict(os.environ, {"INCEPTION_API_KEY": "server-secret"}, clear=True):
with mock.patch.object(litellm, "inception_key", "module-secret"):
# caller overrides api_base without a key -> server key withheld
- api_base, api_key = config._get_openai_compatible_provider_info(
- "https://attacker.example/v1", None
- )
+ api_base, api_key = config._get_openai_compatible_provider_info("https://attacker.example/v1", None)
assert api_base == "https://attacker.example/v1"
assert api_key is None
# caller overrides api_base AND supplies their own key -> used as-is
- _, api_key = config._get_openai_compatible_provider_info(
- "https://attacker.example/v1", "caller-key"
- )
+ _, api_key = config._get_openai_compatible_provider_info("https://attacker.example/v1", "caller-key")
assert api_key == "caller-key"
# default/server base -> server-managed key resolved
@@ -217,9 +211,7 @@ def test_get_llm_provider_inception():
assert model == "mercury-2"
assert provider == "inception"
- model, provider, _, api_base = get_llm_provider(
- "mercury-2", api_base="https://api.inceptionlabs.ai/v1"
- )
+ model, provider, _, api_base = get_llm_provider("mercury-2", api_base="https://api.inceptionlabs.ai/v1")
assert model == "mercury-2"
assert provider == "inception"
assert api_base == "https://api.inceptionlabs.ai/v1"
@@ -293,5 +285,3 @@ def test_inception_completion_targets_inception_endpoint():
assert captured["body"]["model"] == "mercury-2"
assert captured["body"]["tool_choice"] == "auto"
assert response.choices[0].message.content == "hi"
-
-
diff --git a/tests/test_litellm/llms/inception/test_inception_completion_transformation.py b/tests/unit/llms/inception/test_inception_completion_transformation.py
similarity index 95%
rename from tests/test_litellm/llms/inception/test_inception_completion_transformation.py
rename to tests/unit/llms/inception/test_inception_completion_transformation.py
index ed3f34fc744..84923229e20 100644
--- a/tests/test_litellm/llms/inception/test_inception_completion_transformation.py
+++ b/tests/unit/llms/inception/test_inception_completion_transformation.py
@@ -22,9 +22,7 @@ def _fim_response_bytes():
"object": "text_completion",
"created": 1,
"model": "mercury-edit-2",
- "choices": [
- {"text": "a + b", "index": 0, "finish_reason": "stop", "logprobs": None}
- ],
+ "choices": [{"text": "a + b", "index": 0, "finish_reason": "stop", "logprobs": None}],
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
}
).encode()
@@ -47,9 +45,7 @@ def test_inception_fim_supports_suffix_param():
def test_inception_fim_supported_params_match_schema():
"""FIM exposes the OpenAI subset of Inception's FIMCompletionRequest only"""
- params = InceptionTextCompletionConfig().get_supported_openai_params(
- "mercury-edit-2"
- )
+ params = InceptionTextCompletionConfig().get_supported_openai_params("mercury-edit-2")
for p in ("suffix", "top_p", "frequency_penalty", "presence_penalty", "stop"):
assert p in params
# Chat-only sampling controls are not part of Inception's FIM schema
@@ -75,11 +71,7 @@ def test_inception_get_supported_openai_params_dispatch():
@pytest.mark.parametrize("provider", ["inception", "text-completion-inception"])
def test_inception_validate_environment(provider):
- model = (
- "inception/mercury-2"
- if provider == "inception"
- else "text-completion-inception/mercury-edit-2"
- )
+ model = "inception/mercury-2" if provider == "inception" else "text-completion-inception/mercury-edit-2"
with mock.patch.dict(os.environ, {}, clear=True):
result = litellm.validate_environment(model)
@@ -217,9 +209,7 @@ def test_inception_fim_does_not_leak_global_api_key():
content=_fim_response_bytes(),
)
- with mock.patch.dict(
- os.environ, {"INCEPTION_API_KEY": "sk-inception-correct"}, clear=True
- ):
+ with mock.patch.dict(os.environ, {"INCEPTION_API_KEY": "sk-inception-correct"}, clear=True):
with mock.patch.object(litellm, "inception_key", None):
with mock.patch.object(litellm, "api_key", "sk-global-should-not-leak"):
with mock.patch("httpx.Client.send", new=fake_send):
diff --git a/tests/test_litellm/llms/jina_ai/embedding/test_jina_embedding_transformation.py b/tests/unit/llms/jina_ai/embedding/test_jina_embedding_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/jina_ai/embedding/test_jina_embedding_transformation.py
rename to tests/unit/llms/jina_ai/embedding/test_jina_embedding_transformation.py
diff --git a/tests/test_litellm/llms/langflow/chat/test_langflow_chat_transformation.py b/tests/unit/llms/langflow/chat/test_langflow_chat_transformation.py
similarity index 93%
rename from tests/test_litellm/llms/langflow/chat/test_langflow_chat_transformation.py
rename to tests/unit/llms/langflow/chat/test_langflow_chat_transformation.py
index 383a7afbe93..179a6cad4aa 100644
--- a/tests/test_litellm/llms/langflow/chat/test_langflow_chat_transformation.py
+++ b/tests/unit/llms/langflow/chat/test_langflow_chat_transformation.py
@@ -46,7 +46,7 @@ def test_langflow_config_get_complete_url():
def test_langflow_config_get_complete_url_requires_api_base():
config = LangFlowConfig()
- with pytest.raises(ValueError, match='api_base is required for LangFlow\\. Set it via'):
+ with pytest.raises(ValueError, match="api_base is required for LangFlow\\. Set it via"):
config.get_complete_url(
api_base=None,
api_key=None,
@@ -225,9 +225,7 @@ def test_langflow_extra_body_cannot_inject_tweaks_into_run_payload():
posted_bodies.append(json.loads(body) if isinstance(body, str) else body)
resp = MagicMock(spec=httpx.Response)
resp.status_code = 200
- resp.json.return_value = {
- "outputs": [{"outputs": [{"results": {"message": {"text": "hi"}}}]}]
- }
+ resp.json.return_value = {"outputs": [{"outputs": [{"results": {"message": {"text": "hi"}}}]}]}
resp.headers = {}
resp.text = "{}"
return resp
@@ -275,9 +273,7 @@ def test_langflow_config_extract_response_from_outputs_dict():
"outputs": [
{
"results": {},
- "outputs": {
- "message": {"message": {"text": "via outputs dict"}}
- },
+ "outputs": {"message": {"message": {"text": "via outputs dict"}}},
}
]
}
@@ -292,14 +288,9 @@ def test_langflow_extract_response_returns_none_when_no_message():
assert config._extract_content_from_response({"outputs": []}) is None
assert config._extract_content_from_response({"detail": "flow failed"}) is None
assert config._extract_content_from_response({"outputs": ["not-a-dict"]}) is None
+ assert config._extract_content_from_response({"outputs": [{"outputs": ["bad"]}]}) is None
assert (
- config._extract_content_from_response({"outputs": [{"outputs": ["bad"]}]})
- is None
- )
- assert (
- config._extract_content_from_response(
- {"outputs": [{"outputs": [{"results": {"message": {"text": ""}}}]}]}
- )
+ config._extract_content_from_response({"outputs": [{"outputs": [{"results": {"message": {"text": ""}}}]}]})
is None
)
@@ -310,9 +301,7 @@ def test_langflow_transform_response_builds_model_response_with_usage():
status_code=200,
json={
"session_id": "sess-abc",
- "outputs": [
- {"outputs": [{"results": {"message": {"text": "Hello from LangFlow"}}}]}
- ],
+ "outputs": [{"outputs": [{"results": {"message": {"text": "Hello from LangFlow"}}}]}],
},
)
@@ -332,9 +321,7 @@ def test_langflow_transform_response_builds_model_response_with_usage():
assert result.choices[0].finish_reason == "stop"
assert result.model == "langflow/my-flow-id"
assert result.usage.completion_tokens > 0
- assert result.usage.total_tokens == (
- result.usage.prompt_tokens + result.usage.completion_tokens
- )
+ assert result.usage.total_tokens == (result.usage.prompt_tokens + result.usage.completion_tokens)
def test_langflow_transform_response_raises_on_unparseable_body():
@@ -357,9 +344,7 @@ def test_langflow_transform_response_raises_on_unparseable_body():
def test_langflow_transform_response_raises_on_non_json_body():
config = LangFlowConfig()
- raw_response = httpx.Response(
- status_code=200, content=b"not json", headers={"content-type": "text/plain"}
- )
+ raw_response = httpx.Response(status_code=200, content=b"not json", headers={"content-type": "text/plain"})
with pytest.raises(LangFlowError):
config.transform_response(
diff --git a/tests/test_litellm/llms/litellm_proxy/test_sandbox_executor.py b/tests/unit/llms/litellm_proxy/test_sandbox_executor.py
similarity index 84%
rename from tests/test_litellm/llms/litellm_proxy/test_sandbox_executor.py
rename to tests/unit/llms/litellm_proxy/test_sandbox_executor.py
index 422e7a3cf4d..e7a03b9231a 100644
--- a/tests/test_litellm/llms/litellm_proxy/test_sandbox_executor.py
+++ b/tests/unit/llms/litellm_proxy/test_sandbox_executor.py
@@ -55,9 +55,7 @@ def _install_fake_sandbox(monkeypatch, session_cls=_FakeSandboxSession):
def test_execute_installs_inline_requirements_file(monkeypatch):
_install_fake_sandbox(monkeypatch)
executor = SkillsSandboxExecutor()
- monkeypatch.setattr(
- executor, "_collect_generated_files", lambda *args, **kwargs: []
- )
+ monkeypatch.setattr(executor, "_collect_generated_files", lambda *args, **kwargs: [])
requirements = "git+https://example.com/repo.git#egg=foo\n-r extra.txt\n-e ./pkg\n"
result = executor.execute(
@@ -69,22 +67,15 @@ def test_execute_installs_inline_requirements_file(monkeypatch):
assert result["success"] is True
created_session = _FakeSandboxSession.last_instance
- assert created_session.copied_contents[
- "/sandbox/.litellm_requirements.txt"
- ] == requirements.encode("utf-8")
- assert (
- "pip', 'install', '-r', '.litellm_requirements.txt'"
- in created_session.run_calls[0]
- )
+ assert created_session.copied_contents["/sandbox/.litellm_requirements.txt"] == requirements.encode("utf-8")
+ assert "pip', 'install', '-r', '.litellm_requirements.txt'" in created_session.run_calls[0]
assert "os.chdir('/sandbox')" in created_session.run_calls[1]
def test_execute_uses_skill_requirements_txt(monkeypatch):
_install_fake_sandbox(monkeypatch)
executor = SkillsSandboxExecutor()
- monkeypatch.setattr(
- executor, "_collect_generated_files", lambda *args, **kwargs: []
- )
+ monkeypatch.setattr(executor, "_collect_generated_files", lambda *args, **kwargs: [])
result = executor.execute(
code="print('hello')",
@@ -97,9 +88,7 @@ def test_execute_uses_skill_requirements_txt(monkeypatch):
assert result["success"] is True
created_session = _FakeSandboxSession.last_instance
- copied_paths = {
- sandbox_path for _, sandbox_path in created_session.copy_to_runtime_calls
- }
+ copied_paths = {sandbox_path for _, sandbox_path in created_session.copy_to_runtime_calls}
assert "/sandbox/requirements.txt" in copied_paths
assert "/sandbox/.litellm_requirements.txt" not in copied_paths
assert "pip', 'install', '-r', 'requirements.txt'" in created_session.run_calls[0]
@@ -118,9 +107,7 @@ def test_execute_returns_install_failure(monkeypatch):
_install_fake_sandbox(monkeypatch, session_cls=_FailingSandboxSession)
executor = SkillsSandboxExecutor()
- monkeypatch.setattr(
- executor, "_collect_generated_files", lambda *args, **kwargs: []
- )
+ monkeypatch.setattr(executor, "_collect_generated_files", lambda *args, **kwargs: [])
result = executor.execute(
code="print('hello')",
diff --git a/tests/test_litellm/llms/litellm_proxy/test_skills_ownership.py b/tests/unit/llms/litellm_proxy/test_skills_ownership.py
similarity index 88%
rename from tests/test_litellm/llms/litellm_proxy/test_skills_ownership.py
rename to tests/unit/llms/litellm_proxy/test_skills_ownership.py
index e538c50cde8..6caa2da3169 100644
--- a/tests/test_litellm/llms/litellm_proxy/test_skills_ownership.py
+++ b/tests/unit/llms/litellm_proxy/test_skills_ownership.py
@@ -37,12 +37,7 @@ def _skill(skill_id: str, created_by: str | None) -> LiteLLM_SkillsTable:
def test_should_extract_skill_auth_from_supported_metadata_fields():
auth = UserAPIKeyAuth(user_id="user-1")
- assert (
- skills_main._get_user_api_key_auth_from_kwargs(
- {"metadata": {"user_api_key_auth": auth}}
- )
- is auth
- )
+ assert skills_main._get_user_api_key_auth_from_kwargs({"metadata": {"user_api_key_auth": auth}}) is auth
assert (
skills_main._get_user_api_key_auth_from_kwargs(
{"metadata": {}, "litellm_metadata": {"user_api_key_auth": auth}}
@@ -122,9 +117,7 @@ def test_should_forward_skill_auth_through_sdk_entrypoints(monkeypatch):
== "deleted"
)
- assert handler.create_skill_handler.call_args.kwargs["metadata"] == {
- "source": "request"
- }
+ assert handler.create_skill_handler.call_args.kwargs["metadata"] == {"source": "request"}
assert handler.create_skill_handler.call_args.kwargs["user_api_key_dict"] is auth
assert handler.list_skills_handler.call_args.kwargs["user_api_key_dict"] is auth
assert handler.get_skill_handler.call_args.kwargs["user_api_key_dict"] is auth
@@ -149,9 +142,7 @@ def test_should_build_resource_owner_scopes_for_auth_context():
]
assert resource_ownership.get_primary_resource_owner_scope(auth) == "user-1"
assert resource_ownership.user_can_access_resource_owner("team:team-1", auth)
- assert resource_ownership.get_resource_owner_scopes(
- UserAPIKeyAuth(token="token-hash")
- ) == ["key:token-hash"]
+ assert resource_ownership.get_resource_owner_scopes(UserAPIKeyAuth(token="token-hash")) == ["key:token-hash"]
# Identity-less callers get an empty scope set — sharing a sentinel
# would collapse every identity-less caller into the same logical
# owner, which is a cross-tenant data-access primitive.
@@ -165,9 +156,7 @@ def test_should_allow_admin_and_anonymous_resource_owner_paths():
assert resource_ownership.is_proxy_admin(admin)
assert resource_ownership.user_can_access_resource_owner(None, admin)
assert resource_ownership.user_can_access_resource_owner(None, None)
- assert not resource_ownership.user_can_access_resource_owner(
- None, UserAPIKeyAuth(user_id="user-1")
- )
+ assert not resource_ownership.user_can_access_resource_owner(None, UserAPIKeyAuth(user_id="user-1"))
@pytest.mark.asyncio
@@ -218,9 +207,7 @@ async def test_should_forward_skill_auth_through_transformation_handler(monkeypa
async def test_should_store_team_owner_for_keys_without_user_id(monkeypatch):
table = AsyncMock()
table.create.side_effect = lambda data: _skill(data["skill_id"], data["created_by"])
- prisma_client = type(
- "Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()}
- )()
+ prisma_client = type("Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()})()
monkeypatch.setattr(
LiteLLMSkillsHandler,
"_get_prisma_client",
@@ -242,9 +229,7 @@ async def test_should_store_team_owner_for_keys_without_user_id(monkeypatch):
async def test_should_store_token_owner_for_keys_without_user_team_or_org(monkeypatch):
table = AsyncMock()
table.create.side_effect = lambda data: _skill(data["skill_id"], data["created_by"])
- prisma_client = type(
- "Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()}
- )()
+ prisma_client = type("Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()})()
monkeypatch.setattr(
LiteLLMSkillsHandler,
"_get_prisma_client",
@@ -268,9 +253,7 @@ async def test_should_reject_skill_create_for_identityless_proxy_auth(monkeypatc
sentinel as ``created_by`` would let any two such callers see each
other's skills via the resulting shared owner scope."""
table = AsyncMock()
- prisma_client = type(
- "Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()}
- )()
+ prisma_client = type("Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()})()
monkeypatch.setattr(
LiteLLMSkillsHandler,
"_get_prisma_client",
@@ -291,9 +274,7 @@ async def test_should_reject_skill_create_for_identityless_proxy_auth(monkeypatc
async def test_should_filter_list_skills_to_authenticated_owner_scopes(monkeypatch):
table = AsyncMock()
table.find_many.return_value = [_skill("litellm_skill_owner", "user-1")]
- prisma_client = type(
- "Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()}
- )()
+ prisma_client = type("Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()})()
monkeypatch.setattr(
LiteLLMSkillsHandler,
"_get_prisma_client",
@@ -318,9 +299,7 @@ async def test_should_filter_list_skills_to_authenticated_owner_scopes(monkeypat
async def test_should_hide_skill_from_different_owner(monkeypatch):
table = AsyncMock()
table.find_unique.return_value = _skill("litellm_skill_other", "user-2")
- prisma_client = type(
- "Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()}
- )()
+ prisma_client = type("Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()})()
monkeypatch.setattr(
LiteLLMSkillsHandler,
"_get_prisma_client",
@@ -340,9 +319,7 @@ async def test_should_hide_skill_from_different_owner(monkeypatch):
async def test_should_hide_unowned_skill_by_default(monkeypatch):
table = AsyncMock()
table.find_unique.return_value = _skill("litellm_skill_unowned", None)
- prisma_client = type(
- "Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()}
- )()
+ prisma_client = type("Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()})()
monkeypatch.setattr(
LiteLLMSkillsHandler,
"_get_prisma_client",
@@ -364,9 +341,7 @@ async def test_list_skills_excludes_unowned_for_non_admin(monkeypatch):
with ``created_by IS NULL`` are excluded — admin-only."""
table = AsyncMock()
table.find_many.return_value = []
- prisma_client = type(
- "Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()}
- )()
+ prisma_client = type("Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()})()
monkeypatch.setattr(
LiteLLMSkillsHandler,
"_get_prisma_client",
@@ -422,9 +397,7 @@ async def test_load_skill_uses_cache_after_first_db_hit(monkeypatch):
fake_skill = Mock(created_by="user-1", skill_id="litellm_skill_a")
table = AsyncMock()
table.find_unique = AsyncMock(return_value=fake_skill)
- prisma_client = type(
- "Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()}
- )()
+ prisma_client = type("Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()})()
monkeypatch.setattr(
skills_handler.LiteLLMSkillsHandler,
"_get_prisma_client",
@@ -432,10 +405,7 @@ async def test_load_skill_uses_cache_after_first_db_hit(monkeypatch):
)
for _ in range(3):
- assert (
- await skills_handler.LiteLLMSkillsHandler._load_skill("litellm_skill_a")
- is fake_skill
- )
+ assert await skills_handler.LiteLLMSkillsHandler._load_skill("litellm_skill_a") is fake_skill
assert table.find_unique.await_count == 1
@@ -445,9 +415,7 @@ async def test_load_skill_caches_negative_lookups(monkeypatch):
the DB and the caller still sees ``None``."""
table = AsyncMock()
table.find_unique = AsyncMock(return_value=None)
- prisma_client = type(
- "Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()}
- )()
+ prisma_client = type("Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()})()
monkeypatch.setattr(
skills_handler.LiteLLMSkillsHandler,
"_get_prisma_client",
@@ -466,9 +434,7 @@ async def test_delete_skill_invalidates_cache(monkeypatch):
table = AsyncMock()
table.find_unique = AsyncMock(return_value=fake_skill)
table.delete = AsyncMock()
- prisma_client = type(
- "Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()}
- )()
+ prisma_client = type("Prisma", (), {"db": type("DB", (), {"litellm_skillstable": table})()})()
monkeypatch.setattr(
skills_handler.LiteLLMSkillsHandler,
"_get_prisma_client",
@@ -480,12 +446,7 @@ async def test_delete_skill_invalidates_cache(monkeypatch):
assert skills_handler._SKILL_CACHE.get_cache("litellm_skill_a") is fake_skill
auth = UserAPIKeyAuth(user_id="user-1")
- await skills_handler.LiteLLMSkillsHandler.delete_skill(
- "litellm_skill_a", user_api_key_dict=auth
- )
+ await skills_handler.LiteLLMSkillsHandler.delete_skill("litellm_skill_a", user_api_key_dict=auth)
# Post-delete, the cache holds the negative sentinel — not the stale row.
- assert (
- skills_handler._SKILL_CACHE.get_cache("litellm_skill_a")
- == skills_handler._NEGATIVE_SKILL_SENTINEL
- )
+ assert skills_handler._SKILL_CACHE.get_cache("litellm_skill_a") == skills_handler._NEGATIVE_SKILL_SENTINEL
From 99c2ef4d73efbfa657da009ef0a6a70205b03073 Mon Sep 17 00:00:00 2001
From: yuneng
Date: Sun, 20 Sep 2026 08:07:50 +0000
Subject: [PATCH 130/317] test(unit): block external sockets at import time and
add a socket policy regression test
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/unit/conftest.py | 14 ++++++++------
tests/unit/test_socket_policy.py | 17 +++++++++++++++++
2 files changed, 25 insertions(+), 6 deletions(-)
create mode 100644 tests/unit/test_socket_policy.py
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
index 3bdab1d231a..017e63ed1b8 100644
--- a/tests/unit/conftest.py
+++ b/tests/unit/conftest.py
@@ -1,9 +1,11 @@
-from collections.abc import Iterator
+import os
from typing import Final
import pytest
from pytest_socket import enable_socket, socket_allow_hosts
+os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
+
LOOPBACK_HOSTS: Final = ["127.0.0.1", "::1"]
@@ -11,13 +13,13 @@ def _allow_loopback_only() -> None:
socket_allow_hosts(LOOPBACK_HOSTS, allow_unix_socket=True)
-@pytest.fixture(autouse=True, scope="session")
-def block_external_sockets() -> Iterator[None]:
- _allow_loopback_only()
- yield
- enable_socket()
+_allow_loopback_only()
@pytest.hookimpl(trylast=True)
def pytest_runtest_setup() -> None:
_allow_loopback_only()
+
+
+def pytest_sessionfinish() -> None:
+ enable_socket()
diff --git a/tests/unit/test_socket_policy.py b/tests/unit/test_socket_policy.py
new file mode 100644
index 00000000000..f93794d1ba8
--- /dev/null
+++ b/tests/unit/test_socket_policy.py
@@ -0,0 +1,17 @@
+import socket
+
+import pytest
+from pytest_socket import SocketConnectBlockedError
+
+
+def test_external_connect_is_refused_before_a_packet_leaves() -> None:
+ with pytest.raises(SocketConnectBlockedError):
+ socket.create_connection(("192.0.2.1", 9), timeout=1)
+
+
+def test_loopback_connect_is_allowed() -> None:
+ with socket.socket() as server:
+ server.bind(("127.0.0.1", 0))
+ server.listen()
+ with socket.create_connection(server.getsockname(), timeout=1) as client:
+ assert client.getpeername() == server.getsockname()
From d47008129c175a068b23c9e0afe306463e39974f Mon Sep 17 00:00:00 2001
From: yuneng
Date: Sun, 20 Sep 2026 08:08:12 +0000
Subject: [PATCH 131/317] test(llms): migrate phase 7 provider unit tests to
tests/unit
Move the wave 1 phase 7 batch (fireworks_ai, gemini, gigachat, github_copilot; 20 files) from tests/test_litellm to tests/unit after judging every test function under a behaviour mutation. Seven wiring or mock-echo tests that stayed green are deleted. The fireworks cost calculator tests get a local model_cost save/restore fixture since the tests/unit tree has no shared conftest for it
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../test_fireworks_ai_chat_transformation.py | 0
...test_fireworks_ai_rerank_transformation.py | 0
...t_fireworks_ai_responses_transformation.py | 15 --------
.../test_fireworks_ai_cache_pricing.py | 0
.../test_fireworks_ai_common_utils.py | 0
.../test_fireworks_ai_cost_calculator.py | 22 ++++++++---
...mini_audio_transcription_transformation.py | 0
.../files/test_gemini_files_transformation.py | 0
.../test_google_genai_guardrail_handler.py | 0
.../test_gemini_image_edit_transformation.py | 0
.../test_gemini_realtime_transformation.py | 0
.../test_gemini_video_transformation.py | 0
.../chat/test_gigachat_chat_streaming.py | 0
.../chat/test_gigachat_chat_transformation.py | 28 --------------
.../test_gigachat_embedding_transformation.py | 30 ---------------
...est_gigachat_passthrough_transformation.py | 0
.../llms/gigachat/test_authenticator.py | 0
.../llms/gigachat/test_file_handler.py | 38 -------------------
.../llms/gigachat/test_utils.py | 0
...github_copilot_embedding_transformation.py | 0
20 files changed, 16 insertions(+), 117 deletions(-)
rename tests/{test_litellm => unit}/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/fireworks_ai/rerank/test_fireworks_ai_rerank_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py (97%)
rename tests/{test_litellm => unit}/llms/fireworks_ai/test_fireworks_ai_cache_pricing.py (100%)
rename tests/{test_litellm => unit}/llms/fireworks_ai/test_fireworks_ai_common_utils.py (100%)
rename tests/{test_litellm => unit}/llms/fireworks_ai/test_fireworks_ai_cost_calculator.py (90%)
rename tests/{test_litellm => unit}/llms/gemini/audio_transcription/test_gemini_audio_transcription_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/gemini/files/test_gemini_files_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/gemini/google_genai/guardrail_translation/test_google_genai_guardrail_handler.py (100%)
rename tests/{test_litellm => unit}/llms/gemini/image_edit/test_gemini_image_edit_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/gemini/realtime/test_gemini_realtime_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/gemini/videos/test_gemini_video_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/gigachat/chat/test_gigachat_chat_streaming.py (100%)
rename tests/{test_litellm => unit}/llms/gigachat/chat/test_gigachat_chat_transformation.py (95%)
rename tests/{test_litellm => unit}/llms/gigachat/embedding/test_gigachat_embedding_transformation.py (91%)
rename tests/{test_litellm => unit}/llms/gigachat/passthrough/test_gigachat_passthrough_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/gigachat/test_authenticator.py (100%)
rename tests/{test_litellm => unit}/llms/gigachat/test_file_handler.py (91%)
rename tests/{test_litellm => unit}/llms/gigachat/test_utils.py (100%)
rename tests/{test_litellm => unit}/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py (100%)
diff --git a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py b/tests/unit/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py
rename to tests/unit/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py
diff --git a/tests/test_litellm/llms/fireworks_ai/rerank/test_fireworks_ai_rerank_transformation.py b/tests/unit/llms/fireworks_ai/rerank/test_fireworks_ai_rerank_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/fireworks_ai/rerank/test_fireworks_ai_rerank_transformation.py
rename to tests/unit/llms/fireworks_ai/rerank/test_fireworks_ai_rerank_transformation.py
diff --git a/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py b/tests/unit/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py
similarity index 97%
rename from tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py
rename to tests/unit/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py
index d0697ca9b0e..05e3812152e 100644
--- a/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py
+++ b/tests/unit/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py
@@ -406,21 +406,6 @@ def test_responses_call_sends_session_affinity_for_caller_session_id() -> None:
assert headers["x-session-affinity"] == "sess-42"
-def test_responses_call_keeps_caller_supplied_session_affinity_header() -> None:
- client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/kimi-k3"))
- pinned: Final[Mapping[str, str]] = MappingProxyType({"x-session-affinity": "explicit-node"})
- with patch(HTTPX_CLIENT_FACTORY, return_value=client):
- litellm.responses(
- model="fireworks_ai/kimi-k3",
- input="hi",
- api_key="fw-test-key",
- litellm_session_id="sess-42",
- extra_headers=pinned,
- )
- _, headers, _ = _sent_request(client)
- assert headers["x-session-affinity"] == "explicit-node"
-
-
def test_responses_call_maps_provider_errors_to_fireworks_ai() -> None:
client: Final = MagicMock()
request: Final = httpx.Request("POST", FIREWORKS_RESPONSES_URL)
diff --git a/tests/test_litellm/llms/fireworks_ai/test_fireworks_ai_cache_pricing.py b/tests/unit/llms/fireworks_ai/test_fireworks_ai_cache_pricing.py
similarity index 100%
rename from tests/test_litellm/llms/fireworks_ai/test_fireworks_ai_cache_pricing.py
rename to tests/unit/llms/fireworks_ai/test_fireworks_ai_cache_pricing.py
diff --git a/tests/test_litellm/llms/fireworks_ai/test_fireworks_ai_common_utils.py b/tests/unit/llms/fireworks_ai/test_fireworks_ai_common_utils.py
similarity index 100%
rename from tests/test_litellm/llms/fireworks_ai/test_fireworks_ai_common_utils.py
rename to tests/unit/llms/fireworks_ai/test_fireworks_ai_common_utils.py
diff --git a/tests/test_litellm/llms/fireworks_ai/test_fireworks_ai_cost_calculator.py b/tests/unit/llms/fireworks_ai/test_fireworks_ai_cost_calculator.py
similarity index 90%
rename from tests/test_litellm/llms/fireworks_ai/test_fireworks_ai_cost_calculator.py
rename to tests/unit/llms/fireworks_ai/test_fireworks_ai_cost_calculator.py
index 52222f22a51..c6096ba2745 100644
--- a/tests/test_litellm/llms/fireworks_ai/test_fireworks_ai_cost_calculator.py
+++ b/tests/unit/llms/fireworks_ai/test_fireworks_ai_cost_calculator.py
@@ -1,4 +1,5 @@
import math
+from collections.abc import Generator
from datetime import datetime, timezone
from typing import Final
@@ -24,6 +25,15 @@ CACHE_READ_COST = litellm.get_model_info(model=MODEL, custom_llm_provider="firew
OUTPUT_COST = 4.4e-06
+@pytest.fixture(autouse=True)
+def restore_model_cost() -> Generator[None, None, None]:
+ original: Final = litellm.model_cost
+ litellm.get_model_info.cache_clear()
+ yield
+ litellm.model_cost = original
+ litellm.get_model_info.cache_clear()
+
+
def _usage(prompt_tokens: int, cached_tokens: int, completion_tokens: int) -> Usage:
return Usage(
prompt_tokens=prompt_tokens,
@@ -57,7 +67,7 @@ def _register_off_peak_model(
cache_read_cost: float | None = STANDARD_CACHE_READ_COST,
model: str = OFF_PEAK_MODEL,
) -> None:
- litellm.model_cost = { # test-quality-ok: conftest restores litellm.model_cost after each test
+ litellm.model_cost = { # test-quality-ok: the restore_model_cost fixture returns litellm.model_cost to the original object after each test
**litellm.model_cost,
f"fireworks_ai/{model}": {
"litellm_provider": "fireworks_ai",
@@ -151,7 +161,7 @@ def test_an_entry_without_a_cache_read_rate_bills_cached_tokens_at_the_documente
"""Fireworks documents a default 50% cached-token discount for serverless models:
https://docs.fireworks.ai/guides/prompt-caching, accessed 2026-09-19."""
model = "accounts/fireworks/models/default-cache-read-test"
- litellm.model_cost = { # test-quality-ok: conftest restores litellm.model_cost after each test
+ litellm.model_cost = { # test-quality-ok: the restore_model_cost fixture returns litellm.model_cost to the original object after each test
**litellm.model_cost,
f"fireworks_ai/{model}": {
"litellm_provider": "fireworks_ai",
@@ -171,7 +181,7 @@ def test_an_entry_without_a_cache_read_rate_bills_cached_tokens_at_the_documente
def test_fireworks_cache_read_rates_match_breakdown_and_caching_savings():
model = "accounts/fireworks/models/breakdown-cache-read-test"
- litellm.model_cost = { # test-quality-ok: the save/restore conftest returns litellm.model_cost to the original object after each test, so replacing the map for this entry leaks nothing
+ litellm.model_cost = { # test-quality-ok: the restore_model_cost fixture returns litellm.model_cost to the original object after each test, so replacing the map for this entry leaks nothing
**litellm.model_cost,
f"fireworks_ai/{model}": {
"litellm_provider": "fireworks_ai",
@@ -204,7 +214,7 @@ def test_fireworks_cache_read_rates_match_breakdown_and_caching_savings():
def test_generic_cost_per_token_applies_fireworks_cache_read_default_with_or_without_model_info():
model = "accounts/fireworks/models/generic-cache-read-test"
- litellm.model_cost = { # test-quality-ok: conftest restores litellm.model_cost after each test
+ litellm.model_cost = { # test-quality-ok: the restore_model_cost fixture returns litellm.model_cost to the original object after each test
**litellm.model_cost,
f"fireworks_ai/{model}": {
"litellm_provider": "fireworks_ai",
@@ -257,7 +267,7 @@ COMPONENT_AUDIO_OUT_COST = 6e-06
def test_cache_write_reasoning_and_audio_tokens_are_billed_at_their_component_rates():
- litellm.model_cost = { # test-quality-ok: the save/restore conftest returns litellm.model_cost to the original object after each test, so replacing the map for this entry leaks nothing
+ litellm.model_cost = { # test-quality-ok: the restore_model_cost fixture returns litellm.model_cost to the original object after each test, so replacing the map for this entry leaks nothing
**litellm.model_cost,
f"fireworks_ai/{COMPONENT_MODEL}": {
"litellm_provider": "fireworks_ai",
@@ -302,7 +312,7 @@ def test_cache_write_reasoning_and_audio_tokens_are_billed_at_their_component_ra
def test_an_entry_without_an_input_rate_gets_no_cache_read_fallback():
- litellm.model_cost = { # test-quality-ok: the save/restore conftest returns litellm.model_cost to the original object after each test, so replacing the map for this entry leaks nothing
+ litellm.model_cost = { # test-quality-ok: the restore_model_cost fixture returns litellm.model_cost to the original object after each test, so replacing the map for this entry leaks nothing
**litellm.model_cost, # pyright: ignore[reportUnknownMemberType] # the SDK types model_cost as dict[Unknown, Unknown]
"fireworks_ai/accounts/fireworks/models/no-input-rate-test": {
"litellm_provider": "fireworks_ai",
diff --git a/tests/test_litellm/llms/gemini/audio_transcription/test_gemini_audio_transcription_transformation.py b/tests/unit/llms/gemini/audio_transcription/test_gemini_audio_transcription_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/gemini/audio_transcription/test_gemini_audio_transcription_transformation.py
rename to tests/unit/llms/gemini/audio_transcription/test_gemini_audio_transcription_transformation.py
diff --git a/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py b/tests/unit/llms/gemini/files/test_gemini_files_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py
rename to tests/unit/llms/gemini/files/test_gemini_files_transformation.py
diff --git a/tests/test_litellm/llms/gemini/google_genai/guardrail_translation/test_google_genai_guardrail_handler.py b/tests/unit/llms/gemini/google_genai/guardrail_translation/test_google_genai_guardrail_handler.py
similarity index 100%
rename from tests/test_litellm/llms/gemini/google_genai/guardrail_translation/test_google_genai_guardrail_handler.py
rename to tests/unit/llms/gemini/google_genai/guardrail_translation/test_google_genai_guardrail_handler.py
diff --git a/tests/test_litellm/llms/gemini/image_edit/test_gemini_image_edit_transformation.py b/tests/unit/llms/gemini/image_edit/test_gemini_image_edit_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/gemini/image_edit/test_gemini_image_edit_transformation.py
rename to tests/unit/llms/gemini/image_edit/test_gemini_image_edit_transformation.py
diff --git a/tests/test_litellm/llms/gemini/realtime/test_gemini_realtime_transformation.py b/tests/unit/llms/gemini/realtime/test_gemini_realtime_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/gemini/realtime/test_gemini_realtime_transformation.py
rename to tests/unit/llms/gemini/realtime/test_gemini_realtime_transformation.py
diff --git a/tests/test_litellm/llms/gemini/videos/test_gemini_video_transformation.py b/tests/unit/llms/gemini/videos/test_gemini_video_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/gemini/videos/test_gemini_video_transformation.py
rename to tests/unit/llms/gemini/videos/test_gemini_video_transformation.py
diff --git a/tests/test_litellm/llms/gigachat/chat/test_gigachat_chat_streaming.py b/tests/unit/llms/gigachat/chat/test_gigachat_chat_streaming.py
similarity index 100%
rename from tests/test_litellm/llms/gigachat/chat/test_gigachat_chat_streaming.py
rename to tests/unit/llms/gigachat/chat/test_gigachat_chat_streaming.py
diff --git a/tests/test_litellm/llms/gigachat/chat/test_gigachat_chat_transformation.py b/tests/unit/llms/gigachat/chat/test_gigachat_chat_transformation.py
similarity index 95%
rename from tests/test_litellm/llms/gigachat/chat/test_gigachat_chat_transformation.py
rename to tests/unit/llms/gigachat/chat/test_gigachat_chat_transformation.py
index 2f9511e642c..8e84072e549 100644
--- a/tests/test_litellm/llms/gigachat/chat/test_gigachat_chat_transformation.py
+++ b/tests/unit/llms/gigachat/chat/test_gigachat_chat_transformation.py
@@ -141,22 +141,6 @@ class TestValidateEnvironment:
assert self.config._current_credentials == "my-creds"
assert self.config._current_api_base == "https://my-api.example.com"
- @patch(f"{TRANSFORM_MODULE}.get_access_token", return_value="token")
- @patch(f"{TRANSFORM_MODULE}.get_secret_str")
- def test_falls_back_to_env_for_credentials( # test-quality-ok: mock-echo of internal wiring
- self, mock_get_secret, mock_get_token
- ):
- mock_get_secret.return_value = "env-creds"
- self.config.validate_environment(
- headers={},
- model="GigaChat",
- messages=[],
- optional_params={},
- litellm_params={},
- api_key=None,
- api_base=None,
- )
- mock_get_secret.assert_any_call("GIGACHAT_CREDENTIALS") # test-quality-ok: mock-echo of internal wiring
class TestGetSupportedOpenAiParams:
@@ -865,18 +849,6 @@ class TestUploadImage:
def setup_method(self):
self.config = GigaChatConfig()
- @patch(f"{TRANSFORM_MODULE}.upload_file_sync", return_value="file-uploaded")
- def test_upload_image_success(self, mock_upload):
- self.config._current_credentials = "creds"
- self.config._current_api_base = "https://api.example.com"
- result = self.config._upload_image("https://example.com/img.jpg")
- assert result == "file-uploaded"
- mock_upload.assert_called_once_with(
- image_url="https://example.com/img.jpg",
- credentials="creds",
- api_base="https://api.example.com",
- )
-
@patch(f"{TRANSFORM_MODULE}.upload_file_sync", side_effect=Exception("fail"))
def test_upload_image_failure_returns_none(self, mock_upload):
result = self.config._upload_image("https://example.com/img.jpg")
diff --git a/tests/test_litellm/llms/gigachat/embedding/test_gigachat_embedding_transformation.py b/tests/unit/llms/gigachat/embedding/test_gigachat_embedding_transformation.py
similarity index 91%
rename from tests/test_litellm/llms/gigachat/embedding/test_gigachat_embedding_transformation.py
rename to tests/unit/llms/gigachat/embedding/test_gigachat_embedding_transformation.py
index 8537793ea72..01fe66ca4c7 100644
--- a/tests/test_litellm/llms/gigachat/embedding/test_gigachat_embedding_transformation.py
+++ b/tests/unit/llms/gigachat/embedding/test_gigachat_embedding_transformation.py
@@ -37,17 +37,6 @@ def _make_httpx_response(body: dict, status_code: int = 200) -> httpx.Response:
# ---------------------------------------------------------------------------
-class TestGetConfig:
- def setup_method(self):
- self.config = GigaChatEmbeddingConfig()
-
- def test_contains_only_abc_impl(self):
- """get_config returns ABC internal data due to inheritance."""
- result = self.config.get_config()
- # The only key should be _abc_impl from ABC base class
- assert set(result.keys()) == {"_abc_impl"}
-
-
class TestGetSupportedOpenAiParams:
def setup_method(self):
self.config = GigaChatEmbeddingConfig()
@@ -287,25 +276,6 @@ class TestTransformEmbeddingResponse:
)
assert result.model == "Embeddings"
- def test_calls_logging_post_call(self):
- raw = self._make_gigachat_response([
- {"object": "embedding", "embedding": [0.1], "index": 0},
- ])
- model_response = EmbeddingResponse()
- self.config.transform_embedding_response(
- model="gigachat/Embeddings",
- raw_response=raw,
- model_response=model_response,
- logging_obj=self.logging_obj,
- api_key="test-api-key",
- request_data={"input": ["hello"]},
- optional_params={},
- litellm_params={},
- )
- self.logging_obj.post_call.assert_called_once()
- args = self.logging_obj.post_call.call_args.kwargs
- assert args["api_key"] == "test-api-key"
- assert args["input"] == ["hello"]
class TestValidateEnvironment:
diff --git a/tests/test_litellm/llms/gigachat/passthrough/test_gigachat_passthrough_transformation.py b/tests/unit/llms/gigachat/passthrough/test_gigachat_passthrough_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/gigachat/passthrough/test_gigachat_passthrough_transformation.py
rename to tests/unit/llms/gigachat/passthrough/test_gigachat_passthrough_transformation.py
diff --git a/tests/test_litellm/llms/gigachat/test_authenticator.py b/tests/unit/llms/gigachat/test_authenticator.py
similarity index 100%
rename from tests/test_litellm/llms/gigachat/test_authenticator.py
rename to tests/unit/llms/gigachat/test_authenticator.py
diff --git a/tests/test_litellm/llms/gigachat/test_file_handler.py b/tests/unit/llms/gigachat/test_file_handler.py
similarity index 91%
rename from tests/test_litellm/llms/gigachat/test_file_handler.py
rename to tests/unit/llms/gigachat/test_file_handler.py
index ce9505f11f2..de83b2ddf5f 100644
--- a/tests/test_litellm/llms/gigachat/test_file_handler.py
+++ b/tests/unit/llms/gigachat/test_file_handler.py
@@ -344,25 +344,6 @@ class TestUploadFileSync:
assert result is None
- @patch(f"{FILE_MODULE}.get_api_base", return_value="https://api.example.com")
- @patch(f"{FILE_MODULE}.get_access_token", return_value="test-token")
- @patch(f"{FILE_MODULE}._get_httpx_client")
- def test_uploads_without_optional_args(
- self, mock_http_handler_cls, mock_get_token, mock_get_api_base
- ):
- """Verify that credentials, api_base, and litellm_params are optional."""
- mock_client = MagicMock()
- mock_response = MagicMock()
- mock_response.json.return_value = {"id": "file-no-args"}
- mock_response.raise_for_status = MagicMock()
- mock_client.post.return_value = mock_response
- mock_http_handler_cls.return_value = mock_client
-
- result = upload_file_sync(image_url=_RED_PNG_DATA_URL)
-
- assert result == "file-no-args"
- # Should still have called get_access_token without args
- mock_get_token.assert_called_once_with(credentials=None, litellm_params=None)
# ---------------------------------------------------------------------------
@@ -483,22 +464,3 @@ class TestUploadFileAsync:
)
assert result is None
-
- @pytest.mark.asyncio
- @patch(f"{FILE_MODULE}.get_api_base", return_value="https://api.example.com")
- @patch(f"{FILE_MODULE}.get_access_token_async", return_value="test-token-async")
- @patch(f"{FILE_MODULE}.get_async_httpx_client")
- async def test_uploads_without_optional_args(
- self, mock_get_client, mock_get_token, mock_get_api_base
- ):
- mock_client = MagicMock()
- mock_response = MagicMock()
- mock_response.json = MagicMock(return_value={"id": "async-no-args"})
- mock_response.raise_for_status = MagicMock()
- mock_client.post = AsyncMock(return_value=mock_response)
- mock_get_client.return_value = mock_client
-
- result = await upload_file_async(image_url=_RED_PNG_DATA_URL)
-
- assert result == "async-no-args"
- mock_get_token.assert_called_once_with(credentials=None, litellm_params=None)
\ No newline at end of file
diff --git a/tests/test_litellm/llms/gigachat/test_utils.py b/tests/unit/llms/gigachat/test_utils.py
similarity index 100%
rename from tests/test_litellm/llms/gigachat/test_utils.py
rename to tests/unit/llms/gigachat/test_utils.py
diff --git a/tests/test_litellm/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py b/tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py
rename to tests/unit/llms/github_copilot/embedding/test_github_copilot_embedding_transformation.py
From 443c9f838533027f9d09d60afc5021c6561e8197 Mon Sep 17 00:00:00 2001
From: yuneng
Date: Sun, 20 Sep 2026 08:09:49 +0000
Subject: [PATCH 132/317] test: migrate nvidia, oci, ocr, oobabooga and openai
legacy tests to tests/unit
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../audio_transcription/__init__.py | 0
tests/test_litellm/llms/oci/embed/__init__.py | 0
.../ocr/guardrail_translation/__init__.py | 0
.../test_litellm/llms/openai/chat/__init__.py | 0
.../chat/guardrail_translation/__init__.py | 0
...t_nvidia_nim_passthrough_transformation.py | 0
.../test_nvidia_nim_rerank_transformation.py | 0
.../audio_transcription/test_audio_utils.py | 13 --
.../audio_transcription/test_handler.py | 0
.../test_transformation.py | 0
.../oci/chat/test_oci_chat_transformation.py | 150 ------------------
.../test_oci_chat_transformation_for_14158.py | 0
.../oci/chat/test_oci_cohere_tool_calls.py | 20 ---
.../llms/oci/chat/test_oci_generic_chat.py | 12 --
.../llms/oci/chat/test_oci_sse_splitter.py | 0
.../oci/chat/test_oci_streaming_tool_calls.py | 0
.../embed/test_oci_embed_transformation.py | 22 ---
.../llms/oci/embed/test_oci_embedding.py | 0
.../test_ocr_guardrail_handler.py | 0
.../llms/oobabooga/chat/test_oobabooga.py | 0
.../test_openai_guardrail_handler.py | 19 ---
.../chat/test_openai_gpt_transformation.py | 0
.../completion/test_completion_handler.py | 0
.../test_text_completion_guardrail_handler.py | 0
.../test_text_completion_token_ids.py | 0
25 files changed, 236 deletions(-)
delete mode 100644 tests/test_litellm/llms/nvidia_riva/audio_transcription/__init__.py
delete mode 100644 tests/test_litellm/llms/oci/embed/__init__.py
delete mode 100644 tests/test_litellm/llms/ocr/guardrail_translation/__init__.py
delete mode 100644 tests/test_litellm/llms/openai/chat/__init__.py
delete mode 100644 tests/test_litellm/llms/openai/chat/guardrail_translation/__init__.py
rename tests/{test_litellm => unit}/llms/nvidia_nim/passthrough/test_nvidia_nim_passthrough_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/nvidia_nim/rerank/test_nvidia_nim_rerank_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/nvidia_riva/audio_transcription/test_audio_utils.py (90%)
rename tests/{test_litellm => unit}/llms/nvidia_riva/audio_transcription/test_handler.py (100%)
rename tests/{test_litellm => unit}/llms/nvidia_riva/audio_transcription/test_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/oci/chat/test_oci_chat_transformation.py (91%)
rename tests/{test_litellm => unit}/llms/oci/chat/test_oci_chat_transformation_for_14158.py (100%)
rename tests/{test_litellm => unit}/llms/oci/chat/test_oci_cohere_tool_calls.py (97%)
rename tests/{test_litellm => unit}/llms/oci/chat/test_oci_generic_chat.py (97%)
rename tests/{test_litellm => unit}/llms/oci/chat/test_oci_sse_splitter.py (100%)
rename tests/{test_litellm => unit}/llms/oci/chat/test_oci_streaming_tool_calls.py (100%)
rename tests/{test_litellm => unit}/llms/oci/embed/test_oci_embed_transformation.py (95%)
rename tests/{test_litellm => unit}/llms/oci/embed/test_oci_embedding.py (100%)
rename tests/{test_litellm => unit}/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py (100%)
rename tests/{test_litellm => unit}/llms/oobabooga/chat/test_oobabooga.py (100%)
rename tests/{test_litellm => unit}/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py (99%)
rename tests/{test_litellm => unit}/llms/openai/chat/test_openai_gpt_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/openai/completion/test_completion_handler.py (100%)
rename tests/{test_litellm => unit}/llms/openai/completion/test_text_completion_guardrail_handler.py (100%)
rename tests/{test_litellm => unit}/llms/openai/completion/test_text_completion_token_ids.py (100%)
diff --git a/tests/test_litellm/llms/nvidia_riva/audio_transcription/__init__.py b/tests/test_litellm/llms/nvidia_riva/audio_transcription/__init__.py
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/tests/test_litellm/llms/oci/embed/__init__.py b/tests/test_litellm/llms/oci/embed/__init__.py
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/tests/test_litellm/llms/ocr/guardrail_translation/__init__.py b/tests/test_litellm/llms/ocr/guardrail_translation/__init__.py
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/tests/test_litellm/llms/openai/chat/__init__.py b/tests/test_litellm/llms/openai/chat/__init__.py
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/tests/test_litellm/llms/openai/chat/guardrail_translation/__init__.py b/tests/test_litellm/llms/openai/chat/guardrail_translation/__init__.py
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/tests/test_litellm/llms/nvidia_nim/passthrough/test_nvidia_nim_passthrough_transformation.py b/tests/unit/llms/nvidia_nim/passthrough/test_nvidia_nim_passthrough_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/nvidia_nim/passthrough/test_nvidia_nim_passthrough_transformation.py
rename to tests/unit/llms/nvidia_nim/passthrough/test_nvidia_nim_passthrough_transformation.py
diff --git a/tests/test_litellm/llms/nvidia_nim/rerank/test_nvidia_nim_rerank_transformation.py b/tests/unit/llms/nvidia_nim/rerank/test_nvidia_nim_rerank_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/nvidia_nim/rerank/test_nvidia_nim_rerank_transformation.py
rename to tests/unit/llms/nvidia_nim/rerank/test_nvidia_nim_rerank_transformation.py
diff --git a/tests/test_litellm/llms/nvidia_riva/audio_transcription/test_audio_utils.py b/tests/unit/llms/nvidia_riva/audio_transcription/test_audio_utils.py
similarity index 90%
rename from tests/test_litellm/llms/nvidia_riva/audio_transcription/test_audio_utils.py
rename to tests/unit/llms/nvidia_riva/audio_transcription/test_audio_utils.py
index 63a53c2c97b..54fc30e6f2d 100644
--- a/tests/test_litellm/llms/nvidia_riva/audio_transcription/test_audio_utils.py
+++ b/tests/unit/llms/nvidia_riva/audio_transcription/test_audio_utils.py
@@ -62,19 +62,6 @@ def test_resample_16khz_mono_passes_through_int16_bytes_match_length():
assert resampled.duration_seconds == pytest.approx(1.0, abs=0.001)
-def test_resample_preserves_int16_clip_range():
- sample_rate = 16000
- samples = np.array([2.0, -2.0, 0.0, 1.0], dtype=np.float32)
- wav_in = _wav_bytes(samples, sample_rate)
-
- resampled = resample_to_riva_pcm(wav_in)
-
- decoded = np.frombuffer(resampled.pcm_bytes, dtype="= -32767
-
-
def test_unknown_format_raises_clear_error():
# 4 random bytes are not valid audio in any container we can decode.
with pytest.raises(NvidiaRivaException) as excinfo:
diff --git a/tests/test_litellm/llms/nvidia_riva/audio_transcription/test_handler.py b/tests/unit/llms/nvidia_riva/audio_transcription/test_handler.py
similarity index 100%
rename from tests/test_litellm/llms/nvidia_riva/audio_transcription/test_handler.py
rename to tests/unit/llms/nvidia_riva/audio_transcription/test_handler.py
diff --git a/tests/test_litellm/llms/nvidia_riva/audio_transcription/test_transformation.py b/tests/unit/llms/nvidia_riva/audio_transcription/test_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/nvidia_riva/audio_transcription/test_transformation.py
rename to tests/unit/llms/nvidia_riva/audio_transcription/test_transformation.py
diff --git a/tests/test_litellm/llms/oci/chat/test_oci_chat_transformation.py b/tests/unit/llms/oci/chat/test_oci_chat_transformation.py
similarity index 91%
rename from tests/test_litellm/llms/oci/chat/test_oci_chat_transformation.py
rename to tests/unit/llms/oci/chat/test_oci_chat_transformation.py
index 4c9bd29b337..708187b8ae1 100644
--- a/tests/test_litellm/llms/oci/chat/test_oci_chat_transformation.py
+++ b/tests/unit/llms/oci/chat/test_oci_chat_transformation.py
@@ -922,91 +922,6 @@ class TestOCISignerSupport:
assert wrapper.path_url == "/api/v1/chat"
-class TestOCISplitChunks:
- """
- Unit tests for the SSE split_chunks helpers used in sync and async streaming.
-
- These validate the fix for:
- - Sync: JSONDecodeError when iter_text() returns chunks spanning multiple events
- - Async: whitespace-only chunks being yielded before stripping (Greptile P2)
- """
-
- def _run_sync_split(self, raw_chunks):
- """Invoke the sync split_chunks logic directly (extracted for testability)."""
- results = []
- for item in raw_chunks:
- for chunk in item.split("\n\n"):
- stripped = chunk.strip()
- if stripped:
- results.append(stripped)
- return results
-
- async def _run_async_split(self, raw_chunks):
- """Invoke the async split_chunks logic directly."""
- results = []
-
- async def _gen():
- for c in raw_chunks:
- yield c
-
- async for item in _gen():
- for chunk in item.split("\n\n"):
- stripped = chunk.strip()
- if stripped:
- results.append(stripped)
- return results
-
- def test_sync_single_event_per_chunk(self):
- """Normal case: one SSE event per iter_text() chunk."""
- chunks = ['data: {"text":"hello"}', 'data: {"text":"world"}']
- assert self._run_sync_split(chunks) == [
- 'data: {"text":"hello"}',
- 'data: {"text":"world"}',
- ]
-
- def test_sync_multiple_events_in_one_chunk(self):
- """iter_text() returns two SSE events concatenated — must be split."""
- chunks = ['data: {"text":"a"}\n\ndata: {"text":"b"}']
- assert self._run_sync_split(chunks) == [
- 'data: {"text":"a"}',
- 'data: {"text":"b"}',
- ]
-
- def test_sync_whitespace_only_chunks_discarded(self):
- """Whitespace between events must not be yielded."""
- chunks = ["data: {}\n\n \n\ndata: {}"]
- result = self._run_sync_split(chunks)
- assert result == ["data: {}", "data: {}"]
-
- def test_sync_empty_string_discarded(self):
- """Empty string produced by splitting trailing \\n\\n must be discarded."""
- chunks = ["data: {}\n\n"]
- assert self._run_sync_split(chunks) == ["data: {}"]
-
- @pytest.mark.asyncio
- async def test_async_whitespace_only_chunks_discarded(self):
- """
- Regression test for Greptile P2: async version was checking `if not chunk`
- BEFORE stripping, so '\\n ' would pass the guard and yield '' downstream,
- causing ValueError in chunk_creator ('Chunk does not start with data:').
- """
- chunks = ["data: {}\n\n \n\ndata: {}"]
- result = await self._run_async_split(chunks)
- assert result == ["data: {}", "data: {}"]
-
- @pytest.mark.asyncio
- async def test_async_empty_string_discarded(self):
- """Trailing \\n\\n must not produce an empty yielded chunk in async path."""
- chunks = ["data: {}\n\n"]
- result = await self._run_async_split(chunks)
- assert result == ["data: {}"]
-
- @pytest.mark.asyncio
- async def test_async_multiple_events_in_one_chunk(self):
- """Async path must split concatenated SSE events just like sync."""
- chunks = ['data: {"text":"x"}\n\ndata: {"text":"y"}']
- result = await self._run_async_split(chunks)
- assert result == ['data: {"text":"x"}', 'data: {"text":"y"}']
class TestOCIProviderEmbeddingConfig:
@@ -1026,21 +941,6 @@ class TestOCIProviderEmbeddingConfig:
)
assert isinstance(config, OCIEmbedConfig)
- def test_no_duplicate_oci_branch(self):
- """
- Ensure utils.py does not contain two separate OCI embedding branches.
- The dead code was removed in commit 64dfbe2b; this test guards against
- regression (e.g. a future merge re-introducing it).
- """
- import inspect
- from litellm.utils import ProviderConfigManager
-
- source = inspect.getsource(ProviderConfigManager.get_provider_embedding_config)
- oci_count = source.count("LlmProviders.OCI")
- assert oci_count == 1, (
- f"Expected exactly 1 OCI branch in get_provider_embedding_config, found {oci_count}. "
- "A duplicate dead-code branch may have been reintroduced."
- )
class TestOCICohereParamMapping:
@@ -1586,57 +1486,7 @@ def config():
class TestOCIKeyNormalization:
"""Tests for OCI private key content normalization."""
- def test_oci_key_with_escaped_newlines(self, config):
- """Test that escaped newlines (\\n) are converted to actual newlines."""
- # Simulate PEM content with escaped newlines (as would come from JSON/UI input)
- escaped_pem = "-----BEGIN RSA PRIVATE KEY-----\\nMIIEowIBAAKCAQEA...\\n-----END RSA PRIVATE KEY-----"
- optional_params = {
- "oci_user": "ocid1.user.oc1..test",
- "oci_fingerprint": "aa:bb:cc:dd",
- "oci_tenancy": "ocid1.tenancy.oc1..test",
- "oci_region": "us-ashburn-1",
- "oci_key": escaped_pem,
- }
-
- # We can't fully test signing without a real key, but we can verify
- # the error message indicates the key was processed (not a type error)
- with pytest.raises(Exception, match='why-can-t-i-import-my-pem-file for more details\\.') as exc_info:
- sign_with_manual_credentials(
- headers={},
- optional_params=optional_params,
- request_data={"test": "data"},
- api_base="https://test.oci.oraclecloud.com/api",
- )
-
- # The error should be about key format/loading, not about type
- # This confirms the string was processed and newlines were normalized
- error_message = str(exc_info.value)
- assert "must be a string" not in error_message.lower()
-
- def test_oci_key_with_crlf_newlines(self, config):
- """Test that Windows-style CRLF newlines are normalized to LF."""
- # Simulate PEM content with CRLF newlines
- crlf_pem = "-----BEGIN RSA PRIVATE KEY-----\r\nMIIEowIBAAKCAQEA...\r\n-----END RSA PRIVATE KEY-----"
-
- optional_params = {
- "oci_user": "ocid1.user.oc1..test",
- "oci_fingerprint": "aa:bb:cc:dd",
- "oci_tenancy": "ocid1.tenancy.oc1..test",
- "oci_region": "us-ashburn-1",
- "oci_key": crlf_pem,
- }
-
- with pytest.raises(Exception, match='why-can-t-i-import-my-pem-file for more details\\.') as exc_info:
- sign_with_manual_credentials(
- headers={},
- optional_params=optional_params,
- request_data={"test": "data"},
- api_base="https://test.oci.oraclecloud.com/api",
- )
-
- error_message = str(exc_info.value)
- assert "must be a string" not in error_message.lower()
def test_oci_key_rejects_non_string_type(self, config):
"""Test that non-string oci_key values raise OCIError."""
diff --git a/tests/test_litellm/llms/oci/chat/test_oci_chat_transformation_for_14158.py b/tests/unit/llms/oci/chat/test_oci_chat_transformation_for_14158.py
similarity index 100%
rename from tests/test_litellm/llms/oci/chat/test_oci_chat_transformation_for_14158.py
rename to tests/unit/llms/oci/chat/test_oci_chat_transformation_for_14158.py
diff --git a/tests/test_litellm/llms/oci/chat/test_oci_cohere_tool_calls.py b/tests/unit/llms/oci/chat/test_oci_cohere_tool_calls.py
similarity index 97%
rename from tests/test_litellm/llms/oci/chat/test_oci_cohere_tool_calls.py
rename to tests/unit/llms/oci/chat/test_oci_cohere_tool_calls.py
index 729a2d25f41..9b06b01aa00 100644
--- a/tests/test_litellm/llms/oci/chat/test_oci_cohere_tool_calls.py
+++ b/tests/unit/llms/oci/chat/test_oci_cohere_tool_calls.py
@@ -966,13 +966,6 @@ class TestOCICohereStreaming:
completion_stream=mock_stream, model=mock_model, logging_obj=mock_logging
)
- def test_cohere_streaming_wrapper_initialization(self):
- """Test OCIStreamWrapper initialization"""
- stream_wrapper = self._create_stream_wrapper()
-
- # chunk_creator is the public dispatch entry point
- assert hasattr(stream_wrapper, "chunk_creator")
- assert callable(stream_wrapper.chunk_creator)
def test_cohere_streaming_chunk_parsing(self):
"""Test parsing of Cohere streaming chunks"""
@@ -1003,16 +996,3 @@ class TestOCICohereStreaming:
# Test non-JSON chunk
with pytest.raises(OCIError, match="Chunk cannot be parsed as JSON"):
stream_wrapper.chunk_creator("data: invalid json")
-
- def test_cohere_streaming_generic_chunk_fallback(self):
- """Test fallback to generic chunk handling for non-Cohere chunks"""
- stream_wrapper = self._create_stream_wrapper()
-
- # Test generic chunk (no apiFormat or different apiFormat)
- generic_chunk = {"apiFormat": "GEMINI", "text": "Hello from Gemini"}
- chunk_data = f"data: {json.dumps(generic_chunk)}"
-
- # This should fall back to generic handling
- result = stream_wrapper.chunk_creator(chunk_data)
- # The exact structure depends on the generic handler implementation
- assert hasattr(result, "choices")
diff --git a/tests/test_litellm/llms/oci/chat/test_oci_generic_chat.py b/tests/unit/llms/oci/chat/test_oci_generic_chat.py
similarity index 97%
rename from tests/test_litellm/llms/oci/chat/test_oci_generic_chat.py
rename to tests/unit/llms/oci/chat/test_oci_generic_chat.py
index 0a47852d085..9ec5ab9aed4 100644
--- a/tests/test_litellm/llms/oci/chat/test_oci_generic_chat.py
+++ b/tests/unit/llms/oci/chat/test_oci_generic_chat.py
@@ -450,15 +450,3 @@ class TestGpt5MaxCompletionTokens:
)
assert out.get("maxTokens") == 64
assert "maxCompletionTokens" not in out
-
- def test_payload_serializes_max_completion_tokens(self):
- from litellm.types.llms.oci import OCIChatRequestPayload
-
- payload = OCIChatRequestPayload(
- apiFormat="GENERIC",
- messages=[],
- maxCompletionTokens=64,
- )
- dumped = payload.model_dump(exclude_none=True)
- assert dumped["maxCompletionTokens"] == 64
- assert "maxTokens" not in dumped
diff --git a/tests/test_litellm/llms/oci/chat/test_oci_sse_splitter.py b/tests/unit/llms/oci/chat/test_oci_sse_splitter.py
similarity index 100%
rename from tests/test_litellm/llms/oci/chat/test_oci_sse_splitter.py
rename to tests/unit/llms/oci/chat/test_oci_sse_splitter.py
diff --git a/tests/test_litellm/llms/oci/chat/test_oci_streaming_tool_calls.py b/tests/unit/llms/oci/chat/test_oci_streaming_tool_calls.py
similarity index 100%
rename from tests/test_litellm/llms/oci/chat/test_oci_streaming_tool_calls.py
rename to tests/unit/llms/oci/chat/test_oci_streaming_tool_calls.py
diff --git a/tests/test_litellm/llms/oci/embed/test_oci_embed_transformation.py b/tests/unit/llms/oci/embed/test_oci_embed_transformation.py
similarity index 95%
rename from tests/test_litellm/llms/oci/embed/test_oci_embed_transformation.py
rename to tests/unit/llms/oci/embed/test_oci_embed_transformation.py
index 363c0b46809..4ffd79ff147 100644
--- a/tests/test_litellm/llms/oci/embed/test_oci_embed_transformation.py
+++ b/tests/unit/llms/oci/embed/test_oci_embed_transformation.py
@@ -269,28 +269,6 @@ class TestOCIEmbedConfig:
assert result.model == "cohere.embed-v3.0"
assert result.usage.prompt_tokens == 10
- def test_transform_response_no_usage(self):
- cfg = self._config()
- model_response = EmbeddingResponse()
- raw = self._mock_response(
- 200,
- {
- "embeddings": [[0.1]],
- "modelId": "cohere.embed-v3.0",
- "modelVersion": "3.0.0",
- },
- )
- result = cfg.transform_embedding_response(
- model="cohere.embed-v3.0",
- raw_response=raw,
- model_response=model_response,
- logging_obj=MagicMock(),
- api_key=None,
- request_data={},
- optional_params={},
- litellm_params={},
- )
- assert len(result.data) == 1
def test_transform_response_http_error_raises(self):
cfg = self._config()
diff --git a/tests/test_litellm/llms/oci/embed/test_oci_embedding.py b/tests/unit/llms/oci/embed/test_oci_embedding.py
similarity index 100%
rename from tests/test_litellm/llms/oci/embed/test_oci_embedding.py
rename to tests/unit/llms/oci/embed/test_oci_embedding.py
diff --git a/tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py b/tests/unit/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py
similarity index 100%
rename from tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py
rename to tests/unit/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py
diff --git a/tests/test_litellm/llms/oobabooga/chat/test_oobabooga.py b/tests/unit/llms/oobabooga/chat/test_oobabooga.py
similarity index 100%
rename from tests/test_litellm/llms/oobabooga/chat/test_oobabooga.py
rename to tests/unit/llms/oobabooga/chat/test_oobabooga.py
diff --git a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py b/tests/unit/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py
similarity index 99%
rename from tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py
rename to tests/unit/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py
index 258226ae22c..5c85faa5e13 100644
--- a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py
+++ b/tests/unit/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py
@@ -545,25 +545,6 @@ class TestOpenAIChatCompletionsHandlerToolCallsInput:
assert data["messages"][0]["content"] == "HELLO"
assert data["messages"][1]["content"] == "HI THERE!"
- @pytest.mark.asyncio
- async def test_empty_tool_calls_list(self):
- """Test that empty tool_calls list is handled correctly"""
- handler = OpenAIChatCompletionsHandler()
- guardrail = MockGuardrail()
-
- data = {
- "messages": [
- {"role": "assistant", "content": "Hello", "tool_calls": []},
- ]
- }
-
- # Process the input
- await handler.process_input_messages(data, guardrail)
-
- # Verify empty tool_calls doesn't cause issues
- assert guardrail.last_inputs is not None
- tool_calls = guardrail.last_inputs.get("tool_calls", [])
- assert len(tool_calls) == 0
class TestOpenAIChatCompletionsHandlerToolCallsOutput:
diff --git a/tests/test_litellm/llms/openai/chat/test_openai_gpt_transformation.py b/tests/unit/llms/openai/chat/test_openai_gpt_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/openai/chat/test_openai_gpt_transformation.py
rename to tests/unit/llms/openai/chat/test_openai_gpt_transformation.py
diff --git a/tests/test_litellm/llms/openai/completion/test_completion_handler.py b/tests/unit/llms/openai/completion/test_completion_handler.py
similarity index 100%
rename from tests/test_litellm/llms/openai/completion/test_completion_handler.py
rename to tests/unit/llms/openai/completion/test_completion_handler.py
diff --git a/tests/test_litellm/llms/openai/completion/test_text_completion_guardrail_handler.py b/tests/unit/llms/openai/completion/test_text_completion_guardrail_handler.py
similarity index 100%
rename from tests/test_litellm/llms/openai/completion/test_text_completion_guardrail_handler.py
rename to tests/unit/llms/openai/completion/test_text_completion_guardrail_handler.py
diff --git a/tests/test_litellm/llms/openai/completion/test_text_completion_token_ids.py b/tests/unit/llms/openai/completion/test_text_completion_token_ids.py
similarity index 100%
rename from tests/test_litellm/llms/openai/completion/test_text_completion_token_ids.py
rename to tests/unit/llms/openai/completion/test_text_completion_token_ids.py
From feca00248cf78fda0da5a1fa5b7f1a164fbe5608 Mon Sep 17 00:00:00 2001
From: yuneng
Date: Sun, 20 Sep 2026 08:11:56 +0000
Subject: [PATCH 133/317] test(bedrock): isolate host AWS config in realtime
and rerank unit tests
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../bedrock/realtime/test_bedrock_realtime_handler.py | 9 +++++++++
.../rerank/test_bedrock_rerank_header_forwarding.py | 9 +++++++++
2 files changed, 18 insertions(+)
diff --git a/tests/unit/llms/bedrock/realtime/test_bedrock_realtime_handler.py b/tests/unit/llms/bedrock/realtime/test_bedrock_realtime_handler.py
index a7f0f64ef68..73a78a94e9f 100644
--- a/tests/unit/llms/bedrock/realtime/test_bedrock_realtime_handler.py
+++ b/tests/unit/llms/bedrock/realtime/test_bedrock_realtime_handler.py
@@ -18,6 +18,15 @@ from litellm.llms.bedrock.realtime.handler import BedrockRealtime
from litellm.llms.bedrock.realtime.transformation import BedrockRealtimeConfig
+@pytest.fixture(autouse=True)
+def _isolate_host_aws_config(monkeypatch, tmp_path):
+ monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", str(tmp_path / "credentials"))
+ monkeypatch.setenv("AWS_CONFIG_FILE", str(tmp_path / "config"))
+ monkeypatch.setenv("AWS_EC2_METADATA_DISABLED", "true")
+ for env_var in ("AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_BEARER_TOKEN_BEDROCK", "AWS_REGION_NAME", "AWS_DEFAULT_REGION"):
+ monkeypatch.delenv(env_var, raising=False)
+
+
class FakePayloadPart:
def __init__(self, bytes_):
self.bytes_ = bytes_
diff --git a/tests/unit/llms/bedrock/rerank/test_bedrock_rerank_header_forwarding.py b/tests/unit/llms/bedrock/rerank/test_bedrock_rerank_header_forwarding.py
index 2ea61b5e978..aa93ddb21b8 100644
--- a/tests/unit/llms/bedrock/rerank/test_bedrock_rerank_header_forwarding.py
+++ b/tests/unit/llms/bedrock/rerank/test_bedrock_rerank_header_forwarding.py
@@ -15,6 +15,15 @@ from litellm.llms.bedrock.base_aws_llm import Boto3CredentialsInfo
from litellm.llms.bedrock.rerank.handler import BedrockRerankHandler
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
+
+@pytest.fixture(autouse=True)
+def _isolate_host_aws_config(monkeypatch, tmp_path):
+ monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", str(tmp_path / "credentials"))
+ monkeypatch.setenv("AWS_CONFIG_FILE", str(tmp_path / "config"))
+ monkeypatch.setenv("AWS_EC2_METADATA_DISABLED", "true")
+ for env_var in ("AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_BEARER_TOKEN_BEDROCK", "AWS_REGION_NAME", "AWS_DEFAULT_REGION"):
+ monkeypatch.delenv(env_var, raising=False)
+
# Mock response for Bedrock rerank
# Format based on Bedrock rerank API response structure
bedrock_rerank_response = {
From 65a4a009585973a6d328cd981866b4d8e5188c1a Mon Sep 17 00:00:00 2001
From: yuneng
Date: Sun, 20 Sep 2026 08:12:39 +0000
Subject: [PATCH 134/317] fix(ci): excuse retired test-quality rules in the
budget ratchet
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
scripts/budget_ratchet_check.py | 43 +++++++++++++++----
scripts/check_test_quality.py | 4 ++
.../test_litellm/test_budget_ratchet_check.py | 30 +++++++++++++
tests/test_litellm/test_check_test_quality.py | 15 +++++++
4 files changed, 84 insertions(+), 8 deletions(-)
diff --git a/scripts/budget_ratchet_check.py b/scripts/budget_ratchet_check.py
index 485e118efd2..470adddfcd3 100644
--- a/scripts/budget_ratchet_check.py
+++ b/scripts/budget_ratchet_check.py
@@ -7,13 +7,17 @@ driven DOWN over time. This check compares every budget file against its own
content at the merge-base with the target branch and fails (exits 1, red) if:
* a rule's `limit` went up,
- * a rule was dropped from a budget (its ceiling effectively became infinite), or
+ * a rule was dropped from a budget (its ceiling effectively became infinite) while
+ its checker still emits it, or
* an entire budget file was deleted.
New rules and lowered/equal limits are fine. So is a rule that graduated: once a
paired config (ruff.toml for the ruff-strict budget) selects the rule outright it
hard-fails at the first violation, which is stricter than any ceiling the budget
could hold, so dropping its entry tightens the guard rather than removing it.
+Likewise a retired rule: once the paired checker (check_test_quality.py for the
+test-quality budget) no longer emits a code, its entry has no ceiling left to
+loosen.
This is deliberately NOT a gating check. It should turn the run red so that a
loosening is impossible to miss in review, but it must stay OUT of the
@@ -29,11 +33,12 @@ Usage:
from __future__ import annotations
import argparse
+import importlib.util
import json
import subprocess
import sys
from pathlib import Path
-from types import MappingProxyType
+from types import MappingProxyType, ModuleType
from typing import Final, NamedTuple
if sys.version_info >= (3, 11):
@@ -49,6 +54,7 @@ DEFAULT_BUDGETS: tuple[str, ...] = (
"test-quality-budget.json",
)
GRADUATION_CONFIGS = MappingProxyType({"ruff-strict-budget.json": "ruff.toml"})
+RETIREMENT_SOURCES = MappingProxyType({"test-quality-budget.json": "check_test_quality"})
class Regression(NamedTuple):
@@ -139,20 +145,40 @@ def graduated_selectors(rel: str) -> tuple[str, ...]:
)
+def _load_script(name: str) -> ModuleType:
+ if name in sys.modules:
+ return sys.modules[name]
+ spec: Final = importlib.util.spec_from_file_location(name, REPO_ROOT / "scripts" / f"{name}.py")
+ assert spec is not None and spec.loader is not None
+ module: Final = importlib.util.module_from_spec(spec)
+ sys.modules[name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+def retired_rules(rel: str, base: dict) -> frozenset[str]:
+ """Rules in the base budget that the paired checker can no longer emit, so there is no ceiling to loosen."""
+ source: Final = RETIREMENT_SOURCES.get(rel)
+ if source is None:
+ return frozenset()
+ return frozenset(_limits(base)) - _load_script(source).RULE_CODES
+
+
def _regression_detail(
rule: str,
base_limits: dict[str, int],
head_limits: dict[str, int],
graduated: tuple[str, ...],
+ retired: frozenset[str] = frozenset(),
) -> str | None:
- """Why `rule` regressed vs base, or None when it held flat, fell, or graduated.
+ """Why `rule` regressed vs base, or None when it held flat, fell, or left the budget legitimately.
- A dropped rule is terminal unless it graduated; otherwise the only loosening
- left is a raised limit.
+ A dropped rule is terminal unless it graduated or retired; otherwise the only
+ loosening left is a raised limit.
"""
base_limit = base_limits[rule]
if rule not in head_limits:
- if graduated and rule.startswith(graduated):
+ if rule in retired or (graduated and rule.startswith(graduated)):
return None
return f"rule dropped (limit {base_limit} -> removed)"
if head_limits[rule] > base_limit:
@@ -165,6 +191,7 @@ def regressions_for(
base: dict | None,
head: dict | None,
graduated: tuple[str, ...] = (),
+ retired: frozenset[str] = frozenset(),
) -> list[Regression]:
if base is None:
return [] # new budget file: nothing to ratchet against yet
@@ -175,7 +202,7 @@ def regressions_for(
return [
Regression(rel, rule, detail)
for rule in sorted(base_limits)
- if (detail := _regression_detail(rule, base_limits, head_limits, graduated)) is not None
+ if (detail := _regression_detail(rule, base_limits, head_limits, graduated, retired)) is not None
]
@@ -209,7 +236,7 @@ def main() -> int:
print(f"skip {rel}: new file (no base at {base_ref} to ratchet against)")
continue
checked.append(rel)
- regressions.extend(regressions_for(rel, base, head, graduated_selectors(rel)))
+ regressions.extend(regressions_for(rel, base, head, graduated_selectors(rel), retired_rules(rel, base)))
if regressions:
print(
diff --git a/scripts/check_test_quality.py b/scripts/check_test_quality.py
index dddc9d61982..9f93023cd53 100644
--- a/scripts/check_test_quality.py
+++ b/scripts/check_test_quality.py
@@ -146,6 +146,10 @@ SDK_MODULE: Final = "litellm"
SUBPROCESS_SPAWNS: Final = frozenset(("run", "Popen", "check_output", "check_call", "call"))
INTERPRETER_ISOLATION_FLAGS: Final = frozenset(("-I", "-P"))
+RULE_CODES: Final = frozenset((
+ "TQ000", "TQ001", "TQ002", "TQ003", "TQ004", "TQ005", "TQ006", "TQ007", "TQ009",
+))
+
CREDENTIAL_NAME_RE: Final = re.compile(
r"(?:API_KEY|_KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|DATABASE_URL|ACCESS_KEY_ID)$"
)
diff --git a/tests/test_litellm/test_budget_ratchet_check.py b/tests/test_litellm/test_budget_ratchet_check.py
index 22d05f4d00d..d6809b22161 100644
--- a/tests/test_litellm/test_budget_ratchet_check.py
+++ b/tests/test_litellm/test_budget_ratchet_check.py
@@ -92,6 +92,36 @@ def test_graduation_never_excuses_a_raised_limit():
assert "0 -> 7" in regs[0].detail
+def test_dropped_rule_the_checker_retired_is_clean():
+ base = {"TQ008": _spec_of(10993)}
+ assert ratchet.regressions_for("b.json", base, {}, retired=frozenset({"TQ008"})) == []
+
+
+def test_dropped_rule_the_checker_still_emits_is_a_regression():
+ base = {"TQ001": _spec_of(5), "TQ008": _spec_of(10993)}
+ regs = ratchet.regressions_for("b.json", base, {}, retired=frozenset({"TQ008"}))
+ assert [r.rule for r in regs] == ["TQ001"]
+ assert "dropped" in regs[0].detail
+
+
+def test_retirement_never_excuses_a_raised_limit():
+ base = {"TQ008": _spec_of(0)}
+ regs = ratchet.regressions_for("b.json", base, {"TQ008": _spec_of(7)}, retired=frozenset({"TQ008"}))
+ assert [r.rule for r in regs] == ["TQ008"]
+ assert "0 -> 7" in regs[0].detail
+
+
+def test_retired_rules_come_from_the_paired_checker():
+ base = {"TQ001": _spec_of(5), "TQ008": _spec_of(10993)}
+ assert ratchet.retired_rules("test-quality-budget.json", base) == frozenset({"TQ008"})
+
+
+def test_budgets_without_a_paired_checker_never_retire():
+ base = {"TQ008": _spec_of(1)}
+ for rel in ("ruff-strict-budget.json", "type-discipline-budget.json", "basedpyright-code-budget.json"):
+ assert ratchet.retired_rules(rel, base) == frozenset()
+
+
def test_graduated_selectors_come_from_the_paired_ruff_config():
selectors = ratchet.graduated_selectors("ruff-strict-budget.json")
assert "UP006" in selectors
diff --git a/tests/test_litellm/test_check_test_quality.py b/tests/test_litellm/test_check_test_quality.py
index 05c25fb19fb..5a5c53fc31c 100644
--- a/tests/test_litellm/test_check_test_quality.py
+++ b/tests/test_litellm/test_check_test_quality.py
@@ -7,8 +7,10 @@ produced against tests/e2e, where the assertions live in a shared helper rather
in the test body.
"""
+import ast
import importlib.util
import os
+import re
import subprocess
import sys
from pathlib import Path
@@ -612,6 +614,19 @@ def test_a_fanned_out_run_reports_each_generated_file_exactly_once(tmp_path):
assert all(" TQ001 " in line for line in reported)
+def test_rule_codes_match_every_code_the_checker_emits():
+ source = _MODULE_PATH.read_text(encoding="utf-8")
+ tree = ast.parse(source)
+ definition = next(
+ node
+ for node in tree.body
+ if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id == "RULE_CODES"
+ )
+ lines = source.splitlines()
+ outside = "\n".join(lines[: definition.lineno - 1] + lines[definition.end_lineno :])
+ assert frozenset(re.findall(r'"(TQ\d{3})"', outside)) == checker.RULE_CODES
+
+
def test_sys_executable_child_without_isolation_flag_is_flagged(tmp_path):
source = 'import subprocess, sys\nsubprocess.run([sys.executable, "-c", "pass"])\n'
assert _codes(tmp_path, source) == ["TQ009"]
From ad523edb254fdb58e3a965339a0e44362d263320 Mon Sep 17 00:00:00 2001
From: yuneng
Date: Sun, 20 Sep 2026 08:13:11 +0000
Subject: [PATCH 135/317] test: migrate phase 9 legacy llm provider tests to
tests/unit
---
.../test_litellm_proxy_chat_transformation.py | 0
.../skills/test_code_execution.py | 0
.../litellm_proxy/skills/test_skill_search.py | 0
.../test_llamafile_chat_transformation.py | 0
tests/unit/llms/manus/__init__.py | 0
tests/unit/llms/manus/responses/__init__.py | 0
.../test_manus_responses_transformation.py | 0
.../test_meta_realtime_transformation.py | 0
.../test_meta_llama_chat_transformation.py | 0
tests/unit/llms/minimax/__init__.py | 0
tests/unit/llms/minimax/chat/__init__.py | 0
.../llms/minimax/chat/test_transformation.py | 96 -------------------
tests/unit/llms/minimax/messages/__init__.py | 0
.../minimax/messages/test_transformation.py | 74 --------------
tests/unit/llms/mistral/__init__.py | 0
...est_mistral_audio_speech_transformation.py | 0
tests/unit/llms/mistral/batches/__init__.py | 0
.../test_mistral_batches_transformation.py | 0
tests/unit/llms/mistral/files/__init__.py | 0
.../test_mistral_files_transformation.py | 0
tests/unit/llms/mistral/ocr/__init__.py | 0
.../ocr/test_mistral_ocr_transformation.py | 0
...est_modelscope_image_gen_transformation.py | 0
.../test_mongodb_transformation.py | 0
.../test_moonshot_chat_transformation.py | 25 -----
.../llms/neosantara/test_neosantara.py | 0
.../test_nimble_search_transformation.py | 0
.../chat/test_novita_chat_transformation.py | 9 --
.../chat/test_nscale_chat_transformation.py | 0
29 files changed, 204 deletions(-)
rename tests/{test_litellm => unit}/llms/litellm_proxy/chat/test_litellm_proxy_chat_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/litellm_proxy/skills/test_code_execution.py (100%)
rename tests/{test_litellm => unit}/llms/litellm_proxy/skills/test_skill_search.py (100%)
rename tests/{test_litellm => unit}/llms/llamafile/chat/test_llamafile_chat_transformation.py (100%)
create mode 100644 tests/unit/llms/manus/__init__.py
create mode 100644 tests/unit/llms/manus/responses/__init__.py
rename tests/{test_litellm => unit}/llms/manus/responses/test_manus_responses_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/meta/realtime/test_meta_realtime_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/meta_llama/test_meta_llama_chat_transformation.py (100%)
create mode 100644 tests/unit/llms/minimax/__init__.py
create mode 100644 tests/unit/llms/minimax/chat/__init__.py
rename tests/{test_litellm => unit}/llms/minimax/chat/test_transformation.py (54%)
create mode 100644 tests/unit/llms/minimax/messages/__init__.py
rename tests/{test_litellm => unit}/llms/minimax/messages/test_transformation.py (57%)
create mode 100644 tests/unit/llms/mistral/__init__.py
rename tests/{test_litellm => unit}/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py (100%)
create mode 100644 tests/unit/llms/mistral/batches/__init__.py
rename tests/{test_litellm => unit}/llms/mistral/batches/test_mistral_batches_transformation.py (100%)
create mode 100644 tests/unit/llms/mistral/files/__init__.py
rename tests/{test_litellm => unit}/llms/mistral/files/test_mistral_files_transformation.py (100%)
create mode 100644 tests/unit/llms/mistral/ocr/__init__.py
rename tests/{test_litellm => unit}/llms/mistral/ocr/test_mistral_ocr_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/modelscope/image_generation/test_modelscope_image_gen_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/mongodb/vector_stores/test_mongodb_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/moonshot/test_moonshot_chat_transformation.py (96%)
rename tests/{test_litellm => unit}/llms/neosantara/test_neosantara.py (100%)
rename tests/{test_litellm => unit}/llms/nimble/search/test_nimble_search_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/novita/chat/test_novita_chat_transformation.py (85%)
rename tests/{test_litellm => unit}/llms/nscale/chat/test_nscale_chat_transformation.py (100%)
diff --git a/tests/test_litellm/llms/litellm_proxy/chat/test_litellm_proxy_chat_transformation.py b/tests/unit/llms/litellm_proxy/chat/test_litellm_proxy_chat_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/litellm_proxy/chat/test_litellm_proxy_chat_transformation.py
rename to tests/unit/llms/litellm_proxy/chat/test_litellm_proxy_chat_transformation.py
diff --git a/tests/test_litellm/llms/litellm_proxy/skills/test_code_execution.py b/tests/unit/llms/litellm_proxy/skills/test_code_execution.py
similarity index 100%
rename from tests/test_litellm/llms/litellm_proxy/skills/test_code_execution.py
rename to tests/unit/llms/litellm_proxy/skills/test_code_execution.py
diff --git a/tests/test_litellm/llms/litellm_proxy/skills/test_skill_search.py b/tests/unit/llms/litellm_proxy/skills/test_skill_search.py
similarity index 100%
rename from tests/test_litellm/llms/litellm_proxy/skills/test_skill_search.py
rename to tests/unit/llms/litellm_proxy/skills/test_skill_search.py
diff --git a/tests/test_litellm/llms/llamafile/chat/test_llamafile_chat_transformation.py b/tests/unit/llms/llamafile/chat/test_llamafile_chat_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/llamafile/chat/test_llamafile_chat_transformation.py
rename to tests/unit/llms/llamafile/chat/test_llamafile_chat_transformation.py
diff --git a/tests/unit/llms/manus/__init__.py b/tests/unit/llms/manus/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/unit/llms/manus/responses/__init__.py b/tests/unit/llms/manus/responses/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/test_litellm/llms/manus/responses/test_manus_responses_transformation.py b/tests/unit/llms/manus/responses/test_manus_responses_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/manus/responses/test_manus_responses_transformation.py
rename to tests/unit/llms/manus/responses/test_manus_responses_transformation.py
diff --git a/tests/test_litellm/llms/meta/realtime/test_meta_realtime_transformation.py b/tests/unit/llms/meta/realtime/test_meta_realtime_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/meta/realtime/test_meta_realtime_transformation.py
rename to tests/unit/llms/meta/realtime/test_meta_realtime_transformation.py
diff --git a/tests/test_litellm/llms/meta_llama/test_meta_llama_chat_transformation.py b/tests/unit/llms/meta_llama/test_meta_llama_chat_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/meta_llama/test_meta_llama_chat_transformation.py
rename to tests/unit/llms/meta_llama/test_meta_llama_chat_transformation.py
diff --git a/tests/unit/llms/minimax/__init__.py b/tests/unit/llms/minimax/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/unit/llms/minimax/chat/__init__.py b/tests/unit/llms/minimax/chat/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/test_litellm/llms/minimax/chat/test_transformation.py b/tests/unit/llms/minimax/chat/test_transformation.py
similarity index 54%
rename from tests/test_litellm/llms/minimax/chat/test_transformation.py
rename to tests/unit/llms/minimax/chat/test_transformation.py
index 9d51b556500..2645b2832aa 100644
--- a/tests/test_litellm/llms/minimax/chat/test_transformation.py
+++ b/tests/unit/llms/minimax/chat/test_transformation.py
@@ -2,14 +2,9 @@
Test MiniMax OpenAI-compatible API support
"""
-import os
from unittest.mock import MagicMock, patch
-import pytest
-
-
import litellm
-from litellm import completion
from litellm.llms.minimax.chat.transformation import MinimaxChatConfig
@@ -107,97 +102,6 @@ def test_minimax_provider_config_manager():
assert isinstance(config, MinimaxChatConfig)
-@pytest.mark.skip(reason="Requires actual MiniMax API key")
-def test_minimax_chat_completion_basic():
- """Test basic chat completion with MiniMax OpenAI-compatible API"""
- response = completion(
- model="minimax/MiniMax-M2.1",
- messages=[
- {"role": "system", "content": "You are a helpful assistant."},
- {"role": "user", "content": "Hello, how are you?"},
- ],
- api_key=os.getenv("MINIMAX_API_KEY"),
- api_base="https://api.minimax.io/v1",
- )
-
- assert response is not None
- assert hasattr(response, "choices")
- assert len(response.choices) > 0
-
-
-@pytest.mark.skip(reason="Requires actual MiniMax API key")
-def test_minimax_chat_completion_with_reasoning_split():
- """Test completion with reasoning_split parameter (MiniMax M2.1 feature)"""
- response = completion(
- model="minimax/MiniMax-M2.1",
- messages=[
- {"role": "system", "content": "You are a helpful assistant."},
- {"role": "user", "content": "Solve this problem: 2+2=?"},
- ],
- api_key=os.getenv("MINIMAX_API_KEY"),
- api_base="https://api.minimax.io/v1",
- extra_body={"reasoning_split": True},
- )
-
- assert response is not None
- # Check if reasoning_details is present in response
- if hasattr(response.choices[0].message, "reasoning_details"):
- assert response.choices[0].message.reasoning_details is not None
-
-
-@pytest.mark.skip(reason="Requires actual MiniMax API key")
-def test_minimax_chat_completion_with_tools():
- """Test completion with tool calling (function calling)"""
- tools = [
- {
- "type": "function",
- "function": {
- "name": "get_weather",
- "description": "Get the current weather in a location",
- "parameters": {
- "type": "object",
- "properties": {
- "location": {
- "type": "string",
- "description": "The city and state, e.g. San Francisco, CA",
- }
- },
- "required": ["location"],
- },
- },
- }
- ]
-
- response = completion(
- model="minimax/MiniMax-M2.1",
- messages=[{"role": "user", "content": "What's the weather in San Francisco?"}],
- tools=tools,
- api_key=os.getenv("MINIMAX_API_KEY"),
- api_base="https://api.minimax.io/v1",
- )
-
- assert response is not None
- assert hasattr(response, "choices")
-
-
-@pytest.mark.skip(reason="Requires actual MiniMax API key")
-def test_minimax_chat_completion_streaming():
- """Test streaming completion"""
- response = completion(
- model="minimax/MiniMax-M2.1",
- messages=[{"role": "user", "content": "Count to 5"}],
- stream=True,
- api_key=os.getenv("MINIMAX_API_KEY"),
- api_base="https://api.minimax.io/v1",
- )
-
- chunks = []
- for chunk in response:
- chunks.append(chunk)
-
- assert len(chunks) > 0
-
-
if __name__ == "__main__":
# Run basic tests that don't require API key
print("Testing MiniMax Chat Config...")
diff --git a/tests/unit/llms/minimax/messages/__init__.py b/tests/unit/llms/minimax/messages/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/test_litellm/llms/minimax/messages/test_transformation.py b/tests/unit/llms/minimax/messages/test_transformation.py
similarity index 57%
rename from tests/test_litellm/llms/minimax/messages/test_transformation.py
rename to tests/unit/llms/minimax/messages/test_transformation.py
index c7435a52890..a4b075414e3 100644
--- a/tests/test_litellm/llms/minimax/messages/test_transformation.py
+++ b/tests/unit/llms/minimax/messages/test_transformation.py
@@ -2,14 +2,9 @@
Test MiniMax Anthropic-compatible API support
"""
-import os
from unittest.mock import MagicMock, patch
-import pytest
-
-
import litellm
-from litellm import completion
from litellm.llms.minimax.messages.transformation import MinimaxMessagesConfig
@@ -58,75 +53,6 @@ def test_minimax_provider_config_manager():
assert config.custom_llm_provider == "minimax"
-@pytest.mark.skip(reason="Requires actual MiniMax API key")
-def test_minimax_completion_basic():
- """Test basic completion with MiniMax Anthropic-compatible API"""
- response = completion(
- model="minimax/MiniMax-M2.1",
- messages=[{"role": "user", "content": "Hello, how are you?"}],
- api_key=os.getenv("MINIMAX_API_KEY"),
- api_base="https://api.minimax.io/anthropic/v1/messages",
- )
-
- assert response is not None
- assert hasattr(response, "choices")
- assert len(response.choices) > 0
-
-
-@pytest.mark.skip(reason="Requires actual MiniMax API key")
-def test_minimax_completion_with_thinking():
- """Test completion with thinking parameter (MiniMax M2.1 feature)"""
- response = completion(
- model="minimax/MiniMax-M2.1",
- messages=[{"role": "user", "content": "Solve this problem: 2+2=?"}],
- api_key=os.getenv("MINIMAX_API_KEY"),
- api_base="https://api.minimax.io/anthropic/v1/messages",
- thinking={"type": "enabled", "budget_tokens": 1000},
- )
-
- assert response is not None
- # Check if thinking content is present in response
- for choice in response.choices:
- if hasattr(choice.message, "content"):
- # MiniMax returns thinking blocks similar to Anthropic
- assert choice.message.content is not None
-
-
-@pytest.mark.skip(reason="Requires actual MiniMax API key")
-def test_minimax_completion_with_tools():
- """Test completion with tool calling (function calling)"""
- tools = [
- {
- "type": "function",
- "function": {
- "name": "get_weather",
- "description": "Get the current weather in a location",
- "parameters": {
- "type": "object",
- "properties": {
- "location": {
- "type": "string",
- "description": "The city and state, e.g. San Francisco, CA",
- }
- },
- "required": ["location"],
- },
- },
- }
- ]
-
- response = completion(
- model="minimax/MiniMax-M2.1",
- messages=[{"role": "user", "content": "What's the weather in San Francisco?"}],
- tools=tools,
- api_key=os.getenv("MINIMAX_API_KEY"),
- api_base="https://api.minimax.io/anthropic/v1/messages",
- )
-
- assert response is not None
- assert hasattr(response, "choices")
-
-
if __name__ == "__main__":
# Run basic tests that don't require API key
print("Testing MiniMax Anthropic Config...")
diff --git a/tests/unit/llms/mistral/__init__.py b/tests/unit/llms/mistral/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py b/tests/unit/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py
rename to tests/unit/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py
diff --git a/tests/unit/llms/mistral/batches/__init__.py b/tests/unit/llms/mistral/batches/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/test_litellm/llms/mistral/batches/test_mistral_batches_transformation.py b/tests/unit/llms/mistral/batches/test_mistral_batches_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/mistral/batches/test_mistral_batches_transformation.py
rename to tests/unit/llms/mistral/batches/test_mistral_batches_transformation.py
diff --git a/tests/unit/llms/mistral/files/__init__.py b/tests/unit/llms/mistral/files/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/test_litellm/llms/mistral/files/test_mistral_files_transformation.py b/tests/unit/llms/mistral/files/test_mistral_files_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/mistral/files/test_mistral_files_transformation.py
rename to tests/unit/llms/mistral/files/test_mistral_files_transformation.py
diff --git a/tests/unit/llms/mistral/ocr/__init__.py b/tests/unit/llms/mistral/ocr/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py b/tests/unit/llms/mistral/ocr/test_mistral_ocr_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py
rename to tests/unit/llms/mistral/ocr/test_mistral_ocr_transformation.py
diff --git a/tests/test_litellm/llms/modelscope/image_generation/test_modelscope_image_gen_transformation.py b/tests/unit/llms/modelscope/image_generation/test_modelscope_image_gen_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/modelscope/image_generation/test_modelscope_image_gen_transformation.py
rename to tests/unit/llms/modelscope/image_generation/test_modelscope_image_gen_transformation.py
diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/unit/llms/mongodb/vector_stores/test_mongodb_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py
rename to tests/unit/llms/mongodb/vector_stores/test_mongodb_transformation.py
diff --git a/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py b/tests/unit/llms/moonshot/test_moonshot_chat_transformation.py
similarity index 96%
rename from tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py
rename to tests/unit/llms/moonshot/test_moonshot_chat_transformation.py
index f94ea5e3db2..c39affc18a8 100644
--- a/tests/test_litellm/llms/moonshot/test_moonshot_chat_transformation.py
+++ b/tests/unit/llms/moonshot/test_moonshot_chat_transformation.py
@@ -19,31 +19,6 @@ from litellm.llms.moonshot.chat.transformation import MoonshotChatConfig
class TestMoonshotConfig:
"""Test class for Moonshot AI functionality"""
- def test_default_api_base(self):
- """Test that default API base is used when none is provided"""
- config = MoonshotChatConfig()
- headers = {}
- api_key = "fake-moonshot-key"
-
- # Call validate_environment without specifying api_base
- result = config.validate_environment(
- headers=headers,
- model="moonshot-v1-8k",
- messages=[{"role": "user", "content": "Hey"}],
- optional_params={},
- litellm_params={},
- api_key=api_key,
- api_base=None, # Not providing api_base
- )
-
- # Verify headers are still set correctly
- assert result["Authorization"] == f"Bearer {api_key}"
- assert result["Content-Type"] == "application/json"
-
- # We can't directly test the api_base value here since validate_environment
- # only returns the headers, but we can verify it doesn't raise an exception
- # which would happen if api_base handling was incorrect
-
def test_get_supported_openai_params(self):
"""Test that get_supported_openai_params returns correct params"""
config = MoonshotChatConfig()
diff --git a/tests/test_litellm/llms/neosantara/test_neosantara.py b/tests/unit/llms/neosantara/test_neosantara.py
similarity index 100%
rename from tests/test_litellm/llms/neosantara/test_neosantara.py
rename to tests/unit/llms/neosantara/test_neosantara.py
diff --git a/tests/test_litellm/llms/nimble/search/test_nimble_search_transformation.py b/tests/unit/llms/nimble/search/test_nimble_search_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/nimble/search/test_nimble_search_transformation.py
rename to tests/unit/llms/nimble/search/test_nimble_search_transformation.py
diff --git a/tests/test_litellm/llms/novita/chat/test_novita_chat_transformation.py b/tests/unit/llms/novita/chat/test_novita_chat_transformation.py
similarity index 85%
rename from tests/test_litellm/llms/novita/chat/test_novita_chat_transformation.py
rename to tests/unit/llms/novita/chat/test_novita_chat_transformation.py
index 3f2a3f77c41..1381cf95a5d 100644
--- a/tests/test_litellm/llms/novita/chat/test_novita_chat_transformation.py
+++ b/tests/unit/llms/novita/chat/test_novita_chat_transformation.py
@@ -54,12 +54,3 @@ class TestNovitaConfig:
)
assert "Missing Novita AI API Key" in str(excinfo.value)
-
- def test_inheritance(self):
- """Test proper inheritance from OpenAIGPTConfig"""
- config = NovitaConfig()
-
- from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig
-
- assert isinstance(config, OpenAIGPTConfig)
- assert hasattr(config, "get_supported_openai_params")
diff --git a/tests/test_litellm/llms/nscale/chat/test_nscale_chat_transformation.py b/tests/unit/llms/nscale/chat/test_nscale_chat_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/nscale/chat/test_nscale_chat_transformation.py
rename to tests/unit/llms/nscale/chat/test_nscale_chat_transformation.py
From fcabb626acdcf240c43ba78dfb92d7822e239cfe Mon Sep 17 00:00:00 2001
From: yuneng
Date: Sun, 20 Sep 2026 08:19:05 +0000
Subject: [PATCH 136/317] test(unit): migrate wave 1 phase 3 anthropic,
apiserpent, azure and azure_ai legacy tests
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/unit/conftest.py | 11 +++
.../test_reasoning_effort_fields.py | 0
.../test_anthropic_files_transformation.py | 0
.../messages/test_advisor_orchestration.py | 0
.../llms/apiserpent/test_apiserpent_search.py | 0
.../test_azure_image_edit_transformation.py | 0
.../test_azure_image_generation_init.py | 82 -------------------
.../test_azure_passthrough_transformation.py | 0
.../realtime/test_azure_realtime_handler.py | 35 --------
.../response/test_azure_transformation.py | 0
.../foundry_responses_web_search_fixture.json | 0
...st_bing_grounding_search_transformation.py | 0
.../test_azure_tts_transformation.py | 0
...test_azure_vector_stores_transformation.py | 0
.../chat/test_azure_ai_transformation.py | 15 ----
.../embed/test_azure_ai_embed_handler.py | 0
...test_azure_ai_image_edit_transformation.py | 0
.../test_mai_image_edit_transformation.py | 0
...st_azure_ai_cohere_parse_transformation.py | 0
...est_azure_ai_passthrough_transformation.py | 0
.../test_azure_ai_rerank_transformation.py | 0
.../test_azure_ai_responses_transformation.py | 0
22 files changed, 11 insertions(+), 132 deletions(-)
rename tests/{test_litellm => unit}/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py (100%)
rename tests/{test_litellm => unit}/llms/anthropic/files/test_anthropic_files_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/anthropic/messages/test_advisor_orchestration.py (100%)
rename tests/{test_litellm => unit}/llms/apiserpent/test_apiserpent_search.py (100%)
rename tests/{test_litellm => unit}/llms/azure/image_edit/test_azure_image_edit_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/azure/image_generation/test_azure_image_generation_init.py (91%)
rename tests/{test_litellm => unit}/llms/azure/passthrough/test_azure_passthrough_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/azure/realtime/test_azure_realtime_handler.py (94%)
rename tests/{test_litellm => unit}/llms/azure/response/test_azure_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/azure/search/foundry_responses_web_search_fixture.json (100%)
rename tests/{test_litellm => unit}/llms/azure/search/test_bing_grounding_search_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/azure/text_to_speech/test_azure_tts_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/azure/vector_stores/test_azure_vector_stores_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/azure_ai/chat/test_azure_ai_transformation.py (97%)
rename tests/{test_litellm => unit}/llms/azure_ai/embed/test_azure_ai_embed_handler.py (100%)
rename tests/{test_litellm => unit}/llms/azure_ai/image_edit/test_azure_ai_image_edit_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/azure_ai/image_edit/test_mai_image_edit_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/azure_ai/passthrough/test_azure_ai_passthrough_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/azure_ai/rerank/test_azure_ai_rerank_transformation.py (100%)
rename tests/{test_litellm => unit}/llms/azure_ai/responses/test_azure_ai_responses_transformation.py (100%)
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
index 3bdab1d231a..d002452d14c 100644
--- a/tests/unit/conftest.py
+++ b/tests/unit/conftest.py
@@ -2,6 +2,8 @@ from collections.abc import Iterator
from typing import Final
import pytest
+
+import litellm
from pytest_socket import enable_socket, socket_allow_hosts
LOOPBACK_HOSTS: Final = ["127.0.0.1", "::1"]
@@ -21,3 +23,12 @@ def block_external_sockets() -> Iterator[None]:
@pytest.hookimpl(trylast=True)
def pytest_runtest_setup() -> None:
_allow_loopback_only()
+
+
+@pytest.fixture
+def local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
+ monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
+ monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
+ litellm.get_model_info.cache_clear()
+ yield
+ litellm.get_model_info.cache_clear()
diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py b/tests/unit/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py
similarity index 100%
rename from tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py
rename to tests/unit/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py
diff --git a/tests/test_litellm/llms/anthropic/files/test_anthropic_files_transformation.py b/tests/unit/llms/anthropic/files/test_anthropic_files_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/anthropic/files/test_anthropic_files_transformation.py
rename to tests/unit/llms/anthropic/files/test_anthropic_files_transformation.py
diff --git a/tests/test_litellm/llms/anthropic/messages/test_advisor_orchestration.py b/tests/unit/llms/anthropic/messages/test_advisor_orchestration.py
similarity index 100%
rename from tests/test_litellm/llms/anthropic/messages/test_advisor_orchestration.py
rename to tests/unit/llms/anthropic/messages/test_advisor_orchestration.py
diff --git a/tests/test_litellm/llms/apiserpent/test_apiserpent_search.py b/tests/unit/llms/apiserpent/test_apiserpent_search.py
similarity index 100%
rename from tests/test_litellm/llms/apiserpent/test_apiserpent_search.py
rename to tests/unit/llms/apiserpent/test_apiserpent_search.py
diff --git a/tests/test_litellm/llms/azure/image_edit/test_azure_image_edit_transformation.py b/tests/unit/llms/azure/image_edit/test_azure_image_edit_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/azure/image_edit/test_azure_image_edit_transformation.py
rename to tests/unit/llms/azure/image_edit/test_azure_image_edit_transformation.py
diff --git a/tests/test_litellm/llms/azure/image_generation/test_azure_image_generation_init.py b/tests/unit/llms/azure/image_generation/test_azure_image_generation_init.py
similarity index 91%
rename from tests/test_litellm/llms/azure/image_generation/test_azure_image_generation_init.py
rename to tests/unit/llms/azure/image_generation/test_azure_image_generation_init.py
index cfde1760389..eabd5c8427d 100644
--- a/tests/test_litellm/llms/azure/image_generation/test_azure_image_generation_init.py
+++ b/tests/unit/llms/azure/image_generation/test_azure_image_generation_init.py
@@ -133,88 +133,6 @@ def test_azure_image_generation_flattens_extra_body():
assert data["size"] == "1024x1024"
-def test_azure_image_generation_creates_token_provider_from_credentials():
- """
- Test that azure_ad_token_provider is created from tenant_id, client_id, client_secret.
-
- This test verifies the fix in images/main.py where we now create the
- azure_ad_token_provider from credentials in litellm_params if it's not already provided.
- """
- # Simulate the fix in images/main.py
- litellm_params_dict = {
- "tenant_id": "test-tenant-id",
- "client_id": "test-client-id",
- "client_secret": "test-client-secret",
- "azure_scope": None,
- }
-
- azure_ad_token_provider = None
-
- # This is the logic we added in images/main.py
- if azure_ad_token_provider is None:
- tenant_id = litellm_params_dict.get("tenant_id")
- client_id = litellm_params_dict.get("client_id")
- client_secret = litellm_params_dict.get("client_secret")
- azure_scope = (
- litellm_params_dict.get("azure_scope")
- or "https://cognitiveservices.azure.com/.default"
- )
-
- # Verify the credentials are extracted correctly
- assert tenant_id == "test-tenant-id"
- assert client_id == "test-client-id"
- assert client_secret == "test-client-secret"
- assert azure_scope == "https://cognitiveservices.azure.com/.default"
-
- # Verify the condition to create token provider is met
- assert (
- tenant_id and client_id and client_secret
- ), "Credentials should be present to create token provider"
-
-
-def test_azure_image_generation_headers_without_api_key():
- """
- Test that when api_key is None, the api-key header is not added to headers.
-
- This prevents the httpx TypeError: "Header value must be str or bytes, not "
- that was occurring when api_key was None and being set in headers.
-
- This is a unit test for the fix in images/main.py where we now check:
- if api_key is not None:
- default_headers["api-key"] = api_key
- """
- from litellm.images.main import image_generation
-
- # Test the header building logic directly
- api_key = None
-
- default_headers = {
- "Content-Type": "application/json",
- }
-
- # This is the fix: only add api-key if it's not None
- if api_key is not None:
- default_headers["api-key"] = api_key
-
- # Verify api-key is not in headers when api_key is None
- assert "api-key" not in default_headers
-
- # Verify Content-Type is still there
- assert default_headers["Content-Type"] == "application/json"
-
- # Test with a valid api_key
- api_key = "valid-key-123"
- default_headers_with_key = {
- "Content-Type": "application/json",
- }
- if api_key is not None:
- default_headers_with_key["api-key"] = api_key
-
- # Verify api-key is added when api_key is valid
- assert "api-key" in default_headers_with_key
- assert default_headers_with_key["api-key"] == "valid-key-123"
-
-
def test_azure_image_generation_drop_params_response_format():
"""
Test that unsupported params like response_format are dropped when drop_params=True.
diff --git a/tests/test_litellm/llms/azure/passthrough/test_azure_passthrough_transformation.py b/tests/unit/llms/azure/passthrough/test_azure_passthrough_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/azure/passthrough/test_azure_passthrough_transformation.py
rename to tests/unit/llms/azure/passthrough/test_azure_passthrough_transformation.py
diff --git a/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py b/tests/unit/llms/azure/realtime/test_azure_realtime_handler.py
similarity index 94%
rename from tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py
rename to tests/unit/llms/azure/realtime/test_azure_realtime_handler.py
index 7d24e604569..c1ba286f8c0 100644
--- a/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py
+++ b/tests/unit/llms/azure/realtime/test_azure_realtime_handler.py
@@ -426,41 +426,6 @@ async def test_async_realtime_beta_without_api_version_raises():
)
-@pytest.mark.asyncio
-async def test_realtime_protocol_env_var_fallback():
- """
- Test that LITELLM_AZURE_REALTIME_PROTOCOL env var is used as fallback.
- Fixes #22127: no way to set realtime_protocol from config.
- """
- from litellm.realtime_api.main import _arealtime
- from litellm.types.router import GenericLiteLLMParams
-
- with patch.dict(os.environ, {"LITELLM_AZURE_REALTIME_PROTOCOL": "v1"}):
- # Create a GenericLiteLLMParams without realtime_protocol
- litellm_params = GenericLiteLLMParams()
- # The env var should be picked up as fallback
- realtime_protocol = (
- {}.get("realtime_protocol")
- or litellm_params.get("realtime_protocol")
- or os.environ.get("LITELLM_AZURE_REALTIME_PROTOCOL")
- or "beta"
- )
- assert realtime_protocol == "v1"
-
-
-@pytest.mark.asyncio
-async def test_realtime_protocol_from_litellm_params():
- """
- Test that realtime_protocol is read from litellm_params (config.yaml extra field).
- Fixes #22127: realtime_protocol in litellm_params was not used.
- """
- from litellm.types.router import GenericLiteLLMParams
-
- # Simulate config.yaml with realtime_protocol as an extra field
- litellm_params = GenericLiteLLMParams(realtime_protocol="GA")
- assert litellm_params.get("realtime_protocol") == "GA"
-
-
@pytest.mark.asyncio
async def test_arealtime_transcription_intent_defaults_to_ga(monkeypatch):
"""
diff --git a/tests/test_litellm/llms/azure/response/test_azure_transformation.py b/tests/unit/llms/azure/response/test_azure_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/azure/response/test_azure_transformation.py
rename to tests/unit/llms/azure/response/test_azure_transformation.py
diff --git a/tests/test_litellm/llms/azure/search/foundry_responses_web_search_fixture.json b/tests/unit/llms/azure/search/foundry_responses_web_search_fixture.json
similarity index 100%
rename from tests/test_litellm/llms/azure/search/foundry_responses_web_search_fixture.json
rename to tests/unit/llms/azure/search/foundry_responses_web_search_fixture.json
diff --git a/tests/test_litellm/llms/azure/search/test_bing_grounding_search_transformation.py b/tests/unit/llms/azure/search/test_bing_grounding_search_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/azure/search/test_bing_grounding_search_transformation.py
rename to tests/unit/llms/azure/search/test_bing_grounding_search_transformation.py
diff --git a/tests/test_litellm/llms/azure/text_to_speech/test_azure_tts_transformation.py b/tests/unit/llms/azure/text_to_speech/test_azure_tts_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/azure/text_to_speech/test_azure_tts_transformation.py
rename to tests/unit/llms/azure/text_to_speech/test_azure_tts_transformation.py
diff --git a/tests/test_litellm/llms/azure/vector_stores/test_azure_vector_stores_transformation.py b/tests/unit/llms/azure/vector_stores/test_azure_vector_stores_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/azure/vector_stores/test_azure_vector_stores_transformation.py
rename to tests/unit/llms/azure/vector_stores/test_azure_vector_stores_transformation.py
diff --git a/tests/test_litellm/llms/azure_ai/chat/test_azure_ai_transformation.py b/tests/unit/llms/azure_ai/chat/test_azure_ai_transformation.py
similarity index 97%
rename from tests/test_litellm/llms/azure_ai/chat/test_azure_ai_transformation.py
rename to tests/unit/llms/azure_ai/chat/test_azure_ai_transformation.py
index f8cc0b5071e..e4a33d5772c 100644
--- a/tests/test_litellm/llms/azure_ai/chat/test_azure_ai_transformation.py
+++ b/tests/unit/llms/azure_ai/chat/test_azure_ai_transformation.py
@@ -352,21 +352,6 @@ def test_azure_model_router_stamps_selected_model_on_hidden_params():
)
-def test_azure_model_router_stamp_does_not_leak_across_responses():
- """
- ModelResponse declares _hidden_params as a class-level dict, so the stamp has to be written
- as a fresh dict. Mutating in place would bleed the selected model into unrelated responses.
- """
- from litellm.llms.azure_ai.common_utils import (
- AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY,
- )
- from litellm.types.utils import ModelResponse
-
- untouched = ModelResponse()
-
- assert AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY not in (untouched._hidden_params or {})
-
-
def test_drop_tool_level_extra_fields_strips_copilot_mcp_server_name():
"""
Regression test: Azure AI returns 400 when tools contain copilot_mcp_server_name.
diff --git a/tests/test_litellm/llms/azure_ai/embed/test_azure_ai_embed_handler.py b/tests/unit/llms/azure_ai/embed/test_azure_ai_embed_handler.py
similarity index 100%
rename from tests/test_litellm/llms/azure_ai/embed/test_azure_ai_embed_handler.py
rename to tests/unit/llms/azure_ai/embed/test_azure_ai_embed_handler.py
diff --git a/tests/test_litellm/llms/azure_ai/image_edit/test_azure_ai_image_edit_transformation.py b/tests/unit/llms/azure_ai/image_edit/test_azure_ai_image_edit_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/azure_ai/image_edit/test_azure_ai_image_edit_transformation.py
rename to tests/unit/llms/azure_ai/image_edit/test_azure_ai_image_edit_transformation.py
diff --git a/tests/test_litellm/llms/azure_ai/image_edit/test_mai_image_edit_transformation.py b/tests/unit/llms/azure_ai/image_edit/test_mai_image_edit_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/azure_ai/image_edit/test_mai_image_edit_transformation.py
rename to tests/unit/llms/azure_ai/image_edit/test_mai_image_edit_transformation.py
diff --git a/tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py b/tests/unit/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py
rename to tests/unit/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py
diff --git a/tests/test_litellm/llms/azure_ai/passthrough/test_azure_ai_passthrough_transformation.py b/tests/unit/llms/azure_ai/passthrough/test_azure_ai_passthrough_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/azure_ai/passthrough/test_azure_ai_passthrough_transformation.py
rename to tests/unit/llms/azure_ai/passthrough/test_azure_ai_passthrough_transformation.py
diff --git a/tests/test_litellm/llms/azure_ai/rerank/test_azure_ai_rerank_transformation.py b/tests/unit/llms/azure_ai/rerank/test_azure_ai_rerank_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/azure_ai/rerank/test_azure_ai_rerank_transformation.py
rename to tests/unit/llms/azure_ai/rerank/test_azure_ai_rerank_transformation.py
diff --git a/tests/test_litellm/llms/azure_ai/responses/test_azure_ai_responses_transformation.py b/tests/unit/llms/azure_ai/responses/test_azure_ai_responses_transformation.py
similarity index 100%
rename from tests/test_litellm/llms/azure_ai/responses/test_azure_ai_responses_transformation.py
rename to tests/unit/llms/azure_ai/responses/test_azure_ai_responses_transformation.py
From 6d327fff6f812b9c7dbbbb1ecc9b997a7807146e Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Sun, 20 Sep 2026 08:21:28 +0000
Subject: [PATCH 137/317] test(bedrock): keep beta headers fixture teardown off
the network
---
...oke_transformations_anthropic_claude3_transformation.py | 7 ++-----
1 file changed, 2 insertions(+), 5 deletions(-)
diff --git a/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py b/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py
index 2c74d23a6a2..cf2fd78a896 100644
--- a/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py
+++ b/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py
@@ -86,11 +86,8 @@ def local_beta_headers_config(monkeypatch):
monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "True")
reload_beta_headers_config()
- try:
- yield
- finally:
- monkeypatch.delenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", raising=False)
- reload_beta_headers_config()
+ yield
+ reload_beta_headers_config()
def test_get_supported_params_thinking():
From cf2a9b372cffe0e00e44ee252168384c8f5c058d Mon Sep 17 00:00:00 2001
From: yuneng
Date: Sun, 20 Sep 2026 08:21:51 +0000
Subject: [PATCH 138/317] test(gigachat): cover env credential fallback by its
resulting auth header
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../chat/test_gigachat_chat_transformation.py | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/tests/unit/llms/gigachat/chat/test_gigachat_chat_transformation.py b/tests/unit/llms/gigachat/chat/test_gigachat_chat_transformation.py
index 8e84072e549..b1307f56336 100644
--- a/tests/unit/llms/gigachat/chat/test_gigachat_chat_transformation.py
+++ b/tests/unit/llms/gigachat/chat/test_gigachat_chat_transformation.py
@@ -141,6 +141,25 @@ class TestValidateEnvironment:
assert self.config._current_credentials == "my-creds"
assert self.config._current_api_base == "https://my-api.example.com"
+ @patch(
+ f"{TRANSFORM_MODULE}.get_access_token",
+ side_effect=lambda credentials, litellm_params: f"token-for-{credentials}",
+ )
+ def test_falls_back_to_env_credentials_when_api_key_missing(
+ self, mock_get_token, monkeypatch: pytest.MonkeyPatch
+ ):
+ monkeypatch.setenv("GIGACHAT_CREDENTIALS", "env-creds")
+ result = self.config.validate_environment(
+ headers={},
+ model="GigaChat",
+ messages=[],
+ optional_params={},
+ litellm_params={},
+ api_key=None,
+ api_base=None,
+ )
+ assert result["Authorization"] == "Bearer token-for-env-creds"
+ assert self.config._current_credentials == "env-creds"
class TestGetSupportedOpenAiParams:
From 129c4a703b7224965b7d0c6ff402a4db2b85a635 Mon Sep 17 00:00:00 2001
From: Yuneng Jiang
Date: Sun, 20 Sep 2026 01:21:01 -0700
Subject: [PATCH 139/317] fix(proxy): only warn about the deprecated flag when
it came from the CLI
USE_V2_MIGRATION_RESOLVER=true is a supported way to select v2, but click
sets the same parameter from that env var, so the deprecation notice fired
for environment-based config that is not deprecated. The notice now keys off
click's parameter source.
Also drops an em dash from the notice, and moves the resolver decision under
mock-free tests by making it take the env value as an argument.
---
litellm/proxy/proxy_cli.py | 25 ++++++---
tests/test_litellm/proxy/test_proxy_cli.py | 63 ++++++++++++++--------
2 files changed, 58 insertions(+), 30 deletions(-)
diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py
index 14d0331c0ff..78885461724 100644
--- a/litellm/proxy/proxy_cli.py
+++ b/litellm/proxy/proxy_cli.py
@@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Any, Final
import click
import httpx
+from click.core import ParameterSource
from dotenv import load_dotenv
from pydantic import BaseModel, ConfigDict
@@ -181,12 +182,21 @@ def append_query_params(url: str | None, params: dict) -> str:
return modified_url
-def resolve_v2_migration_resolver(*, use_legacy_flag: bool) -> bool:
+def resolve_v2_migration_resolver(*, use_legacy_flag: bool, env_value: str | None) -> bool:
from litellm_proxy_extras.utils import str_to_bool
if use_legacy_flag:
return False
- return bool(str_to_bool(os.getenv("USE_V2_MIGRATION_RESOLVER", "true")))
+ if env_value is None:
+ return True
+ return bool(str_to_bool(env_value))
+
+
+def deprecated_v2_flag_passed_on_cli() -> bool:
+ ctx: Final = click.get_current_context(silent=True)
+ if ctx is None:
+ return False
+ return ctx.get_parameter_source("use_v2_migration_resolver") is ParameterSource.COMMANDLINE
class ProxyInitializationHelpers:
@@ -1368,14 +1378,15 @@ def run_server(
check_prisma_schema_diff(db_url=None)
else:
use_v2_resolver: Final = resolve_v2_migration_resolver(
- use_legacy_flag=use_legacy_migration_resolver
+ use_legacy_flag=use_legacy_migration_resolver,
+ env_value=os.getenv("USE_V2_MIGRATION_RESOLVER"),
)
- if use_v2_migration_resolver and use_v2_resolver:
+ if deprecated_v2_flag_passed_on_cli() and use_v2_resolver:
print(
"\033[1;33mLiteLLM Proxy: --use_v2_migration_resolver is "
- "deprecated and has no effect \u2014 the v2 migration resolver "
- "is now the default. You can safely remove it. To opt back "
- "into the legacy v1 resolver, pass "
+ "deprecated and has no effect, because the v2 migration "
+ "resolver is now the default. You can safely remove it. To "
+ "opt back into the legacy v1 resolver, pass "
"--use_legacy_migration_resolver.\033[0m"
)
if not use_v2_resolver:
diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py
index d2835142194..a38470d1fdf 100644
--- a/tests/test_litellm/proxy/test_proxy_cli.py
+++ b/tests/test_litellm/proxy/test_proxy_cli.py
@@ -2203,6 +2203,7 @@ class TestRunServerDbSetup:
mock_setup_database,
mock_atexit_register,
mock_subprocess_run,
+ capsys,
):
"""USE_V2_MIGRATION_RESOLVER=true must select the v2 resolver.
@@ -2248,44 +2249,58 @@ class TestRunServerDbSetup:
mock_setup_database.assert_called_once_with(
use_migrate=True, use_v2_resolver=True
)
+ assert "--use_v2_migration_resolver is deprecated" not in capsys.readouterr().out
@pytest.mark.parametrize(
- "argv_extra, env_extra, expected_v2",
+ "use_legacy_flag, env_value, expected",
[
- ([], {}, True),
- ([], {"USE_V2_MIGRATION_RESOLVER": "false"}, False),
- (["--use_legacy_migration_resolver"], {}, False),
- (
- ["--use_legacy_migration_resolver"],
- {"USE_V2_MIGRATION_RESOLVER": "true"},
- False,
- ),
- (["--use_v2_migration_resolver"], {}, True),
+ (False, None, True),
+ (False, "true", True),
+ (False, "false", False),
+ (True, None, False),
+ (True, "true", False),
],
ids=[
- "default-is-v2",
- "env-false-opts-out",
- "legacy-flag-opts-out",
+ "unset-env-defaults-to-v2",
+ "env-true-selects-v2",
+ "env-false-selects-v1",
+ "legacy-flag-selects-v1",
"legacy-flag-beats-env-true",
- "deprecated-v2-flag-still-accepted",
],
)
+ def test_resolve_v2_migration_resolver(self, use_legacy_flag, env_value, expected):
+ from litellm.proxy.proxy_cli import resolve_v2_migration_resolver
+
+ assert (
+ resolve_v2_migration_resolver(
+ use_legacy_flag=use_legacy_flag, env_value=env_value
+ )
+ is expected
+ )
+
+ def test_deprecated_v2_flag_not_reported_outside_a_cli_invocation(self):
+ from litellm.proxy.proxy_cli import deprecated_v2_flag_passed_on_cli
+
+ assert deprecated_v2_flag_passed_on_cli() is False
+
@patch("subprocess.run")
@patch("atexit.register")
@patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database")
@patch("litellm.proxy.db.check_migration.check_prisma_schema_diff")
@patch("litellm.proxy.db.prisma_client.should_update_prisma_schema")
- def test_migration_resolver_selection(
+ def test_legacy_resolver_flag_reaches_database_setup(
self,
mock_should_update_schema,
mock_check_schema_diff,
mock_setup_database,
mock_atexit_register,
mock_subprocess_run,
- argv_extra,
- env_extra,
- expected_v2,
):
+ """--use_legacy_migration_resolver must reach the database setup call.
+
+ The resolver decision itself is covered mock-free above; this is the
+ one wiring check that the flag is threaded through run_server.
+ """
from litellm.proxy.proxy_cli import run_server
mock_subprocess_run.return_value = MagicMock(returncode=0)
@@ -2302,11 +2317,9 @@ class TestRunServerDbSetup:
clean_env = {
k: v
for k, v in os.environ.items()
- if k
- not in ("DATABASE_URL", "DIRECT_URL", "USE_V2_MIGRATION_RESOLVER")
+ if k not in ("DATABASE_URL", "DIRECT_URL", "USE_V2_MIGRATION_RESOLVER")
}
clean_env["DATABASE_URL"] = "postgresql://test:test@localhost:5432/test"
- clean_env.update(env_extra)
with (
patch.dict(os.environ, clean_env, clear=True),
@@ -2319,12 +2332,16 @@ class TestRunServerDbSetup:
),
):
run_server.main(
- ["--local", "--skip_server_startup", *argv_extra],
+ [
+ "--local",
+ "--skip_server_startup",
+ "--use_legacy_migration_resolver",
+ ],
standalone_mode=False,
)
mock_setup_database.assert_called_once_with(
- use_migrate=True, use_v2_resolver=expected_v2
+ use_migrate=True, use_v2_resolver=False
)
From 9850cd14f7e72bbe99f0b025cbb722d9a18cb523 Mon Sep 17 00:00:00 2001
From: yuneng
Date: Sun, 20 Sep 2026 08:27:02 +0000
Subject: [PATCH 140/317] test(ci): prove RULE_CODES by running every checker
rule
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
scripts/budget_ratchet_check.py | 2 +-
tests/test_litellm/test_check_test_quality.py | 49 ++++++++++++++-----
2 files changed, 38 insertions(+), 13 deletions(-)
diff --git a/scripts/budget_ratchet_check.py b/scripts/budget_ratchet_check.py
index 470adddfcd3..3ca5e9f3e9d 100644
--- a/scripts/budget_ratchet_check.py
+++ b/scripts/budget_ratchet_check.py
@@ -156,7 +156,7 @@ def _load_script(name: str) -> ModuleType:
return module
-def retired_rules(rel: str, base: dict) -> frozenset[str]:
+def retired_rules(rel: str, base: dict[str, object]) -> frozenset[str]:
"""Rules in the base budget that the paired checker can no longer emit, so there is no ceiling to loosen."""
source: Final = RETIREMENT_SOURCES.get(rel)
if source is None:
diff --git a/tests/test_litellm/test_check_test_quality.py b/tests/test_litellm/test_check_test_quality.py
index 5a5c53fc31c..2a0e32d4de7 100644
--- a/tests/test_litellm/test_check_test_quality.py
+++ b/tests/test_litellm/test_check_test_quality.py
@@ -7,13 +7,13 @@ produced against tests/e2e, where the assertions live in a shared helper rather
in the test body.
"""
-import ast
import importlib.util
import os
-import re
import subprocess
import sys
from pathlib import Path
+from types import MappingProxyType
+from typing import Final
import pytest
@@ -614,17 +614,42 @@ def test_a_fanned_out_run_reports_each_generated_file_exactly_once(tmp_path):
assert all(" TQ001 " in line for line in reported)
-def test_rule_codes_match_every_code_the_checker_emits():
- source = _MODULE_PATH.read_text(encoding="utf-8")
- tree = ast.parse(source)
- definition = next(
- node
- for node in tree.body
- if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id == "RULE_CODES"
+_VIOLATING_SNIPPETS: Final = MappingProxyType(
+ {
+ "TQ000": ("test_snippet.py", "def test_broken(:\n pass\n"),
+ "TQ001": ("test_snippet.py", "def test_nothing():\n compute()\n"),
+ "TQ002": (
+ "test_snippet.py",
+ "from unittest.mock import patch\n"
+ "\n"
+ "\n"
+ "def test_echo():\n"
+ " with patch('litellm.completion') as mock_completion:\n"
+ " run()\n"
+ " mock_completion.assert_called_once()\n",
+ ),
+ "TQ003": ("test_snippet.py", "import sys\n\nsys.path.insert(0, '..')\n"),
+ "TQ004": ("test_snippet.py", "import os\n\nos.environ['KEY'] = 'v'\n"),
+ "TQ005": ("test_snippet.py", "import litellm\n\nlitellm.drop_params = True\n"),
+ "TQ006": ("test_snippet.py", _DIRECT_GATE),
+ "TQ007": ("conftest.py", _SNAPSHOT_CONFTEST),
+ "TQ009": (
+ "test_snippet.py",
+ 'import subprocess, sys\nsubprocess.run([sys.executable, "-c", "pass"])\n',
+ ),
+ }
+)
+
+
+def test_rule_codes_match_every_code_the_checker_emits(tmp_path):
+ emitted = frozenset(
+ v.code
+ for name, source in _VIOLATING_SNIPPETS.values()
+ for v in checker.check_file(_written(tmp_path, source, name))
)
- lines = source.splitlines()
- outside = "\n".join(lines[: definition.lineno - 1] + lines[definition.end_lineno :])
- assert frozenset(re.findall(r'"(TQ\d{3})"', outside)) == checker.RULE_CODES
+ for code, (name, source) in _VIOLATING_SNIPPETS.items():
+ assert code in [v.code for v in checker.check_file(_written(tmp_path, source, name))], code
+ assert emitted == checker.RULE_CODES
def test_sys_executable_child_without_isolation_flag_is_flagged(tmp_path):
From decb28f5b575378cff5e8ce941c999da24df3875 Mon Sep 17 00:00:00 2001
From: yuneng
Date: Sun, 20 Sep 2026 08:34:55 +0000
Subject: [PATCH 141/317] test(unit): restore live router and runtime model
cost state between unit tests
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/unit/conftest.py | 22 +++++++++++++++++++++-
1 file changed, 21 insertions(+), 1 deletion(-)
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
index d002452d14c..d47f71b21a8 100644
--- a/tests/unit/conftest.py
+++ b/tests/unit/conftest.py
@@ -2,9 +2,11 @@ from collections.abc import Iterator
from typing import Final
import pytest
+from pytest_socket import enable_socket, socket_allow_hosts
import litellm
-from pytest_socket import enable_socket, socket_allow_hosts
+import litellm.router as litellm_router_module
+import litellm.utils as litellm_utils_module
LOOPBACK_HOSTS: Final = ["127.0.0.1", "::1"]
@@ -25,6 +27,24 @@ def pytest_runtest_setup() -> None:
_allow_loopback_only()
+@pytest.fixture(autouse=True)
+def isolate_router_model_cost_state() -> Iterator[None]:
+ original_live_routers: Final = frozenset(litellm_router_module._live_routers)
+ original_runtime_registered_model_cost: Final = {
+ model_key: dict(model_value)
+ for model_key, model_value in litellm_utils_module._runtime_registered_model_cost.items()
+ }
+ yield
+ for router in tuple(litellm_router_module._live_routers):
+ litellm_router_module._live_routers.discard(router)
+ for router in original_live_routers:
+ litellm_router_module._live_routers.add(router)
+ litellm_utils_module._runtime_registered_model_cost.clear()
+ litellm_utils_module._runtime_registered_model_cost.update(original_runtime_registered_model_cost)
+ litellm_utils_module._invalidate_model_cost_lowercase_map()
+ litellm.get_model_info.cache_clear()
+
+
@pytest.fixture
def local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
From 7fc114c24f393d9329c5b6cd919cee459a96fba7 Mon Sep 17 00:00:00 2001
From: yucheng
Date: Sun, 20 Sep 2026 08:24:47 +0000
Subject: [PATCH 142/317] feat(policy_engine): add default fallback policy
attachments
A policy attachment with default: true applies only when no non-default
attachment matches the request, so an opt-in guardrail policy replaces the
fallback one instead of running alongside it. Supported in config.yaml,
/policies/attachments, the Admin UI Attachments tab and the resolver
(matched_via is prefixed with default:).
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../migration.sql | 1 +
.../litellm_proxy_extras/schema.prisma | 1 +
litellm/proxy/_lazy_openapi_snapshot.json | 18 +++
.../policy_engine/attachment_registry.py | 24 ++-
.../proxy/policy_engine/policy_endpoints.py | 1 +
litellm/proxy/schema.prisma | 1 +
.../types/proxy/policy_engine/policy_types.py | 4 +
.../proxy/policy_engine/resolver_types.py | 8 +
schema.prisma | 1 +
.../policy_engine/test_attachment_registry.py | 152 ++++++++++++------
.../_components/AttachmentTable.test.tsx | 13 ++
.../_components/AttachmentTableColumns.tsx | 14 ++
.../_components/add_attachment_form.test.tsx | 15 ++
.../_components/add_attachment_form.tsx | 18 +++
.../_components/build_attachment_data.test.ts | 10 ++
.../_components/build_attachment_data.ts | 2 +
.../src/components/policies/types.ts | 2 +
ui/litellm-dashboard/src/lib/http/schema.d.ts | 12 ++
18 files changed, 241 insertions(+), 56 deletions(-)
create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260920041500_add_policy_attachment_is_default/migration.sql
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260920041500_add_policy_attachment_is_default/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260920041500_add_policy_attachment_is_default/migration.sql
new file mode 100644
index 00000000000..a6c45448d03
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260920041500_add_policy_attachment_is_default/migration.sql
@@ -0,0 +1 @@
+ALTER TABLE "LiteLLM_PolicyAttachmentTable" ADD COLUMN IF NOT EXISTS "is_default" BOOLEAN NOT NULL DEFAULT false;
diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
index d2032cec0d0..2d7e557a9d1 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
+++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
@@ -1419,6 +1419,7 @@ model LiteLLM_PolicyAttachmentTable {
models String[] @default([]) // Model names or patterns
tags String[] @default([]) // Tag patterns (e.g., ["healthcare", "prod-*"])
priority Int? // Explicit execution order
+ is_default Boolean @default(false) // Applied only when no non-default attachment matches
created_at DateTime @default(now())
created_by String?
updated_at DateTime @default(now()) @updatedAt
diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json
index 06e157498aa..6f9a2d8c96d 100644
--- a/litellm/proxy/_lazy_openapi_snapshot.json
+++ b/litellm/proxy/_lazy_openapi_snapshot.json
@@ -34982,6 +34982,12 @@
"PolicyAttachmentCreateRequest": {
"description": "Request body for creating a policy attachment.",
"properties": {
+ "default": {
+ "default": false,
+ "description": "Apply this attachment only when no non-default attachment matches the request.",
+ "title": "Default",
+ "type": "boolean"
+ },
"keys": {
"anyOf": [
{
@@ -35113,6 +35119,12 @@
"description": "Who created the attachment.",
"title": "Created By"
},
+ "default": {
+ "default": false,
+ "description": "Apply this attachment only when no non-default attachment matches the request.",
+ "title": "Default",
+ "type": "boolean"
+ },
"definition_location": {
"default": "db",
"description": "Where this attachment is defined: 'db' (database) or 'config' (config.yaml).",
@@ -37141,6 +37153,12 @@
"PolicyAttachmentCreateRequest": {
"description": "Request body for creating a policy attachment.",
"properties": {
+ "default": {
+ "default": false,
+ "description": "Apply this attachment only when no non-default attachment matches the request.",
+ "title": "Default",
+ "type": "boolean"
+ },
"keys": {
"anyOf": [
{
diff --git a/litellm/proxy/policy_engine/attachment_registry.py b/litellm/proxy/policy_engine/attachment_registry.py
index 3735c335bd4..04009151487 100644
--- a/litellm/proxy/policy_engine/attachment_registry.py
+++ b/litellm/proxy/policy_engine/attachment_registry.py
@@ -119,6 +119,7 @@ class AttachmentRegistry:
models=attachment_data.get("models"),
tags=attachment_data.get("tags"),
priority=attachment_data.get("priority"),
+ default=attachment_data.get("default", False),
)
def get_attached_policies(self, context: PolicyMatchContext) -> list[str]:
@@ -142,12 +143,14 @@ class AttachmentRegistry:
"""
from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher
+ in_scope: Final = tuple(
+ attachment
+ for attachment in self._attachments
+ if PolicyMatcher.scope_matches(scope=attachment.to_policy_scope(), context=context)
+ )
+ non_default: Final = tuple(attachment for attachment in in_scope if not attachment.default)
matching_attachments: Final = sorted(
- (
- attachment
- for attachment in self._attachments
- if PolicyMatcher.scope_matches(scope=attachment.to_policy_scope(), context=context)
- ),
+ non_default or tuple(attachment for attachment in in_scope if attachment.default),
key=_attachment_sort_key,
)
broadest_attachment_by_policy: Final = MappingProxyType(
@@ -169,6 +172,11 @@ class AttachmentRegistry:
@staticmethod
def _describe_match_reason(attachment: PolicyAttachment, context: PolicyMatchContext) -> str:
"""Describe why an attachment matched the context."""
+ reason: Final = AttachmentRegistry._describe_scope_match(attachment, context)
+ return f"default:{reason}" if attachment.default else reason
+
+ @staticmethod
+ def _describe_scope_match(attachment: PolicyAttachment, context: PolicyMatchContext) -> str:
from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher
if attachment.is_global():
@@ -324,6 +332,7 @@ class AttachmentRegistry:
"models": attachment_request.models or [],
"tags": attachment_request.tags or [],
"priority": attachment_request.priority,
+ "is_default": attachment_request.default,
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
"created_by": created_by,
@@ -340,6 +349,7 @@ class AttachmentRegistry:
models=attachment_request.models,
tags=attachment_request.tags,
priority=attachment_request.priority,
+ default=attachment_request.default,
)
self.add_attachment(attachment)
@@ -352,6 +362,7 @@ class AttachmentRegistry:
models=created_attachment.models or [],
tags=created_attachment.tags or [],
priority=created_attachment.priority,
+ default=created_attachment.is_default,
created_at=created_attachment.created_at,
updated_at=created_attachment.updated_at,
created_by=created_attachment.created_by,
@@ -429,6 +440,7 @@ class AttachmentRegistry:
models=attachment.models or [],
tags=attachment.tags or [],
priority=attachment.priority,
+ default=attachment.is_default,
created_at=attachment.created_at,
updated_at=attachment.updated_at,
created_by=attachment.created_by,
@@ -468,6 +480,7 @@ class AttachmentRegistry:
models=a.models or [],
tags=a.tags or [],
priority=a.priority,
+ default=a.is_default,
created_at=a.created_at,
updated_at=a.updated_at,
created_by=a.created_by,
@@ -502,6 +515,7 @@ class AttachmentRegistry:
models=(attachment_response.models if attachment_response.models else None),
tags=attachment_response.tags if attachment_response.tags else None,
priority=attachment_response.priority,
+ default=attachment_response.default,
)
for attachment_response in attachments
]
diff --git a/litellm/proxy/policy_engine/policy_endpoints.py b/litellm/proxy/policy_engine/policy_endpoints.py
index 1e30238c8b4..f4b38bea14e 100644
--- a/litellm/proxy/policy_engine/policy_endpoints.py
+++ b/litellm/proxy/policy_engine/policy_endpoints.py
@@ -61,6 +61,7 @@ def _config_attachment_to_db_response(index: int, attachment: PolicyAttachment)
models=attachment.models or [],
tags=attachment.tags or [],
priority=attachment.priority,
+ default=attachment.default,
definition_location="config",
)
diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma
index d2032cec0d0..2d7e557a9d1 100644
--- a/litellm/proxy/schema.prisma
+++ b/litellm/proxy/schema.prisma
@@ -1419,6 +1419,7 @@ model LiteLLM_PolicyAttachmentTable {
models String[] @default([]) // Model names or patterns
tags String[] @default([]) // Tag patterns (e.g., ["healthcare", "prod-*"])
priority Int? // Explicit execution order
+ is_default Boolean @default(false) // Applied only when no non-default attachment matches
created_at DateTime @default(now())
created_by String?
updated_at DateTime @default(now()) @updatedAt
diff --git a/litellm/types/proxy/policy_engine/policy_types.py b/litellm/types/proxy/policy_engine/policy_types.py
index 66e5fbb4b49..73eeffa3585 100644
--- a/litellm/types/proxy/policy_engine/policy_types.py
+++ b/litellm/types/proxy/policy_engine/policy_types.py
@@ -294,6 +294,10 @@ class PolicyAttachment(BaseModel):
le=2147483647,
description="Explicit execution order, lower runs first. Prioritised attachments run before those without one.",
)
+ default: bool = Field(
+ default=False,
+ description="Apply this attachment only when no non-default attachment matches the request.",
+ )
model_config = ConfigDict(extra="forbid")
diff --git a/litellm/types/proxy/policy_engine/resolver_types.py b/litellm/types/proxy/policy_engine/resolver_types.py
index e6f501ed4b5..ebdedb98b12 100644
--- a/litellm/types/proxy/policy_engine/resolver_types.py
+++ b/litellm/types/proxy/policy_engine/resolver_types.py
@@ -311,6 +311,10 @@ class PolicyAttachmentCreateRequest(BaseModel):
le=2147483647,
description="Explicit execution order, lower runs first. Prioritised attachments run before those without one.",
)
+ default: bool = Field(
+ default=False,
+ description="Apply this attachment only when no non-default attachment matches the request.",
+ )
class PolicyAttachmentDBResponse(BaseModel):
@@ -327,6 +331,10 @@ class PolicyAttachmentDBResponse(BaseModel):
default=None,
description="Explicit execution order, lower runs first. Prioritised attachments run before those without one.",
)
+ default: bool = Field(
+ default=False,
+ description="Apply this attachment only when no non-default attachment matches the request.",
+ )
created_at: datetime | None = Field(default=None, description="When the attachment was created.")
updated_at: datetime | None = Field(default=None, description="When the attachment was last updated.")
created_by: str | None = Field(default=None, description="Who created the attachment.")
diff --git a/schema.prisma b/schema.prisma
index d2032cec0d0..2d7e557a9d1 100644
--- a/schema.prisma
+++ b/schema.prisma
@@ -1419,6 +1419,7 @@ model LiteLLM_PolicyAttachmentTable {
models String[] @default([]) // Model names or patterns
tags String[] @default([]) // Tag patterns (e.g., ["healthcare", "prod-*"])
priority Int? // Explicit execution order
+ is_default Boolean @default(false) // Applied only when no non-default attachment matches
created_at DateTime @default(now())
created_by String?
updated_at DateTime @default(now()) @updatedAt
diff --git a/tests/test_litellm/proxy/policy_engine/test_attachment_registry.py b/tests/test_litellm/proxy/policy_engine/test_attachment_registry.py
index 089bec59583..b419f3db060 100644
--- a/tests/test_litellm/proxy/policy_engine/test_attachment_registry.py
+++ b/tests/test_litellm/proxy/policy_engine/test_attachment_registry.py
@@ -30,9 +30,7 @@ class TestGetAttachedPolicies:
)
# Should match any context
- context = PolicyMatchContext(
- team_alias="any-team", key_alias="any-key", model="any-model"
- )
+ context = PolicyMatchContext(team_alias="any-team", key_alias="any-key", model="any-model")
attached = registry.get_attached_policies(context)
assert "global-baseline" in attached
@@ -46,15 +44,11 @@ class TestGetAttachedPolicies:
)
# Match
- context = PolicyMatchContext(
- team_alias="healthcare-team", key_alias="key", model="gpt-4"
- )
+ context = PolicyMatchContext(team_alias="healthcare-team", key_alias="key", model="gpt-4")
assert "healthcare-policy" in registry.get_attached_policies(context)
# No match - different team
- context_other = PolicyMatchContext(
- team_alias="finance-team", key_alias="key", model="gpt-4"
- )
+ context_other = PolicyMatchContext(team_alias="finance-team", key_alias="key", model="gpt-4")
assert "healthcare-policy" not in registry.get_attached_policies(context_other)
def test_key_wildcard_pattern_attachment(self):
@@ -67,15 +61,11 @@ class TestGetAttachedPolicies:
)
# Match - key starts with dev-key-
- context = PolicyMatchContext(
- team_alias="team", key_alias="dev-key-123", model="gpt-4"
- )
+ context = PolicyMatchContext(team_alias="team", key_alias="dev-key-123", model="gpt-4")
assert "dev-policy" in registry.get_attached_policies(context)
# No match - different prefix
- context_prod = PolicyMatchContext(
- team_alias="team", key_alias="prod-key-123", model="gpt-4"
- )
+ context_prod = PolicyMatchContext(team_alias="team", key_alias="prod-key-123", model="gpt-4")
assert "dev-policy" not in registry.get_attached_policies(context_prod)
def test_model_specific_attachment(self):
@@ -92,9 +82,7 @@ class TestGetAttachedPolicies:
assert "gpt4-policy" in registry.get_attached_policies(context)
# No match
- context_other = PolicyMatchContext(
- team_alias="team", key_alias="key", model="gpt-3.5"
- )
+ context_other = PolicyMatchContext(team_alias="team", key_alias="key", model="gpt-3.5")
assert "gpt4-policy" not in registry.get_attached_policies(context_other)
def test_model_wildcard_pattern(self):
@@ -107,15 +95,11 @@ class TestGetAttachedPolicies:
)
# Match
- context = PolicyMatchContext(
- team_alias="team", key_alias="key", model="bedrock/claude-3"
- )
+ context = PolicyMatchContext(team_alias="team", key_alias="key", model="bedrock/claude-3")
assert "bedrock-policy" in registry.get_attached_policies(context)
# No match
- context_other = PolicyMatchContext(
- team_alias="team", key_alias="key", model="openai/gpt-4"
- )
+ context_other = PolicyMatchContext(team_alias="team", key_alias="key", model="openai/gpt-4")
assert "bedrock-policy" not in registry.get_attached_policies(context_other)
def test_multiple_attachments_match_same_context(self):
@@ -129,9 +113,7 @@ class TestGetAttachedPolicies:
]
)
- context = PolicyMatchContext(
- team_alias="healthcare-team", key_alias="key", model="gpt-4"
- )
+ context = PolicyMatchContext(team_alias="healthcare-team", key_alias="key", model="gpt-4")
attached = registry.get_attached_policies(context)
# All three should match
@@ -277,9 +259,7 @@ class TestGetAttachedPolicies:
]
)
- context = PolicyMatchContext(
- team_alias="healthcare-team", key_alias="key", model="gpt-4"
- )
+ context = PolicyMatchContext(team_alias="healthcare-team", key_alias="key", model="gpt-4")
attached = registry.get_attached_policies(context)
# Should only appear once
@@ -288,9 +268,7 @@ class TestGetAttachedPolicies:
def test_many_distinct_policies_resolve_in_linear_time(self):
policy_count = 20_000
registry = AttachmentRegistry()
- registry.load_attachments(
- [{"policy": f"policy-{index}", "scope": "*"} for index in range(policy_count)]
- )
+ registry.load_attachments([{"policy": f"policy-{index}", "scope": "*"} for index in range(policy_count)])
context = PolicyMatchContext(team_alias="team", key_alias="key", model="gpt-4")
started = time.perf_counter()
@@ -318,9 +296,7 @@ class TestGetAttachedPolicies:
]
)
- context = PolicyMatchContext(
- team_alias="finance-team", key_alias="key", model="gpt-4"
- )
+ context = PolicyMatchContext(team_alias="finance-team", key_alias="key", model="gpt-4")
attached = registry.get_attached_policies(context)
assert attached == []
@@ -338,23 +314,15 @@ class TestGetAttachedPolicies:
)
# Match - both team and model match
- context = PolicyMatchContext(
- team_alias="healthcare-team", key_alias="key", model="gpt-4"
- )
+ context = PolicyMatchContext(team_alias="healthcare-team", key_alias="key", model="gpt-4")
assert "strict-policy" in registry.get_attached_policies(context)
# No match - team matches but model doesn't
- context_wrong_model = PolicyMatchContext(
- team_alias="healthcare-team", key_alias="key", model="gpt-3.5"
- )
- assert "strict-policy" not in registry.get_attached_policies(
- context_wrong_model
- )
+ context_wrong_model = PolicyMatchContext(team_alias="healthcare-team", key_alias="key", model="gpt-3.5")
+ assert "strict-policy" not in registry.get_attached_policies(context_wrong_model)
# No match - model matches but team doesn't
- context_wrong_team = PolicyMatchContext(
- team_alias="finance-team", key_alias="key", model="gpt-4"
- )
+ context_wrong_team = PolicyMatchContext(team_alias="finance-team", key_alias="key", model="gpt-4")
assert "strict-policy" not in registry.get_attached_policies(context_wrong_team)
@@ -527,6 +495,79 @@ class TestMatchAttribution:
assert "catch-all" in attached
+class TestDefaultAttachments:
+ """`default: true` attachments apply only when no non-default attachment matches."""
+
+ @staticmethod
+ def _registry() -> AttachmentRegistry:
+ registry = AttachmentRegistry()
+ registry.load_attachments(
+ [
+ {"policy": "guardrail-y", "scope": "*", "default": True},
+ {"policy": "guardrail-x", "tags": ["opt-in"]},
+ ]
+ )
+ return registry
+
+ def test_opted_in_request_gets_only_the_opt_in_policy(self):
+ context = PolicyMatchContext(team_alias="t", key_alias="k", model="gpt-5.2", tags=["opt-in"])
+
+ assert self._registry().get_attached_policies(context) == ["guardrail-x"]
+
+ def test_request_without_opt_in_falls_back_to_default_policy(self):
+ context = PolicyMatchContext(team_alias="t", key_alias="k", model="gpt-5.2")
+
+ assert self._registry().get_attached_policies(context) == ["guardrail-y"]
+
+ def test_default_attachment_still_honors_its_own_scope(self):
+ registry = AttachmentRegistry()
+ registry.load_attachments([{"policy": "team-default", "teams": ["team-a"], "default": True}])
+
+ assert registry.get_attached_policies(PolicyMatchContext(team_alias="team-a", key_alias="k", model="m")) == [
+ "team-default"
+ ]
+ assert registry.get_attached_policies(PolicyMatchContext(team_alias="team-b", key_alias="k", model="m")) == []
+
+ def test_all_matching_defaults_apply_when_nothing_else_matches(self):
+ registry = AttachmentRegistry()
+ registry.load_attachments(
+ [
+ {"policy": "default-a", "scope": "*", "default": True},
+ {"policy": "default-b", "teams": ["team-a"], "default": True},
+ {"policy": "opt-in", "tags": ["opt-in"]},
+ ]
+ )
+ context = PolicyMatchContext(team_alias="team-a", key_alias="k", model="m")
+
+ assert registry.get_attached_policies(context) == ["default-a", "default-b"]
+
+ def test_non_default_attachments_remain_additive(self):
+ registry = AttachmentRegistry()
+ registry.load_attachments(
+ [
+ {"policy": "baseline", "scope": "*"},
+ {"policy": "opt-in", "tags": ["opt-in"]},
+ {"policy": "fallback", "scope": "*", "default": True},
+ ]
+ )
+ context = PolicyMatchContext(team_alias="t", key_alias="k", model="m", tags=["opt-in"])
+
+ assert registry.get_attached_policies(context) == ["baseline", "opt-in"]
+
+ def test_default_match_reason_is_labelled(self):
+ context = PolicyMatchContext(team_alias="t", key_alias="k", model="m")
+
+ results = self._registry().get_attached_policies_with_reasons(context)
+
+ assert results == [{"policy_name": "guardrail-y", "matched_via": "default:scope:*"}]
+
+ def test_default_defaults_to_false_when_omitted(self):
+ registry = AttachmentRegistry()
+ registry.load_attachments([{"policy": "p"}])
+
+ assert registry.get_all_attachments()[0].default is False
+
+
class TestAttachmentRegistrySingleton:
"""Test global singleton behavior."""
@@ -557,6 +598,7 @@ def _make_db_attachment_row(
scope: str | None = None,
teams: list[str] | None = None,
priority: int | None = None,
+ is_default: bool = False,
) -> MagicMock:
row = MagicMock()
row.attachment_id = attachment_id
@@ -567,6 +609,7 @@ def _make_db_attachment_row(
row.models = []
row.tags = []
row.priority = priority
+ row.is_default = is_default
row.created_at = datetime.now(timezone.utc)
row.updated_at = datetime.now(timezone.utc)
row.created_by = None
@@ -576,9 +619,7 @@ def _make_db_attachment_row(
def _prisma_with_attachment_rows(rows: list[MagicMock]) -> MagicMock:
prisma = MagicMock()
- prisma.configure_mock(
- **{"db.litellm_policyattachmenttable.find_many": AsyncMock(return_value=rows)}
- )
+ prisma.configure_mock(**{"db.litellm_policyattachmenttable.find_many": AsyncMock(return_value=rows)})
return prisma
@@ -629,6 +670,15 @@ class TestConfigAttachmentsPreservedAcrossDbSync:
assert registry.get_all_attachments()[0].priority == 7
+ @pytest.mark.asyncio
+ async def test_sync_round_trips_db_attachment_default_flag(self):
+ registry = AttachmentRegistry()
+ db_row = _make_db_attachment_row(is_default=True)
+
+ await registry.sync_attachments_from_db(_prisma_with_attachment_rows([db_row]))
+
+ assert registry.get_all_attachments()[0].default is True
+
@pytest.mark.asyncio
async def test_clear_removes_config_snapshot_so_sync_does_not_resurrect(self):
registry = AttachmentRegistry()
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTable.test.tsx
index 43ad6a7cc9e..be83f73bb2e 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTable.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTable.test.tsx
@@ -65,6 +65,19 @@ describe("AttachmentTable", () => {
);
});
+ it("should show a Default badge only for default attachments", () => {
+ const attachments = [
+ makeAttachment({ attachment_id: "att-def00001", policy_name: "fallback", default: true }),
+ makeAttachment({ attachment_id: "att-def00002", policy_name: "regular" }),
+ ];
+ renderWithProviders( );
+ const rows = screen.getAllByRole("row").slice(1);
+ const fallbackRow = rows.find((row) => within(row).queryByText("fallback"));
+ const regularRow = rows.find((row) => within(row).queryByText("regular"));
+ expect(within(fallbackRow!).getByText("Default")).toBeInTheDocument();
+ expect(within(regularRow!).queryByText("Default")).not.toBeInTheDocument();
+ });
+
it("should show skeleton rows when isLoading is true", () => {
renderWithProviders( );
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTableColumns.tsx
index 9a190401d08..3265b9db834 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTableColumns.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTableColumns.tsx
@@ -181,6 +181,20 @@ export const getAttachmentTableColumns = ({
{row.original.priority}
),
},
+ {
+ id: "default",
+ accessorFn: (row) => (row.default ? 1 : 0),
+ meta: { title: "Default" },
+ header: ({ column }) => ,
+ size: 100,
+ enableSorting: true,
+ cell: ({ row }) =>
+ row.original.default ? (
+
+ ) : (
+ -
+ ),
+ },
{
id: "created_at",
accessorFn: (row) => row.created_at ?? "",
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.test.tsx
index dfc023d428e..14af4a2b8f3 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.test.tsx
@@ -237,6 +237,21 @@ describe("AddAttachmentForm", () => {
expect(createAttachment).toHaveBeenCalledWith("test-token", { policy_name: "policy-alpha", scope: "*" });
});
+ it("sends default: true when the Default switch is turned on", async () => {
+ const user = userEvent.setup();
+ const createAttachment = vi.fn().mockResolvedValue({});
+ renderWithProviders( );
+ await selectPolicy(user, "policy-alpha");
+ await user.click(screen.getByRole("switch", { name: /default/i }));
+ await submit(user);
+ await waitFor(() => expect(createAttachment).toHaveBeenCalledTimes(1));
+ expect(createAttachment).toHaveBeenCalledWith("test-token", {
+ policy_name: "policy-alpha",
+ scope: "*",
+ default: true,
+ });
+ });
+
it.each([
["2147483648", /at most 2147483647/i],
["-2147483649", /at least -2147483648/i],
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx
index 02463a89139..74c4978392f 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
+import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useZodForm } from "@/lib/forms/useZodForm";
@@ -38,6 +39,7 @@ interface AttachmentFormValues {
models: string[];
tags: string[];
priority: number | null;
+ default: boolean;
}
const EMPTY_VALUES: AttachmentFormValues = {
@@ -47,6 +49,7 @@ const EMPTY_VALUES: AttachmentFormValues = {
models: [],
tags: [],
priority: null,
+ default: false,
};
const INT32_MIN = -2147483648;
@@ -64,6 +67,7 @@ const attachmentShape = {
.min(INT32_MIN, `Priority must be at least ${INT32_MIN}`)
.max(INT32_MAX, `Priority must be at most ${INT32_MAX}`)
.nullable(),
+ default: z.boolean(),
};
const buildAttachmentSchema = (scopeType: ScopeType, teamsLoaded: boolean, availableTeams: string[]) =>
@@ -453,6 +457,20 @@ const AddAttachmentForm: React.FC = ({
/>
)}