Skip to content
Merged
Show file tree
Hide file tree
Changes from 81 commits
Commits
Show all changes
102 commits
Select commit Hold shift + click to select a range
1a6c50c
feat(platform): add platform cost tracking for system credentials
majdyz Apr 2, 2026
308357d
fix(platform): address PR review round 1
majdyz Apr 2, 2026
26b0c95
fix(platform): address PR review round 2
majdyz Apr 2, 2026
072b623
fix(platform): address PR review round 3
majdyz Apr 2, 2026
476d9dc
fix(platform): address PR review round 4
majdyz Apr 2, 2026
f3b5d58
fix(platform): address PR review round 5
majdyz Apr 2, 2026
c054799
fix: regenerate API schema and block docs
majdyz Apr 2, 2026
9924ded
fix(platform): address bot review comments (sentry + coderabbit)
majdyz Apr 2, 2026
b933698
fix(platform): re-add credit_cost to platform cost log metadata
majdyz Apr 2, 2026
7020ae2
fix(backend): handle NULL userId in platform cost models and queries
majdyz Apr 2, 2026
2c517ff
feat(platform): add per-provider cost extraction
majdyz Apr 2, 2026
95524e9
feat(platform): add tracking_type and tracking_amount to cost log met…
majdyz Apr 2, 2026
83b00f4
feat(platform): add copilot/autopilot cost tracking via token_trackin…
majdyz Apr 2, 2026
c2a054c
fix(backend): prevent provider_cost loss on stats merge and widen cos…
majdyz Apr 2, 2026
7f4398e
feat(platform): provider-specific tracking types for accurate cost me…
majdyz Apr 2, 2026
a338c72
Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into co…
majdyz Apr 2, 2026
fbe634f
fix(platform): handle null user_id in cost logs and fix 0.0 cost stor…
majdyz Apr 2, 2026
173614b
fix(platform): audit and fix per-provider tracking accuracy
majdyz Apr 2, 2026
a8a62ee
feat(platform): add merge_stats tracking to all system credential blocks
majdyz Apr 2, 2026
993c43b
feat(platform): add merge_stats to remaining blocks (FAL, Revid, D-ID…
majdyz Apr 2, 2026
4bad083
feat(platform): extract OpenRouter cost from baseline copilot path
majdyz Apr 2, 2026
6fa66ac
feat(platform): add cost/token OTEL attributes to both copilot paths
majdyz Apr 2, 2026
9a08011
fix(platform): move opentelemetry import to top-level in both copilot…
majdyz Apr 2, 2026
45d3193
fix(platform): move baseline cost extraction to finally + accumulate …
majdyz Apr 2, 2026
ab16e63
fix(platform): pass model name to copilot cost tracking
majdyz Apr 2, 2026
405403e
fix(backend): initialize response before try block to satisfy pyright
majdyz Apr 2, 2026
61ef54a
Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into co…
majdyz Apr 2, 2026
567bc73
fix(blocks): regenerate block docs after merge with dev
majdyz Apr 2, 2026
1d7d2f7
feat(platform): tracking-aware dashboard with generated API client
majdyz Apr 2, 2026
54e5059
fix(platform): use generated Pagination type + estimate token costs
majdyz Apr 2, 2026
da544d3
fix(platform): fix CI - regenerate API schema + fix Date type mismatch
majdyz Apr 2, 2026
45b6ada
Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into co…
majdyz Apr 3, 2026
759effa
test(frontend): add unit tests for onboarding store and GenericTool h…
majdyz Apr 3, 2026
960f893
test(platform): add unit tests for platform cost helpers and data layer
majdyz Apr 3, 2026
71315aa
fix(backend): use actual provider in persist_and_record_usage cost lo…
majdyz Apr 3, 2026
4190f75
test: additional coverage for platform cost and token tracking
majdyz Apr 3, 2026
1ee0bd6
fix(platform): use round() for microdollar conversion and add cost tr…
majdyz Apr 3, 2026
b5acfb7
fix: resolve merge conflict with dev in helpers.test.ts
majdyz Apr 3, 2026
b00e16b
fix(platform): fix model_test to use Optional fields for None-skip test
majdyz Apr 3, 2026
8d22653
fix(platform): address 4 review blockers on cost tracking
majdyz Apr 3, 2026
7afd2b2
fix(platform): address 9 should-fix items from PR review
majdyz Apr 3, 2026
372900c
fix(platform): address 5 self-review items on cost tracking
majdyz Apr 5, 2026
7b36bed
fix(platform): address autogpt-reviewer feedback on cost tracking
majdyz Apr 5, 2026
5e59523
test(platform): align actions tests with string date passthrough
majdyz Apr 5, 2026
6f0c1df
fix(platform): close tracking gaps found during audit
majdyz Apr 5, 2026
78b95f8
fix(platform): add provider_cost tracking to Exa code_context block
majdyz Apr 5, 2026
44714f1
refactor(platform): use provider_cost_type Literal instead of output_…
majdyz Apr 5, 2026
f04cd08
feat(platform): add trackingAmount column + per-type rate estimation
majdyz Apr 5, 2026
2f63fcd
test(frontend): update platform-costs helpers tests for per-type rate…
majdyz Apr 5, 2026
dca89d1
refactor(platform): address autogpt-reviewer feedback (batch 1)
majdyz Apr 5, 2026
689cd67
refactor(platform): address autogpt-reviewer feedback (batch 2)
majdyz Apr 5, 2026
d57da6c
refactor(platform): extract usePlatformCostContent hook
majdyz Apr 5, 2026
6de79fb
fix: resolve merge conflicts with dev branch
majdyz Apr 6, 2026
fb63ae5
refactor(platform): address review comments on platform cost tracking
majdyz Apr 6, 2026
0365a26
refactor(backend): add clarifying comment to NodeExecutionStats.__iad…
majdyz Apr 6, 2026
1812085
fix(backend): update token_tracking_test mock targets after _schedule…
majdyz Apr 6, 2026
3895d95
fix(platform): address reviewer comments — tests, a11y, and frontend …
majdyz Apr 6, 2026
b64d1ed
Merge branch 'dev' into codex/platform-cost-tracking
ntindle Apr 6, 2026
f87bbd5
fix(backend): route platform cost logging through DatabaseManagerAsyn…
majdyz Apr 7, 2026
16d696e
fix(platform): address autogpt-reviewer blockers and should-fix items
majdyz Apr 7, 2026
27e34e9
fix(platform): drain pending cost logs on shutdown, remove dark: badges
majdyz Apr 7, 2026
1d45f2f
fix(platform): fix baseline copilot OpenRouter cost extraction and cr…
majdyz Apr 7, 2026
a31be77
fix(platform): address additional reviewer feedback on platform cost …
majdyz Apr 7, 2026
1c15d6a
fix(frontend): update platform-costs tests for Skeleton loading state…
majdyz Apr 7, 2026
3f7a8dc
fix(backend/copilot): use active_model instead of config.model for co…
majdyz Apr 7, 2026
50a8df3
Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into co…
majdyz Apr 7, 2026
c946183
fix(backend): address all open review comments on platform cost tracking
majdyz Apr 7, 2026
a616e5a
fix(backend): address PR review — email masking, semaphore, openroute…
majdyz Apr 7, 2026
254e605
fix(backend/copilot): connect Prisma in copilot executor for cost log…
majdyz Apr 7, 2026
2a73d1b
fix(backend/copilot): route copilot cost logging through DatabaseMana…
majdyz Apr 7, 2026
752a238
refactor(frontend): replace server actions with React Query hooks for…
majdyz Apr 7, 2026
9b11754
fix(backend/copilot): update stale comment in processor.py after cost…
majdyz Apr 7, 2026
db6b444
fix(platform): address autogpt-reviewer should-fix items
majdyz Apr 7, 2026
e1ea4cf
test(frontend): rewrite PlatformCostContent tests to mock Orval hooks
majdyz Apr 7, 2026
bbdf13c
test(backend): add missing cost-tracking tests for Exa research, Apol…
majdyz Apr 7, 2026
7c685c6
fix(backend): update platform_cost_test to expect masked email in das…
majdyz Apr 7, 2026
630d6d4
fix(backend): add semaphore to executor cost log tasks; fix type anno…
majdyz Apr 7, 2026
b89321a
Merge remote-tracking branch 'origin/dev' into codex/platform-cost-tr…
majdyz Apr 7, 2026
1c3fe14
fix(backend): address 4 unresolved review threads on cost tracking
majdyz Apr 7, 2026
22fb354
test(frontend): fix null-user dash assertion using getAllByText to ha…
majdyz Apr 7, 2026
e7bd05c
fix(backend): filter drain tasks by current event loop to prevent cro…
majdyz Apr 7, 2026
cf605ef
fix(backend): fix race condition on _pending_log_tasks and uncapped t…
majdyz Apr 7, 2026
5164fa8
fix(backend): fix race condition on _copilot_tasks concurrent iterati…
majdyz Apr 7, 2026
e7ca81e
fix(backend): address coderabbitai nitpicks in cost tracking files
majdyz Apr 7, 2026
91af007
fix(backend): guard against non-finite cost_usd in persist_and_record…
majdyz Apr 7, 2026
0e310c7
fix(backend): fix TestGetPlatformCostDashboard mocks to match 3-query…
majdyz Apr 7, 2026
b80d7ab
fix(backend): remove per-worker Prisma connect, route DB via Database…
majdyz Apr 8, 2026
3e01650
fix(backend): replace getattr/hasattr duck-typing with try/except in …
majdyz Apr 8, 2026
fa06502
refactor(backend): extract shared _update_title_async to copilot/serv…
majdyz Apr 8, 2026
5cc72e7
fix(backend): validate non-finite float in extract_openrouter_cost, r…
majdyz Apr 8, 2026
91a3874
Merge remote-tracking branch 'origin/dev' into codex/platform-cost-tr…
majdyz Apr 8, 2026
0c196cc
fix(backend): fix rate_limit_test mocks and reject negative extract_o…
majdyz Apr 8, 2026
1f70eb1
test(backend): add missing edge-case tests for cost tracking
majdyz Apr 8, 2026
4185040
fix(backend): fix TestSetUserTier cache_invalidated test to use user_…
majdyz Apr 8, 2026
e99a46f
refactor(backend): replace manual TTLCache with @cached decorator in …
majdyz Apr 8, 2026
db60929
fix(platform): address active review threads — UI labels, cost valida…
majdyz Apr 8, 2026
a9fa464
fix(backend/executor): clamp negative provider_cost in resolve_tracki…
majdyz Apr 8, 2026
b9789d1
fix(platform): add response=None baseline test and fix PlatformCostCo…
majdyz Apr 8, 2026
c2d3910
fix(backend): clear @cached result between TestGetPlatformCostDashboa…
majdyz Apr 8, 2026
de19865
Merge branch 'dev' into codex/platform-cost-tracking
majdyz Apr 8, 2026
7e16c3b
fix(platform): address Pwuts review — accessor pattern, Prisma insert…
majdyz Apr 8, 2026
fde3f6a
Merge remote-tracking branch 'origin/codex/platform-cost-tracking' in…
majdyz Apr 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import logging
from datetime import datetime

from autogpt_libs.auth import get_user_id, requires_admin_user
from cachetools import TTLCache
from fastapi import APIRouter, Query, Security
from pydantic import BaseModel

from backend.data.platform_cost import (
CostLogRow,
PlatformCostDashboard,
get_platform_cost_dashboard,
get_platform_cost_logs,
)
from backend.util.models import Pagination

logger = logging.getLogger(__name__)

# Cache dashboard results for 30 seconds per unique filter combination.
# The table is append-only so stale reads are acceptable for analytics.
_DASHBOARD_CACHE_TTL = 30
_dashboard_cache: TTLCache[tuple, PlatformCostDashboard] = TTLCache(
Comment thread
majdyz marked this conversation as resolved.
Outdated
maxsize=256, ttl=_DASHBOARD_CACHE_TTL
Comment thread
majdyz marked this conversation as resolved.
Outdated
Comment thread
majdyz marked this conversation as resolved.
Outdated
Comment thread
majdyz marked this conversation as resolved.
Outdated
Comment thread
majdyz marked this conversation as resolved.
Outdated
Comment thread
majdyz marked this conversation as resolved.
Outdated
Comment thread
majdyz marked this conversation as resolved.
Outdated
Comment thread
majdyz marked this conversation as resolved.
Outdated
)


router = APIRouter(
prefix="/platform-costs",
tags=["platform-cost", "admin"],
dependencies=[Security(requires_admin_user)],
)
Comment thread
majdyz marked this conversation as resolved.
Comment thread
majdyz marked this conversation as resolved.


class PlatformCostLogsResponse(BaseModel):
logs: list[CostLogRow]
pagination: Pagination


@router.get(
"/dashboard",
response_model=PlatformCostDashboard,
summary="Get Platform Cost Dashboard",
)
async def get_cost_dashboard(
admin_user_id: str = Security(get_user_id),
start: datetime | None = Query(None),
end: datetime | None = Query(None),
provider: str | None = Query(None),
user_id: str | None = Query(None),
):
logger.info("Admin %s fetching platform cost dashboard", admin_user_id)
cache_key = (start, end, provider, user_id)
cached = _dashboard_cache.get(cache_key)
if cached is not None:
return cached
result = await get_platform_cost_dashboard(
start=start,
end=end,
provider=provider,
user_id=user_id,
)
_dashboard_cache[cache_key] = result
return result


@router.get(
"/logs",
response_model=PlatformCostLogsResponse,
Comment thread
majdyz marked this conversation as resolved.
summary="Get Platform Cost Logs",
)
async def get_cost_logs(
admin_user_id: str = Security(get_user_id),
start: datetime | None = Query(None),
end: datetime | None = Query(None),
provider: str | None = Query(None),
user_id: str | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
):
logger.info("Admin %s fetching platform cost logs", admin_user_id)
logs, total = await get_platform_cost_logs(
start=start,
Comment thread
majdyz marked this conversation as resolved.
end=end,
provider=provider,
user_id=user_id,
page=page,
page_size=page_size,
)
total_pages = (total + page_size - 1) // page_size
return PlatformCostLogsResponse(
logs=logs,
pagination=Pagination(
total_items=total,
total_pages=total_pages,
current_page=page,
page_size=page_size,
),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
from unittest.mock import AsyncMock

import fastapi
import fastapi.testclient
import pytest
import pytest_mock
from autogpt_libs.auth.jwt_utils import get_jwt_payload

from backend.data.platform_cost import PlatformCostDashboard

from . import platform_cost_routes
from .platform_cost_routes import router as platform_cost_router

app = fastapi.FastAPI()
app.include_router(platform_cost_router)

client = fastapi.testclient.TestClient(app)


@pytest.fixture(autouse=True)
def setup_app_admin_auth(mock_jwt_admin):
"""Setup admin auth overrides for all tests in this module"""
app.dependency_overrides[get_jwt_payload] = mock_jwt_admin["get_jwt_payload"]
# Clear TTL cache so each test starts cold.
platform_cost_routes._dashboard_cache.clear()
yield
app.dependency_overrides.clear()


def test_get_dashboard_success(
mocker: pytest_mock.MockerFixture,
) -> None:
real_dashboard = PlatformCostDashboard(
by_provider=[],
by_user=[],
total_cost_microdollars=0,
total_requests=0,
total_users=0,
)
mocker.patch(
"backend.api.features.admin.platform_cost_routes.get_platform_cost_dashboard",
AsyncMock(return_value=real_dashboard),
)

response = client.get("/platform-costs/dashboard")
assert response.status_code == 200
data = response.json()
assert "by_provider" in data
assert "by_user" in data
assert data["total_cost_microdollars"] == 0


def test_get_logs_success(
mocker: pytest_mock.MockerFixture,
) -> None:
mocker.patch(
"backend.api.features.admin.platform_cost_routes.get_platform_cost_logs",
AsyncMock(return_value=([], 0)),
)

response = client.get("/platform-costs/logs")
assert response.status_code == 200
data = response.json()
assert data["logs"] == []
assert data["pagination"]["total_items"] == 0


def test_get_dashboard_with_filters(
mocker: pytest_mock.MockerFixture,
) -> None:
real_dashboard = PlatformCostDashboard(
by_provider=[],
by_user=[],
total_cost_microdollars=0,
total_requests=0,
total_users=0,
)
mock_dashboard = AsyncMock(return_value=real_dashboard)
mocker.patch(
"backend.api.features.admin.platform_cost_routes.get_platform_cost_dashboard",
mock_dashboard,
)

response = client.get(
"/platform-costs/dashboard",
params={
"start": "2026-01-01T00:00:00",
"end": "2026-04-01T00:00:00",
"provider": "openai",
"user_id": "test-user-123",
},
)
assert response.status_code == 200
mock_dashboard.assert_called_once()
call_kwargs = mock_dashboard.call_args.kwargs
assert call_kwargs["provider"] == "openai"
assert call_kwargs["user_id"] == "test-user-123"
assert call_kwargs["start"] is not None
assert call_kwargs["end"] is not None


def test_get_logs_with_pagination(
mocker: pytest_mock.MockerFixture,
) -> None:
mocker.patch(
"backend.api.features.admin.platform_cost_routes.get_platform_cost_logs",
AsyncMock(return_value=([], 0)),
)

response = client.get(
"/platform-costs/logs",
params={"page": 2, "page_size": 25, "provider": "anthropic"},
)
assert response.status_code == 200
data = response.json()
assert data["pagination"]["current_page"] == 2
assert data["pagination"]["page_size"] == 25


def test_get_dashboard_requires_admin() -> None:
import fastapi
from fastapi import HTTPException

def reject_jwt(request: fastapi.Request):
raise HTTPException(status_code=401, detail="Not authenticated")

app.dependency_overrides[get_jwt_payload] = reject_jwt
try:
response = client.get("/platform-costs/dashboard")
assert response.status_code == 401
response = client.get("/platform-costs/logs")
assert response.status_code == 401
finally:
app.dependency_overrides.clear()


def test_get_dashboard_rejects_non_admin(mock_jwt_user, mock_jwt_admin) -> None:
"""Non-admin JWT must be rejected with 403 by requires_admin_user."""
app.dependency_overrides[get_jwt_payload] = mock_jwt_user["get_jwt_payload"]
try:
response = client.get("/platform-costs/dashboard")
assert response.status_code == 403
response = client.get("/platform-costs/logs")
assert response.status_code == 403
finally:
app.dependency_overrides[get_jwt_payload] = mock_jwt_admin["get_jwt_payload"]


def test_get_logs_invalid_page_size_too_large() -> None:
"""page_size > 200 must be rejected with 422."""
response = client.get("/platform-costs/logs", params={"page_size": 201})
assert response.status_code == 422


def test_get_logs_invalid_page_size_zero() -> None:
"""page_size = 0 (below ge=1) must be rejected with 422."""
response = client.get("/platform-costs/logs", params={"page_size": 0})
assert response.status_code == 422


def test_get_logs_invalid_page_negative() -> None:
"""page < 1 must be rejected with 422."""
response = client.get("/platform-costs/logs", params={"page": 0})
assert response.status_code == 422


def test_get_dashboard_invalid_date_format() -> None:
"""Malformed start date must be rejected with 422."""
response = client.get("/platform-costs/dashboard", params={"start": "not-a-date"})
assert response.status_code == 422


def test_get_dashboard_cache_hit(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Second identical request returns cached result without calling the DB again."""
real_dashboard = PlatformCostDashboard(
by_provider=[],
by_user=[],
total_cost_microdollars=42,
total_requests=1,
total_users=1,
)
mock_fn = mocker.patch(
"backend.api.features.admin.platform_cost_routes.get_platform_cost_dashboard",
AsyncMock(return_value=real_dashboard),
)

client.get("/platform-costs/dashboard")
client.get("/platform-costs/dashboard")

mock_fn.assert_awaited_once() # second request hit the cache
6 changes: 6 additions & 0 deletions autogpt_platform/backend/backend/api/rest_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import backend.api.features.admin.credit_admin_routes
import backend.api.features.admin.execution_analytics_routes
import backend.api.features.admin.platform_cost_routes
import backend.api.features.admin.rate_limit_admin_routes
import backend.api.features.admin.store_admin_routes
import backend.api.features.builder
Expand Down Expand Up @@ -329,6 +330,11 @@ async def validation_error_handler(
tags=["v2", "admin"],
prefix="/api/copilot",
)
app.include_router(
Comment thread
majdyz marked this conversation as resolved.
backend.api.features.admin.platform_cost_routes.router,
tags=["v2", "admin"],
prefix="/api/admin",
)
app.include_router(
backend.api.features.executions.review.routes.router,
tags=["v2", "executions", "review"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
PrimaryPhone,
SearchOrganizationsRequest,
)
from backend.data.model import CredentialsField, SchemaField
from backend.data.model import CredentialsField, NodeExecutionStats, SchemaField


class SearchOrganizationsBlock(Block):
Expand Down Expand Up @@ -218,6 +218,11 @@ async def run(
) -> BlockOutput:
query = SearchOrganizationsRequest(**input_data.model_dump())
organizations = await self.search_organizations(query, credentials)
self.merge_stats(
NodeExecutionStats(
provider_cost=float(len(organizations)), provider_cost_type="items"
)
)
for organization in organizations:
yield "organization", organization
yield "organizations", organizations
7 changes: 6 additions & 1 deletion autogpt_platform/backend/backend/blocks/apollo/people.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
SearchPeopleRequest,
SenorityLevels,
)
from backend.data.model import CredentialsField, SchemaField
from backend.data.model import CredentialsField, NodeExecutionStats, SchemaField


class SearchPeopleBlock(Block):
Expand Down Expand Up @@ -366,4 +366,9 @@ async def enrich_or_fallback(person: Contact):
*(enrich_or_fallback(person) for person in people)
)

self.merge_stats(
NodeExecutionStats(
provider_cost=float(len(people)), provider_cost_type="items"
)
)
yield "people", people
Loading
Loading