diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 980104845b55..47d15f416a0c 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -4,11 +4,86 @@ "count": 1 } }, + "src/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "src/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "src/app/(dashboard)/hooks/blogPosts/useBlogPosts.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "src/app/(dashboard)/hooks/cloudzero/useCloudZeroCreate.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "src/app/(dashboard)/hooks/cloudzero/useCloudZeroDryRun.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "src/app/(dashboard)/hooks/cloudzero/useCloudZeroExport.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "src/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings.ts": { + "no-restricted-syntax": { + "count": 3 + } + }, + "src/app/(dashboard)/hooks/configOverrides/hashicorpVaultApi.ts": { + "no-restricted-syntax": { + "count": 4 + } + }, + "src/app/(dashboard)/hooks/guardrails/useRegisterGuardrail.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "src/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "src/app/(dashboard)/hooks/keys/useKeyAliases.test.ts": { "react/display-name": { "count": 1 } }, + "src/app/(dashboard)/hooks/keys/useKeys.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "src/app/(dashboard)/hooks/keys/useResetKeySpend.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "src/app/(dashboard)/hooks/models/useModels.ts": { "max-params": { "count": 1 @@ -19,26 +94,76 @@ "count": 1 } }, + "src/app/(dashboard)/hooks/projects/useCreateProject.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "src/app/(dashboard)/hooks/projects/useDeleteProject.test.ts": { "react/display-name": { "count": 1 } }, + "src/app/(dashboard)/hooks/projects/useDeleteProject.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "src/app/(dashboard)/hooks/projects/useProjectDetails.test.ts": { "react/display-name": { "count": 1 } }, + "src/app/(dashboard)/hooks/projects/useProjectDetails.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "src/app/(dashboard)/hooks/projects/useProjects.test.ts": { "react/display-name": { "count": 1 } }, + "src/app/(dashboard)/hooks/projects/useProjects.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts": { "react/display-name": { "count": 1 } }, + "src/app/(dashboard)/hooks/projects/useUpdateProject.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.ts": { + "no-restricted-syntax": { + "count": 2 + } + }, + "src/app/(dashboard)/hooks/router/useRouterFields.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "src/app/(dashboard)/hooks/storeModelInDB/useStoreModelInDB.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "src/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, + "src/app/(dashboard)/hooks/teams/useTeams.ts": { + "no-restricted-syntax": { + "count": 2 + } + }, "src/app/(dashboard)/layout.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -231,6 +356,11 @@ "count": 1 } }, + "src/components/CostTrackingSettings/pricing_calculator/use_multi_cost_estimate.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "src/components/CostTrackingSettings/provider_discount_table.test.tsx": { "unused-imports/no-unused-imports": { "count": 1 @@ -251,6 +381,16 @@ "count": 1 } }, + "src/components/CostTrackingSettings/use_discount_config.ts": { + "no-restricted-syntax": { + "count": 2 + } + }, + "src/components/CostTrackingSettings/use_margin_config.ts": { + "no-restricted-syntax": { + "count": 2 + } + }, "src/components/CreateUserButton.tsx": { "no-restricted-imports": { "count": 1 @@ -562,6 +702,9 @@ } }, "src/components/WebRTCTester.jsx": { + "no-restricted-syntax": { + "count": 2 + }, "react/no-unescaped-entities": { "count": 2 } @@ -830,6 +973,9 @@ "no-restricted-imports": { "count": 1 }, + "no-restricted-syntax": { + "count": 3 + }, "react-hooks/immutability": { "count": 1 } @@ -1032,6 +1178,9 @@ "no-restricted-imports": { "count": 1 }, + "no-restricted-syntax": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1104,6 +1253,11 @@ "count": 1 } }, + "src/components/mcp_tools/ByokCredentialModal.tsx": { + "no-restricted-syntax": { + "count": 1 + } + }, "src/components/mcp_tools/MCPLogoSelector.test.tsx": { "unused-imports/no-unused-imports": { "count": 1 @@ -1348,6 +1502,9 @@ "src/components/networking.tsx": { "max-params": { "count": 23 + }, + "no-restricted-syntax": { + "count": 241 } }, "src/components/object_permissions_view.tsx": { @@ -1464,6 +1621,11 @@ "count": 13 } }, + "src/components/playground/chat_ui/CodeInterpreterOutput.tsx": { + "no-restricted-syntax": { + "count": 2 + } + }, "src/components/playground/chat_ui/CodeInterpreterTool.tsx": { "no-restricted-imports": { "count": 1 @@ -1495,6 +1657,9 @@ "src/components/playground/llm_calls/a2a_send_message.tsx": { "max-params": { "count": 2 + }, + "no-restricted-syntax": { + "count": 2 } }, "src/components/playground/llm_calls/anthropic_messages.tsx": { @@ -1520,6 +1685,14 @@ "src/components/playground/llm_calls/embeddings_api.tsx": { "max-params": { "count": 1 + }, + "no-restricted-syntax": { + "count": 1 + } + }, + "src/components/playground/llm_calls/fetch_agents.tsx": { + "no-restricted-syntax": { + "count": 1 } }, "src/components/playground/llm_calls/image_edits.tsx": { @@ -1535,6 +1708,9 @@ "src/components/playground/llm_calls/interactions_api.tsx": { "max-params": { "count": 1 + }, + "no-restricted-syntax": { + "count": 1 } }, "src/components/playground/llm_calls/responses_api.tsx": { @@ -1728,6 +1904,11 @@ "count": 1 } }, + "src/components/prompts/prompt_editor_view/conversation_panel/useConversation.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "src/components/prompts/prompt_info.tsx": { "no-restricted-imports": { "count": 1 @@ -1789,6 +1970,11 @@ "count": 1 } }, + "src/components/survey/SurveyModal.tsx": { + "no-restricted-syntax": { + "count": 1 + } + }, "src/components/tag_management/TagTable.tsx": { "no-restricted-imports": { "count": 1 @@ -1851,6 +2037,11 @@ "count": 1 } }, + "src/components/team/useMyTeamMember.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "src/components/templates/key_edit_view.tsx": { "no-restricted-imports": { "count": 1 @@ -1878,6 +2069,9 @@ "no-restricted-imports": { "count": 1 }, + "no-restricted-syntax": { + "count": 3 + }, "react-hooks/immutability": { "count": 1 } @@ -2029,6 +2223,9 @@ } }, "src/components/workflow_runs/index.tsx": { + "no-restricted-syntax": { + "count": 3 + }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -2038,6 +2235,11 @@ "count": 1 } }, + "src/contexts/ThemeContext.tsx": { + "no-restricted-syntax": { + "count": 1 + } + }, "src/data/claimsCompliancePrompts.ts": { "max-params": { "count": 1 diff --git a/ui/litellm-dashboard/eslint.config.mjs b/ui/litellm-dashboard/eslint.config.mjs index ca635aef1fc1..8235b435950a 100644 --- a/ui/litellm-dashboard/eslint.config.mjs +++ b/ui/litellm-dashboard/eslint.config.mjs @@ -32,6 +32,14 @@ const eslintConfig = [ "max-depth": ["warn", 4], "max-params": ["error", 4], "max-nested-callbacks": ["error", 4], + "no-restricted-syntax": [ + "error", + { + selector: "CallExpression[callee.name='fetch']", + message: + "Raw fetch() is only allowed in src/lib/http/. Use the shared client (createApiClient / apiClient) from @/lib/http/client instead.", + }, + ], "no-restricted-imports": [ "error", { @@ -45,6 +53,12 @@ const eslintConfig = [ ], }, }, + { + files: ["src/lib/http/**"], + rules: { + "no-restricted-syntax": "off", + }, + }, ]; export default eslintConfig; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 07385e6b89f5..601840cc2578 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -76,6 +76,10 @@ import { UserInfo } from "./view_users/types"; import { EmailEventSettingsResponse, EmailEventSettingsUpdateRequest } from "./email_events/types"; import { jsonFields } from "./common_components/check_openapi_schema"; import NotificationsManager from "./molecules/notifications_manager"; +import { createApiClient, deriveErrorMessage } from "@/lib/http/client"; + +export { deriveErrorMessage }; +export { ApiError } from "@/lib/http/client"; const isLocal = process.env.NODE_ENV === "development"; // In dev, if NEXT_PUBLIC_USE_REWRITES=true the Next.js dev server proxies API calls @@ -417,6 +421,12 @@ export function getGlobalLitellmHeaderName(): string { return globalLitellmHeaderName; } +const apiClient = createApiClient({ + getBaseUrl: getProxyBaseUrl, + getAuthHeaderName: getGlobalLitellmHeaderName, + onError: handleError, +}); + export const makeModelGroupPublic = async (accessToken: string, modelGroups: string[]) => { const url = proxyBaseUrl ? `${proxyBaseUrl}/model_group/make_public` : `/model_group/make_public`; const response = await fetch(url, { @@ -595,26 +605,12 @@ export const getModelCostMapReloadStatus = async (accessToken: string) => { }; export const modelCreateCall = async (accessToken: string, formValues: Model) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/model/new` : `/model/new`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const data = await apiClient.post(`/model/new`, { + accessToken, + body: { ...formValues, - }), + }, }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); console.log("API Response:", data); // Close any existing messages before showing new ones @@ -633,26 +629,12 @@ export const modelCreateCall = async (accessToken: string, formValues: Model) => export const modelDeleteCall = async (accessToken: string, model_id: string) => { console.log(`model_id in model delete call: ${model_id}`); try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/model/delete` : `/model/delete`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const data = await apiClient.post(`/model/delete`, { + accessToken, + body: { id: model_id, - }), + }, }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); console.log("API Response:", data); return data; } catch (error) { @@ -669,26 +651,12 @@ export const budgetDeleteCall = async (accessToken: string | null, budget_id: st } try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/budget/delete` : `/budget/delete`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const data = await apiClient.post(`/budget/delete`, { + accessToken, + body: { id: budget_id, - }), + }, }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); console.log("API Response:", data); return data; } catch (error) { @@ -705,26 +673,12 @@ export const budgetCreateCall = async ( console.log("Form Values in budgetCreateCall:", formValues); // Log the form values before making the API call console.log("Form Values after check:", formValues); - const url = proxyBaseUrl ? `${proxyBaseUrl}/budget/new` : `/budget/new`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const data = await apiClient.post(`/budget/new`, { + accessToken, + body: { ...formValues, // Include formValues in the request body - }), + }, }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); console.log("API Response:", data); return data; // Handle success - you might want to update some state or UI based on the created key @@ -742,26 +696,12 @@ export const budgetUpdateCall = async ( console.log("Form Values in budgetUpdateCall:", formValues); // Log the form values before making the API call console.log("Form Values after check:", formValues); - const url = proxyBaseUrl ? `${proxyBaseUrl}/budget/update` : `/budget/update`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const data = await apiClient.post(`/budget/update`, { + accessToken, + body: { ...formValues, // Include formValues in the request body - }), + }, }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); console.log("API Response:", data); return data; // Handle success - you might want to update some state or UI based on the created key @@ -776,26 +716,12 @@ export const invitationCreateCall = async ( userID: string, // Assuming formValues is an object ) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/invitation/new` : `/invitation/new`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const data = await apiClient.post(`/invitation/new`, { + accessToken, + body: { user_id: userID, // Include formValues in the request body - }), + }, }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); console.log("API Response:", data); return data; // Handle success - you might want to update some state or UI based on the created key @@ -1671,26 +1597,12 @@ export const organizationCreateCall = async ( } } - const url = proxyBaseUrl ? `${proxyBaseUrl}/organization/new` : `/organization/new`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const data = await apiClient.post(`/organization/new`, { + accessToken, + body: { ...formValues, // Include formValues in the request body - }), + }, }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); console.log("API Response:", data); return data; // Handle success - you might want to update some state or UI based on the created key @@ -1707,26 +1619,12 @@ export const organizationUpdateCall = async ( try { console.log("Form Values in organizationUpdateCall:", formValues); // Log the form values before making the API call - const url = proxyBaseUrl ? `${proxyBaseUrl}/organization/update` : `/organization/update`; - const response = await fetch(url, { - method: "PATCH", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const data = await apiClient.patch(`/organization/update`, { + accessToken, + body: { ...formValues, // Include formValues in the request body - }), + }, }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); console.log("Update Team Response:", data); return data; // Handle success - you might want to update some state or UI based on the created key @@ -2423,23 +2321,10 @@ export const updateUsefulLinksCall = async ( useful_links: Record, ) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/model_hub/update_useful_links` : `/model_hub/update_useful_links`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ useful_links: useful_links }), + return await apiClient.post(`/model_hub/update_useful_links`, { + accessToken, + body: { useful_links: useful_links }, }); - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - return await response.json(); } catch (error) { console.error("Failed to create key:", error); throw error; @@ -3422,26 +3307,12 @@ export const teamCreateCall = async ( } } - const url = proxyBaseUrl ? `${proxyBaseUrl}/team/new` : `/team/new`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const data = await apiClient.post(`/team/new`, { + accessToken, + body: { ...formValues, // Include formValues in the request body - }), + }, }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); console.log("API Response:", data); return data; // Handle success - you might want to update some state or UI based on the created key @@ -3467,26 +3338,12 @@ export const credentialCreateCall = async ( } } - const url = proxyBaseUrl ? `${proxyBaseUrl}/credentials` : `/credentials`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const data = await apiClient.post(`/credentials`, { + accessToken, + body: { ...formValues, // Include formValues in the request body - }), + }, }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); console.log("API Response:", data); return data; // Handle success - you might want to update some state or UI based on the created key @@ -3612,26 +3469,12 @@ export const credentialUpdateCall = async ( } } - const url = proxyBaseUrl ? `${proxyBaseUrl}/credentials/${credentialName}` : `/credentials/${credentialName}`; - const response = await fetch(url, { - method: "PATCH", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const data = await apiClient.patch(`/credentials/${credentialName}`, { + accessToken, + body: { ...formValues, // Include formValues in the request body - }), + }, }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); console.log("API Response:", data); return data; // Handle success - you might want to update some state or UI based on the created key @@ -3970,14 +3813,9 @@ export const teamMemberDeleteCall = async ( try { console.log("Form Values in teamMemberAddCall:", formValues); // Log the form values before making the API call - const url = proxyBaseUrl ? `${proxyBaseUrl}/team/member_delete` : `/team/member_delete`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const data = await apiClient.post(`/team/member_delete`, { + accessToken, + body: { team_id: teamId, ...(formValues.user_email !== undefined && { user_email: formValues.user_email, @@ -3985,17 +3823,8 @@ export const teamMemberDeleteCall = async ( ...(formValues.user_id !== undefined && { user_id: formValues.user_id, }), - }), + }, }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); console.log("API Response:", data); return data; // Handle success - you might want to update some state or UI based on the created key @@ -4877,23 +4706,7 @@ export const getMCPSemanticFilterSettings = async (accessToken: string) => { * Get MCP semantic filter configuration */ try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/get/mcp_semantic_filter_settings` : `/get/mcp_semantic_filter_settings`; - const response = await fetch(url, { - method: "GET", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); + const data = await apiClient.get(`/get/mcp_semantic_filter_settings`, { accessToken }); return data; } catch (error) { console.error("Failed to get MCP semantic filter settings:", error); @@ -5245,23 +5058,7 @@ export const getGuardrailsUsageLogs = async ( export const getPoliciesList = async (accessToken: string) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/policies/list` : `/policies/list`; - const response = await fetch(url, { - method: "GET", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); + const data = await apiClient.get(`/policies/list`, { accessToken }); return data; } catch (error) { console.error("Failed to get policies list:", error); @@ -5358,23 +5155,7 @@ export const testPoliciesAndGuardrails = async ( export const getPolicyInfoWithGuardrails = async (accessToken: string, policyName: string) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/policy/info/${policyName}` : `/policy/info/${policyName}`; - const response = await fetch(url, { - method: "GET", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); + const data = await apiClient.get(`/policy/info/${policyName}`, { accessToken }); return data; } catch (error) { console.error(`Failed to get policy info for ${policyName}:`, error); @@ -5384,23 +5165,7 @@ export const getPolicyInfoWithGuardrails = async (accessToken: string, policyNam export const getPolicyTemplates = async (accessToken: string) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/policy/templates` : `/policy/templates`; - const response = await fetch(url, { - method: "GET", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); + const data = await apiClient.get(`/policy/templates`, { accessToken }); return data; } catch (error) { console.error("Failed to get policy templates:", error); @@ -5451,28 +5216,14 @@ export const suggestPolicyTemplates = async ( model: string, ) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/policy/templates/suggest` : `/policy/templates/suggest`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + return await apiClient.post(`/policy/templates/suggest`, { + accessToken, + body: { attack_examples: attackExamples.filter((e) => e.trim()), description, model, - }), + }, }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - return response.json(); } catch (error) { console.error("Failed to suggest policy templates:", error); throw error; @@ -5481,27 +5232,13 @@ export const suggestPolicyTemplates = async ( export const testPolicyTemplate = async (accessToken: string, guardrailDefinitions: any[], text: string) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/policy/templates/test` : `/policy/templates/test`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ + return await apiClient.post(`/policy/templates/test`, { + accessToken, + body: { guardrail_definitions: guardrailDefinitions, text, - }), + }, }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - return response.json(); } catch (error) { console.error("Failed to test policy template:", error); throw error; @@ -5656,24 +5393,7 @@ export const usageAiChatStream = async ( export const createPolicyCall = async (accessToken: string, policyData: any) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/policies` : `/policies`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(policyData), - }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); + const data = await apiClient.post(`/policies`, { accessToken, body: policyData }); return data; } catch (error) { console.error("Failed to create policy:", error); @@ -5683,24 +5403,7 @@ export const createPolicyCall = async (accessToken: string, policyData: any) => export const updatePolicyCall = async (accessToken: string, policyId: string, policyData: any) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/policies/${policyId}` : `/policies/${policyId}`; - const response = await fetch(url, { - method: "PUT", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(policyData), - }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); + const data = await apiClient.put(`/policies/${policyId}`, { accessToken, body: policyData }); return data; } catch (error) { console.error("Failed to update policy:", error); @@ -5778,24 +5481,10 @@ export const updatePolicyVersionStatus = async ( versionStatus: "published" | "production", ): Promise => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/policies/${policyId}/status` : `/policies/${policyId}/status`; - const response = await fetch(url, { - method: "PUT", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ version_status: versionStatus }), + return await apiClient.put(`/policies/${policyId}/status`, { + accessToken, + body: { version_status: versionStatus }, }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - return await response.json(); } catch (error) { console.error("Failed to update policy version status:", error); throw error; @@ -5804,23 +5493,7 @@ export const updatePolicyVersionStatus = async ( export const deletePolicyCall = async (accessToken: string, policyId: string) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/policies/${policyId}` : `/policies/${policyId}`; - const response = await fetch(url, { - method: "DELETE", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); + const data = await apiClient.delete(`/policies/${policyId}`, { accessToken }); return data; } catch (error) { console.error("Failed to delete policy:", error); @@ -5830,23 +5503,7 @@ export const deletePolicyCall = async (accessToken: string, policyId: string) => export const getPolicyInfo = async (accessToken: string, policyId: string) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/policies/${policyId}` : `/policies/${policyId}`; - const response = await fetch(url, { - method: "GET", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); + const data = await apiClient.get(`/policies/${policyId}`, { accessToken }); return data; } catch (error) { console.error("Failed to get policy info:", error); @@ -5858,23 +5515,7 @@ export const getPolicyInfo = async (accessToken: string, policyId: string) => { export const getPolicyAttachmentsList = async (accessToken: string) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/policies/attachments/list` : `/policies/attachments/list`; - const response = await fetch(url, { - method: "GET", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); + const data = await apiClient.get(`/policies/attachments/list`, { accessToken }); return data; } catch (error) { console.error("Failed to get policy attachments list:", error); @@ -5884,24 +5525,7 @@ export const getPolicyAttachmentsList = async (accessToken: string) => { export const createPolicyAttachmentCall = async (accessToken: string, attachmentData: any) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/policies/attachments` : `/policies/attachments`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(attachmentData), - }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); + const data = await apiClient.post(`/policies/attachments`, { accessToken, body: attachmentData }); return data; } catch (error) { console.error("Failed to create policy attachment:", error); @@ -5943,24 +5567,10 @@ export const testPipelineCall = async ( testMessages: Array<{ role: string; content: string }>, ) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/policies/test-pipeline` : `/policies/test-pipeline`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ pipeline, test_messages: testMessages }), + const data = await apiClient.post(`/policies/test-pipeline`, { + accessToken, + body: { pipeline, test_messages: testMessages }, }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); return data; } catch (error) { console.error("Failed to test pipeline:", error); @@ -6001,24 +5611,7 @@ export const resolvePoliciesCall = async ( context: { team_alias?: string; key_alias?: string; model?: string; tags?: string[] }, ) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/policies/resolve` : `/policies/resolve`; - const response = await fetch(url, { - method: "POST", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(context), - }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - return await response.json(); + return await apiClient.post(`/policies/resolve`, { accessToken, body: context }); } catch (error) { console.error("Failed to resolve policies:", error); throw error; @@ -6711,24 +6304,7 @@ export const createMCPServer = async ( export const updateMCPServer = async (accessToken: string, formValues: Record) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/server` : `/v1/mcp/server`; - const response = await fetch(url, { - method: "PUT", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(formValues), - }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - return await response.json(); + return await apiClient.put(`/v1/mcp/server`, { accessToken, body: formValues }); } catch (error) { console.error("Failed to update MCP server:", error); throw error; @@ -9303,22 +8879,6 @@ export const perUserAnalyticsCall = async ( } }; -export const deriveErrorMessage = (errorData: any): string => { - const detail = errorData?.detail; - const detailStr = Array.isArray(detail) - ? detail.map((d: any) => d?.msg || JSON.stringify(d)).join("; ") - : typeof detail === "string" - ? detail - : undefined; - return ( - (errorData?.error && - (errorData.error.message || (typeof errorData.error === "string" ? errorData.error : undefined))) || - errorData?.message || - detailStr || - JSON.stringify(errorData) - ); -}; - export interface LoginRequest { username: string; password: string; diff --git a/ui/litellm-dashboard/src/lib/http/client.test.ts b/ui/litellm-dashboard/src/lib/http/client.test.ts new file mode 100644 index 000000000000..772a7b63db18 --- /dev/null +++ b/ui/litellm-dashboard/src/lib/http/client.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from "vitest"; +import { createApiClient, ApiError } from "./client"; + +const okResponse = (data: unknown): Response => + ({ ok: true, status: 200, text: async () => JSON.stringify(data) }) as unknown as Response; + +const emptyResponse = (status: number): Response => ({ ok: true, status, text: async () => "" }) as unknown as Response; + +const errorResponse = (status: number, body: unknown): Response => + ({ ok: false, status, text: async () => JSON.stringify(body) }) as unknown as Response; + +const rawErrorResponse = (status: number, text: string): Response => + ({ ok: false, status, text: async () => text }) as unknown as Response; + +describe("createApiClient", () => { + it("builds the URL from base + path + query and sets the auth + JSON headers", async () => { + const fetchImpl = vi.fn(async () => okResponse({ ok: true })); + const client = createApiClient({ + getBaseUrl: () => "https://proxy.example", + getAuthHeaderName: () => "x-litellm-key", + fetchImpl, + }); + + const result = await client.get("/models", { accessToken: "sk-123", query: { team: "t1", page: 2 } }); + + expect(result).toEqual({ ok: true }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe("https://proxy.example/models?team=t1&page=2"); + expect(init).toMatchObject({ method: "GET" }); + expect(init.headers).toEqual({ + "Content-Type": "application/json", + "x-litellm-key": "Bearer sk-123", + }); + expect(init.body).toBeUndefined(); + }); + + it("JSON-serializes the body for writes", async () => { + const fetchImpl = vi.fn(async () => okResponse({})); + const client = createApiClient({ getBaseUrl: () => "", fetchImpl }); + + await client.post("/model/new", { accessToken: "sk", body: { model_name: "gpt" } }); + + const [, init] = fetchImpl.mock.calls[0]; + expect(init.method).toBe("POST"); + expect(init.body).toBe(JSON.stringify({ model_name: "gpt" })); + }); + + it("throws ApiError with the derived message and invokes onError on a non-2xx response", async () => { + const fetchImpl = vi.fn(async () => errorResponse(403, { error: { message: "no access" } })); + const onError = vi.fn(); + const client = createApiClient({ getBaseUrl: () => "", onError, fetchImpl }); + + const promise = client.get("/keys", { accessToken: "sk" }); + + await expect(promise).rejects.toBeInstanceOf(ApiError); + await expect(promise).rejects.toMatchObject({ message: "no access", status: 403 }); + expect(onError).toHaveBeenCalledWith("no access"); + }); + + it("falls back to the raw text body when a non-2xx response is not JSON (e.g. an HTML 502)", async () => { + const fetchImpl = vi.fn(async () => rawErrorResponse(502, "Bad Gateway")); + const onError = vi.fn(); + const client = createApiClient({ getBaseUrl: () => "", onError, fetchImpl }); + + const promise = client.get("/keys", { accessToken: "sk" }); + + await expect(promise).rejects.toMatchObject({ message: "Bad Gateway", status: 502 }); + expect(onError).toHaveBeenCalledWith("Bad Gateway"); + }); + + it("returns undefined for an empty success body (e.g. a 204 No Content)", async () => { + const fetchImpl = vi.fn(async () => emptyResponse(204)); + const client = createApiClient({ getBaseUrl: () => "", fetchImpl }); + + await expect(client.delete("/policies/abc", { accessToken: "sk" })).resolves.toBeUndefined(); + }); + + it("omits the auth header when no token is provided", async () => { + const fetchImpl = vi.fn(async () => okResponse({})); + const client = createApiClient({ getBaseUrl: () => "", getAuthHeaderName: () => "Authorization", fetchImpl }); + + await client.get("/public/info"); + + const [, init] = fetchImpl.mock.calls[0]; + expect(init.headers).toEqual({ "Content-Type": "application/json" }); + }); +}); diff --git a/ui/litellm-dashboard/src/lib/http/client.ts b/ui/litellm-dashboard/src/lib/http/client.ts new file mode 100644 index 000000000000..60d66dde8c23 --- /dev/null +++ b/ui/litellm-dashboard/src/lib/http/client.ts @@ -0,0 +1,155 @@ +/** + * The single HTTP client for the dashboard. This is the only file allowed to + * call fetch() directly (enforced by the no-restricted-syntax lint rule and its + * src/lib/http/** override in eslint.config.mjs). + * + * It is framework-agnostic on purpose (no React, no module-level singletons from + * the component tree) so the same client can run in client components today and + * in server components later. Everything environment-specific (base URL, auth + * header name, the logout side effect) is injected through createApiClient. + */ + +export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + +export type QueryValue = string | number | boolean | null | undefined; + +export type QueryParams = Record; + +export interface RequestOptions { + /** Bearer token. When present, the auth header is set automatically. */ + accessToken?: string | null; + /** Serialized to JSON unless `rawBody` is provided. */ + body?: unknown; + /** Sent verbatim (FormData, Blob, pre-stringified text); disables JSON handling. */ + rawBody?: BodyInit; + query?: QueryParams; + headers?: Record; + signal?: AbortSignal; +} + +export class ApiError extends Error { + readonly status: number; + readonly body: unknown; + + constructor(message: string, status: number, body: unknown) { + super(message); + this.name = "ApiError"; + this.status = status; + this.body = body; + } +} + +/** + * Best-effort extraction of a human-readable message from a proxy error body. + * Lives here because error parsing is the client's job; networking.tsx re-exports + * it so existing `@/components/networking` import paths keep working. + */ +export const deriveErrorMessage = (errorData: any): string => { + const detail = errorData?.detail; + const detailStr = Array.isArray(detail) + ? detail.map((d: any) => d?.msg || JSON.stringify(d)).join("; ") + : typeof detail === "string" + ? detail + : undefined; + return ( + (errorData?.error && + (errorData.error.message || (typeof errorData.error === "string" ? errorData.error : undefined))) || + errorData?.message || + detailStr || + JSON.stringify(errorData) + ); +}; + +export interface ApiClientConfig { + /** Resolves the API origin at call time (it can change at runtime). */ + getBaseUrl: () => string; + /** Resolves the auth header name at call time. Defaults to "Authorization". */ + getAuthHeaderName?: () => string; + /** Invoked with the derived message right before a non-2xx response throws. Fire-and-forget. */ + onError?: (message: string) => void | Promise; + /** Injectable fetch implementation; defaults to the global. */ + fetchImpl?: typeof fetch; +} + +export interface ApiClient { + request(method: HttpMethod, path: string, options?: RequestOptions): Promise; + get(path: string, options?: RequestOptions): Promise; + post(path: string, options?: RequestOptions): Promise; + put(path: string, options?: RequestOptions): Promise; + delete(path: string, options?: RequestOptions): Promise; + patch(path: string, options?: RequestOptions): Promise; +} + +const appendQuery = (url: string, query: QueryParams | undefined): string => { + if (!query) return url; + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) { + value.forEach((v) => v !== undefined && v !== null && search.append(key, String(v))); + } else { + search.append(key, String(value)); + } + } + const qs = search.toString(); + if (!qs) return url; + return url.includes("?") ? `${url}&${qs}` : `${url}?${qs}`; +}; + +export function createApiClient(config: ApiClientConfig): ApiClient { + const { getBaseUrl, getAuthHeaderName, onError, fetchImpl } = config; + const doFetch = fetchImpl ?? fetch; + + async function request(method: HttpMethod, path: string, options: RequestOptions = {}): Promise { + const { accessToken, body, rawBody, query, headers: extraHeaders, signal } = options; + + const url = appendQuery(`${getBaseUrl()}${path}`, query); + + const headers: Record = {}; + if (rawBody === undefined) { + headers["Content-Type"] = "application/json"; + } + if (accessToken) { + const headerName = getAuthHeaderName ? getAuthHeaderName() : "Authorization"; + headers[headerName] = `Bearer ${accessToken}`; + } + if (extraHeaders) { + Object.assign(headers, extraHeaders); + } + + const init: RequestInit = { method, headers, signal }; + if (rawBody !== undefined) { + init.body = rawBody; + } else if (body !== undefined) { + init.body = JSON.stringify(body); + } + + const response = await doFetch(url, init); + + if (!response.ok) { + const raw = await response.text(); + let errorBody: unknown = raw; + let message: string; + try { + errorBody = JSON.parse(raw); + message = deriveErrorMessage(errorBody); + } catch { + message = raw || `HTTP ${response.status}`; + } + onError?.(message); + throw new ApiError(message, response.status, errorBody); + } + + const text = await response.text(); + return (text ? JSON.parse(text) : undefined) as T; + } + + return { + request, + get: (path, options) => request("GET", path, options), + post: (path, options) => request("POST", path, options), + put: (path, options) => request("PUT", path, options), + delete: (path, options) => request("DELETE", path, options), + patch: (path, options) => request("PATCH", path, options), + }; +} diff --git a/ui/litellm-dashboard/tests/fetch-location-rule.test.ts b/ui/litellm-dashboard/tests/fetch-location-rule.test.ts new file mode 100644 index 000000000000..bd495ad3c402 --- /dev/null +++ b/ui/litellm-dashboard/tests/fetch-location-rule.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { ESLint } from "eslint"; + +const FETCH_CODE = `export const load = async () => {\n const res = await fetch("/api/thing");\n return res.json();\n};\n`; + +const RULE_ID = "no-restricted-syntax"; + +let eslint: ESLint; + +const fetchMessages = async (filePath: string) => { + const [result] = await eslint.lintText(FETCH_CODE, { filePath }); + return result.messages.filter((m) => m.ruleId === RULE_ID); +}; + +describe("location-based fetch() rule", () => { + beforeAll(() => { + eslint = new ESLint(); + }); + + it("flags a raw fetch() in a normal source file", async () => { + const messages = await fetchMessages("src/components/some_feature.tsx"); + expect(messages).toHaveLength(1); + expect(messages[0].message).toMatch(/@\/lib\/http\/client/); + }); + + it("allows fetch() inside src/lib/http/ (the one place it lives)", async () => { + const messages = await fetchMessages("src/lib/http/client.ts"); + expect(messages).toHaveLength(0); + }); +});