From eeb192d4a70d22e60796880bbc4628acefcf20a2 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Mon, 5 Oct 2026 10:02:19 -0700 Subject: [PATCH] chore!: retire the integrated ROI calculator (#44477) * chore!: retire the integrated ROI calculator * chore(ui): remove unused ROI demo notice --- litellm/proxy/_lazy_features.py | 5 - litellm/proxy/_lazy_openapi_snapshot.json | 2970 ----------------- litellm/proxy/proxy_server.py | 5 - litellm/proxy/roi_calculator/README.md | 55 +- tests/unit/proxy/test__lazy_features.py | 27 + .../src/app/(dashboard)/legacyPageRoutes.ts | 1 - .../_components/MatchedPeopleToggle.tsx | 12 - .../ObservedAccounts.integration.test.tsx | 88 - .../_components/ObservedAccounts.tsx | 209 -- .../ObservedConnections.integration.test.tsx | 198 -- .../_components/ObservedConnections.tsx | 606 ---- .../_components/ObservedDetails.tsx | 239 -- .../ObservedROIView.integration.test.tsx | 384 --- .../_components/ObservedROIView.tsx | 252 -- .../_components/ObservedReport.tsx | 631 ---- .../_components/ROICalculatorDialogs.tsx | 217 -- .../ROICalculatorView.integration.test.tsx | 814 ----- .../_components/ROICalculatorView.tsx | 508 --- .../_components/ROICalculatorViews.tsx | 569 ---- .../_components/ROISettingsPanel.tsx | 566 ---- .../_components/demoUrlState.ts | 6 - .../_components/observedData.test.ts | 234 -- .../_components/observedData.ts | 298 -- .../_components/observedDemo.test.ts | 42 - .../_components/observedDemo.ts | 120 - .../_components/roiCalculatorData.test.ts | 203 -- .../_components/roiCalculatorData.ts | 151 - .../_components/useObservedReport.ts | 60 - .../app/(dashboard)/roi-calculator/page.tsx | 10 - .../src/components/leftnav.tsx | 12 - .../src/components/page_metadata.ts | 1 - .../src/components/shared/DemoNotice.tsx | 16 - ui/litellm-dashboard/src/lib/http/schema.d.ts | 1550 --------- 33 files changed, 30 insertions(+), 11029 deletions(-) delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/MatchedPeopleToggle.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedAccounts.integration.test.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedAccounts.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedConnections.integration.test.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedConnections.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedDetails.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedROIView.integration.test.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedROIView.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedReport.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorDialogs.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.integration.test.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorViews.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROISettingsPanel.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/demoUrlState.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/observedData.test.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/observedData.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/observedDemo.test.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/observedDemo.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/roiCalculatorData.test.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/roiCalculatorData.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/useObservedReport.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/page.tsx delete mode 100644 ui/litellm-dashboard/src/components/shared/DemoNotice.tsx diff --git a/litellm/proxy/_lazy_features.py b/litellm/proxy/_lazy_features.py index cb6d47ca4c8..4a7ca257d9e 100644 --- a/litellm/proxy/_lazy_features.py +++ b/litellm/proxy/_lazy_features.py @@ -139,11 +139,6 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = ( module_path="litellm.proxy.management_endpoints.model_insights_endpoints", path_prefixes=("/model-insights",), ), - LazyFeature( - name="roi_calculator", - module_path="litellm.proxy.management_endpoints.roi_calculator_endpoints", - path_prefixes=("/roi-calculator",), - ), LazyFeature( name="search_tools", module_path="litellm.proxy.search_endpoints.search_tool_management", diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index a927d4b415f..8530a4d31b4 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -49003,2976 +49003,6 @@ } } }, - "roi_calculator": { - "components": { - "schemas": { - "HTTPValidationError": { - "properties": { - "detail": { - "items": { - "$ref": "#/components/schemas/ValidationError" - }, - "title": "Detail", - "type": "array" - } - }, - "title": "HTTPValidationError", - "type": "object" - }, - "ObservedAccount": { - "properties": { - "connection_id": { - "title": "Connection Id", - "type": "string" - }, - "login": { - "title": "Login", - "type": "string" - } - }, - "required": [ - "connection_id", - "login" - ], - "title": "ObservedAccount", - "type": "object" - }, - "ObservedApp": { - "properties": { - "api_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Api Url" - }, - "callback_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Callback Url" - }, - "can_install": { - "default": false, - "title": "Can Install", - "type": "boolean" - }, - "configured": { - "title": "Configured", - "type": "boolean" - } - }, - "required": [ - "configured" - ], - "title": "ObservedApp", - "type": "object" - }, - "ObservedApps": { - "properties": { - "github": { - "$ref": "#/components/schemas/ObservedApp" - }, - "gitlab": { - "$ref": "#/components/schemas/ObservedApp" - } - }, - "required": [ - "github", - "gitlab" - ], - "title": "ObservedApps", - "type": "object" - }, - "ObservedAuthorization": { - "properties": { - "url": { - "title": "Url", - "type": "string" - } - }, - "required": [ - "url" - ], - "title": "ObservedAuthorization", - "type": "object" - }, - "ObservedConnection": { - "properties": { - "api_url": { - "title": "Api Url", - "type": "string" - }, - "connection_type": { - "enum": [ - "token", - "app" - ], - "title": "Connection Type", - "type": "string" - }, - "has_token": { - "title": "Has Token", - "type": "boolean" - }, - "id": { - "default": "", - "title": "Id", - "type": "string" - }, - "ready": { - "title": "Ready", - "type": "boolean" - }, - "repos": { - "items": { - "type": "string" - }, - "title": "Repos", - "type": "array" - }, - "source_provider": { - "enum": [ - "github", - "gitlab" - ], - "title": "Source Provider", - "type": "string" - }, - "update_interval_minutes": { - "title": "Update Interval Minutes", - "type": "number" - } - }, - "required": [ - "source_provider", - "api_url", - "repos", - "has_token", - "update_interval_minutes", - "ready", - "connection_type" - ], - "title": "ObservedConnection", - "type": "object" - }, - "ObservedConnectionIdentities": { - "properties": { - "api_url": { - "title": "Api Url", - "type": "string" - }, - "id": { - "title": "Id", - "type": "string" - }, - "identity_map": { - "additionalProperties": { - "type": "string" - }, - "title": "Identity Map", - "type": "object" - }, - "repos": { - "items": { - "type": "string" - }, - "title": "Repos", - "type": "array" - }, - "source_provider": { - "enum": [ - "github", - "gitlab" - ], - "title": "Source Provider", - "type": "string" - }, - "unmatched_logins": { - "items": { - "type": "string" - }, - "title": "Unmatched Logins", - "type": "array" - } - }, - "required": [ - "id", - "source_provider", - "api_url", - "repos", - "identity_map", - "unmatched_logins" - ], - "title": "ObservedConnectionIdentities", - "type": "object" - }, - "ObservedHumanSummary": { - "properties": { - "median_merge_hours": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Median Merge Hours" - } - }, - "required": [ - "median_merge_hours" - ], - "title": "ObservedHumanSummary", - "type": "object" - }, - "ObservedIdentities": { - "properties": { - "connections": { - "default": [], - "items": { - "$ref": "#/components/schemas/ObservedConnectionIdentities" - }, - "title": "Connections", - "type": "array" - }, - "gateway_emails": { - "items": { - "type": "string" - }, - "title": "Gateway Emails", - "type": "array" - }, - "identity_map": { - "additionalProperties": { - "type": "string" - }, - "title": "Identity Map", - "type": "object" - }, - "unmatched_logins": { - "items": { - "type": "string" - }, - "title": "Unmatched Logins", - "type": "array" - } - }, - "required": [ - "gateway_emails", - "identity_map", - "unmatched_logins" - ], - "title": "ObservedIdentities", - "type": "object" - }, - "ObservedIdentityUpdate": { - "additionalProperties": false, - "properties": { - "accounts": { - "anyOf": [ - { - "items": { - "$ref": "#/components/schemas/ObservedAccount" - }, - "maxItems": 500, - "type": "array" - }, - { - "type": "null" - } - ], - "title": "Accounts" - }, - "email": { - "title": "Email", - "type": "string" - }, - "logins": { - "default": [], - "items": { - "type": "string" - }, - "maxItems": 100, - "title": "Logins", - "type": "array" - } - }, - "required": [ - "email" - ], - "title": "ObservedIdentityUpdate", - "type": "object" - }, - "ObservedPeriod": { - "properties": { - "agent_authored": { - "title": "Agent Authored", - "type": "integer" - }, - "agents_without_requester": { - "title": "Agents Without Requester", - "type": "integer" - }, - "explicitly_titled_revert_prs": { - "title": "Explicitly Titled Revert Prs", - "type": "integer" - }, - "human_authored": { - "title": "Human Authored", - "type": "integer" - }, - "human_summary": { - "$ref": "#/components/schemas/ObservedHumanSummary" - }, - "matched_internal_prs": { - "title": "Matched Internal Prs", - "type": "integer" - }, - "matched_users_recorded_spend": { - "title": "Matched Users Recorded Spend", - "type": "number" - }, - "median_merge_hours": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Median Merge Hours" - }, - "merged_prs": { - "title": "Merged Prs", - "type": "integer" - }, - "missing_author": { - "title": "Missing Author", - "type": "integer" - }, - "new_bug_labeled_issues": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "New Bug Labeled Issues" - }, - "new_regression_labeled_issues": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "New Regression Labeled Issues" - }, - "spend_observation": { - "enum": [ - "records_present", - "no_records" - ], - "title": "Spend Observation", - "type": "string" - }, - "window": { - "$ref": "#/components/schemas/ObservedWindow" - } - }, - "required": [ - "window", - "merged_prs", - "median_merge_hours", - "human_authored", - "agent_authored", - "missing_author", - "agents_without_requester", - "matched_internal_prs", - "new_bug_labeled_issues", - "new_regression_labeled_issues", - "explicitly_titled_revert_prs", - "matched_users_recorded_spend", - "spend_observation", - "human_summary" - ], - "title": "ObservedPeriod", - "type": "object" - }, - "ObservedPeriods": { - "properties": { - "current": { - "$ref": "#/components/schemas/ObservedPeriod" - }, - "last_year": { - "$ref": "#/components/schemas/ObservedPeriod" - }, - "previous": { - "$ref": "#/components/schemas/ObservedPeriod" - } - }, - "required": [ - "current", - "previous", - "last_year" - ], - "title": "ObservedPeriods", - "type": "object" - }, - "ObservedPerson": { - "properties": { - "accounts": { - "default": [], - "items": { - "$ref": "#/components/schemas/ObservedAccount" - }, - "title": "Accounts", - "type": "array" - }, - "email": { - "title": "Email", - "type": "string" - }, - "logins": { - "items": { - "type": "string" - }, - "title": "Logins", - "type": "array" - }, - "name": { - "title": "Name", - "type": "string" - }, - "periods": { - "$ref": "#/components/schemas/ObservedPersonPeriods" - } - }, - "required": [ - "name", - "email", - "logins", - "periods" - ], - "title": "ObservedPerson", - "type": "object" - }, - "ObservedPersonPeriod": { - "properties": { - "declared_agent_owned": { - "title": "Declared Agent Owned", - "type": "integer" - }, - "direct_authored": { - "title": "Direct Authored", - "type": "integer" - }, - "gateway_recorded_spend": { - "title": "Gateway Recorded Spend", - "type": "number" - }, - "median_merge_hours": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Median Merge Hours" - }, - "merged_prs": { - "title": "Merged Prs", - "type": "integer" - }, - "pr_urls": { - "items": { - "type": "string" - }, - "title": "Pr Urls", - "type": "array" - }, - "prs_per_week": { - "title": "Prs Per Week", - "type": "number" - }, - "recorded_spend_per_attributed_pr": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Recorded Spend Per Attributed Pr" - }, - "spend_observation": { - "enum": [ - "records_present", - "no_records" - ], - "title": "Spend Observation", - "type": "string" - } - }, - "required": [ - "merged_prs", - "prs_per_week", - "median_merge_hours", - "direct_authored", - "declared_agent_owned", - "gateway_recorded_spend", - "recorded_spend_per_attributed_pr", - "spend_observation", - "pr_urls" - ], - "title": "ObservedPersonPeriod", - "type": "object" - }, - "ObservedPersonPeriods": { - "properties": { - "current": { - "$ref": "#/components/schemas/ObservedPersonPeriod" - }, - "last_year": { - "$ref": "#/components/schemas/ObservedPersonPeriod" - }, - "previous": { - "$ref": "#/components/schemas/ObservedPersonPeriod" - } - }, - "required": [ - "current", - "previous", - "last_year" - ], - "title": "ObservedPersonPeriods", - "type": "object" - }, - "ObservedPullPeriods": { - "properties": { - "current": { - "items": { - "$ref": "#/components/schemas/ObservedPullResponse" - }, - "title": "Current", - "type": "array" - }, - "last_year": { - "items": { - "$ref": "#/components/schemas/ObservedPullResponse" - }, - "title": "Last Year", - "type": "array" - }, - "previous": { - "items": { - "$ref": "#/components/schemas/ObservedPullResponse" - }, - "title": "Previous", - "type": "array" - } - }, - "required": [ - "current", - "previous", - "last_year" - ], - "title": "ObservedPullPeriods", - "type": "object" - }, - "ObservedPullResponse": { - "properties": { - "agent": { - "default": false, - "title": "Agent", - "type": "boolean" - }, - "author": { - "title": "Author", - "type": "string" - }, - "branch_cost": { - "$ref": "#/components/schemas/ROIBranchAttribution" - }, - "connection_id": { - "default": "", - "title": "Connection Id", - "type": "string" - }, - "created_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Created At" - }, - "merge_hours": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Merge Hours" - }, - "merged_at": { - "format": "date-time", - "title": "Merged At", - "type": "string" - }, - "number": { - "title": "Number", - "type": "integer" - }, - "profile_email": { - "default": "", - "title": "Profile Email", - "type": "string" - }, - "repo": { - "title": "Repo", - "type": "string" - }, - "requester": { - "default": "", - "title": "Requester", - "type": "string" - }, - "source_branch": { - "default": "", - "title": "Source Branch", - "type": "string" - }, - "source_repo": { - "default": "", - "title": "Source Repo", - "type": "string" - }, - "title": { - "title": "Title", - "type": "string" - }, - "url": { - "title": "Url", - "type": "string" - } - }, - "required": [ - "repo", - "number", - "title", - "url", - "author", - "merged_at", - "merge_hours", - "branch_cost" - ], - "title": "ObservedPullResponse", - "type": "object" - }, - "ObservedReport": { - "properties": { - "captured_at": { - "format": "date-time", - "title": "Captured At", - "type": "string" - }, - "connections": { - "default": [], - "items": { - "$ref": "#/components/schemas/ObservedSource" - }, - "title": "Connections", - "type": "array" - }, - "people": { - "items": { - "$ref": "#/components/schemas/ObservedPerson" - }, - "title": "People", - "type": "array" - }, - "periods": { - "$ref": "#/components/schemas/ObservedPeriods" - }, - "pulls": { - "$ref": "#/components/schemas/ObservedPullPeriods" - }, - "repos": { - "items": { - "type": "string" - }, - "title": "Repos", - "type": "array" - }, - "source_provider": { - "enum": [ - "github", - "gitlab", - "mixed" - ], - "title": "Source Provider", - "type": "string" - }, - "unlinked_branches": { - "items": { - "$ref": "#/components/schemas/ROIBranchSpend" - }, - "title": "Unlinked Branches", - "type": "array" - }, - "unmatched_logins": { - "items": { - "type": "string" - }, - "title": "Unmatched Logins", - "type": "array" - } - }, - "required": [ - "source_provider", - "repos", - "captured_at", - "periods", - "people", - "pulls", - "unlinked_branches", - "unmatched_logins" - ], - "title": "ObservedReport", - "type": "object" - }, - "ObservedReportResponse": { - "properties": { - "report": { - "anyOf": [ - { - "$ref": "#/components/schemas/ObservedReport" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "report" - ], - "title": "ObservedReportResponse", - "type": "object" - }, - "ObservedSettings": { - "properties": { - "api_url": { - "title": "Api Url", - "type": "string" - }, - "connection_type": { - "enum": [ - "token", - "app" - ], - "title": "Connection Type", - "type": "string" - }, - "connections": { - "default": [], - "items": { - "$ref": "#/components/schemas/ObservedConnection" - }, - "title": "Connections", - "type": "array" - }, - "has_token": { - "title": "Has Token", - "type": "boolean" - }, - "id": { - "default": "", - "title": "Id", - "type": "string" - }, - "ready": { - "title": "Ready", - "type": "boolean" - }, - "repos": { - "items": { - "type": "string" - }, - "title": "Repos", - "type": "array" - }, - "source_provider": { - "enum": [ - "github", - "gitlab" - ], - "title": "Source Provider", - "type": "string" - }, - "update_interval_minutes": { - "title": "Update Interval Minutes", - "type": "number" - } - }, - "required": [ - "source_provider", - "api_url", - "repos", - "has_token", - "update_interval_minutes", - "ready", - "connection_type" - ], - "title": "ObservedSettings", - "type": "object" - }, - "ObservedSettingsUpdate": { - "additionalProperties": false, - "properties": { - "api_url": { - "title": "Api Url", - "type": "string" - }, - "connection_id": { - "anyOf": [ - { - "maxLength": 100, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Connection Id" - }, - "repos": { - "items": { - "type": "string" - }, - "title": "Repos", - "type": "array" - }, - "source_provider": { - "enum": [ - "github", - "gitlab" - ], - "title": "Source Provider", - "type": "string" - }, - "token": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Token" - }, - "update_interval_minutes": { - "anyOf": [ - { - "maximum": 43200.0, - "minimum": 0.0, - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Update Interval Minutes" - } - }, - "required": [ - "source_provider", - "api_url", - "repos" - ], - "title": "ObservedSettingsUpdate", - "type": "object" - }, - "ObservedSource": { - "properties": { - "api_url": { - "title": "Api Url", - "type": "string" - }, - "id": { - "title": "Id", - "type": "string" - }, - "repos": { - "items": { - "type": "string" - }, - "title": "Repos", - "type": "array" - }, - "source_provider": { - "enum": [ - "github", - "gitlab" - ], - "title": "Source Provider", - "type": "string" - } - }, - "required": [ - "id", - "source_provider", - "api_url", - "repos" - ], - "title": "ObservedSource", - "type": "object" - }, - "ObservedWindow": { - "properties": { - "end": { - "format": "date", - "title": "End", - "type": "string" - }, - "start": { - "format": "date", - "title": "Start", - "type": "string" - } - }, - "required": [ - "start", - "end" - ], - "title": "ObservedWindow", - "type": "object" - }, - "ROIBranchAttribution": { - "properties": { - "branch": { - "title": "Branch", - "type": "string" - }, - "repo": { - "title": "Repo", - "type": "string" - }, - "requests": { - "default": 0, - "title": "Requests", - "type": "integer" - }, - "spend": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Spend" - }, - "status": { - "default": "unattributed", - "enum": [ - "matched", - "unattributed", - "ambiguous", - "unavailable" - ], - "title": "Status", - "type": "string" - } - }, - "required": [ - "repo", - "branch" - ], - "title": "ROIBranchAttribution", - "type": "object" - }, - "ROIBranchMetrics": { - "properties": { - "cost_per_hour": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Cost Per Hour" - }, - "hours": { - "default": 0, - "title": "Hours", - "type": "number" - }, - "matched_pulls": { - "default": 0, - "title": "Matched Pulls", - "type": "integer" - }, - "spend": { - "default": 0, - "title": "Spend", - "type": "number" - }, - "total_tagged_spend": { - "default": 0, - "title": "Total Tagged Spend", - "type": "number" - }, - "unlinked_spend": { - "default": 0, - "title": "Unlinked Spend", - "type": "number" - } - }, - "title": "ROIBranchMetrics", - "type": "object" - }, - "ROIBranchSpend": { - "properties": { - "branch": { - "title": "Branch", - "type": "string" - }, - "repo": { - "title": "Repo", - "type": "string" - }, - "requests": { - "title": "Requests", - "type": "integer" - }, - "spend": { - "title": "Spend", - "type": "number" - } - }, - "required": [ - "repo", - "branch", - "spend", - "requests" - ], - "title": "ROIBranchSpend", - "type": "object" - }, - "ROIEstimateResponse": { - "properties": { - "cached": { - "default": false, - "title": "Cached", - "type": "boolean" - }, - "effort_basis": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Effort Basis" - }, - "evidence_source": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Evidence Source" - }, - "hours": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Hours" - }, - "model": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Model" - }, - "reasoning": { - "title": "Reasoning", - "type": "string" - }, - "status": { - "enum": [ - "estimated", - "needs_review", - "error" - ], - "title": "Status", - "type": "string" - } - }, - "required": [ - "status", - "hours", - "reasoning" - ], - "title": "ROIEstimateResponse", - "type": "object" - }, - "ROIEstimatorModel": { - "properties": { - "model_name": { - "title": "Model Name", - "type": "string" - }, - "provider_models": { - "items": { - "type": "string" - }, - "title": "Provider Models", - "type": "array" - } - }, - "required": [ - "model_name", - "provider_models" - ], - "title": "ROIEstimatorModel", - "type": "object" - }, - "ROIIdentityMapResponse": { - "properties": { - "identity_map": { - "additionalProperties": { - "type": "string" - }, - "title": "Identity Map", - "type": "object" - }, - "report": { - "anyOf": [ - { - "$ref": "#/components/schemas/ROISummaryResponse" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "report", - "identity_map" - ], - "title": "ROIIdentityMapResponse", - "type": "object" - }, - "ROIIdentityMapUpdate": { - "properties": { - "email": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Email" - }, - "github_login": { - "title": "Github Login", - "type": "string" - } - }, - "required": [ - "github_login", - "email" - ], - "title": "ROIIdentityMapUpdate", - "type": "object" - }, - "ROIMetricsResponse": { - "properties": { - "cohort_people": { - "title": "Cohort People", - "type": "integer" - }, - "cost_per_hour": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Cost Per Hour" - }, - "estimated_prs": { - "title": "Estimated Prs", - "type": "integer" - }, - "excluded_spend": { - "title": "Excluded Spend", - "type": "number" - }, - "hours_per_dollar": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Hours Per Dollar" - }, - "matched_prs": { - "title": "Matched Prs", - "type": "integer" - }, - "matched_spend": { - "title": "Matched Spend", - "type": "number" - }, - "merged_prs": { - "title": "Merged Prs", - "type": "integer" - }, - "output_hours": { - "title": "Output Hours", - "type": "number" - }, - "pending_prs": { - "title": "Pending Prs", - "type": "integer" - }, - "people_with_prs": { - "title": "People With Prs", - "type": "integer" - }, - "total_output_hours": { - "title": "Total Output Hours", - "type": "number" - }, - "total_spend": { - "title": "Total Spend", - "type": "number" - } - }, - "required": [ - "matched_spend", - "output_hours", - "total_spend", - "total_output_hours", - "excluded_spend", - "cost_per_hour", - "hours_per_dollar", - "merged_prs", - "estimated_prs", - "matched_prs", - "cohort_people", - "people_with_prs", - "pending_prs" - ], - "title": "ROIMetricsResponse", - "type": "object" - }, - "ROIPersonResponse": { - "properties": { - "cost_per_hour": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Cost Per Hour" - }, - "eligible": { - "title": "Eligible", - "type": "boolean" - }, - "email": { - "title": "Email", - "type": "string" - }, - "estimated_prs": { - "title": "Estimated Prs", - "type": "integer" - }, - "hours": { - "title": "Hours", - "type": "number" - }, - "id": { - "title": "Id", - "type": "string" - }, - "logins": { - "items": { - "type": "string" - }, - "title": "Logins", - "type": "array" - }, - "match_methods": { - "items": { - "type": "string" - }, - "title": "Match Methods", - "type": "array" - }, - "pending_prs": { - "title": "Pending Prs", - "type": "integer" - }, - "prs": { - "title": "Prs", - "type": "integer" - }, - "spend": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Spend" - } - }, - "required": [ - "id", - "email", - "logins", - "spend", - "hours", - "prs", - "estimated_prs", - "pending_prs", - "match_methods", - "eligible", - "cost_per_hour" - ], - "title": "ROIPersonResponse", - "type": "object" - }, - "ROIPullResponse": { - "properties": { - "additions": { - "title": "Additions", - "type": "integer" - }, - "branch_cost": { - "$ref": "#/components/schemas/ROIBranchAttribution" - }, - "cache_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Cache Key" - }, - "changed_files": { - "title": "Changed Files", - "type": "integer" - }, - "commit_count": { - "title": "Commit Count", - "type": "integer" - }, - "deletions": { - "title": "Deletions", - "type": "integer" - }, - "email": { - "title": "Email", - "type": "string" - }, - "emails": { - "items": { - "type": "string" - }, - "title": "Emails", - "type": "array" - }, - "estimate": { - "$ref": "#/components/schemas/ROIEstimateResponse" - }, - "head_sha": { - "title": "Head Sha", - "type": "string" - }, - "incomplete_metadata": { - "title": "Incomplete Metadata", - "type": "boolean" - }, - "login": { - "title": "Login", - "type": "string" - }, - "match_method": { - "title": "Match Method", - "type": "string" - }, - "matched": { - "title": "Matched", - "type": "boolean" - }, - "merged_at": { - "title": "Merged At", - "type": "string" - }, - "number": { - "title": "Number", - "type": "integer" - }, - "profile_email": { - "title": "Profile Email", - "type": "string" - }, - "repo": { - "title": "Repo", - "type": "string" - }, - "source_branch": { - "default": "", - "title": "Source Branch", - "type": "string" - }, - "source_repo": { - "default": "", - "title": "Source Repo", - "type": "string" - }, - "title": { - "title": "Title", - "type": "string" - }, - "url": { - "title": "Url", - "type": "string" - } - }, - "required": [ - "repo", - "number", - "title", - "url", - "login", - "emails", - "profile_email", - "merged_at", - "head_sha", - "additions", - "deletions", - "changed_files", - "commit_count", - "incomplete_metadata", - "estimate", - "email", - "match_method", - "matched" - ], - "title": "ROIPullResponse", - "type": "object" - }, - "ROIReportResponse": { - "properties": { - "report": { - "anyOf": [ - { - "$ref": "#/components/schemas/ROISummaryResponse" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "report" - ], - "title": "ROIReportResponse", - "type": "object" - }, - "ROIRepositoriesResponse": { - "properties": { - "has_more": { - "title": "Has More", - "type": "boolean" - }, - "page": { - "title": "Page", - "type": "integer" - }, - "repositories": { - "items": { - "$ref": "#/components/schemas/ROIRepository" - }, - "title": "Repositories", - "type": "array" - } - }, - "required": [ - "repositories", - "page", - "has_more" - ], - "title": "ROIRepositoriesResponse", - "type": "object" - }, - "ROIRepository": { - "properties": { - "archived": { - "title": "Archived", - "type": "boolean" - }, - "name": { - "title": "Name", - "type": "string" - }, - "visibility": { - "title": "Visibility", - "type": "string" - } - }, - "required": [ - "name", - "visibility", - "archived" - ], - "title": "ROIRepository", - "type": "object" - }, - "ROISettingsResponse": { - "properties": { - "available_models": { - "items": { - "type": "string" - }, - "title": "Available Models", - "type": "array" - }, - "backfill_days": { - "title": "Backfill Days", - "type": "integer" - }, - "default_prompt": { - "title": "Default Prompt", - "type": "string" - }, - "estimator_model": { - "title": "Estimator Model", - "type": "string" - }, - "estimator_models": { - "default": [], - "items": { - "$ref": "#/components/schemas/ROIEstimatorModel" - }, - "title": "Estimator Models", - "type": "array" - }, - "estimator_prompt": { - "title": "Estimator Prompt", - "type": "string" - }, - "github_api_url": { - "title": "Github Api Url", - "type": "string" - }, - "gitlab_api_url": { - "default": "https://gitlab.com/api/v4", - "title": "Gitlab Api Url", - "type": "string" - }, - "has_estimator_key": { - "title": "Has Estimator Key", - "type": "boolean" - }, - "has_github_token": { - "title": "Has Github Token", - "type": "boolean" - }, - "has_gitlab_token": { - "default": false, - "title": "Has Gitlab Token", - "type": "boolean" - }, - "identity_map": { - "additionalProperties": { - "type": "string" - }, - "title": "Identity Map", - "type": "object" - }, - "ready": { - "title": "Ready", - "type": "boolean" - }, - "report_mode": { - "default": "legacy", - "enum": [ - "legacy", - "observed" - ], - "title": "Report Mode", - "type": "string" - }, - "repos": { - "items": { - "type": "string" - }, - "title": "Repos", - "type": "array" - }, - "source_provider": { - "default": "github", - "enum": [ - "github", - "gitlab" - ], - "title": "Source Provider", - "type": "string" - }, - "update_interval_minutes": { - "title": "Update Interval Minutes", - "type": "number" - } - }, - "required": [ - "github_api_url", - "repos", - "estimator_model", - "estimator_prompt", - "backfill_days", - "update_interval_minutes", - "has_estimator_key", - "identity_map", - "has_github_token", - "default_prompt", - "available_models", - "ready" - ], - "title": "ROISettingsResponse", - "type": "object" - }, - "ROISettingsUpdate": { - "additionalProperties": false, - "properties": { - "backfill_days": { - "anyOf": [ - { - "maximum": 3650.0, - "minimum": 1.0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Backfill Days" - }, - "estimator_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Estimator Key" - }, - "estimator_model": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Estimator Model" - }, - "estimator_prompt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Estimator Prompt" - }, - "github_api_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Github Api Url" - }, - "github_token": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Github Token" - }, - "gitlab_api_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Gitlab Api Url" - }, - "gitlab_token": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Gitlab Token" - }, - "report_mode": { - "anyOf": [ - { - "enum": [ - "legacy", - "observed" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Report Mode" - }, - "repos": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "title": "Repos" - }, - "source_provider": { - "anyOf": [ - { - "enum": [ - "github", - "gitlab" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Source Provider" - }, - "update_interval_minutes": { - "anyOf": [ - { - "maximum": 43200.0, - "minimum": 0.0, - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Update Interval Minutes" - } - }, - "title": "ROISettingsUpdate", - "type": "object" - }, - "ROISummaryResponse": { - "properties": { - "branch_metrics": { - "$ref": "#/components/schemas/ROIBranchMetrics" - }, - "effort_basis": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Effort Basis" - }, - "end": { - "title": "End", - "type": "string" - }, - "estimator_model": { - "title": "Estimator Model", - "type": "string" - }, - "estimator_prompt": { - "title": "Estimator Prompt", - "type": "string" - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Id" - }, - "metrics": { - "$ref": "#/components/schemas/ROIMetricsResponse" - }, - "mode": { - "title": "Mode", - "type": "string" - }, - "people": { - "items": { - "$ref": "#/components/schemas/ROIPersonResponse" - }, - "title": "People", - "type": "array" - }, - "pulls": { - "items": { - "$ref": "#/components/schemas/ROIPullResponse" - }, - "title": "Pulls", - "type": "array" - }, - "repos": { - "items": { - "type": "string" - }, - "title": "Repos", - "type": "array" - }, - "source_provider": { - "default": "github", - "enum": [ - "github", - "gitlab" - ], - "title": "Source Provider", - "type": "string" - }, - "start": { - "title": "Start", - "type": "string" - }, - "synced_at": { - "title": "Synced At", - "type": "string" - }, - "trend": { - "items": { - "$ref": "#/components/schemas/ROITrendResponse" - }, - "title": "Trend", - "type": "array" - }, - "unlinked_branches": { - "default": [], - "items": { - "$ref": "#/components/schemas/ROIBranchSpend" - }, - "title": "Unlinked Branches", - "type": "array" - }, - "warnings": { - "items": { - "type": "string" - }, - "title": "Warnings", - "type": "array" - } - }, - "required": [ - "id", - "mode", - "start", - "end", - "synced_at", - "repos", - "estimator_model", - "estimator_prompt", - "warnings", - "effort_basis", - "metrics", - "people", - "pulls", - "trend" - ], - "title": "ROISummaryResponse", - "type": "object" - }, - "ROISyncStatus": { - "properties": { - "done": { - "title": "Done", - "type": "integer" - }, - "elapsed_seconds": { - "default": 0, - "title": "Elapsed Seconds", - "type": "integer" - }, - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Error" - }, - "estimated": { - "title": "Estimated", - "type": "integer" - }, - "finished_at": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Finished At" - }, - "needs_attention": { - "title": "Needs Attention", - "type": "integer" - }, - "next_update": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Next Update" - }, - "phase": { - "enum": [ - "idle", - "spend", - "repositories", - "estimates", - "complete", - "cancelled", - "error" - ], - "title": "Phase", - "type": "string" - }, - "remaining_seconds": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Remaining Seconds" - }, - "reused": { - "title": "Reused", - "type": "integer" - }, - "running": { - "title": "Running", - "type": "boolean" - }, - "stage": { - "title": "Stage", - "type": "string" - }, - "started_at": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Started At" - }, - "total": { - "title": "Total", - "type": "integer" - } - }, - "required": [ - "running", - "phase", - "stage", - "done", - "total", - "estimated", - "reused", - "needs_attention", - "error" - ], - "title": "ROISyncStatus", - "type": "object" - }, - "ROITrendResponse": { - "properties": { - "date": { - "title": "Date", - "type": "string" - }, - "hours": { - "title": "Hours", - "type": "number" - }, - "prs": { - "title": "Prs", - "type": "integer" - }, - "spend": { - "title": "Spend", - "type": "number" - } - }, - "required": [ - "date", - "spend", - "hours", - "prs" - ], - "title": "ROITrendResponse", - "type": "object" - }, - "ValidationError": { - "properties": { - "ctx": { - "title": "Context", - "type": "object" - }, - "input": { - "title": "Input" - }, - "loc": { - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer" - } - ] - }, - "title": "Location", - "type": "array" - }, - "msg": { - "title": "Message", - "type": "string" - }, - "type": { - "title": "Error Type", - "type": "string" - } - }, - "required": [ - "loc", - "msg", - "type" - ], - "title": "ValidationError", - "type": "object" - } - } - }, - "paths": { - "/roi-calculator/connections/test": { - "post": { - "operationId": "test_roi_calculator_connections_roi_calculator_connections_test_post", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ROISettingsResponse" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Test Roi Calculator Connections", - "tags": [ - "roi_calculator" - ] - } - }, - "/roi-calculator/identity-map": { - "put": { - "operationId": "update_roi_calculator_identity_map_roi_calculator_identity_map_put", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ROIIdentityMapUpdate" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ROIIdentityMapResponse" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Update Roi Calculator Identity Map", - "tags": [ - "roi_calculator" - ] - } - }, - "/roi-calculator/observed/apps": { - "get": { - "operationId": "observed_apps_roi_calculator_observed_apps_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObservedApps" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Observed Apps", - "tags": [ - "roi_calculator" - ] - } - }, - "/roi-calculator/observed/identities": { - "get": { - "operationId": "get_observed_identities_roi_calculator_observed_identities_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObservedIdentities" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Get Observed Identities", - "tags": [ - "roi_calculator" - ] - }, - "put": { - "operationId": "save_observed_identities_roi_calculator_observed_identities_put", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObservedIdentityUpdate" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObservedReportResponse" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Save Observed Identities", - "tags": [ - "roi_calculator" - ] - } - }, - "/roi-calculator/observed/oauth/{provider}/start": { - "post": { - "operationId": "start_observed_authorization_roi_calculator_observed_oauth__provider__start_post", - "parameters": [ - { - "in": "path", - "name": "provider", - "required": true, - "schema": { - "enum": [ - "github", - "gitlab" - ], - "title": "Provider", - "type": "string" - } - }, - { - "in": "query", - "name": "install", - "required": false, - "schema": { - "default": false, - "title": "Install", - "type": "boolean" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObservedAuthorization" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Start Observed Authorization", - "tags": [ - "roi_calculator" - ] - } - }, - "/roi-calculator/observed/report": { - "get": { - "operationId": "get_observed_report_roi_calculator_observed_report_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObservedReportResponse" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Get Observed Report", - "tags": [ - "roi_calculator" - ] - } - }, - "/roi-calculator/observed/repositories": { - "get": { - "operationId": "observed_repositories_roi_calculator_observed_repositories_get", - "parameters": [ - { - "in": "query", - "name": "connection", - "required": false, - "schema": { - "anyOf": [ - { - "maxLength": 100, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Connection" - } - }, - { - "in": "query", - "name": "query", - "required": false, - "schema": { - "default": "", - "maxLength": 200, - "title": "Query", - "type": "string" - } - }, - { - "in": "query", - "name": "page", - "required": false, - "schema": { - "default": 1, - "maximum": 1000, - "minimum": 1, - "title": "Page", - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ROIRepositoriesResponse" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Observed Repositories", - "tags": [ - "roi_calculator" - ] - } - }, - "/roi-calculator/observed/settings": { - "get": { - "operationId": "get_observed_settings_roi_calculator_observed_settings_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObservedSettings" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Get Observed Settings", - "tags": [ - "roi_calculator" - ] - }, - "put": { - "operationId": "save_observed_settings_roi_calculator_observed_settings_put", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObservedSettingsUpdate" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObservedSettings" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Save Observed Settings", - "tags": [ - "roi_calculator" - ] - } - }, - "/roi-calculator/observed/sync": { - "delete": { - "operationId": "cancel_observed_sync_roi_calculator_observed_sync_delete", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ROISyncStatus" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Cancel Observed Sync", - "tags": [ - "roi_calculator" - ] - }, - "get": { - "operationId": "get_observed_sync_roi_calculator_observed_sync_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ROISyncStatus" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Get Observed Sync", - "tags": [ - "roi_calculator" - ] - }, - "post": { - "operationId": "start_observed_sync_roi_calculator_observed_sync_post", - "parameters": [ - { - "in": "query", - "name": "days", - "required": false, - "schema": { - "anyOf": [ - { - "maximum": 366, - "minimum": 1, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Days" - } - } - ], - "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ROISyncStatus" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Start Observed Sync", - "tags": [ - "roi_calculator" - ] - } - }, - "/roi-calculator/report": { - "get": { - "operationId": "get_roi_calculator_report_roi_calculator_report_get", - "parameters": [ - { - "in": "query", - "name": "mode", - "required": false, - "schema": { - "default": "live", - "enum": [ - "live", - "demo" - ], - "title": "Mode", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ROIReportResponse" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Get Roi Calculator Report", - "tags": [ - "roi_calculator" - ] - } - }, - "/roi-calculator/repositories": { - "get": { - "operationId": "get_roi_calculator_repositories_roi_calculator_repositories_get", - "parameters": [ - { - "in": "query", - "name": "query", - "required": false, - "schema": { - "default": "", - "maxLength": 200, - "title": "Query", - "type": "string" - } - }, - { - "in": "query", - "name": "page", - "required": false, - "schema": { - "default": 1, - "maximum": 1000, - "minimum": 1, - "title": "Page", - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ROIRepositoriesResponse" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Get Roi Calculator Repositories", - "tags": [ - "roi_calculator" - ] - } - }, - "/roi-calculator/settings": { - "get": { - "operationId": "get_roi_calculator_settings_roi_calculator_settings_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ROISettingsResponse" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Get Roi Calculator Settings", - "tags": [ - "roi_calculator" - ] - }, - "put": { - "operationId": "update_roi_calculator_settings_roi_calculator_settings_put", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ROISettingsUpdate" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ROISettingsResponse" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Update Roi Calculator Settings", - "tags": [ - "roi_calculator" - ] - } - }, - "/roi-calculator/setup/reset": { - "post": { - "operationId": "reset_roi_calculator_setup_roi_calculator_setup_reset_post", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ROISettingsResponse" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Reset Roi Calculator Setup", - "tags": [ - "roi_calculator" - ] - } - }, - "/roi-calculator/sync": { - "delete": { - "operationId": "cancel_roi_calculator_sync_roi_calculator_sync_delete", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ROISyncStatus" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Cancel Roi Calculator Sync", - "tags": [ - "roi_calculator" - ] - }, - "get": { - "operationId": "get_roi_calculator_sync_status_roi_calculator_sync_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ROISyncStatus" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Get Roi Calculator Sync Status", - "tags": [ - "roi_calculator" - ] - }, - "post": { - "operationId": "start_roi_calculator_sync_roi_calculator_sync_post", - "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ROISyncStatus" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "APIKeyHeader": [] - } - ], - "summary": "Start Roi Calculator Sync", - "tags": [ - "roi_calculator" - ] - } - } - } - }, "scim": { "components": { "schemas": { diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index ed2a5b89a32..ac116255082 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1604,11 +1604,6 @@ async def proxy_startup_event(app: FastAPI) -> AsyncGenerator[ProxyLifespanState if not model_info_scheduler.running: model_info_scheduler.start() - if scheduler is not None and prisma_client is not None: - from litellm.proxy.management_endpoints.roi_calculator_endpoints import register_scheduled_sync - - register_scheduled_sync(scheduler) - tracing_settings: Final = cast( # cast-ok: Pydantic validates the legacy untyped settings value dict[str, object] | None, TypeAdapter(dict[str, object] | None).validate_python(general_settings.get("tracing")), diff --git a/litellm/proxy/roi_calculator/README.md b/litellm/proxy/roi_calculator/README.md index 82dd5463398..441f8f98c1c 100644 --- a/litellm/proxy/roi_calculator/README.md +++ b/litellm/proxy/roi_calculator/README.md @@ -1,54 +1,5 @@ -# ROI Calculator +# ROI Calculator (retired) -The dashboard compares merged pull or merge requests, elapsed time from opening to merge, new bug and regression issues, and recorded gateway spend over 7, 28, or 90 complete UTC days. Compare against the immediately preceding period of the same length or the same-length period last year +The integrated ROI Calculator is retired. Its dashboard page, API endpoints, OAuth callbacks, and scheduled refresh are no longer available -The calculator combines repository activity with spend recorded by the gateway. Spend per merged change is a person's recorded gateway spend during the period divided by their matched merged changes. To track a branch's AI cost, send repository and branch tags with each request - -## Connect repositories - -Use **Preview sample report** beside the title to explore the dashboard before connecting repositories. Sample periods, engineer details, quality signals, and branch spend work without changing your connections or live report. **Exit demo** returns to your report or setup - -Open `/ui/roi-calculator/`, choose GitHub or GitLab, then connect with an app or access token. Select several repositories and start the sync. Use **Add connection** to keep both providers connected. Each provider and API host retains its credentials, repositories, and identity mappings, and the report combines their activity while counting each person’s gateway spend once. Public repositories also accept an empty token, subject to the provider's anonymous API limits - -For GitHub tokens, grant read access to metadata, pull requests and issues. GitLab tokens require `read_api`. Self-hosted instances use their API URL, for example `https://git.example.com/api/v4` - -## Configure app authorization - -Register a GitHub App with read-only repository permissions for metadata, pull requests and issues. Enable expiring user access tokens and leave authorization during installation disabled, since the gateway starts authorization after installation. Generate a private key in the app settings to allow installation and store it securely. The gateway uses a generated client secret for authorization and does not need the private key - -Register a confidential GitLab OAuth application with `read_api` and `read_user` scopes - -Set `PROXY_BASE_URL` to the gateway's public URL. The callback URLs are `/roi-calculator/observed/oauth/github/callback` and `/roi-calculator/observed/oauth/gitlab/callback` - -Set the GitHub App setup URL to `/roi-calculator/observed/oauth/github/installed`, enable **Redirect on update**, and set `LITELLM_ROI_GITHUB_APP_SLUG` to its URL slug. The first connection then starts with repository installation and continues to user authorization - -Set `LITELLM_ROI_GITHUB_CLIENT_ID` and `LITELLM_ROI_GITHUB_CLIENT_SECRET` for GitHub, or `LITELLM_ROI_GITLAB_CLIENT_ID` and `LITELLM_ROI_GITLAB_CLIENT_SECRET` for GitLab. For a self-hosted provider, set `LITELLM_ROI_GITHUB_URL` or `LITELLM_ROI_GITLAB_URL` to its base URL without the API suffix - -The gateway encrypts access and refresh tokens using its configured encryption key. Authorization uses PKCE and an expiring, single-use state tied to an HTTP-only browser cookie. Refreshes are coordinated across gateway workers - -## Link people - -Use **Link accounts** to associate several current or historical usernames with one internal email. Each connection has a separate username field, so a GitHub username never matches a GitLab user implicitly. Saving immediately recalculates the report without fetching repositories again. Public profile emails match automatically when they resolve unambiguously to an internal user - -**Matched people only** is on by default for people, merged changes, and branch lists. Turn it off to include outside contributors and their branches. Matching depends on the linked internal account, even when no spend was recorded. This switch filters the lists; summary metrics and quality signals still cover all selected repositories - -Agent-authored changes count for a person only when the supported agent metadata explicitly names a requester. Repository issue counts and revert titles are quality signals, not an individual defect score - -Bug and regression counts combine repositories with issue tracking enabled. They remain unavailable when none of the selected repositories has issue tracking enabled - -## Sync behavior - -The default refresh interval is daily and applies to every connection in the workspace. Adding or editing a connection preserves it unless `update_interval_minutes` is supplied. The observed settings API accepts `update_interval_minutes: 0` for manual updates. A cancelled or failed sync preserves the last complete report - -Existing settings retain `report_mode: legacy` and their scheduled reports until an administrator saves a connection, authorizes an app, or starts an observed sync. Reading the new dashboard alone does not change the mode. The legacy settings API can explicitly select `report_mode: legacy` again - -GitHub collection splits large searches into smaller date ranges to avoid its search-result limit. Both providers validate pagination and reject incomplete responses instead of publishing partial counts - - -## Branch request tags - -Send `repo:github.com/owner/repo` or `repo:gitlab.com/group/project` together with `branch:feature/name` in `metadata.tags`, top-level `tags`, or the comma-separated `x-litellm-tags` header. The tags must identify the source repository and branch, including forks - -The report sums recorded requests inside its UTC dates. A branch cost is assigned to a merged change only when that source branch matches one change in the period. Reused branches stay visible in Branch spend without duplicating costs across changes. No retained tagged requests means unknown cost; a recorded zero remains zero - -An empty repository produces a successful report with zero merged changes and no merge duration or spend-per-change ratio +The backend implementation and stored configuration are retained. Reverting the retirement change restores the dashboard and registration points. The standalone [litellm-roi-calculator](https://github.com/BerriAI/litellm-roi-calculator) project is separate and remains available diff --git a/tests/unit/proxy/test__lazy_features.py b/tests/unit/proxy/test__lazy_features.py index d2bb8244c2f..c3c5068b890 100644 --- a/tests/unit/proxy/test__lazy_features.py +++ b/tests/unit/proxy/test__lazy_features.py @@ -50,6 +50,33 @@ def _has_lazy_middleware(app: FastAPI) -> bool: return any(middleware.cls is LazyFeatureMiddleware for middleware in app.user_middleware) +@pytest.mark.parametrize("disable_lazy_routes", ("false", "true")) +def test_retired_roi_calculator_cannot_be_loaded_or_called( + monkeypatch: pytest.MonkeyPatch, disable_lazy_routes: str +) -> None: + monkeypatch.setenv(FLAG, disable_lazy_routes) + app: Final = FastAPI() + attach_lazy_features(app) + + with TestClient(app) as client: + for method, path in ( + ("POST", "/lazy/warm/roi_calculator"), + ("GET", "/roi-calculator/settings"), + ("GET", "/roi-calculator/report?mode=demo"), + ("POST", "/roi-calculator/sync"), + ("GET", "/roi-calculator/observed/report"), + ("GET", "/roi-calculator/observed/apps"), + ("POST", "/roi-calculator/observed/sync"), + ("POST", "/roi-calculator/observed/oauth/github/start"), + ("GET", "/roi-calculator/observed/oauth/github/callback"), + ("GET", "/roi-calculator/observed/oauth/github/installed"), + ("POST", "/roi-calculator/observed/oauth/gitlab/start"), + ("GET", "/roi-calculator/observed/oauth/gitlab/callback"), + ): + response: Final = client.request(method, path) + assert response.status_code == 404, f"{method} {path}: {response.text}" + + @pytest.mark.parametrize("value", ("1", "true", "TRUE", "yes", "on")) def test_flag_registers_every_feature_at_startup(monkeypatch: pytest.MonkeyPatch, value: str) -> None: monkeypatch.setenv(FLAG, value) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts index eecb897634b..09cc4ef0a75 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts @@ -37,7 +37,6 @@ const LEGACY_PAGE_ROUTES: ReadonlyMap = new Map( usage: "old-usage", "cost-optimization": "cost-optimization", "model-insights": "model-insights", - "roi-calculator": "roi-calculator", agents: "agents", "router-settings": "router-settings", users: "users", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/MatchedPeopleToggle.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/MatchedPeopleToggle.tsx deleted file mode 100644 index 5c7eaf22384..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/MatchedPeopleToggle.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { useId } from "react"; -import { Switch } from "@/components/ui/switch"; - -export function MatchedPeopleToggle({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) { - const id = useId(); - return ( - - ); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedAccounts.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedAccounts.integration.test.tsx deleted file mode 100644 index bc242ebe14a..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedAccounts.integration.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, expect, it, vi } from "vitest"; -import ObservedAccounts from "./ObservedAccounts"; - -afterEach(() => vi.unstubAllGlobals()); -it("links several usernames on both providers to one email in a single save", async () => { - const writes: unknown[] = []; - const saved = vi.fn(); - vi.stubGlobal( - "fetch", - vi.fn(async (_input: string, init: RequestInit) => { - if (init.method === "PUT") { - writes.push(JSON.parse(String(init.body))); - return Response.json({ report: null }); - } - const identities = { - gateway_emails: ["ari@example.test"], - identity_map: { old: "ari@example.test" }, - unmatched_logins: ["new"], - connections: [ - { - id: "github-id", - source_provider: "github", - api_url: "https://api.github.com", - identity_map: { old: "ari@example.test" }, - unmatched_logins: ["new"], - }, - { - id: "gitlab-id", - source_provider: "gitlab", - api_url: "https://gitlab.com/api/v4", - identity_map: {}, - unmatched_logins: ["new"], - }, - ], - }; - return Response.json(identities); - }), - ); - const user = userEvent.setup(); - render(); - await screen.findByLabelText(/GitHub usernames/); - fireEvent.change(screen.getByLabelText("Internal email"), { target: { value: "ari@example.test" } }); - expect(screen.getByLabelText(/GitHub usernames/)).toHaveValue("old"); - fireEvent.change(screen.getByLabelText(/GitHub usernames/), { target: { value: "@Old, new, NEW" } }); - fireEvent.change(screen.getByLabelText(/GitLab usernames/), { target: { value: "new" } }); - await user.click(screen.getByRole("button", { name: "Save accounts" })); - await waitFor(() => expect(saved).toHaveBeenCalledOnce()); - expect(writes).toEqual([ - { - email: "ari@example.test", - accounts: [ - { connection_id: "github-id", login: "old" }, - { connection_id: "github-id", login: "new" }, - { connection_id: "gitlab-id", login: "new" }, - ], - }, - ]); -}); -it("keeps a conflicting link editable", async () => { - const saved = vi.fn(); - vi.stubGlobal( - "fetch", - vi.fn(async (_input: string, init: RequestInit) => - init.method === "PUT" - ? Response.json({ detail: "An account is already linked to another email. Unlink it first" }, { status: 409 }) - : Response.json({ gateway_emails: ["ari@example.test"], identity_map: {}, unmatched_logins: [] }), - ), - ); - const user = userEvent.setup(); - render( - , - ); - fireEvent.change(screen.getByLabelText("Source usernames"), { target: { value: "old, new" } }); - const button = screen.getByRole("button", { name: "Save accounts" }); - await waitFor(() => expect(button).toBeEnabled()); - await user.click(button); - expect(await screen.findByRole("alert")).toHaveTextContent("An account is already linked to another email"); - expect(screen.getByLabelText("Source usernames")).toHaveValue("old, new"); - expect(saved).not.toHaveBeenCalled(); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedAccounts.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedAccounts.tsx deleted file mode 100644 index ab6c4cf38df..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedAccounts.tsx +++ /dev/null @@ -1,209 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { z } from "zod"; -import { apiClient } from "@/components/networking"; -import { extractProxyErrorMessage } from "@/lib/http/client"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; -import { accountLogins, type ObservedPerson } from "./observedData"; - -const connectionIdentityFields = { - id: z.string(), - source_provider: z.enum(["github", "gitlab"]), - api_url: z.string(), - identity_map: z.record(z.string(), z.string()), - unmatched_logins: z.array(z.string()), -}; -const identitiesFields = { - gateway_emails: z.array(z.string()), - identity_map: z.record(z.string(), z.string()), - unmatched_logins: z.array(z.string()), - connections: z.array(z.object(connectionIdentityFields)).optional(), -}; -const identitiesSchema = z.object(identitiesFields); - -function matches(identities: z.infer, email: string, people: ObservedPerson[]) { - const person = people.find((entry) => entry.email === email); - return Object.fromEntries( - (identities.connections ?? []).map((entry) => [ - entry.id, - [ - ...new Set([ - ...Object.entries(entry.identity_map) - .filter(([, address]) => address === email) - .map(([login]) => login), - ...(person?.accounts ?? []) - .filter((account) => account.connection_id === entry.id) - .map((account) => account.login), - ]), - ].join(", "), - ]), - ); -} - -export default function ObservedAccounts({ - accessToken, - people, - initialEmail = "", - onClose, - onSaved, -}: { - accessToken: string; - people: ObservedPerson[]; - initialEmail?: string; - onClose: () => void; - onSaved: () => void; -}) { - const [identities, setIdentities] = useState | null>(null); - const [email, setEmail] = useState(initialEmail); - const [logins, setLogins] = useState(people.find((person) => person.email === initialEmail)?.logins.join(", ") ?? ""); - const [linked, setLinked] = useState>({}); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(""); - useEffect(() => { - const controller = new AbortController(); - apiClient - .get("/roi-calculator/observed/identities", { accessToken, signal: controller.signal }) - .then((data) => { - if (!controller.signal.aborted) { - const parsed = identitiesSchema.parse(data); - setIdentities(parsed); - setLinked(matches(parsed, initialEmail, people)); - } - }) - .catch((reason: unknown) => { - if (!controller.signal.aborted) setError(extractProxyErrorMessage(reason)); - }); - return () => controller.abort(); - }, [accessToken, initialEmail, people]); - function selectEmail(value: string) { - setEmail(value); - if (identities) setLinked(matches(identities, value, people)); - const automatic = people.find((person) => person.email === value)?.logins ?? []; - const manual = Object.entries(identities?.identity_map ?? {}) - .filter(([, address]) => address === value) - .map(([login]) => login); - setLogins([...new Set([...automatic, ...manual])].join(", ")); - } - async function save() { - setSaving(true); - setError(""); - try { - await apiClient.put("/roi-calculator/observed/identities", { - accessToken, - body: { - email: email.trim().toLowerCase(), - ...(identities?.connections?.length - ? { - accounts: identities.connections.flatMap((entry) => - accountLogins(linked[entry.id] ?? "").map((login) => ({ connection_id: entry.id, login })), - ), - } - : { logins: accountLogins(logins) }), - }, - }); - onSaved(); - onClose(); - } catch (reason) { - setError(extractProxyErrorMessage(reason)); - } finally { - setSaving(false); - } - } - return ( - { - if (!open) onClose(); - }} - > - - - Link accounts - Match one internal user to all their source accounts - -
-
- - selectEmail(event.target.value)} - /> - - {identities?.gateway_emails.map((address) => -
- {identities?.connections?.length ? ( - identities.connections.map((entry) => ( -
- - setLinked({ ...linked, [entry.id]: event.target.value })} - placeholder="current-account, old-account" - /> - {entry.unmatched_logins.length > 0 && ( -
- {entry.unmatched_logins.length} unmatched accounts -
- {entry.unmatched_logins.map((login) => ( - - ))} -
-
- )} -
- )) - ) : ( -
- - setLogins(event.target.value)} - placeholder="current-account, old-account" - /> -
- )} -

- Separate accounts with commas. Their merged changes are combined, and gateway spend is counted once -

- {error && ( -

- {error} -

- )} - -
-
-
- ); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedConnections.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedConnections.integration.test.tsx deleted file mode 100644 index 730f0c8faf5..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedConnections.integration.test.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import ObservedConnections from "./ObservedConnections"; -import type { ObservedSettings } from "./observedData"; - -const settings: ObservedSettings = { - id: "initial-github", - source_provider: "github", - api_url: "https://api.github.com", - repos: [], - has_token: false, - connection_type: "token", - update_interval_minutes: 1440, - ready: false, -}; -const app = { configured: true, api_url: null, callback_url: null }; -afterEach(() => vi.unstubAllGlobals()); - -describe("observed ROI connections", () => { - it("identifies the saved connection when editing its host", async () => { - const writes: unknown[] = []; - const existing = { ...settings, id: "saved-github", has_token: true, repos: ["org/service"], ready: true }; - vi.stubGlobal( - "fetch", - vi.fn(async (input: string, init: RequestInit) => { - const path = new URL(input, "http://localhost").pathname; - if (path.endsWith("/apps")) return Response.json({ github: app, gitlab: app }); - if (path.endsWith("/repositories")) return Response.json({ repositories: [], has_more: false }); - if (path.endsWith("/settings")) { - writes.push(JSON.parse(String(init.body))); - return Response.json({ ...existing, id: "enterprise-github", api_url: "https://git.example.test/api/v3" }); - } - throw new Error(path); - }), - ); - const user = userEvent.setup(); - render( - , - ); - await user.click(screen.getByRole("button", { name: "Edit GitHub api.github.com" })); - await user.click(screen.getByRole("button", { name: "Change connection" })); - await user.click(screen.getByText("Self-hosted instance")); - fireEvent.change(screen.getByLabelText("API URL"), { target: { value: "https://git.example.test/api/v3" } }); - fireEvent.change(screen.getByLabelText("GitHub access token"), { target: { value: "enterprise-test-token" } }); - await user.click(screen.getByRole("button", { name: "Continue" })); - expect(await screen.findByRole("heading", { name: "Choose repositories" })).toBeInTheDocument(); - expect(writes).toEqual([ - { - connection_id: "saved-github", - source_provider: "github", - api_url: "https://git.example.test/api/v3", - token: "enterprise-test-token", - repos: [], - update_interval_minutes: 1440, - }, - ]); - }); - it("starts a GitHub installation when switching from a GitLab app connection", async () => { - const starts = vi.fn(); - vi.stubGlobal( - "fetch", - vi.fn(async (input: string, init: RequestInit) => { - const url = new URL(input, "http://localhost"); - if (url.pathname.endsWith("/apps")) - return Response.json({ github: { ...app, can_install: true }, gitlab: app }); - if (url.pathname.endsWith("/repositories")) return Response.json({ repositories: [], has_more: false }); - starts(url.searchParams.get("install"), init.credentials); - return Response.json({ detail: "Authorization test stopped before redirect" }, { status: 502 }); - }), - ); - const user = userEvent.setup(); - const connected: ObservedSettings = { - ...settings, - source_provider: "gitlab", - connection_type: "app", - has_token: true, - }; - render(); - await user.click(screen.getByRole("button", { name: "Change connection" })); - await user.click(screen.getByRole("button", { name: "GitHub", exact: true })); - const connect = screen.getByRole("button", { name: "Connect GitHub", exact: true }); - await waitFor(() => expect(connect).toBeEnabled()); - await user.click(connect); - expect(await screen.findByRole("alert")).toHaveTextContent("Authorization test stopped before redirect"); - expect(starts).toHaveBeenCalledWith("true", "include"); - }); - it.each(["GitHub", "GitLab"] as const)( - "connects %s with a token, saves repositories, and starts a sync", - async (label) => { - const provider = label === "GitHub" ? "github" : "gitlab"; - const apiUrl = provider === "github" ? "https://api.github.com" : "https://gitlab.com/api/v4"; - const saved = vi.fn(); - const closed = vi.fn(); - const writes: { path: string; body: unknown }[] = []; - vi.stubGlobal( - "fetch", - vi.fn(async (input: string, init: RequestInit) => { - const path = new URL(input, "http://localhost").pathname; - if (init.method === "PUT" || init.method === "POST") - writes.push({ path, body: init.body ? JSON.parse(String(init.body)) : undefined }); - if (path.endsWith("/apps")) return Response.json({ github: app, gitlab: app }); - if (path.endsWith("/repositories")) - return Response.json({ - repositories: [{ name: "org/service", visibility: "private", archived: false }], - has_more: false, - }); - if (path.endsWith("/settings")) { - const request = JSON.parse(String(init.body)) as { repos: string[] }; - const connected = { - ...settings, - id: `saved-${provider}`, - source_provider: provider, - api_url: apiUrl, - has_token: true, - repos: request.repos, - ready: request.repos.length > 0, - }; - return Response.json(connected); - } - if (path.endsWith("/sync")) return Response.json({ running: true }, { status: 202 }); - throw new Error(path); - }), - ); - const user = userEvent.setup(); - render( - , - ); - await user.click(screen.getByRole("button", { name: label, exact: true })); - await user.click(screen.getByRole("button", { name: "Access token", exact: true })); - fireEvent.change(screen.getByLabelText(`${label} access token`), { target: { value: "source-test-token" } }); - await user.click(screen.getByRole("button", { name: "Continue" })); - await user.click(await screen.findByRole("checkbox", { name: /org\/service/ })); - await user.click(screen.getByRole("button", { name: "Save and sync" })); - await waitFor(() => expect(saved).toHaveBeenCalledOnce()); - expect(closed).toHaveBeenCalledOnce(); - expect(writes).toEqual([ - { - path: "/roi-calculator/observed/settings", - body: { - source_provider: provider, - api_url: apiUrl, - token: "source-test-token", - repos: [], - update_interval_minutes: 1440, - }, - }, - { - path: "/roi-calculator/observed/settings", - body: { - connection_id: `saved-${provider}`, - source_provider: provider, - api_url: apiUrl, - repos: ["org/service"], - update_interval_minutes: 1440, - }, - }, - { path: "/roi-calculator/observed/sync", body: undefined }, - ]); - }, - ); - it.each(["GitHub", "GitLab"] as const)( - "starts %s app authorization with a browser cookie and displays provider failures", - async (label) => { - const provider = label.toLowerCase(); - vi.stubGlobal( - "fetch", - vi.fn(async (input: string, init: RequestInit) => { - if (String(input).endsWith("/apps")) return Response.json({ github: app, gitlab: app }); - expect(String(input)).toContain(`/oauth/${provider}/start`); - expect(init.credentials).toBe("include"); - expect(init.method).toBe("POST"); - return Response.json({ detail: "Provider unavailable. Try again" }, { status: 502 }); - }), - ); - const user = userEvent.setup(); - render( - , - ); - await user.click(screen.getByRole("button", { name: label, exact: true })); - const button = await screen.findByRole("button", { name: `Connect ${label}`, exact: true }); - await waitFor(() => expect(button).toBeEnabled()); - await user.click(button); - expect(await screen.findByRole("alert")).toHaveTextContent("Provider unavailable. Try again"); - expect(button).toBeEnabled(); - }, - ); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedConnections.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedConnections.tsx deleted file mode 100644 index 591b7066e48..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedConnections.tsx +++ /dev/null @@ -1,606 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Github, Gitlab, ArrowLeft, KeyRound } from "lucide-react"; -import { z } from "zod"; -import { apiClient } from "@/components/networking"; -import { extractProxyErrorMessage } from "@/lib/http/client"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; -import { - observedSettingsSchema, - repositoryNames, - type ObservedSettings, - type ObservedConnection, -} from "./observedData"; - -const appFields = { - configured: z.boolean(), - can_install: z.boolean().optional().default(false), - api_url: z.string().nullable(), - callback_url: z.string().nullable(), -}; -const appSchema = z.object(appFields); -const appsSchema = z.object({ github: appSchema, gitlab: appSchema }); -const repositoriesSchema = z.object({ - repositories: z.array(z.object({ name: z.string(), visibility: z.string(), archived: z.boolean() })), - has_more: z.boolean(), -}); -const defaultUrl = { github: "https://api.github.com", gitlab: "https://gitlab.com/api/v4" }; - -function preferredConnectionMethod(selected: "app" | "token" | null, configured: boolean | undefined) { - return selected ?? (configured ? "app" : "token"); -} - -function TokenFields({ - label, - provider, - token, - onToken, - apiUrl, - onUrl, - hasToken, -}: { - label: string; - provider: ObservedSettings["source_provider"]; - token: string; - onToken: (value: string) => void; - apiUrl: string; - onUrl: (value: string) => void; - hasToken: boolean; -}) { - return ( - <> - - onToken(event.target.value)} - placeholder={hasToken ? "Leave blank to keep the saved token" : "Optional for public repositories"} - /> -

- {provider === "github" - ? "Fine-grained token: read access to pull requests, issues, and metadata" - : "Token with read_api scope"} -

-
- Self-hosted instance - - onUrl(event.target.value)} /> -
- - ); -} -function AppMessage({ configured, label }: { configured: boolean; label: string }) { - return ( -

- {configured - ? `You’ll authorize ${label}, then choose repositories` - : `Register the ${label} app in gateway settings, or connect with a token`} -

- ); -} - -function RepositoryChoices({ - available, - repos, - setRepos, - query, - setQuery, - page, - setPage, -}: { - available: z.infer | null; - repos: string; - setRepos: (value: string) => void; - query: string; - setQuery: (value: string) => void; - page: number; - setPage: (value: number) => void; -}) { - return ( - <> - { - setQuery(event.target.value); - setPage(1); - }} - placeholder="Find repositories…" - /> -
- {!available && ( -

- Loading repositories… -

- )} - {available?.repositories.length === 0 && ( -

No repositories found

- )} - {available?.repositories - .filter((repo) => !repo.archived) - .map((repo) => ( - - ))} -
-
- - -
- - ); -} - -function ConnectionMethod({ - method, - setMethod, - label, - provider, - token, - setToken, - apiUrl, - setApiUrl, - connected, - apps, - busy, - connect, -}: { - method: "app" | "token"; - setMethod: (value: "app" | "token") => void; - label: string; - provider: ObservedSettings["source_provider"]; - token: string; - setToken: (value: string) => void; - apiUrl: string; - setApiUrl: (value: string) => void; - connected: ObservedConnection; - apps: z.infer | null; - busy: boolean; - connect: () => void; -}) { - const connectLabel = method === "app" ? `Connect ${label}` : "Continue"; - const sameSource = connected.source_provider === provider && connected.api_url === apiUrl; - const hasSavedToken = sameSource && connected.has_token && connected.connection_type === "token"; - return ( - <> -
- - -
- {method === "token" && ( - - )} - {method === "app" && } - - - ); -} - -type ConnectionStep = "list" | "connect" | "repos"; -const stepTitles = { list: "Connections", connect: "Connect your code", repos: "Choose repositories" }; - -function initialStep(settings: ObservedSettings, afterAuthorization: boolean): ConnectionStep { - if (afterAuthorization) return "repos"; - if (settings.connections?.length) return "list"; - return settings.has_token || settings.ready ? "repos" : "connect"; -} - -function stepDescription(step: ConnectionStep, label: string) { - if (step === "list") return "All selected repositories appear in one report"; - if (step === "connect") return "Connect GitHub and GitLab with an app or access token"; - return `Select ${label} repositories to compare`; -} - -function connectionMethodLabel(entry: ObservedConnection) { - if (entry.connection_type === "app") return "App"; - return entry.has_token ? "Token" : "Public access"; -} - -function ConnectionList({ - connections, - onEdit, - onAdd, -}: { - connections: ObservedConnection[]; - onEdit: (entry: ObservedConnection) => void; - onAdd: () => void; -}) { - return ( - <> - {connections.map((entry) => ( -
-
-

- {entry.source_provider === "github" ? : } - {entry.source_provider === "github" ? "GitHub" : "GitLab"} -

-

{new URL(entry.api_url).host}

-

- {entry.repos.length} repositories · {connectionMethodLabel(entry)} -

-
- -
- ))} - - - ); -} - -function initialMethod(settings: ObservedSettings) { - return settings.has_token ? settings.connection_type : null; -} - -function hasConnections(settings: ObservedSettings) { - return Boolean(settings.connections?.length); -} - -function canManageApp(connected: ObservedConnection, apps: z.infer | null) { - return ( - connected.connection_type === "app" && connected.source_provider === "github" && Boolean(apps?.github.can_install) - ); -} - -export default function ObservedConnections({ - accessToken, - settings, - onClose, - onSaved, - initialError = "", - afterAuthorization = false, -}: { - accessToken: string; - settings: ObservedSettings; - onClose: () => void; - onSaved: () => void; - initialError?: string; - afterAuthorization?: boolean; -}) { - const [savedSettings, setSavedSettings] = useState(settings); - const [connected, setConnected] = useState(settings); - const [provider, setProvider] = useState(settings.source_provider); - const [apiUrl, setApiUrl] = useState(settings.api_url); - const [selectedMethod, setMethod] = useState<"app" | "token" | null>(initialMethod(settings)); - const [step, setStep] = useState(() => initialStep(settings, afterAuthorization)); - const [token, setToken] = useState(""); - const [repos, setRepos] = useState(settings.repos.join(", ")); - const [apps, setApps] = useState | null>(null); - const method = preferredConnectionMethod(selectedMethod, apps?.[provider].configured); - const [available, setAvailable] = useState | null>(null); - const [query, setQuery] = useState(""); - const [page, setPage] = useState(1); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(initialError); - const label = provider === "github" ? "GitHub" : "GitLab"; - const saveLabel = repositoryNames(repos).length ? "Save and sync" : "Save repositories"; - const manageApp = canManageApp(connected, apps); - const showConnections = hasConnections(savedSettings); - useEffect(() => { - const controller = new AbortController(); - apiClient - .get("/roi-calculator/observed/apps", { accessToken, signal: controller.signal }) - .then((data) => { - if (!controller.signal.aborted) setApps(appsSchema.parse(data)); - }) - .catch((reason: unknown) => { - if (!controller.signal.aborted) setError(extractProxyErrorMessage(reason)); - }); - return () => controller.abort(); - }, [accessToken]); - useEffect(() => { - if (step !== "repos" || (!connected.has_token && connected.source_provider === "github")) return; - const controller = new AbortController(); - const timer = setTimeout(() => { - apiClient - .get("/roi-calculator/observed/repositories", { - accessToken, - signal: controller.signal, - query: { query, page, connection: connected.id }, - }) - .then((data) => { - if (!controller.signal.aborted) setAvailable(repositoriesSchema.parse(data)); - }) - .catch((reason: unknown) => { - if (!controller.signal.aborted) setError(extractProxyErrorMessage(reason)); - }); - }, 250); - return () => { - clearTimeout(timer); - controller.abort(); - }; - }, [accessToken, step, connected, query, page]); - function selectProvider(value: ObservedSettings["source_provider"]) { - setProvider(value); - const existing = savedSettings.connections?.find( - (entry) => entry.source_provider === value && entry.api_url === defaultUrl[value], - ); - setApiUrl(existing?.api_url ?? defaultUrl[value]); - setConnected( - existing ?? { - ...settings, - source_provider: value, - api_url: defaultUrl[value], - repos: [], - has_token: false, - ready: false, - connection_type: "token", - id: undefined, - }, - ); - setMethod(existing?.connection_type ?? null); - setToken(""); - setError(""); - } - function searchRepositories(value: string) { - setAvailable(null); - setError(""); - setQuery(value); - } - function changePage(value: number) { - setAvailable(null); - setError(""); - setPage(value); - } - async function connect(install = false) { - setBusy(true); - setError(""); - try { - if (method === "app" || install) { - const sameApp = connected.source_provider === provider && connected.connection_type === "app"; - const firstInstallation = provider === "github" && !sameApp && apps?.github.can_install; - const result = z.object({ url: z.string().url() }).parse( - await apiClient.post(`/roi-calculator/observed/oauth/${provider}/start`, { - accessToken, - credentials: "include", - query: { install: install || Boolean(firstInstallation) }, - }), - ); - window.location.assign(result.url); - return; - } - const same = provider === connected.source_provider && apiUrl === connected.api_url; - const keepToken = same && connected.has_token && connected.connection_type === "token"; - const result = observedSettingsSchema.parse( - await apiClient.put("/roi-calculator/observed/settings", { - accessToken, - body: { - connection_id: savedSettings.connections?.find((entry) => entry.id === connected.id)?.id, - source_provider: provider, - api_url: apiUrl, - token: token || (keepToken ? undefined : ""), - repos: same ? connected.repos : [], - update_interval_minutes: connected.update_interval_minutes, - }, - }), - ); - setSavedSettings(result); - setConnected(result); - setToken(""); - setRepos(result.repos.join(", ")); - setAvailable(null); - setStep("repos"); - } catch (reason) { - setError(extractProxyErrorMessage(reason)); - } finally { - setBusy(false); - } - } - async function save() { - setBusy(true); - setError(""); - try { - const result = observedSettingsSchema.parse( - await apiClient.put("/roi-calculator/observed/settings", { - accessToken, - body: { - connection_id: connected.id, - source_provider: connected.source_provider, - api_url: connected.api_url, - repos: repositoryNames(repos), - update_interval_minutes: connected.update_interval_minutes, - }, - }), - ); - if (result.ready) await apiClient.post("/roi-calculator/observed/sync", { accessToken }); - onSaved(); - onClose(); - } catch (reason) { - setError(extractProxyErrorMessage(reason)); - } finally { - setBusy(false); - } - } - function edit(entry: ObservedConnection) { - setConnected(entry); - setProvider(entry.source_provider); - setApiUrl(entry.api_url); - setMethod(entry.connection_type); - setRepos(entry.repos.join(", ")); - setToken(""); - setQuery(""); - setPage(1); - setAvailable(null); - setError(""); - setStep("repos"); - } - return ( - { - if (!open) onClose(); - }} - > - - - {stepTitles[step]} - {stepDescription(step, label)} - -
- {step === "list" && ( - { - selectProvider( - savedSettings.connections?.some((entry) => entry.source_provider === "github") ? "gitlab" : "github", - ); - setStep("connect"); - }} - /> - )} - {step === "connect" && ( - <> - {showConnections && ( - - )} -
- {(["github", "gitlab"] as const).map((value) => ( - - ))} -
- connect()} - /> - - )} - {step === "repos" && ( - <> - {showConnections && ( - - )} - - {manageApp && ( - - )} - - setRepos(event.target.value)} - placeholder={ - provider === "github" ? "owner/repo, owner/another-repo" : "group/project, group/subgroup/project" - } - /> - {(connected.has_token || connected.source_provider === "gitlab") && ( - <> - - - )} - - - )} - {error && ( -

- {error} -

- )} -
-
-
- ); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedDetails.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedDetails.tsx deleted file mode 100644 index 84a4762f046..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedDetails.tsx +++ /dev/null @@ -1,239 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { ExternalLink, GitPullRequest } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { - dateRange, - changeTerms, - duration, - money, - number, - recordedBranches, - type Comparison, - type ObservedPerson, - type ObservedPull, - type ObservedSnapshot, - type Period, -} from "./observedData"; - -export function PullList({ - pulls, - provider, - matchedOnly = false, -}: { - matchedOnly?: boolean; - pulls: ObservedPull[]; - provider: ObservedSnapshot["source_provider"]; -}) { - const terms = changeTerms(provider); - const emptyMessage = matchedOnly - ? "No merged changes from matched people in this period" - : `No ${terms.lower} in this period`; - const [query, setQuery] = useState(""); - const [limit, setLimit] = useState(20); - const filtered = pulls.filter((pull) => - `${pull.number} ${pull.title} ${pull.author} ${pull.repo} ${pull.source_repo} ${pull.source_branch}` - .toLowerCase() - .includes(query.toLowerCase()), - ); - return ( -
- { - setQuery(event.target.value); - setLimit(20); - }} - className="max-w-sm" - /> - - - - {terms.requests} - Author - Opened to merged - Tagged spend - - - - {filtered.slice(0, limit).map((pull) => ( - - - - - - #{pull.number} - {pull.title} - {pull.repo} - - - - - - {pull.author || "Deleted author"} - {pull.agent && ( - - Agent - - )} - - {duration(pull.merge_hours)} - - {money(pull.branch_cost.spend)} - - - ))} - -
-

- Elapsed time from opening to merge, not engineering effort or time saved -

- {filtered.length === 0 && ( -

- {query ? `No ${terms.lower} match this search` : emptyMessage} -

- )} -
- - {number(Math.min(limit, filtered.length))} of {number(filtered.length)} {terms.lower} - - {limit < filtered.length && ( - - )} -
-
- ); -} - -export function PersonDetails({ - person, - snapshot, - comparison, - onClose, - onEdit, -}: { - person: ObservedPerson; - snapshot: ObservedSnapshot; - comparison: Comparison; - onClose: () => void; - onEdit?: () => void; -}) { - const terms = changeTerms(snapshot.source_provider); - const [period, setPeriod] = useState("current"); - const current = person.periods.current; - const baseline = person.periods[comparison]; - const urls = new Set(person.periods[period].pr_urls); - const pulls = snapshot.pulls[period].filter((pull) => urls.has(pull.url)); - return ( - { - if (!open) onClose(); - }} - > - - - {person.name} - {[person.email, person.logins.join(", ")].filter(Boolean).join(" · ")} - - {onEdit && ( - - )} -
-
-

Merged {terms.plural}

-

{number(current.merged_prs)}

-

{number(baseline.merged_prs)} in comparison

-
-
-

Recorded spend

-

- {money(current.spend_observation === "no_records" ? null : current.gateway_recorded_spend)} -

-

Gateway only

-
-
-

Spend / matched {terms.singular}

-

{money(current.recorded_spend_per_attributed_pr)}

-

Period average

-
-
-
-

Merged {terms.plural}

-
- - -
-
-

{dateRange(snapshot.periods[period].window)} · UTC

- -
-
- ); -} - -export function BranchSpend({ snapshot, matchedOnly = true }: { snapshot: ObservedSnapshot; matchedOnly?: boolean }) { - const rows = recordedBranches(snapshot, matchedOnly); - return ( -
- - - - Repository - Branch - Requests - Tagged spend - - - - {rows.map((row) => ( - - {row.repo} - {row.branch} - {number(row.requests)} - {money(row.spend)} - - ))} - -
- {rows.length === 0 && ( -

- {matchedOnly - ? "No tagged branch spend for matched people in this period" - : "No tagged branch spend in this period"} -

- )} -
- ); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedROIView.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedROIView.integration.test.tsx deleted file mode 100644 index b16fe5aa7d3..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedROIView.integration.test.tsx +++ /dev/null @@ -1,384 +0,0 @@ -import { fireEvent, render as renderWithoutNuqs, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { NuqsAdapter } from "nuqs/adapters/react"; -import ObservedROIView from "./ObservedROIView"; -import { createObservedDemo } from "./observedDemo"; -import type { ObservedSettings, ObservedSnapshot, ObservedStatus } from "./observedData"; - -const render = (ui: Parameters[0]) => renderWithoutNuqs(ui, { wrapper: NuqsAdapter }); - -const settings: ObservedSettings = { - source_provider: "gitlab", - api_url: "https://gitlab.com/api/v4", - repos: ["org/service"], - has_token: true, - connection_type: "app", - update_interval_minutes: 1440, - ready: true, -}; -const idle: ObservedStatus = { - running: false, - phase: "complete", - stage: "", - done: 0, - total: 0, - error: null, - finished_at: null, -}; -const period = { - window: { start: "2026-09-01", end: "2026-09-28" }, - merged_prs: 1, - median_merge_hours: 16 / 3600, - human_authored: 1, - agent_authored: 0, - missing_author: 0, - agents_without_requester: 0, - matched_internal_prs: 0, - new_bug_labeled_issues: 0, - new_regression_labeled_issues: 0, - explicitly_titled_revert_prs: 0, - matched_users_recorded_spend: 0, - spend_observation: "no_records" as const, - human_summary: { median_merge_hours: 16 / 3600 }, -}; -const report: ObservedSnapshot = { - source_provider: "gitlab", - repos: ["org/service"], - unmatched_logins: [], - unlinked_branches: [], - captured_at: "2026-09-29T00:00:00Z", - periods: { current: period, previous: period, last_year: period }, - people: [], - pulls: { current: [], previous: [], last_year: [] }, -}; -const app = { configured: true, api_url: null, callback_url: null }; - -afterEach(() => { - vi.unstubAllGlobals(); - window.history.replaceState(null, "", "/"); -}); - -describe("observed ROI dashboard", () => { - it("defaults every contributor list to matched people and keeps the switch across tabs", async () => { - const sample = createObservedDemo(7); - const outside = { - ...sample.pulls.current[0], - url: "https://gitlab.com/outside/api/-/merge_requests/999", - author: "outside", - agent: false, - title: "Outside change", - source_branch: "outside-only", - branch_cost: { - repo: "gitlab.com/outside/api", - branch: "outside-only", - spend: 123, - requests: 5, - status: "matched" as const, - }, - }; - const report = { - ...sample, - pulls: { ...sample.pulls, current: [...sample.pulls.current, outside] }, - unlinked_branches: [{ repo: "gitlab.com/outside/api", branch: "orphan-only", spend: 5, requests: 1 }], - }; - const requests = vi.fn(async (input: string, _init: RequestInit) => { - const path = new URL(input, "http://localhost").pathname; - if (path.endsWith("/settings")) return Response.json(settings); - if (path.endsWith("/report")) return Response.json({ report }); - if (path.endsWith("/sync")) return Response.json(idle); - throw new Error(path); - }); - vi.stubGlobal("fetch", requests); - const user = userEvent.setup(); - render(); - expect(await screen.findByRole("switch", { name: "Matched people only" })).toBeChecked(); - expect(screen.getByRole("tab", { name: "Engineers 3" })).toBeInTheDocument(); - expect(screen.queryByText("outside")).not.toBeInTheDocument(); - await user.click(screen.getByRole("switch", { name: "Matched people only" })); - expect(screen.getByRole("tab", { name: "Engineers 4" })).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "View outside's merged changes" })); - expect(await screen.findByRole("dialog", { name: "outside" })).toHaveTextContent("Outside change"); - expect(screen.queryByRole("button", { name: "Edit linked accounts" })).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Close" })); - await user.click(screen.getByRole("switch", { name: "Matched people only" })); - await user.click(screen.getByRole("tab", { name: "Merged changes" })); - expect(screen.queryByText("Outside change")).not.toBeInTheDocument(); - await user.click(screen.getByRole("tab", { name: "Branch spend" })); - expect(screen.getAllByText(/feature\/sample-/).length).toBeGreaterThan(0); - expect(screen.queryByText("outside-only")).not.toBeInTheDocument(); - expect(screen.queryByText("orphan-only")).not.toBeInTheDocument(); - await user.click(screen.getByRole("switch", { name: "Matched people only" })); - expect(screen.getByText("outside-only")).toBeInTheDocument(); - expect(screen.getByText("orphan-only")).toBeInTheDocument(); - await user.click(screen.getByRole("tab", { name: "Merged changes" })); - expect(screen.getByText("Outside change")).toBeInTheDocument(); - expect(requests.mock.calls.every(([, init]) => init.method === "GET")).toBe(true); - }); - - it("shows a useful empty matched view and exposes all changes when the filter is off", async () => { - const sample = { ...createObservedDemo(7), people: [] }; - vi.stubGlobal( - "fetch", - vi.fn(async (input: string) => { - const path = new URL(input, "http://localhost").pathname; - if (path.endsWith("/settings")) return Response.json(settings); - if (path.endsWith("/report")) return Response.json({ report: sample }); - return Response.json(idle); - }), - ); - const user = userEvent.setup(); - render(); - expect(await screen.findByText("No merged changes from matched people in this period")).toBeInTheDocument(); - await user.click(screen.getByRole("switch", { name: "Matched people only" })); - expect(screen.queryByText("No merged changes from matched people in this period")).not.toBeInTheDocument(); - expect(screen.getAllByRole("link", { name: /Add repository search/ }).length).toBeGreaterThan(0); - }); - - it("previews every sample view before setup, changes sample periods without writes, and exits back to setup", async () => { - const requests = vi.fn(async (input: string, _init: RequestInit) => { - const path = new URL(input, "http://localhost").pathname; - const disconnected = { ...settings, ready: false, repos: [], has_token: false }; - if (path.endsWith("/settings")) return Response.json(disconnected); - if (path.endsWith("/report")) return Response.json({ report: null }); - if (path.endsWith("/sync")) return Response.json(idle); - throw new Error(path); - }); - vi.stubGlobal("fetch", requests); - const user = userEvent.setup(); - render(); - expect(await screen.findByRole("heading", { name: "Connect your repositories" })).toBeInTheDocument(); - expect(screen.queryByText("Ready to sync")).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Preview sample report" })); - expect(screen.getByRole("status")).toHaveTextContent("You’re viewing demo data"); - expect(screen.getByRole("tab", { name: "Engineers 3", selected: true })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Connections" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Link accounts" })).not.toBeInTheDocument(); - await waitFor(() => expect(window.location.search).toBe("?demo=1")); - await user.click(screen.getByRole("button", { name: "View Alex Rivera's merged changes" })); - expect(await screen.findByRole("dialog", { name: "Alex Rivera" })).toHaveTextContent("alex-demo@example.com"); - expect(screen.getByRole("heading", { name: "Merged changes" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Edit linked accounts" })).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Close" })); - await user.click(screen.getByRole("tab", { name: "Merged changes" })); - expect(screen.getByRole("img", { name: /Merged changes by week/ })).toBeInTheDocument(); - await user.click(screen.getByRole("tab", { name: "Quality" })); - expect(screen.getByText("New regression-labeled issues")).toBeInTheDocument(); - await user.click(screen.getByRole("tab", { name: "Branch spend" })); - expect(screen.getAllByText(/feature\/sample-/).length).toBeGreaterThan(0); - await user.click(screen.getByRole("combobox", { name: "Reporting period" })); - await user.click(await screen.findByRole("option", { name: "Last 7 days" })); - expect(screen.getByRole("combobox", { name: "Reporting period" })).toHaveTextContent("Last 7 days"); - await user.click(screen.getByRole("combobox", { name: "Comparison period" })); - await user.click(await screen.findByRole("option", { name: "vs. same period last year" })); - expect(screen.getByRole("combobox", { name: "Comparison period" })).toHaveTextContent("vs. same period last year"); - await user.click(screen.getByRole("button", { name: "Exit demo" })); - expect(screen.getByRole("heading", { name: "Connect your repositories" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Connect GitHub or GitLab" })).toBeEnabled(); - await waitFor(() => expect(window.location.search).toBe("")); - expect(requests.mock.calls.every(([, init]) => init.method === "GET")).toBe(true); - }); - - it("keeps live sync and its report intact when entering and exiting the demo", async () => { - const requests = vi.fn(async (input: string, _init: RequestInit) => { - const path = new URL(input, "http://localhost").pathname; - if (path.endsWith("/settings")) return Response.json(settings); - if (path.endsWith("/report")) return Response.json({ report }); - if (path.endsWith("/sync")) return Response.json({ ...idle, running: true, stage: "Reading changes" }); - throw new Error(path); - }); - vi.stubGlobal("fetch", requests); - window.history.replaceState(null, "", "/roi-calculator/?from=review#report"); - const user = userEvent.setup(); - render(); - expect(await screen.findByRole("button", { name: "Cancel sync" })).toBeEnabled(); - await user.click(screen.getByRole("button", { name: "Preview sample report" })); - expect(screen.queryByRole("button", { name: "Cancel sync" })).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "2 repositories" })).toBeInTheDocument(); - await waitFor(() => expect(window.location.search).toBe("?from=review&demo=1")); - await user.click(screen.getByRole("button", { name: "Exit demo" })); - expect(screen.getByRole("button", { name: "Cancel sync" })).toBeEnabled(); - expect(screen.getByRole("button", { name: "1 repository" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Merge requests", selected: true })).toBeInTheDocument(); - await waitFor(() => expect(window.location.search).toBe("?from=review")); - expect(window.location.hash).toBe("#report"); - expect(requests.mock.calls.every(([, init]) => init.method === "GET")).toBe(true); - }); - - it.each(["failed", "pending"])("opens a demo URL even when live requests are %s", async (state) => { - window.history.replaceState(null, "", "/roi-calculator/?demo=1"); - const pending = Promise.withResolvers(); - vi.stubGlobal( - "fetch", - vi.fn(() => (state === "failed" ? Promise.reject(new Error("Live data unavailable")) : pending.promise)), - ); - const user = userEvent.setup(); - render(); - expect(screen.getByRole("status")).toHaveTextContent("You’re viewing demo data"); - expect(screen.getByText("Alex Rivera")).toBeInTheDocument(); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Exit demo" })); - expect(screen.queryByText("Alex Rivera")).not.toBeInTheDocument(); - expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument(); - await waitFor(() => expect(window.location.search).toBe("")); - if (state === "failed") expect(await screen.findByRole("alert")).toHaveTextContent("Live data unavailable"); - }); - - it("retries failures, keeps the report during cancellation, and refreshes after completion", async () => { - let status: ObservedStatus = { ...idle, phase: "error", error: "Provider temporarily unavailable" }; - let completeOnPoll = false; - let currentReport = { ...report, repos: ["org/service", "org/docs"] }; - vi.stubGlobal( - "fetch", - vi.fn(async (input: string, init: RequestInit) => { - const path = new URL(input, "http://localhost").pathname; - if (path.endsWith("/settings")) return Response.json(settings); - if (path.endsWith("/report")) return Response.json({ report: currentReport }); - if (path.endsWith("/sync")) { - if (init.method === "POST") status = { ...idle, running: true, phase: "pulls" }; - if (init.method === "DELETE") status = { ...idle, phase: "cancelled" }; - if (init.method === "GET" && completeOnPoll) { - status = { ...idle, finished_at: "2026-09-29T00:01:00Z" }; - currentReport = { ...report, repos: ["org/updated"] }; - } - return Response.json(status); - } - throw new Error(path); - }), - ); - const user = userEvent.setup(); - render(); - expect(await screen.findByRole("tab", { name: "Merge requests", selected: true })).toBeInTheDocument(); - await user.click(screen.getByRole("tab", { name: "Quality" })); - expect(screen.getByRole("alert")).toHaveTextContent("Provider temporarily unavailable"); - await user.click(screen.getByRole("button", { name: "Retry" })); - await user.click(await screen.findByRole("button", { name: "Cancel sync" })); - expect(await screen.findByRole("button", { name: "Sync now" })).toBeEnabled(); - expect(screen.queryByText("org/service")).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "2 repositories" })); - const repositories = await screen.findByRole("dialog", { name: "Repositories" }); - expect(within(repositories).getByText("org/service")).toBeInTheDocument(); - expect(within(repositories).getByText("org/docs")).toBeInTheDocument(); - await user.keyboard("{Escape}"); - await waitFor(() => expect(screen.queryByRole("dialog", { name: "Repositories" })).not.toBeInTheDocument()); - await user.click(screen.getByRole("button", { name: "Sync now" })); - expect(await screen.findByRole("button", { name: "Cancel sync" })).toBeEnabled(); - completeOnPoll = true; - await user.click(await screen.findByRole("button", { name: "1 repository" }, { timeout: 4000 })); - expect( - await within(screen.getByRole("dialog", { name: "Repositories" })).findByText("org/updated"), - ).toBeInTheDocument(); - await user.keyboard("{Escape}"); - expect(screen.getByRole("tab", { name: "Quality", selected: true })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Sync now" })).toBeEnabled(); - }); - - it("syncs the selected range and labels its equal-length comparison", async () => { - let currentReport = report; - const requested: string[] = []; - vi.stubGlobal( - "fetch", - vi.fn(async (input: string, init: RequestInit) => { - const url = new URL(input, "http://localhost"); - if (url.pathname.endsWith("/settings")) return Response.json(settings); - if (url.pathname.endsWith("/report")) return Response.json({ report: currentReport }); - if (url.pathname.endsWith("/sync")) { - if (init.method === "POST") { - requested.push(url.searchParams.get("days") ?? ""); - currentReport = { - ...report, - periods: { - ...report.periods, - current: { ...period, window: { start: "2026-09-22", end: "2026-09-28" } }, - previous: { ...period, window: { start: "2026-09-15", end: "2026-09-21" } }, - }, - }; - } - return Response.json(idle); - } - throw new Error(url.pathname); - }), - ); - const user = userEvent.setup(); - render(); - await user.click(await screen.findByRole("combobox", { name: "Reporting period" })); - await user.click(await screen.findByRole("option", { name: "Last 7 days" })); - await waitFor(() => expect(requested).toEqual(["7"])); - expect(await screen.findByText(/Comparing with Sep 15.*Sep 21/)).toBeInTheDocument(); - expect(screen.getByRole("combobox", { name: "Reporting period" })).toHaveTextContent("Last 7 days"); - expect(screen.getByRole("combobox", { name: "Comparison period" })).toHaveTextContent("vs. previous period"); - }); - - it("shows a successful empty repository without a setup prompt or invented durations", async () => { - const emptyPeriod = { - ...period, - merged_prs: 0, - human_authored: 0, - median_merge_hours: null, - human_summary: { median_merge_hours: null }, - }; - const empty = { ...report, periods: { current: emptyPeriod, previous: emptyPeriod, last_year: emptyPeriod } }; - vi.stubGlobal( - "fetch", - vi.fn(async (input: string) => { - const path = new URL(input, "http://localhost").pathname; - if (path.endsWith("/settings")) return Response.json(settings); - if (path.endsWith("/report")) return Response.json({ report: empty }); - if (path.endsWith("/sync")) return Response.json(idle); - throw new Error(path); - }), - ); - render(); - expect(await screen.findByRole("heading", { name: "No merged changes yet" })).toBeInTheDocument(); - expect(screen.getByText("No merges")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Sync now" })).toBeEnabled(); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument(); - expect(screen.queryByText("0h")).not.toBeInTheDocument(); - }); - - it.each([ - { query: "connected=gitlab", alerts: [] }, - { query: "connection_failed=1", alerts: ["Connection failed or expired. Try again or use a token"] }, - { query: "connection_cancelled=1", alerts: ["Connection cancelled. Choose an app or token to try again"] }, - ])("resumes setup after $query and refreshes saved changes after closing", async ({ query, alerts }) => { - window.history.replaceState(null, "", `/roi-calculator/?${query}`); - let currentSettings = settings; - vi.stubGlobal( - "fetch", - vi.fn(async (input: string, init: RequestInit) => { - const path = new URL(input, "http://localhost").pathname; - if (path.endsWith("/apps")) return Response.json({ github: app, gitlab: app }); - if (path.endsWith("/repositories")) return Response.json({ repositories: [], has_more: false }); - if (path.endsWith("/settings")) { - if (init.method === "PUT") currentSettings = { ...settings, repos: ["org/changed"] }; - return Response.json(currentSettings); - } - if (path.endsWith("/report")) return Response.json({ report: { ...report, repos: currentSettings.repos } }); - if (path.endsWith("/sync")) - return init.method === "POST" - ? Response.json({ detail: "Provider unavailable" }, { status: 502 }) - : Response.json(idle); - throw new Error(path); - }), - ); - const user = userEvent.setup(); - render(); - const dialog = await screen.findByRole("dialog", { name: "Choose repositories" }); - expect( - within(dialog) - .queryAllByRole("alert") - .map((alert) => alert.textContent), - ).toEqual(alerts); - await waitFor(() => expect(window.location.search).toBe("")); - fireEvent.change(within(dialog).getByLabelText("Repositories"), { target: { value: "org/changed" } }); - await user.click(within(dialog).getByRole("button", { name: "Save and sync" })); - expect(await within(dialog).findByRole("alert")).toHaveTextContent("Provider unavailable"); - await user.click(within(dialog).getByRole("button", { name: "Close" })); - await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); - await user.click(await screen.findByRole("button", { name: "1 repository" })); - expect( - await within(screen.getByRole("dialog", { name: "Repositories" })).findByText("org/changed"), - ).toBeInTheDocument(); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedROIView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedROIView.tsx deleted file mode 100644 index 4a9d20fb870..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedROIView.tsx +++ /dev/null @@ -1,252 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { parseAsString, useQueryStates } from "nuqs"; -import { Link2, RefreshCw } from "lucide-react"; -import { apiClient } from "@/components/networking"; -import { extractProxyErrorMessage } from "@/lib/http/client"; -import { Page } from "@/components/shared/Page"; -import { PageHeader, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader"; -import { DemoNotice } from "@/components/shared/DemoNotice"; -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import ObservedConnections from "./ObservedConnections"; -import ObservedReport from "./ObservedReport"; -import { useObservedReport, type ObservedViewData } from "./useObservedReport"; -import { syncMessage, type ObservedSnapshot } from "./observedData"; -import { createObservedDemo } from "./observedDemo"; -import { parseAsDemoFlag } from "./demoUrlState"; - -const OBSERVED_ROI_QUERY_PARSERS = { - demo: parseAsDemoFlag, - connected: parseAsString, - connection_cancelled: parseAsString, - connection_failed: parseAsString, -}; - -function SyncActions({ - data, - error, - busy, - readOnly, - compact = false, - onSync, - onRetry, -}: { - data: ObservedViewData | null; - error: string; - busy: boolean; - readOnly: boolean; - compact?: boolean; - onSync: (cancel: boolean) => void; - onRetry: () => void; -}) { - const message = error || data?.status.error; - const statusMessage = data ? syncMessage(data.status, data.report) : ""; - const canSync = data?.settings.ready && !readOnly; - if (!message && !statusMessage && !canSync) return null; - return ( -
- {message && ( -
- {message} - -
- )} - {data && ( -
- {statusMessage && ( - - {statusMessage} - - )} - {!readOnly && data.settings.ready && ( - - )} -
- )} -
- ); -} - -function EmptyReport({ - data, - readOnly, - onConnect, -}: { - data: ObservedViewData; - readOnly: boolean; - onConnect: () => void; -}) { - function title() { - if (data.status.running) return "Reading repository activity"; - return data.settings.ready ? "Ready for your first report" : "Connect your repositories"; - } - return ( -
-

{title()}

-

- {data.status.running - ? "Your report will appear here when the first sync finishes" - : "Compare merged changes, issue trends, and recorded AI spend across your team"} -

- {!readOnly && !data.status.running && ( - - )} -
- ); -} - -export default function ObservedROIView({ - accessToken, - isViewOnly = false, -}: { - accessToken: string; - isViewOnly?: boolean; -}) { - const { data, error, refresh } = useObservedReport(accessToken); - const [{ demo, connected, connection_cancelled, connection_failed }, setQueryParams] = - useQueryStates(OBSERVED_ROI_QUERY_PARSERS); - const [sample, setSample] = useState(() => (demo === true ? createObservedDemo(28) : null)); - const [connections, setConnections] = useState( - ["github", "gitlab"].includes(connected ?? "") || connection_cancelled !== null || connection_failed !== null, - ); - const [connectionError, setConnectionError] = useState(() => { - if (connection_failed !== null) return "Connection failed or expired. Try again or use a token"; - if (connection_cancelled !== null) return "Connection cancelled. Choose an app or token to try again"; - return ""; - }); - const [afterAuthorization, setAfterAuthorization] = useState(Boolean(connected)); - const [busy, setBusy] = useState(false); - const [actionError, setActionError] = useState(""); - useEffect(() => { - setQueryParams({ connected: null, connection_cancelled: null, connection_failed: null }); - }, [setQueryParams]); - function previewSample(enabled: boolean) { - setQueryParams({ demo: enabled ? true : null }); - setSample(enabled ? createObservedDemo(28) : null); - } - function closeConnections() { - setConnections(false); - setConnectionError(""); - setAfterAuthorization(false); - refresh(); - } - async function sync(cancel: boolean, days?: number) { - setBusy(true); - setActionError(""); - try { - if (cancel) await apiClient.delete("/roi-calculator/observed/sync", { accessToken }); - else await apiClient.post("/roi-calculator/observed/sync", { accessToken, query: { days } }); - refresh(); - } catch (reason) { - setActionError(extractProxyErrorMessage(reason)); - } finally { - setBusy(false); - } - } - function retry() { - if (error || isViewOnly || !data?.settings.ready) refresh(); - else void sync(data.status.running); - } - if (sample) { - return ( - setConnections(true)} - actions={null} - notice={ previewSample(false)} />} - syncing={false} - onPeriod={(days) => setSample(createObservedDemo(days))} - /> - ); - } - const previewButton = ( - - ); - const actions = ( - <> - {previewButton} - - - ); - const content = data?.report ? ( - setConnections(true)} - actions={actions} - syncing={busy || data.status.running} - onPeriod={isViewOnly ? undefined : (days) => void sync(false, days)} - /> - ) : ( - - -
- ROI Calculator - {previewButton} -
- Are we shipping more, with fewer bugs, at a better cost? -
- - {!data && !error && ( - <> - - - - )} - {data && setConnections(true)} />} -
- ); - const showConnections = connections && data && !isViewOnly; - return ( - <> - {content} - {showConnections && ( - - )} - - ); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedReport.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedReport.tsx deleted file mode 100644 index 630aafa555d..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedReport.tsx +++ /dev/null @@ -1,631 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { ArrowDown, ArrowUp, CalendarDays, ChevronDown, ChevronRight, Link2, Search, Users } from "lucide-react"; -import { Page, PageTabsList, PageTabsTrigger } from "@/components/shared/Page"; -import { PageHeader, PageHeaderTitle } from "@/components/shared/PageHeader"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Tabs, TabsContent } from "@/components/ui/tabs"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import ObservedAccounts from "./ObservedAccounts"; -import { MatchedPeopleToggle } from "./MatchedPeopleToggle"; -import { BranchSpend, PersonDetails, PullList } from "./ObservedDetails"; -import { - change, - dateRange, - changeTerms, - duration, - money, - number, - visiblePeople, - reportPeople, - filterObservedPulls, - weeklyMerges, - type Comparison, - type ReportPerson, - type ObservedSnapshot, - type PeopleSort, -} from "./observedData"; - -function Delta({ - current, - baseline, - neutral = false, -}: { - current: number | null; - baseline: number | null; - neutral?: boolean; -}) { - const delta = current === null || baseline === null ? null : change(current, baseline); - if (delta === null) return No baseline; - const Icon = delta >= 0 ? ArrowUp : ArrowDown; - return ( - = 0 ? "increase" : "decrease"}`} - className={`inline-flex items-center gap-1 text-xs tabular-nums ${neutral ? "text-muted-foreground" : "text-foreground"}`} - > - - {number(Math.abs(delta))}% - - ); -} - -function Metric({ - label, - value, - detail, - current, - baseline, -}: { - label: string; - value: string; - detail: string; - current?: number | null; - baseline?: number | null; -}) { - return ( -
-
{label}
-
- {value} - {current !== undefined && baseline !== undefined && } -
-

{detail}

-
- ); -} - -function ShippingTrend({ snapshot, comparison }: { snapshot: ObservedSnapshot; comparison: Comparison }) { - const terms = changeTerms(snapshot.source_provider); - const current = weeklyMerges(snapshot, "current"); - const baseline = weeklyMerges(snapshot, comparison); - const max = Math.max(1, ...current, ...baseline); - return ( -
-
-

Repository shipping activity

-
- - - Current period - - - - {comparison === "previous" ? "Previous period" : "Last year"} - -
-
-
- {current.map((value, week) => ( -
-
-
- - {baseline[week]} - -
-
- {value} -
-
-

W{week + 1}

-
- ))} -
-
- ); -} - -function PeopleTable({ - rows, - provider, - matchedOnly, - comparison, - onSelect, -}: { - rows: ReportPerson[]; - provider: ObservedSnapshot["source_provider"]; - matchedOnly: boolean; - comparison: Comparison; - onSelect: (person: ReportPerson) => void; -}) { - const terms = changeTerms(provider); - const [query, setQuery] = useState(""); - const [sort, setSort] = useState("merged"); - const people = visiblePeople(rows, query, sort); - const emptyMessage = matchedOnly - ? "No matched people. Link accounts or turn off the filter to see all contributors" - : "No contributors in these periods"; - return ( -
-
-
- - setQuery(event.target.value)} - className="pl-9" - /> -
-
- - {people.length} {people.length === 1 ? "engineer" : "engineers"} - - -
-
-
- - - - Engineer - Merged {terms.plural} - Authored / agent - - {comparison === "previous" ? "vs. previous" : "vs. last year"} - - Median merge - Recorded spend - Spend / {terms.singular} - - Details - - - - - {people.map((person) => { - const current = person.periods.current; - const baseline = person.periods[comparison]; - return ( - - - - - {number(current.merged_prs)} - -
-
- - -
- - {current.direct_authored}/{current.declared_agent_owned} - -
-
- - - ({baseline.merged_prs}) - - {duration(current.median_merge_hours)} - - {money(current.spend_observation === "no_records" ? null : current.gateway_recorded_spend)} - - - {money(current.recorded_spend_per_attributed_pr)} - - - - -
- ); - })} -
-
- {people.length === 0 && ( -
- {query ? `No engineers match “${query}”` : emptyMessage} -
- )} -
-

- - - Authored - - - - Agent, explicit requester - - Spend / {terms.singular} is recorded period spend divided by matched {terms.plural} -

-
- ); -} - -function Quality({ snapshot, comparison }: { snapshot: ObservedSnapshot; comparison: Comparison }) { - const terms = changeTerms(snapshot.source_provider); - const current = snapshot.periods.current; - const baseline = snapshot.periods[comparison]; - const rows = [ - { - label: "New bug-labeled issues", - current: current.new_bug_labeled_issues, - baseline: baseline.new_bug_labeled_issues, - detail: "Opened during the period, with bug or kind:bug labels at collection", - }, - { - label: "New regression-labeled issues", - current: current.new_regression_labeled_issues, - baseline: baseline.new_regression_labeled_issues, - detail: "Opened during the period and labeled as regressions", - }, - { - label: `Revert-titled ${terms.plural}`, - current: current.explicitly_titled_revert_prs, - baseline: baseline.explicitly_titled_revert_prs, - detail: `Merged ${terms.plural} whose titles explicitly indicate a revert`, - }, - ]; - return ( -
-
- - - - Repository signal - Current - Comparison - Change - - - - {rows.map((row) => ( - - -

{row.label}

-

{row.detail}

-
- {number(row.current)} - {number(row.baseline)} - - - -
- ))} -
-
-
-

- These signals help check whether more shipping comes with more bugs. Labels and revert titles are incomplete - proxies; they do not establish a change-failure rate or attribute bugs to an engineer. -

-
- ); -} - -function costPerChange(period: ObservedSnapshot["periods"]["current"]) { - if (period.spend_observation !== "records_present" || period.matched_internal_prs === 0) return null; - return period.matched_users_recorded_spend / period.matched_internal_prs; -} - -export default function ObservedReport({ - snapshot, - accessToken, - readOnly, - onRefresh, - onConnect, - actions, - notice, - syncing, - onPeriod, -}: { - snapshot: ObservedSnapshot; - accessToken: string; - readOnly: boolean; - onRefresh: () => void; - onConnect: () => void; - actions: React.ReactNode; - notice?: React.ReactNode; - syncing: boolean; - onPeriod?: (days: number) => void; -}) { - const [comparison, setComparison] = useState("previous"); - const [activeTab, setActiveTab] = useState( - snapshot.people.length && snapshot.periods.current.merged_prs > 0 ? "people" : "pulls", - ); - const [accountEmail, setAccountEmail] = useState(null); - const [matchedOnly, setMatchedOnly] = useState(true); - const people = useMemo(() => reportPeople(snapshot, matchedOnly), [snapshot, matchedOnly]); - const pulls = useMemo(() => filterObservedPulls(snapshot, "current", matchedOnly), [snapshot, matchedOnly]); - const [personId, setPersonId] = useState(null); - const person = people.find((entry) => entry.id === personId) ?? null; - const terms = changeTerms(snapshot.source_provider); - const current = snapshot.periods.current; - const baseline = snapshot.periods[comparison]; - const days = Math.round((Date.parse(current.window.end) - Date.parse(current.window.start)) / 86400000) + 1; - const rangeOptions = [...new Set([7, 28, 90, days])].sort((a, b) => a - b); - const cost = costPerChange(current); - const baselineCost = costPerChange(baseline); - return ( - - - ROI Calculator -
- {actions} - {!readOnly && ( - - )} -
-
- {notice} -
- - }> - {number(snapshot.repos.length)} {snapshot.repos.length === 1 ? "repository" : "repositories"} - - - - Repositories -
    - {snapshot.repos.map((repo) => ( -
  • {repo}
  • - ))} -
-
-
-
- - - - {dateRange(current.window)} - - -
-
-
- - - - -
- -
- - - Engineers {people.length} - - {terms.requests} - Quality - Branch spend - - {activeTab !== "quality" && } - {!readOnly && ( - - )} -
- - setPersonId(selected.id)} - /> - - - {current.merged_prs > 0 || baseline.merged_prs > 0 ? ( -
- -
-
-

Behind the numbers

-

- {number(current.agent_authored)} of {number(current.merged_prs)} {terms.plural} were authored by - agents or bots. -

-

- Human-authored median merge time:{" "} - - {duration(current.human_summary.median_merge_hours)} - - , compared with {duration(baseline.human_summary.median_merge_hours)}. -

-
-
- - {number(current.agents_without_requester)} agent {terms.plural} have no requester - -
-
-
- ) : ( -
-

No merged changes yet

-

- Your repositories are connected. New activity will appear after the next sync -

-
- )} - -

- {matchedOnly - ? "Changes from people linked to internal accounts" - : `All repository ${terms.plural}, including agent work without a known requester`} -

- -
- - - - - - -
-
- - {number(current.matched_internal_prs)} {terms.plural} matched to {snapshot.people.length} engineers ·{" "} - {number(current.agents_without_requester)} agent {terms.plural} without a requester - - Comparing with {dateRange(baseline.window)} · All dates UTC - Spend recorded by this gateway · Merge time is elapsed time, not effort -
- {accountEmail !== null && ( - setAccountEmail(null)} - onSaved={onRefresh} - /> - )} - {person && ( - setPersonId(null)} - onEdit={ - readOnly || !person.matched - ? undefined - : () => { - setAccountEmail(person.email); - setPersonId(null); - } - } - /> - )} -
- ); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorDialogs.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorDialogs.tsx deleted file mode 100644 index 39d7ef582ef..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorDialogs.tsx +++ /dev/null @@ -1,217 +0,0 @@ -"use client"; - -import React from "react"; - -import { extractErrorMessage } from "@/utils/errorUtils"; -import { Button, buttonVariants } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { effortNote, estimateLabel, branchCostLabel } from "./roiCalculatorData"; -import type { ROIIdentityMapUpdate, ROIPull, ROISummary } from "./roiCalculatorData"; -import type { ROIPerson } from "./roiCalculatorData"; - -export type PersonMatchSelection = { person: ROIPerson; login: string }; - -export function PullReasoningDialog({ - pull, - summary, - onClose, -}: { - pull: ROIPull | null; - summary: ROISummary | null; - onClose: () => void; -}) { - const titleRef = React.useRef(null); - return ( - !open && onClose()}> - - {pull && ( - <> - - - {pull.title} - - - {pull.repo} #{pull.number} · {pull.login} - - -
-
-
-
Estimated effort
-
{estimateLabel(pull.estimate)}
-
-
-
Recorded AI cost
-
{branchCostLabel(pull)}
- {pull.branch_cost?.status === "matched" && ( -
{pull.branch_cost.requests} requests
- )} -
-
-

- {effortNote(pull.estimate.effort_basis ?? summary?.effort_basis)} -

-
-
-

Reasoning

-

- {pull.estimate.reasoning || "No estimate available."} -

-
-
-
Model
-
{pull.estimate.model || summary?.estimator_model}
-
Merged
-
{new Date(pull.merged_at).toLocaleDateString(undefined, { timeZone: "UTC" })}
-
Email match
-
{pull.email || "Not matched"}
-
-
-

Track costs for this branch

- {pull.branch_cost?.status === "matched" && ( -

- {pull.branch_cost.spend?.toFixed(8)} USD across {pull.branch_cost.requests} requests -

- )} - {pull.source_repo && pull.source_branch ? ( - <> -

Send both tags with each gateway request from this branch:

-
-                    {JSON.stringify(
-                      { metadata: { tags: [`repo:${pull.source_repo}`, `branch:${pull.source_branch}`] } },
-                      null,
-                      2,
-                    )}
-                  
-

- Retained requests in the report’s UTC period. Branch names are case-sensitive. Reused branches - cannot be split between changes. -

- - ) : ( -

- The source repository or branch is unavailable. Sync again to refresh its metadata. -

- )} -
- {summary?.estimator_prompt && ( -
- Estimator prompt -

{summary.estimator_prompt}

-
- )} - - {pull.url && ( - - View on {summary?.source_provider === "gitlab" ? "GitLab" : "GitHub"} - - )} - - - )} -
-
- ); -} - -export function IdentityMatchDialog({ - selection, - identityMap, - gatewayEmails, - onClose, - onSave, -}: { - selection: PersonMatchSelection | null; - identityMap: Record; - gatewayEmails: string[]; - onClose: () => void; - onSave: (payload: ROIIdentityMapUpdate) => Promise; -}) { - const [email, setEmail] = React.useState(() => - selection ? identityMap[selection.login.toLowerCase()] ?? selection.person.email ?? "" : "", - ); - const [error, setError] = React.useState(null); - const [busy, setBusy] = React.useState(false); - const person = selection?.person ?? null; - const login = selection?.login ?? ""; - const existingEmail = identityMap[login.toLowerCase()]; - - const save = async (value: string | null) => { - if (!login) return; - try { - setBusy(true); - await onSave({ github_login: login, email: value }); - setError(null); - onClose(); - } catch (reason) { - setError(extractErrorMessage(reason)); - } finally { - setBusy(false); - } - }; - - return ( - !open && onClose()}> - - - Match email - Link {login} to their gateway email. Manual matches take priority. - -
{ - event.preventDefault(); - void save(email.trim()); - }} - > -
- - setEmail(event.target.value)} - required - /> -
- - {Array.from(new Set(gatewayEmails)).map((address) => ( - - {error && ( -

- {error} -

- )} - - {existingEmail && ( - - )} - - -
-
-
- ); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.integration.test.tsx deleted file mode 100644 index 30cf5e09eee..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.integration.test.tsx +++ /dev/null @@ -1,814 +0,0 @@ -import userEvent from "@testing-library/user-event"; -import { act, fireEvent, render as renderWithoutNuqs, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { NuqsAdapter } from "nuqs/adapters/react"; - -import { apiClient } from "@/components/networking"; -import ROICalculatorView from "./ROICalculatorView"; - -const render = (ui: Parameters[0]) => renderWithoutNuqs(ui, { wrapper: NuqsAdapter }); - -vi.mock("@/components/networking", () => ({ - apiClient: { - delete: vi.fn(), - get: vi.fn(), - post: vi.fn(), - put: vi.fn(), - }, -})); - -const summary = { - id: null, - mode: "live", - start: "2026-09-01", - end: "2026-09-30", - synced_at: "2026-09-30T12:00:00Z", - repos: ["org/repo"], - estimator_model: "estimator", - estimator_prompt: "Estimate hours.", - warnings: [], - effort_basis: "without_ai", - metrics: { - matched_spend: 12, - output_hours: 4, - total_spend: 20, - total_output_hours: 4, - excluded_spend: 8, - cost_per_hour: 3, - hours_per_dollar: 1 / 3, - merged_prs: 1, - estimated_prs: 1, - matched_prs: 1, - cohort_people: 1, - people_with_prs: 1, - pending_prs: 0, - }, - people: [ - { - id: "alice@example.com", - email: "alice@example.com", - logins: ["alice", "alice-work"], - spend: 12, - hours: 4, - prs: 1, - estimated_prs: 1, - pending_prs: 0, - match_methods: ["profile email"], - eligible: true, - cost_per_hour: 3, - }, - ], - pulls: [ - { - repo: "org/repo", - source_repo: "github.com/org/repo", - source_branch: "feature/routing", - branch_cost: { - repo: "github.com/org/repo", - branch: "feature/routing", - status: "matched", - spend: 8, - requests: 12, - }, - number: 42, - title: "Improve request routing", - url: "https://github.com/org/repo/pull/42", - login: "alice", - emails: ["alice@example.com"], - profile_email: "alice@example.com", - merged_at: "2026-09-12T00:00:00Z", - head_sha: "abc", - additions: 10, - deletions: 2, - changed_files: 1, - commit_count: 1, - incomplete_metadata: false, - estimate: { - status: "estimated", - hours: 4, - reasoning: "Updated routing and added a regression test.", - model: "estimator", - evidence_source: "pr_metadata", - effort_basis: "without_ai", - cached: false, - }, - cache_key: "cache", - email: "alice@example.com", - match_method: "profile email", - matched: true, - }, - ], - trend: [{ date: "2026-09-12", spend: 12, hours: 4, prs: 1 }], -} as const; - -const settings = { - github_api_url: "https://api.github.com", - repos: ["org/repo"], - estimator_model: "estimator", - estimator_prompt: "Estimate hours.", - backfill_days: 30, - identity_map: {}, - has_github_token: true, - default_prompt: "Estimate hours.", - available_models: ["estimator"], - ready: true, -}; - -const idleStatus = { - running: false, - phase: "idle", - stage: "Idle", - done: 0, - total: 0, - estimated: 0, - reused: 0, - needs_attention: 0, - error: null, -}; - -describe("ROICalculatorView", () => { - beforeEach(() => { - window.history.replaceState(null, "", "/roi-calculator/"); - vi.mocked(apiClient.get).mockReset(); - vi.mocked(apiClient.put).mockReset(); - vi.mocked(apiClient.post).mockReset(); - vi.mocked(apiClient.get).mockImplementation((path: string) => { - if (path === "/roi-calculator/settings") return Promise.resolve(settings); - if (path === "/roi-calculator/report") return Promise.resolve({ report: summary }); - return Promise.resolve(idleStatus); - }); - vi.mocked(apiClient.put).mockResolvedValue({ report: summary, identity_map: { alice: "alice@example.com" } }); - }); - - it.each(["github", "gitlab"])("filters people and branches by linked accounts for %s", async (provider) => { - const outsidePerson = { - ...summary.people[0], - id: "outside", - email: "", - logins: ["outside"], - match_methods: ["no gateway match"], - spend: null, - }; - const outsidePull = { - ...summary.pulls[0], - number: 43, - title: "External contribution", - login: "outside", - email: "", - match_method: "no gateway match", - matched: false, - }; - const linkedPerson = { ...summary.people[0], spend: null, match_methods: ["manual"], eligible: false }; - const spendOnlyPerson = { - ...summary.people[0], - id: "internal@example.test", - email: "internal@example.test", - logins: [], - spend: 8.5, - prs: 0, - match_methods: [], - estimated_prs: 0, - hours: 0, - eligible: false, - cost_per_hour: null, - }; - const linkedPull = { ...summary.pulls[0], matched: false, match_method: "manual" }; - const report = { - ...summary, - source_provider: provider, - people: [linkedPerson, spendOnlyPerson, outsidePerson], - pulls: [linkedPull, outsidePull], - }; - vi.mocked(apiClient.get).mockImplementation((path: string) => { - if (path.endsWith("/settings")) return Promise.resolve(settings); - if (path.endsWith("/report")) return Promise.resolve({ report }); - return Promise.resolve(idleStatus); - }); - const user = userEvent.setup(); - render(); - await user.click(await screen.findByRole("tab", { name: "People" })); - expect(screen.getByRole("switch", { name: "Matched people only" })).toBeChecked(); - expect(screen.getByRole("button", { name: "alice" })).toBeInTheDocument(); - expect(screen.getByRole("row", { name: /internal@example.test/ })).toHaveTextContent("$8.50"); - expect(screen.queryByRole("button", { name: "outside" })).not.toBeInTheDocument(); - await user.click(screen.getByRole("switch", { name: "Matched people only" })); - expect(screen.getByRole("button", { name: "outside" })).toBeInTheDocument(); - await user.click(screen.getByRole("tab", { name: "Branches" })); - expect(screen.getByRole("switch", { name: "Matched people only" })).not.toBeChecked(); - expect(screen.getByText("External contribution")).toBeInTheDocument(); - await user.click(screen.getByRole("switch", { name: "Matched people only" })); - expect(screen.queryByText("External contribution")).not.toBeInTheDocument(); - expect(screen.getByText("Improve request routing")).toBeInTheDocument(); - expect(apiClient.put).not.toHaveBeenCalled(); - }); - - it("shows the spend summary and opens an accessible pull reasoning dialog", async () => { - render(); - - expect(await screen.findByText("Gateway AI cost")).toBeInTheDocument(); - expect(screen.getByText("$20.00")).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Open estimate for org/repo pull request 42" })); - - expect(await screen.findByRole("dialog")).toBeInTheDocument(); - expect(screen.getByText("Updated routing and added a regression test.")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "View on GitHub" })).toHaveAttribute( - "href", - "https://github.com/org/repo/pull/42", - ); - }); - - it("separates the overview, people, and branch reports into three tabs", async () => { - render(); - expect(await screen.findByRole("heading", { name: "Where AI costs are matched" })).toBeVisible(); - fireEvent.click(screen.getByRole("tab", { name: "Branches" })); - expect(screen.getByRole("heading", { name: "Costs by branch" })).toBeVisible(); - expect(screen.getByRole("cell", { name: "$8.00" })).toBeVisible(); - fireEvent.click(screen.getByRole("tab", { name: "People" })); - expect(screen.getByRole("heading", { name: "People and account matches" })).toBeVisible(); - fireEvent.click(screen.getByRole("tab", { name: "Overview" })); - expect(screen.getByRole("heading", { name: "Highest-cost changes" })).toBeVisible(); - expect(screen.queryByRole("radio")).not.toBeInTheDocument(); - }); - - it("shows incomplete repository results without a spend-per-hour figure", async () => { - const warning = "Incomplete report: could not read org/unavailable. Spend-per-hour figures are unavailable."; - vi.mocked(apiClient.get).mockImplementation((path: string) => { - if (path === "/roi-calculator/settings") return Promise.resolve(settings); - if (path === "/roi-calculator/report") { - return Promise.resolve({ - report: { - ...summary, - warnings: [warning], - metrics: { ...summary.metrics, cost_per_hour: null, hours_per_dollar: null }, - people: summary.people.map((person) => ({ ...person, cost_per_hour: null })), - }, - }); - } - return Promise.resolve(idleStatus); - }); - - render(); - - expect(await screen.findByRole("alert")).toHaveTextContent(warning); - expect(screen.getByRole("button", { name: "Open estimate for org/repo pull request 42" })).toBeInTheDocument(); - expect(screen.queryByText("$3.00")).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("tab", { name: "People" })); - fireEvent.click(screen.getByText("How this is calculated")); - expect( - screen.getByText("Spend per estimated hour is unavailable until all selected repositories can be read."), - ).toBeVisible(); - }); - - it("lets a view-only admin read the report without write controls", async () => { - const runningStatus = { - ...idleStatus, - running: true, - phase: "estimating", - stage: "Estimating pull requests", - total: 1, - }; - vi.mocked(apiClient.get).mockImplementation((path: string) => { - if (path === "/roi-calculator/settings") return Promise.resolve(settings); - if (path === "/roi-calculator/report") return Promise.resolve({ report: summary }); - return Promise.resolve(runningStatus); - }); - - render(); - - expect(await screen.findByText("Gateway AI cost")).toBeInTheDocument(); - expect(screen.getByRole("note")).toHaveTextContent("Read-only access"); - expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Cancel sync" })).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole("tab", { name: "People" })); - expect(screen.getByText("alice-work")).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "alice-work" })).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "Settings" })); - expect(screen.getByLabelText("GitHub token (optional for public repositories)")).toBeDisabled(); - expect(screen.queryByRole("button", { name: "Save settings" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument(); - }); - - it("lets an admin open the people view and save a manual email match", async () => { - render(); - - fireEvent.click(await screen.findByRole("tab", { name: "People" })); - fireEvent.click(await screen.findByRole("button", { name: "alice-work" })); - fireEvent.change(screen.getByLabelText("Gateway email"), { - target: { value: "alice+work@example.com" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Save match" })); - - await waitFor(() => - expect(apiClient.put).toHaveBeenCalledWith("/roi-calculator/identity-map", { - accessToken: "token", - body: { github_login: "alice-work", email: "alice+work@example.com" }, - }), - ); - }); - - it("presents onboarding settings once when no report exists", async () => { - const emptySettings = { ...settings, has_github_token: false, ready: false, repos: [], estimator_model: "" }; - vi.mocked(apiClient.get).mockImplementation((path: string) => { - if (path === "/roi-calculator/settings") return Promise.resolve(emptySettings); - if (path === "/roi-calculator/report") return Promise.resolve({ report: null }); - return Promise.resolve(idleStatus); - }); - - render(); - - expect(await screen.findByRole("heading", { name: "Connect your repositories" })).toBeInTheDocument(); - expect(screen.getByLabelText("GitHub token (optional for public repositories)")).toHaveAttribute( - "type", - "password", - ); - expect(screen.getAllByText("Connect your repositories")).toHaveLength(1); - }); - - it.each(["github", "gitlab"])("only permits unauthenticated repository browsing for GitLab: %s", async (provider) => { - const publicSettings = { - ...settings, - source_provider: provider, - gitlab_api_url: "https://gitlab.com/api/v4", - has_github_token: false, - has_gitlab_token: false, - }; - vi.mocked(apiClient.get).mockImplementation((path: string) => { - if (path === "/roi-calculator/settings") { - return Promise.resolve(publicSettings); - } - if (path === "/roi-calculator/report") return Promise.resolve({ report: summary }); - return Promise.resolve(idleStatus); - }); - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - const load = screen.getByRole("button", { name: "Load repositories" }); - if (provider === "github") expect(load).toBeDisabled(); - else expect(load).toBeEnabled(); - expect(screen.getByRole("textbox", { name: "Repository name" })).toBeEnabled(); - }); - - it("searches by the real model name and saves the selected gateway alias", async () => { - const user = userEvent.setup(); - const modelSettings = { - ...settings, - available_models: ["estimator", "fast-estimator"], - estimator_models: [ - { model_name: "estimator", provider_models: ["custom-model"] }, - { model_name: "fast-estimator", provider_models: ["openai/gpt-6-luna"] }, - ], - }; - vi.mocked(apiClient.get).mockImplementation((path: string) => { - if (path === "/roi-calculator/settings") return Promise.resolve(modelSettings); - if (path === "/roi-calculator/report") return Promise.resolve({ report: summary }); - return Promise.resolve(idleStatus); - }); - vi.mocked(apiClient.put).mockResolvedValue({ ...modelSettings, estimator_model: "fast-estimator" }); - render(); - await user.click(await screen.findByRole("button", { name: "Settings" })); - const search = screen.getByRole("combobox", { name: "Estimator model" }); - await user.clear(search); - await user.type(search, "Luna"); - expect(screen.queryByRole("option", { name: /custom-model/ })).not.toBeInTheDocument(); - await user.click(await screen.findByRole("option", { name: /GPT-6 Luna.*Recommended/ })); - expect(search).toHaveValue("GPT-6 Luna"); - await user.click(screen.getByRole("button", { name: "Save settings" })); - expect(apiClient.put).toHaveBeenCalledWith( - "/roi-calculator/settings", - expect.objectContaining({ - body: expect.objectContaining({ estimator_model: "fast-estimator" }), - }), - ); - }); - - it("closes the old settings dialog when saving a different source", async () => { - const gitlabSettings = { - ...settings, - source_provider: "gitlab", - gitlab_api_url: "https://gitlab.com/api/v4", - has_gitlab_token: false, - repos: [], - ready: false, - }; - vi.mocked(apiClient.put).mockResolvedValue(gitlabSettings); - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - fireEvent.change(screen.getByLabelText("Repository source"), { target: { value: "gitlab" } }); - fireEvent.click(screen.getByRole("button", { name: "Save settings" })); - expect(await screen.findByRole("heading", { name: "Connect your repositories" })).toBeVisible(); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(screen.getAllByLabelText("Repository source")).toHaveLength(1); - expect(screen.getByLabelText("Repository source")).toHaveValue("gitlab"); - }); - - it("keeps a running analysis visible when a source change finishes saving", async () => { - const gitlabSettings = { ...settings, source_provider: "gitlab", repos: ["group/project"] }; - const runningStatus = { ...idleStatus, running: true, phase: "estimating", total: 1 }; - const saveRequest = Promise.withResolvers(); - vi.mocked(apiClient.put).mockReturnValue(saveRequest.promise); - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - fireEvent.change(screen.getByLabelText("Repository source"), { target: { value: "gitlab" } }); - fireEvent.click(screen.getByRole("button", { name: "Save settings" })); - vi.mocked(apiClient.get).mockResolvedValue(runningStatus); - expect(await screen.findByRole("progressbar", { hidden: true }, { timeout: 3000 })).toBeInTheDocument(); - - saveRequest.resolve(gitlabSettings); - await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); - expect(screen.getByRole("progressbar", { name: "Sync progress" })).toBeVisible(); - expect(screen.getByRole("button", { name: "Cancel sync" })).toBeEnabled(); - expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument(); - expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument(); - expect(apiClient.post).not.toHaveBeenCalled(); - }); - - it.each(["success", "failure"])("ignores an old report refresh %s after switching sources", async (outcome) => { - const gitlabSettings = { ...settings, source_provider: "gitlab", repos: [], ready: false }; - const oldRequest = Promise.withResolvers<{ report: typeof summary }>(); - const complete = { ...idleStatus, phase: "complete", finished_at: "2026-09-30T12:00:00Z" }; - vi.mocked(apiClient.put).mockResolvedValue(gitlabSettings); - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - vi.mocked(apiClient.get) - .mockClear() - .mockImplementation((path: string) => - path === "/roi-calculator/report" ? oldRequest.promise : Promise.resolve(complete), - ); - await waitFor( - () => expect(apiClient.get).toHaveBeenCalledWith("/roi-calculator/report", { accessToken: "token" }), - { - timeout: 3000, - }, - ); - fireEvent.change(screen.getByLabelText("Repository source"), { target: { value: "gitlab" } }); - fireEvent.click(screen.getByRole("button", { name: "Save settings" })); - expect(await screen.findByRole("heading", { name: "Connect your repositories" })).toBeVisible(); - - await act(async () => { - if (outcome === "success") oldRequest.resolve({ report: summary }); - else oldRequest.reject(new Error("The old source is unavailable")); - }); - expect(screen.getByRole("heading", { name: "Connect your repositories" })).toBeVisible(); - expect(screen.getByLabelText("Repository source")).toHaveValue("gitlab"); - expect(screen.queryByText("Improve request routing")).not.toBeInTheDocument(); - expect(screen.queryByText("The old source is unavailable")).not.toBeInTheDocument(); - - const nextReport = { ...summary, pulls: [{ ...summary.pulls[0], title: "New source merge request" }] }; - vi.mocked(apiClient.get).mockImplementation((path: string) => - Promise.resolve( - path === "/roi-calculator/report" - ? { report: nextReport } - : { ...complete, finished_at: "2026-09-30T13:00:00Z" }, - ), - ); - expect(await screen.findByText("New source merge request", {}, { timeout: 3000 })).toBeVisible(); - expect(screen.queryByText("Improve request routing")).not.toBeInTheDocument(); - }); - - it("clearly identifies the sample report and returns to setup when exiting", async () => { - const emptySettings = { ...settings, has_github_token: false, ready: false, repos: [], estimator_model: "" }; - vi.mocked(apiClient.get).mockImplementation((path: string, options) => { - if (path === "/roi-calculator/settings") return Promise.resolve(emptySettings); - if (path === "/roi-calculator/report") { - return Promise.resolve({ report: options?.query?.mode === "demo" ? { ...summary, mode: "demo" } : null }); - } - return Promise.resolve(idleStatus); - }); - - render(); - fireEvent.click(await screen.findByRole("button", { name: "Preview sample report" })); - - expect(await screen.findByText("You’re viewing demo data")).toBeVisible(); - expect(screen.getByRole("tab", { name: "Branches" })).toHaveAttribute("aria-selected", "true"); - expect(screen.getByText("Cost / estimated hour")).toBeVisible(); - expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument(); - expect(screen.queryByRole("tab", { name: "Settings" })).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "Exit demo" })); - - expect(screen.getByRole("heading", { name: "Connect your repositories" })).toBeVisible(); - expect(screen.queryByText("You’re viewing demo data")).not.toBeInTheDocument(); - expect(apiClient.post).not.toHaveBeenCalled(); - expect(apiClient.put).not.toHaveBeenCalled(); - }); - - it("opens sample PR costs from a live report and restores the live data on exit", async () => { - const samplePull = { - ...summary.pulls[0], - title: "Sample usage breakdown", - source_repo: "github.com/org/repo", - source_branch: "feature/usage", - branch_cost: { - status: "matched", - spend: 9.1, - requests: 75, - repo: "github.com/org/repo", - branch: "feature/usage", - }, - }; - vi.mocked(apiClient.get).mockImplementation((path: string, options) => { - if (path === "/roi-calculator/settings") return Promise.resolve(settings); - if (path === "/roi-calculator/report") { - return Promise.resolve({ - report: options?.query?.mode === "demo" ? { ...summary, mode: "demo", pulls: [samplePull] } : summary, - }); - } - return Promise.resolve(idleStatus); - }); - - render(); - fireEvent.click(await screen.findByRole("tab", { name: "Branches" })); - fireEvent.change(screen.getByRole("searchbox"), { target: { value: "no matching PR" } }); - fireEvent.click(screen.getByRole("button", { name: "Preview sample report" })); - - expect(await screen.findByText("You’re viewing demo data")).toBeVisible(); - await waitFor(() => expect(window.location.search).toBe("?demo=1")); - expect(screen.getByRole("tab", { name: "Branches" })).toHaveAttribute("aria-selected", "true"); - expect(screen.getByRole("searchbox")).toHaveValue(""); - expect(screen.getByRole("cell", { name: "$9.10" })).toBeVisible(); - expect(screen.queryByText("Improve request routing")).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument(); - - const runningStatus = { ...idleStatus, running: true, phase: "estimating", total: 1 }; - vi.mocked(apiClient.get).mockClear().mockResolvedValue(runningStatus); - await waitFor(() => expect(apiClient.get).toHaveBeenCalledWith("/roi-calculator/sync", { accessToken: "token" }), { - timeout: 3000, - }); - expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "Open estimate for org/repo pull request 42" })); - expect(await screen.findByRole("dialog")).toBeVisible(); - expect(screen.getByText("75 requests")).toBeVisible(); - expect(screen.getByText(/branch:feature\/usage/)).toBeVisible(); - fireEvent.click(screen.getByRole("button", { name: "Close" })); - fireEvent.click(screen.getByRole("button", { name: "Exit demo" })); - - expect(screen.getByText("Improve request routing")).toBeVisible(); - expect(screen.queryByText("Sample usage breakdown")).not.toBeInTheDocument(); - await waitFor(() => expect(window.location.search).toBe("")); - expect(screen.getByRole("button", { name: "Syncing…" })).toBeDisabled(); - expect(apiClient.post).not.toHaveBeenCalled(); - expect(apiClient.put).not.toHaveBeenCalled(); - }); - - it("opens a demo link with sample data even while live analysis is running", async () => { - window.history.replaceState(null, "", "/roi-calculator/?demo=1"); - const demoSummary = { ...summary, mode: "demo", metrics: { ...summary.metrics, total_spend: 38.4 } }; - const runningStatus = { ...idleStatus, running: true, phase: "estimating", total: 1 }; - vi.mocked(apiClient.get).mockImplementation((path: string, options) => { - if (path === "/roi-calculator/settings") return Promise.resolve(settings); - if (path === "/roi-calculator/report") { - return Promise.resolve({ report: options?.query?.mode === "demo" ? demoSummary : summary }); - } - return Promise.resolve(runningStatus); - }); - render(); - expect(await screen.findByText("You’re viewing demo data")).toBeVisible(); - expect(screen.getByText("$38.40")).toBeVisible(); - expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument(); - expect(apiClient.post).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole("button", { name: "Exit demo" })); - expect(screen.getByRole("progressbar")).toBeVisible(); - expect(screen.getByText("$20.00")).toBeVisible(); - await waitFor(() => expect(window.location.search).toBe("")); - }); - - it.each(["report", "sync"])("loads a demo link when the live %s request fails", async (failedRequest) => { - window.history.replaceState(null, "", "/roi-calculator/?demo=1"); - vi.mocked(apiClient.get).mockImplementation((path: string, options) => { - if (path === "/roi-calculator/settings") return Promise.resolve(settings); - if (options?.query?.mode === "demo") return Promise.resolve({ report: { ...summary, mode: "demo" } }); - if (path === `/roi-calculator/${failedRequest}`) return Promise.reject(new Error("Live data unavailable")); - if (path === "/roi-calculator/report") return Promise.resolve({ report: summary }); - return Promise.resolve(idleStatus); - }); - render(); - expect(await screen.findByText("You’re viewing demo data")).toBeVisible(); - expect(screen.getByText("Gateway AI cost")).toBeVisible(); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - expect(apiClient.post).not.toHaveBeenCalled(); - }); - - it("shows the demo without waiting for a stalled live request", async () => { - window.history.replaceState(null, "", "/roi-calculator/?demo=1"); - vi.mocked(apiClient.get).mockImplementation((path: string, options) => { - if (path === "/roi-calculator/settings") return Promise.resolve(settings); - if (options?.query?.mode === "demo") return Promise.resolve({ report: { ...summary, mode: "demo" } }); - return new Promise(() => {}); - }); - render(); - expect(await screen.findByText("You’re viewing demo data")).toBeVisible(); - expect(screen.getByText("Gateway AI cost")).toBeVisible(); - }); - - it("keeps the live calculator usable when a demo link cannot load sample data", async () => { - window.history.replaceState(null, "", "/roi-calculator/?demo=1&from=review#overview"); - vi.mocked(apiClient.get).mockImplementation((path: string, options) => { - if (path === "/roi-calculator/settings") return Promise.resolve(settings); - if (options?.query?.mode === "demo") return Promise.reject(new Error("Sample data unavailable")); - if (path === "/roi-calculator/report") return Promise.resolve({ report: summary }); - return Promise.resolve(idleStatus); - }); - render(); - expect(await screen.findByText("Gateway AI cost")).toBeVisible(); - expect(screen.getByText("$20.00")).toBeVisible(); - expect(screen.getByRole("alert")).toHaveTextContent("Sample data unavailable"); - expect(screen.getByRole("button", { name: "Settings" })).toBeEnabled(); - expect(screen.queryByText("You’re viewing demo data")).not.toBeInTheDocument(); - expect(apiClient.post).not.toHaveBeenCalled(); - await waitFor(() => expect(window.location.search).toBe("?from=review")); - expect(window.location.hash).toBe("#overview"); - }); - - it.each(["report", "sync"])("waits for the live %s when exiting a demo", async (pendingRequest) => { - window.history.replaceState(null, "", "/roi-calculator/?demo=1"); - const pending = Promise.withResolvers<{ report: typeof summary } | typeof idleStatus>(); - vi.mocked(apiClient.get).mockImplementation((path: string, options) => { - if (path === "/roi-calculator/settings") return Promise.resolve(settings); - if (options?.query?.mode === "demo") return Promise.resolve({ report: { ...summary, mode: "demo" } }); - if (path === `/roi-calculator/${pendingRequest}`) return pending.promise; - if (path === "/roi-calculator/report") return Promise.resolve({ report: summary }); - return Promise.resolve(idleStatus); - }); - render(); - fireEvent.click(await screen.findByRole("button", { name: "Exit demo" })); - expect(screen.getByText("Loading ROI Calculator…")).toBeVisible(); - expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument(); - await waitFor(() => expect(window.location.search).toBe("")); - - pending.resolve(pendingRequest === "report" ? { report: summary } : idleStatus); - expect(await screen.findByText("Gateway AI cost")).toBeVisible(); - expect(screen.getByText("$20.00")).toBeVisible(); - expect(screen.queryByText("Loading ROI Calculator…")).not.toBeInTheDocument(); - expect(apiClient.post).not.toHaveBeenCalled(); - }); - - it.each(["report", "demo"])("retains a failed %s load after a successful sync poll", async (failedRequest) => { - if (failedRequest === "demo") window.history.replaceState(null, "", "/roi-calculator/?demo=1"); - const message = `The ${failedRequest} is unavailable`; - vi.mocked(apiClient.get).mockImplementation((path: string, options) => { - if (path === "/roi-calculator/settings") return Promise.resolve(settings); - if (options?.query?.mode === "demo") return Promise.reject(new Error(message)); - if (path === "/roi-calculator/report") { - return failedRequest === "report" ? Promise.reject(new Error(message)) : Promise.resolve({ report: summary }); - } - return Promise.resolve(idleStatus); - }); - render(); - expect(await screen.findByRole("alert")).toHaveTextContent(message); - const running = { ...idleStatus, running: true, phase: "estimating", total: 1 }; - vi.mocked(apiClient.get).mockImplementation((path: string) => { - if (path === "/roi-calculator/report") return Promise.reject(new Error(message)); - return Promise.resolve(running); - }); - expect(await screen.findByRole("progressbar", { name: "Sync progress" }, { timeout: 3000 })).toBeVisible(); - expect(screen.getByRole("alert")).toHaveTextContent(message); - }); - - it("retries a failed initial report and clears its error only when the report recovers", async () => { - vi.mocked(apiClient.get).mockImplementation((path: string) => { - if (path === "/roi-calculator/settings") return Promise.resolve(settings); - if (path === "/roi-calculator/report") return Promise.reject(new Error("Report unavailable")); - return Promise.resolve(idleStatus); - }); - render(); - expect(await screen.findByRole("alert")).toHaveTextContent("Report unavailable"); - expect(screen.getByRole("button", { name: "Settings" })).toBeEnabled(); - expect(screen.getByRole("button", { name: "Run analysis" })).toBeEnabled(); - vi.mocked(apiClient.get).mockImplementation((path: string) => - Promise.resolve(path === "/roi-calculator/report" ? { report: summary } : idleStatus), - ); - expect(await screen.findByText("Gateway AI cost", {}, { timeout: 3000 })).toBeVisible(); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument(); - }); - - it("returns to Overview and shows the last sync time when completion is polled from Settings", async () => { - const runningStatus = { - ...idleStatus, - running: true, - phase: "estimating", - stage: "Estimating pull requests", - total: 1, - }; - const completedStatus = { ...idleStatus, phase: "complete", done: 57, total: 57, reused: 57 }; - vi.mocked(apiClient.get) - .mockResolvedValueOnce(settings) - .mockResolvedValueOnce({ report: null }) - .mockResolvedValueOnce(runningStatus) - .mockResolvedValueOnce(completedStatus) - .mockImplementationOnce( - () => - new Promise((resolve) => { - window.setTimeout(() => resolve({ report: summary }), 25); - }), - ); - - render(); - - expect(await screen.findByRole("progressbar", { name: "Sync progress" })).toBeInTheDocument(); - expect(await screen.findByText("Gateway AI cost", {}, { timeout: 5000 })).toBeInTheDocument(); - expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument(); - expect(screen.getByRole("status")).toHaveTextContent("Last synced Sep 30, 2026, 12:00 PM UTC"); - }); - - it("shows the sync error returned by the status endpoint", async () => { - const runningStatus = { - ...idleStatus, - running: true, - phase: "estimating", - stage: "Estimating pull requests", - total: 1, - }; - const errorStatus = { - ...idleStatus, - phase: "error", - error: "The estimator could not score a pull request.", - }; - vi.mocked(apiClient.get) - .mockResolvedValueOnce(settings) - .mockResolvedValueOnce({ report: null }) - .mockResolvedValueOnce(runningStatus) - .mockResolvedValueOnce(errorStatus); - - render(); - - expect(await screen.findByRole("alert", {}, { timeout: 5000 })).toHaveTextContent( - "The estimator could not score a pull request.", - ); - expect(screen.getByText("Sync failed")).toBeInTheDocument(); - }); - - it("shows a report error and ends progress when the completed report cannot load", async () => { - const runningStatus = { - ...idleStatus, - running: true, - phase: "estimating", - stage: "Estimating pull requests", - total: 1, - }; - const completedStatus = { ...idleStatus, phase: "complete", done: 1, total: 1 }; - vi.mocked(apiClient.get) - .mockResolvedValueOnce(settings) - .mockResolvedValueOnce({ report: null }) - .mockResolvedValueOnce(runningStatus) - .mockResolvedValueOnce(completedStatus) - .mockRejectedValueOnce(new Error("The report could not be loaded.")); - - render(); - - expect(await screen.findByRole("progressbar", { name: "Sync progress" })).toBeInTheDocument(); - expect(await screen.findByRole("alert", {}, { timeout: 5000 })).toHaveTextContent( - "The report could not be loaded.", - ); - expect(screen.queryByRole("progressbar", { name: "Sync progress" })).not.toBeInTheDocument(); - }); - - it("clears a transient poll error when the next poll completes and loads the report", async () => { - const runningStatus = { - ...idleStatus, - running: true, - phase: "estimating", - stage: "Estimating pull requests", - total: 1, - }; - const completedStatus = { ...idleStatus, phase: "complete", done: 1, total: 1 }; - vi.mocked(apiClient.get) - .mockResolvedValueOnce(settings) - .mockResolvedValueOnce({ report: null }) - .mockResolvedValueOnce(runningStatus) - .mockRejectedValueOnce(new Error("The sync status could not be loaded.")) - .mockResolvedValueOnce(completedStatus) - .mockResolvedValueOnce({ report: summary }); - - render(); - - expect(await screen.findByRole("progressbar", { name: "Sync progress" })).toBeInTheDocument(); - expect(await screen.findByRole("alert", {}, { timeout: 5000 })).toHaveTextContent( - "The sync status could not be loaded.", - ); - expect(await screen.findByText("Gateway AI cost", {}, { timeout: 7000 })).toBeInTheDocument(); - expect(screen.queryByText("The sync status could not be loaded.")).not.toBeInTheDocument(); - }); - it("saves the edited schedule before running from Settings", async () => { - vi.mocked(apiClient.put).mockResolvedValue(settings); - vi.mocked(apiClient.post).mockResolvedValue({ ...idleStatus, running: true }); - render(); - fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - fireEvent.change(screen.getByLabelText("Update interval (hours)"), { target: { value: "6" } }); - fireEvent.click(screen.getByRole("button", { name: "Save and run analysis" })); - await waitFor(() => expect(apiClient.post).toHaveBeenCalledWith("/roi-calculator/sync", { accessToken: "token" })); - expect(apiClient.put).toHaveBeenCalledWith( - "/roi-calculator/settings", - expect.objectContaining({ - body: expect.objectContaining({ update_interval_minutes: 360, estimator_model: "estimator" }), - }), - ); - expect(vi.mocked(apiClient.put).mock.invocationCallOrder[0]).toBeLessThan( - vi.mocked(apiClient.post).mock.invocationCallOrder[0], - ); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.tsx deleted file mode 100644 index 36cb3d8d736..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.tsx +++ /dev/null @@ -1,508 +0,0 @@ -"use client"; - -import { Page, PageTabsList, PageTabsTrigger } from "@/components/shared/Page"; -import React from "react"; -import { Calculator, RefreshCw, Settings2 } from "lucide-react"; - -import { apiClient } from "@/components/networking"; -import { DemoNotice } from "@/components/shared/DemoNotice"; -import { PageHeader, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Tabs } from "@/components/ui/tabs"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; -import { extractErrorMessage } from "@/utils/errorUtils"; -import { isProxyAdminTierRole } from "@/utils/roles"; -import { useQueryState } from "nuqs"; -import ROISettingsPanel from "./ROISettingsPanel"; -import { MatchedPeopleToggle } from "./MatchedPeopleToggle"; -import { IdentityMatchDialog, type PersonMatchSelection, PullReasoningDialog } from "./ROICalculatorDialogs"; -import { ROIBranches, ROIOverview, ROIPeopleView } from "./ROICalculatorViews"; -import { filterPulls, formatSyncedAt } from "./roiCalculatorData"; -import type { - ROIIdentityMapResponse, - ROIIdentityMapUpdate, - ROIPull, - ROIReportResponse, - ROISettings, - ROISummary, - ROISyncStatus, -} from "./roiCalculatorData"; -import { parseAsDemoFlag } from "./demoUrlState"; - -type View = "overview" | "people" | "branches"; - -const IDLE_STATUS: ROISyncStatus = { - running: false, - elapsed_seconds: 0, - phase: "idle", - stage: "Idle", - done: 0, - total: 0, - estimated: 0, - reused: 0, - needs_attention: 0, - error: null, -}; - -export default function ROICalculatorView({ - accessToken, - userRole = null, - isViewOnly = false, -}: { - accessToken: string | null; - userRole?: string | null; - isViewOnly?: boolean; -}) { - const [demo, setDemo] = useQueryState("demo", parseAsDemoFlag); - const [demoRequestedOnLoad] = React.useState(demo === true); - const [sampleSummary, setSampleSummary] = React.useState(null); - const adminReadOnly = isViewOnly && isProxyAdminTierRole(userRole ?? ""); - const readOnly = adminReadOnly || sampleSummary !== null; - const [settingsOpen, setSettingsOpen] = React.useState(false); - const [view, setView] = React.useState("overview"); - const [settings, setSettings] = React.useState(null); - const [liveSummary, setSummary] = React.useState(null); - const summary = sampleSummary ?? liveSummary; - const [status, setStatus] = React.useState(IDLE_STATUS); - const [selectedPull, setSelectedPull] = React.useState(null); - const [matchingPerson, setMatchingPerson] = React.useState(null); - const [error, setError] = React.useState(null); - const [demoError, setDemoError] = React.useState(null); - const [reportError, setReportError] = React.useState(null); - const [syncError, setSyncError] = React.useState(null); - const [loadingInitialData, setLoadingInitialData] = React.useState(true); - const [loadingLiveData, setLoadingLiveData] = React.useState(true); - const statusRef = React.useRef(IDLE_STATUS); - const reportNeedsRefresh = React.useRef(false); - const sourceRevision = React.useRef(0); - const settingsLoaded = settings !== null && !loadingInitialData && !loadingLiveData; - const requestError = [error, demoError, reportError, syncError].filter(Boolean).join(" "); - const [query, setQuery] = React.useState(""); - const [matchedOnly, setMatchedOnly] = React.useState(true); - - const loadReport = React.useCallback(async () => { - if (!accessToken) return null; - const response: ROIReportResponse = await apiClient.get("/roi-calculator/report", { accessToken }); - return response.report; - }, [accessToken]); - - React.useEffect(() => { - if (!accessToken) return; - let cancelled = false; - const settingsRequest = apiClient.get("/roi-calculator/settings", { accessToken }); - const reportRequest = apiClient - .get("/roi-calculator/report", { accessToken }) - .then((response) => { - if (cancelled) return; - setSummary(response.report); - setReportError(null); - reportNeedsRefresh.current = false; - }) - .catch((reason: unknown) => { - if (cancelled) return; - setReportError(extractErrorMessage(reason)); - reportNeedsRefresh.current = true; - }); - const statusRequest = apiClient - .get("/roi-calculator/sync", { accessToken }) - .then((syncStatus) => { - if (cancelled) return; - setStatus(syncStatus); - statusRef.current = syncStatus; - setSyncError(null); - }) - .catch((reason: unknown) => { - if (!cancelled) setSyncError(extractErrorMessage(reason)); - }); - const liveData = Promise.all([reportRequest, statusRequest]) - .then(() => null) - .finally(() => { - if (!cancelled) setLoadingLiveData(false); - }); - Promise.all([ - settingsRequest, - demoRequestedOnLoad - ? apiClient - .get("/roi-calculator/report", { accessToken, query: { mode: "demo" } }) - .catch((reason: unknown) => { - if (!cancelled) { - setDemoError(`Could not load demo data: ${extractErrorMessage(reason)}`); - setDemo(null); - } - return liveData; - }) - : liveData, - ]) - .then(([nextSettings, sampleResponse]) => { - if (cancelled) return; - setSettings(nextSettings); - setSampleSummary(sampleResponse?.report ?? null); - setError(null); - if (sampleResponse) setDemoError(null); - }) - .catch((reason: unknown) => { - if (!cancelled) setError(extractErrorMessage(reason)); - }) - .finally(() => { - if (!cancelled) setLoadingInitialData(false); - }); - return () => { - cancelled = true; - }; - }, [accessToken, demoRequestedOnLoad, setDemo]); - - React.useEffect(() => { - if (!accessToken || !settingsLoaded) return; - let cancelled = false; - let requestInFlight = false; - const interval = window.setInterval(() => { - if (requestInFlight) return; - requestInFlight = true; - const revision = sourceRevision.current; - const isCurrent = () => !cancelled && revision === sourceRevision.current; - apiClient - .get("/roi-calculator/sync", { accessToken }) - .then(async (nextStatus) => { - if (!isCurrent()) return; - const previousStatus = statusRef.current; - statusRef.current = nextStatus; - setStatus(nextStatus); - setSyncError(null); - const finished = !nextStatus.running && nextStatus.phase === "complete"; - const reportChanged = previousStatus.running || nextStatus.finished_at !== previousStatus.finished_at; - if (reportNeedsRefresh.current || (finished && reportChanged)) { - reportNeedsRefresh.current = true; - try { - const report = await loadReport(); - if (!isCurrent()) return; - setSummary(report); - setReportError(null); - reportNeedsRefresh.current = false; - } catch (reason: unknown) { - if (isCurrent()) setReportError(extractErrorMessage(reason)); - } - } - }) - .catch((reason: unknown) => { - if (isCurrent()) setSyncError(extractErrorMessage(reason)); - }) - .finally(() => { - requestInFlight = false; - }); - }, 1500); - return () => { - cancelled = true; - window.clearInterval(interval); - }; - }, [accessToken, loadReport, settingsLoaded]); - - const startSync = React.useCallback(async () => { - if (!accessToken || readOnly) return; - try { - setError(null); - const nextStatus = await apiClient.post("/roi-calculator/sync", { accessToken }); - statusRef.current = nextStatus; - setStatus(nextStatus); - setSyncError(null); - setSettingsOpen(false); - } catch (reason) { - setError(extractErrorMessage(reason)); - } - }, [accessToken, readOnly]); - - const cancelSync = React.useCallback(async () => { - if (!accessToken || readOnly) return; - try { - const nextStatus = await apiClient.delete("/roi-calculator/sync", { accessToken }); - setStatus(nextStatus); - statusRef.current = nextStatus; - setSyncError(null); - setError(null); - } catch (reason) { - setError(extractErrorMessage(reason)); - } - }, [accessToken, readOnly]); - - const updateIdentity = React.useCallback( - async (payload: ROIIdentityMapUpdate) => { - if (!accessToken || readOnly) return; - const response: ROIIdentityMapResponse = await apiClient.put("/roi-calculator/identity-map", { - accessToken, - body: payload, - }); - setSummary(response.report); - setReportError(null); - reportNeedsRefresh.current = false; - setSettings((current) => (current ? { ...current, identity_map: response.identity_map } : current)); - }, - [accessToken, readOnly], - ); - - const filteredPulls = React.useMemo( - () => (summary ? filterPulls(summary.pulls, query, matchedOnly) : []), - [query, summary, matchedOnly], - ); - - if (error && !settings && !loadingInitialData) { - return ( -
- - Could not load ROI Calculator - {error} - -
- ); - } - - const awaitingLiveData = !sampleSummary && loadingLiveData; - if (!settings || loadingInitialData || awaitingLiveData) { - return ( -
-

Loading ROI Calculator…

- - -
- ); - } - - const previewSample = async () => { - try { - const response = await apiClient.get("/roi-calculator/report", { - accessToken, - query: { mode: "demo" }, - }); - setSampleSummary(response.report); - setDemoError(null); - setDemo(true); - setView("branches"); - setQuery(""); - } catch (reason) { - setDemoError(`Could not load demo data: ${extractErrorMessage(reason)}`); - } - }; - const resetView = (updated: ROISettings, resetSyncStatus = true) => { - sourceRevision.current += 1; - setSettings(updated); - setSummary(null); - setReportError(null); - reportNeedsRefresh.current = false; - setSettingsOpen(false); - setView("overview"); - if (resetSyncStatus) { - setStatus(IDLE_STATUS); - statusRef.current = IDLE_STATUS; - setSyncError(null); - setError(null); - } - }; - const showLiveStatus = !sampleSummary && !status.running; - const showReportActions = summary !== null || reportError !== null; - const progress = status.total > 0 ? Math.min(100, (status.done / status.total) * 100) : 0; - const statusIsIdleOrComplete = status.phase === "idle" || status.phase === "complete"; - const syncIsUpToDate = !status.running && statusIsIdleOrComplete; - const syncedAt = sampleSummary?.synced_at ?? (syncIsUpToDate ? summary?.synced_at : null); - - return ( - - -
- - - ROI Calculator - - {!sampleSummary && ( -
- {showLiveStatus && ( - - )} - {showReportActions && ( - - )} - {showReportActions && !readOnly && ( - - )} -
- )} -
- - - {summary - ? `${summary.start} through ${summary.end} · UTC` - : "Compare AI costs with estimated engineering effort"} - - {syncedAt && ( - - Last synced {formatSyncedAt(syncedAt)} - - )} - -
- {sampleSummary && ( - { - setDemo(null); - setSampleSummary(null); - }} - /> - )} - {adminReadOnly && ( -

- Read-only access. Settings, analysis runs, and email matches are unavailable. -

- )} - - {summary && ( - setView(value as View)}> - - Overview - People - Branches - - {view !== "overview" && ( -
- -
- )} -
- )} - - {!sampleSummary && requestError && ( - - ROI Calculator request failed - {requestError} - - )} - {!sampleSummary && status.error && ( - - Sync failed - {status.error} - - )} - {summary?.warnings.map((warning) => ( - - Sync note - {warning} - - ))} - {!sampleSummary && status.running && ( - - -
-

{status.stage}

-
-
-
-

- {status.done} of {status.total} changes processed · {status.reused} reused - {` · ${status.elapsed_seconds ?? 0}s elapsed`} - {status.remaining_seconds != null ? ` · about ${status.remaining_seconds}s remaining` : ""} -

-
- {!readOnly && ( - - )} - - - )} - - {!summary && !status.running && !reportError ? ( - - ) : null} - {view === "overview" && summary && ( - setView("people")} - onViewBranches={() => setView("branches")} - /> - )} - {view === "branches" && summary && ( - - )} - {view === "people" && summary && ( - setMatchingPerson({ person, login })} - readOnly={readOnly} - /> - )} - - - - Calculator settings - Connect repositories and choose how to estimate effort. - - { - if ( - updated.source_provider !== settings.source_provider || - updated.github_api_url !== settings.github_api_url || - updated.gitlab_api_url !== settings.gitlab_api_url - ) { - resetView(updated, false); - return; - } - setSettings(updated); - }} - onReset={resetView} - onStartSync={startSync} - readOnly={readOnly} - syncDisabled={status.running} - /> - - - setSelectedPull(null)} /> - {!readOnly && ( - (person.email ? [person.email] : [])) ?? []} - onClose={() => setMatchingPerson(null)} - onSave={updateIdentity} - /> - )} - - ); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorViews.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorViews.tsx deleted file mode 100644 index b581e080e6c..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorViews.tsx +++ /dev/null @@ -1,569 +0,0 @@ -"use client"; - -import React from "react"; -import { ChevronDown, Download, GitBranch, Search } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { - peopleCsv, - isMatchedPerson, - effortNote, - estimateLabel, - formatMoney, - formatNumber, - branchCostLabel, - highestCostPulls, -} from "./roiCalculatorData"; -import type { ROIPerson, ROIPull, ROISummary } from "./roiCalculatorData"; - -type PullSelection = { summary: ROISummary; onSelectPull: (pull: ROIPull) => void }; - -export function ROIOverview({ - summary, - onSelectPull, - onViewPeople, - onViewBranches, -}: PullSelection & { - onViewPeople: () => void; - onViewBranches: () => void; -}) { - const metrics = summary.metrics; - const branches = summary.branch_metrics; - const topPulls = highestCostPulls(summary.pulls); - return ( -
-
-
- - - - -
-
-
-
-

Where AI costs are matched

-

- People use gateway account costs. Branches use tagged requests for these repositories. -

-
-
- - - - View - Matched cost - Unmatched cost - Changes matched - - - - - - - - - {formatMoney(metrics.matched_spend)} - - - {formatMoney(metrics.excluded_spend)} - - - {metrics.matched_prs} / {metrics.merged_prs} - - - - - - - - {formatMoney(branches?.spend)} - - - {formatMoney(branches?.unlinked_spend)} - - - {branches?.matched_pulls ?? 0} / {metrics.merged_prs} - - - -
-
-
- -
- ); -} - -export function ROIBranches({ - summary, - onSelectPull, - pulls, - query, - onQueryChange, - matchedOnly, -}: PullSelection & { - pulls: ROIPull[]; - query: string; - matchedOnly: boolean; - onQueryChange: (query: string) => void; -}) { - return ( -
-
-
- - -
-
- -
- ); -} - -function ROIPulls({ - summary, - onSelectPull, - pulls, - query = "", - onQueryChange, - compact = false, - matchedOnly = false, - onViewBranches, -}: PullSelection & { - pulls: ROIPull[]; - query?: string; - onQueryChange?: (query: string) => void; - compact?: boolean; - matchedOnly?: boolean; - onViewBranches?: () => void; -}) { - const changeName = summary.source_provider === "gitlab" ? "merge request" : "pull request"; - const [pagination, setPagination] = React.useState({ query, visibleCount: 10 }); - const visibleCount = pagination.query === query ? pagination.visibleCount : 10; - const metrics = summary.metrics; - const emptyMessage = compact - ? "No merged changes with tagged costs yet. Open Branches to see how to add tags." - : `No merged ${changeName}s ${matchedOnly ? "from matched people " : ""}in this period.`; - return ( -
-
-
-

{compact ? "Highest-cost changes" : "Costs by branch"}

-

- {compact - ? "Merged work ranked by tagged AI cost" - : `${pulls.length} of ${metrics.merged_prs} ${changeName}s`} -

-
- {compact ? ( - - ) : ( -
-
- )} -
-
- - - - - {changeName === "merge request" ? "Merge request" : "Pull request"} - - AI cost - Estimated effort - - - - {pulls.slice(0, visibleCount).map((pull) => ( - - - - - - {branchCostLabel(pull)} - - {estimateLabel(pull.estimate)} - - ))} - {pulls.length === 0 && ( - - - {query ? `No matching ${changeName}s. Try another search.` : emptyMessage} - - - )} - -
- {pulls.length > visibleCount && ( -
-

- Showing {Math.min(visibleCount, pulls.length)} of {pulls.length} -

- -
- )} -
-
- ); -} - -function ROIMetrics({ summary, branchMode }: { summary: ROISummary; branchMode: boolean }) { - const branches = summary.branch_metrics; - const metrics = summary.metrics; - return ( -
- - - - -
- ); -} - -function MetricCard({ - title, - value, - description, - primary = false, -}: { - title: string; - value: string; - description: string; - primary?: boolean; -}) { - return ( -
-
{title}
-
- {value} -
-
{description}
-
- ); -} - -function ROIComparison({ - summary, - branchMode, - onViewPeople, -}: { - summary: ROISummary; - branchMode: boolean; - onViewPeople?: () => void; -}) { - const branches = summary.branch_metrics; - const metrics = summary.metrics; - const unavailableRate = - metrics.output_hours > 0 - ? "Spend per estimated hour is unavailable until all selected repositories can be read." - : "Match gateway accounts to calculate costs per estimated hour."; - return ( -
- {!branchMode && metrics.cohort_people === 0 && onViewPeople && ( -
-

Match people to gateway accounts to see their AI costs.

- -
- )} -
- - - - - {formatMoney(branchMode ? branches?.unlinked_spend : metrics.excluded_spend)}{" "} - {branchMode ? "in unmatched costs" : "excluded from calculation"} - - -
-

{effortNote(summary.effort_basis)}

- {branchMode ? ( - <> -

- Only branches with matched request costs and complete effort estimates enter the calculation. Costs - cover retained requests in this report’s UTC dates, not the branch’s lifetime. -

-

- Open a change below to find its repository and branch tags. Send both with each gateway request. Email - matching is not required. Shared branches stay ambiguous so their costs are not counted twice. -

-

- {formatMoney(branches?.total_tagged_spend)} in tagged costs was found for these repositories. Costs - without a unique, fully estimated change stay unmatched. -

- {(summary.unlinked_branches?.length ?? 0) > 0 && ( -
-

Unmatched branches

-
    - {summary.unlinked_branches?.map((row) => ( -
  • - - {row.repo} -
    - {row.branch} -
    - {formatMoney(row.spend)} -
  • - ))} -
-
- )} - - ) : ( - <> -

- {metrics.cost_per_hour != null - ? `${formatMoney(metrics.matched_spend)} AI costs ÷ ${formatNumber(metrics.output_hours)} estimated hours = ${formatMoney(metrics.cost_per_hour)} per estimated hour.` - : unavailableRate} -

-

- Includes {metrics.cohort_people} matched {metrics.cohort_people === 1 ? "person" : "people"} with - complete estimates. Costs include each person’s full gateway usage across repositories during this UTC - period. -

- {onViewPeople && ( - - )} - - )} -
-
-
- ); -} - -export function ROIPeopleView({ - summary, - identityMap, - onMatch, - readOnly = false, - matchedOnly = true, -}: { - summary: ROISummary; - identityMap: Record; - onMatch: (person: ROIPerson, login: string) => void; - readOnly?: boolean; - matchedOnly?: boolean; -}) { - const people = matchedOnly ? summary.people.filter(isMatchedPerson) : summary.people; - const exportCsv = () => { - const url = URL.createObjectURL(new Blob([peopleCsv({ ...summary, people })], { type: "text/csv;charset=utf-8" })); - const link = document.createElement("a"); - link.href = url; - link.download = "litellm-roi.csv"; - link.click(); - window.setTimeout(() => URL.revokeObjectURL(url), 1000); - }; - return ( -
-
- - -
-
-
-

People and account matches

-

Select a person to match their gateway email.

-
- -
-
- - - - Person - AI cost - Estimated effort - Cost / est. hour - - - - {people.map((person) => ( - - -
- {person.logins.length ? ( - person.logins.map((login) => - readOnly ? ( - {login} - ) : ( - - ), - ) - ) : ( - Unassigned gateway spend - )} - - {isMatchedPerson(person) ? "Matched" : "Unmatched"} - -
-

- {person.email || "No public email"} - {person.logins.some((login) => identityMap[login.toLowerCase()]) ? " · Manual match" : ""} -

-
- {formatMoney(person.spend)} - - {person.estimated_prs > 0 ? `${formatNumber(person.hours)} hrs` : "—"} -

- {person.prs} {person.prs === 1 ? "change" : "changes"} - {person.pending_prs > 0 ? ` · ${person.pending_prs} pending` : ""} -

-
- - {formatMoney(person.cost_per_hour)} - {!person.eligible &&

Not included

} -
-
- ))} - {people.length === 0 && ( - - - {matchedOnly - ? "No matched people in this period. Turn off the filter to see all contributors" - : "No people in this period"} - - - )} -
-
-
-
- - -
-

- Matches use the author’s public profile email - {summary.source_provider === "gitlab" - ? "." - : " or commit emails associated with their GitHub account."}{" "} - Private, noreply, and ambiguous emails stay unmatched. Manual matches take priority. -

-

- Costs include each person’s full gateway usage for this period. People without a spend record or with - incomplete estimates are not included in the calculation. -

-

{effortNote(summary.effort_basis)}

-
-
-
- ); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROISettingsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROISettingsPanel.tsx deleted file mode 100644 index 535655ed574..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROISettingsPanel.tsx +++ /dev/null @@ -1,566 +0,0 @@ -"use client"; - -import React from "react"; - -import { SearchSelect } from "@/components/shared/SearchSelect"; -import { estimatorModelOptions } from "./roiCalculatorData"; - -import { apiClient } from "@/components/networking"; -import { extractErrorMessage } from "@/utils/errorUtils"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; -import { Textarea } from "@/components/ui/textarea"; -import type { ROIRepository, ROIRepositoriesResponse, ROISettings, ROISettingsUpdate } from "./roiCalculatorData"; - -export default function ROISettingsPanel({ - accessToken, - initialSettings, - onboarding, - onSaved, - onReset, - onStartSync, - readOnly, - syncDisabled, -}: { - accessToken: string | null; - initialSettings: ROISettings; - onboarding: boolean; - onSaved: (settings: ROISettings) => void; - onReset: (settings: ROISettings) => void; - onStartSync: () => Promise; - readOnly: boolean; - syncDisabled: boolean; -}) { - const [provider, setProvider] = React.useState<"github" | "gitlab">(initialSettings.source_provider ?? "github"); - const sourceName = provider === "gitlab" ? "GitLab" : "GitHub"; - const savedToken = provider === "gitlab" ? initialSettings.has_gitlab_token : initialSettings.has_github_token; - const savedUrl = provider === "gitlab" ? initialSettings.gitlab_api_url : initialSettings.github_api_url; - const initialStep = savedToken ? 1 : 0; - const [step, setStep] = React.useState(initialSettings.ready ? 2 : initialStep); - const [apiUrl, setApiUrl] = React.useState(savedUrl ?? "https://gitlab.com/api/v4"); - const [token, setToken] = React.useState(""); - const [clearToken, setClearToken] = React.useState(false); - const [repos, setRepos] = React.useState(initialSettings.repos); - const [model, setModel] = React.useState(initialSettings.estimator_model); - const [prompt, setPrompt] = React.useState(initialSettings.estimator_prompt); - const [backfillDays, setBackfillDays] = React.useState(String(initialSettings.backfill_days)); - const [intervalHours, setIntervalHours] = React.useState( - String((initialSettings.update_interval_minutes ?? 1440) / 60), - ); - const [estimatorKey, setEstimatorKey] = React.useState(""); - const [clearEstimatorKey, setClearEstimatorKey] = React.useState(false); - const [repositoryName, setRepositoryName] = React.useState(""); - const [resetOpen, setResetOpen] = React.useState(false); - const [repositoryQuery, setRepositoryQuery] = React.useState(""); - const [repositoryPage, setRepositoryPage] = React.useState(1); - const [availableRepos, setAvailableRepos] = React.useState([]); - const [hasMoreRepos, setHasMoreRepos] = React.useState(false); - const [busy, setBusy] = React.useState(false); - const [error, setError] = React.useState(null); - const [message, setMessage] = React.useState(null); - - const sourceUnchanged = provider === (initialSettings.source_provider ?? "github") && apiUrl === savedUrl; - const credentialsSaved = sourceUnchanged && !token.trim() && !clearToken; - const canLoadRepositories = credentialsSaved && (provider === "gitlab" || savedToken); - const tokenHelp = - provider === "gitlab" - ? "For private projects, use a token with read_api scope and project access." - : "For private repositories, use a token with read access to contents and pull requests."; - - const changeProvider = (next: "github" | "gitlab") => { - setProvider(next); - setApiUrl( - next === "gitlab" - ? initialSettings.gitlab_api_url ?? "https://gitlab.com/api/v4" - : initialSettings.github_api_url, - ); - setToken(""); - setClearToken(false); - setRepos([]); - setAvailableRepos([]); - setHasMoreRepos(false); - }; - - const loadRepositories = async (page: number) => { - if (!accessToken || !canLoadRepositories) return; - try { - setBusy(true); - const response: ROIRepositoriesResponse = await apiClient.get("/roi-calculator/repositories", { - accessToken, - query: { query: repositoryQuery, page }, - }); - setAvailableRepos((current) => (page === 1 ? response.repositories : [...current, ...response.repositories])); - setHasMoreRepos(response.has_more); - setRepositoryPage(page); - setError(null); - } catch (reason) { - setError(extractErrorMessage(reason)); - } finally { - setBusy(false); - } - }; - - const saveSettings = async () => { - if (!accessToken || readOnly) return false; - const tokenValue = clearToken ? null : token.trim() || undefined; - const body: ROISettingsUpdate = { - source_provider: provider, - ...(provider === "gitlab" ? { gitlab_api_url: apiUrl } : { github_api_url: apiUrl }), - repos, - estimator_model: model, - estimator_prompt: prompt, - backfill_days: Number(backfillDays), - update_interval_minutes: Number(intervalHours) * 60, - ...(clearEstimatorKey ? { estimator_key: null } : {}), - ...(estimatorKey.trim() ? { estimator_key: estimatorKey.trim() } : {}), - ...(provider === "gitlab" ? { gitlab_token: tokenValue } : { github_token: tokenValue }), - }; - try { - setBusy(true); - const updated: ROISettings = await apiClient.put("/roi-calculator/settings", { accessToken, body }); - onSaved(updated); - setToken(""); - setEstimatorKey(""); - setClearEstimatorKey(false); - setClearToken(false); - setMessage("Settings saved."); - setError(null); - return true; - } catch (reason) { - setError(extractErrorMessage(reason)); - setMessage(null); - return false; - } finally { - setBusy(false); - } - }; - - const submit = async (event: React.FormEvent) => { - event.preventDefault(); - if (!(await saveSettings())) return; - if (onboarding && step === 0) { - setStep(1); - if (!(token.trim() || savedToken) || clearToken) return; - try { - const result = await apiClient.get("/roi-calculator/repositories", { accessToken }); - setAvailableRepos(result.repositories); - setHasMoreRepos(result.has_more); - setRepositoryPage(1); - setStep(1); - } catch (reason) { - setError(extractErrorMessage(reason)); - } - } else if (onboarding && step === 1) setStep(2); - else if (onboarding) await onStartSync(); - }; - - const saveAndRun = async () => { - if (await saveSettings()) await onStartSync(); - }; - - const testConnections = async () => { - if (!(await saveSettings())) return; - setBusy(true); - try { - await apiClient.post("/roi-calculator/connections/test", { accessToken }); - setMessage("Gateway model and selected repositories are available."); - } catch (reason) { - setError(extractErrorMessage(reason)); - } finally { - setBusy(false); - } - }; - - const resetSetup = async () => { - setBusy(true); - try { - const updated = await apiClient.post("/roi-calculator/setup/reset", { accessToken }); - setRepos([]); - setStep(0); - setResetOpen(false); - onReset(updated); - } catch (reason) { - setError(extractErrorMessage(reason)); - } finally { - setBusy(false); - } - }; - - const toggleRepository = (name: string) => { - setRepos((current) => (current.includes(name) ? current.filter((repo) => repo !== name) : [...current, name])); - }; - - const formDisabled = busy || syncDisabled; - const runDisabled = formDisabled || !repos.length || !model; - const sourceUrlChanged = apiUrl !== savedUrl; - const missingReplacementToken = savedToken && sourceUrlChanged && !token.trim(); - const stepReady = [true, repos.length > 0, Boolean(model)][step]; - const onboardingLabel = step < 2 ? "Continue" : "Start backfill"; - const submitLabel = onboarding ? onboardingLabel : "Save settings"; - - return ( - - {onboarding && ( - -

- {["Connect your repositories", "Choose repositories", "Choose an estimator"][step]} -

- - Your gateway is already connected. Choose a source and an estimator for your first report. - -
- )} - - {error && ( -

- {error} -

- )} - {message && ( -

- {message} -

- )} - {onboarding && ( -

Step {step + 1} of 3 · Source / Repositories / Estimator

- )} -
void submit(event)}> -
- {(!onboarding || step === 0) && ( -
- {!onboarding &&

Connection

} -
- - - {provider !== (initialSettings.source_provider ?? "github") && !onboarding && ( -

- Switching source starts a new report and resets email matches. -

- )} -
-
- Self-hosted {sourceName} -
- - setApiUrl(event.target.value)} - /> -
-
-
- - { - setToken(event.target.value); - setClearToken(false); - }} - placeholder={savedToken ? "Token saved" : `Enter a ${sourceName} token`} - /> -

- {savedToken ? "A token is saved securely and is never shown here." : tokenHelp} -

- {missingReplacementToken && ( -

- Changing the API URL clears the saved token. Enter a replacement token to keep access. -

- )} - {savedToken && ( - - )} -
-
- )} - {(!onboarding || step === 1) && ( -
-

Repositories

- -
- setRepositoryQuery(event.target.value)} - placeholder="Search repositories" - /> - -
- {!canLoadRepositories && ( -

- {provider === "github" && !savedToken - ? "Save a GitHub token to browse repositories, or add a public repository by name." - : "Save the source and connection settings before loading repositories."} -

- )} - {repos.length > 0 && ( -
- {repos.map((repo) => ( - - ))} -
- )} -
- -
- setRepositoryName(e.target.value)} - /> - -
-
- {availableRepos.length > 0 && ( -
- {availableRepos.map((repository) => ( - - ))} -
- )} - {hasMoreRepos && ( - - )} -
- )} - {(!onboarding || step === 2) && ( -
- {!onboarding &&

Estimation and updates

} -
- - setModel(value ?? "")} - placeholder="Search estimator models" - emptyText="No matching models configured on this gateway" - disabled={readOnly} - className="h-9" - /> -

- We recommend GPT-6 Luna for estimating PR effort. Choose a model configured on your gateway. -

-
-
- Advanced estimator options -
- -