Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
2 changes: 2 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
from app.modules.dashboard import api as dashboard_api
from app.modules.dashboard_auth import api as dashboard_auth_api
from app.modules.firewall import api as firewall_api
from app.modules.fleet import api as fleet_api
from app.modules.health import api as health_api
from app.modules.oauth import api as oauth_api
from app.modules.proxy import api as proxy_api
Expand Down Expand Up @@ -404,6 +405,7 @@ def create_app() -> FastAPI:
app.include_router(dashboard_auth_api.router)
app.include_router(settings_api.router)
app.include_router(firewall_api.router)
app.include_router(fleet_api.router)
app.include_router(sticky_sessions_api.router)
app.include_router(api_keys_api.router)
app.include_router(health_api.router)
Expand Down
9 changes: 9 additions & 0 deletions app/modules/accounts/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ async def list_accounts(self, *, refresh_existing: bool = False) -> list[Account
result = await self._session.execute(stmt)
return list(result.scalars().all())

async def list_accounts_by_ids(self, account_ids: list[str], *, refresh_existing: bool = False) -> list[Account]:
if not account_ids:
return []
stmt = select(Account).where(Account.id.in_(account_ids)).order_by(Account.email)
if refresh_existing:
stmt = stmt.execution_options(populate_existing=True)
result = await self._session.execute(stmt)
return list(result.scalars().all())

async def list_request_usage_summary_by_account(
self,
account_ids: list[str] | None = None,
Expand Down
49 changes: 37 additions & 12 deletions app/modules/accounts/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,18 +128,35 @@ def __init__(
self._encryptor = TokenEncryptor()
self._auth_manager = auth_manager

async def list_accounts(self) -> list[AccountSummary]:
accounts = await self._repo.list_accounts()
async def list_accounts(self, *, account_ids: list[str] | None = None) -> list[AccountSummary]:
accounts = (
await self._repo.list_accounts_by_ids(account_ids)
if account_ids is not None
else await self._repo.list_accounts()
)
if not accounts:
return []
account_ids = [account.id for account in accounts]
account_id_set = set(account_ids)
primary_usage = await self._usage_repo.latest_by_account(window="primary") if self._usage_repo else {}
secondary_usage = await self._usage_repo.latest_by_account(window="secondary") if self._usage_repo else {}
monthly_usage = await self._usage_repo.latest_by_account(window="monthly") if self._usage_repo else {}
request_usage_rows = await self._repo.list_request_usage_summary_by_account(account_ids)
visible_account_ids = [account.id for account in accounts]
account_id_set = set(visible_account_ids)
usage_account_ids = visible_account_ids if account_ids is not None else None
primary_usage = (
await self._usage_repo.latest_by_account(window="primary", account_ids=usage_account_ids)
if self._usage_repo
else {}
)
secondary_usage = (
await self._usage_repo.latest_by_account(window="secondary", account_ids=usage_account_ids)
if self._usage_repo
else {}
)
monthly_usage = (
await self._usage_repo.latest_by_account(window="monthly", account_ids=usage_account_ids)
if self._usage_repo
else {}
)
request_usage_rows = await self._repo.list_request_usage_summary_by_account(visible_account_ids)
limit_warmups_by_account = (
await self._limit_warmup_repo.latest_by_account(account_ids) if self._limit_warmup_repo else {}
await self._limit_warmup_repo.latest_by_account(visible_account_ids) if self._limit_warmup_repo else {}
)
request_usage_by_account = {
account_id: AccountRequestUsage(
Expand All @@ -154,10 +171,18 @@ async def list_accounts(self) -> list[AccountSummary]:
additional_usage_repo = cast(AdditionalUsageRepository | None, self._additional_usage_repo)
if additional_usage_repo:
additional_quota_routing_overrides = await self._repo.additional_quota_routing_policy_overrides()
quota_keys = await additional_usage_repo.list_quota_keys(account_ids=account_ids)
quota_keys = await additional_usage_repo.list_quota_keys(account_ids=visible_account_ids)
for quota_key in quota_keys:
primary_entries = await additional_usage_repo.latest_by_account(quota_key, "primary")
secondary_entries = await additional_usage_repo.latest_by_account(quota_key, "secondary")
primary_entries = await additional_usage_repo.latest_by_account(
quota_key,
"primary",
account_ids=visible_account_ids,
)
secondary_entries = await additional_usage_repo.latest_by_account(
quota_key,
"secondary",
account_ids=visible_account_ids,
)
for account_id in (set(primary_entries) | set(secondary_entries)) & account_id_set:
primary_entry = primary_entries.get(account_id)
secondary_entry = secondary_entries.get(account_id)
Expand Down
1 change: 1 addition & 0 deletions app/modules/fleet/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Fleet summary endpoints for external local dashboards."""
120 changes: 120 additions & 0 deletions app/modules/fleet/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
from __future__ import annotations

import asyncio
import logging

from fastapi import APIRouter, Depends, Security

from app.core.auth.dependencies import set_dashboard_error_format, validate_usage_api_key
from app.core.config.settings_cache import get_settings_cache
from app.core.utils.time import utcnow
from app.db.models import AccountStatus
from app.db.session import get_background_session
from app.dependencies import AccountsContext, get_accounts_context
from app.modules.accounts.repository import AccountsRepository
from app.modules.api_keys.service import ApiKeyData
from app.modules.fleet.mappers import build_fleet_account_summaries
from app.modules.fleet.schemas import FleetRefreshResponse, FleetSummaryResponse
from app.modules.proxy.account_cache import get_account_selection_cache
from app.modules.proxy.rate_limit_cache import get_rate_limit_headers_cache
from app.modules.usage.repository import AdditionalUsageRepository, UsageRepository
from app.modules.usage.updater import UsageUpdater

logger = logging.getLogger(__name__)

router = APIRouter(
prefix="/api/fleet",
tags=["fleet"],
dependencies=[Depends(set_dashboard_error_format)],
)

_REFRESH_SKIP_STATUSES = {AccountStatus.PAUSED, AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED}


def _visible_account_ids(api_key: ApiKeyData) -> list[str] | None:
return list(api_key.assigned_account_ids) if api_key.account_assignment_scope_enabled else None


def _usage_sections(raw: str) -> set[str]:
if not raw or not raw.strip():
return set()
return {section.strip() for section in raw.split(",") if section.strip()}


async def _can_view_fleet_usage(api_key: ApiKeyData) -> bool:
if "account_pool_usage" not in _usage_sections(api_key.usage_sections):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate reset/window fields on upstream_limits permission

Fresh evidence after the quota-visibility fix is that this predicate still treats account_pool_usage alone as permission to return the entire fleet summary. /v1/usage gates upstream_limits separately, but the fleet mapper returns per-account resetAt and windowMinutes; a key configured with usage_sections="account_pool_usage" and no upstream_limits can recover reset/window quota details that are hidden from /v1/usage. Require upstream_limits for those fields or null them independently.

Useful? React with 👍 / 👎.

return False
settings = await get_settings_cache().get()
return not bool(getattr(settings, "hide_upstream_quota_from_api_keys", False))


@router.get("/summary", response_model=FleetSummaryResponse)
async def get_fleet_summary(
context: AccountsContext = Depends(get_accounts_context),
api_key: ApiKeyData = Security(validate_usage_api_key),
) -> FleetSummaryResponse:
"""Read-only, minimal per-account capacity summary for fleet consumers."""

accounts = await context.service.list_accounts(account_ids=_visible_account_ids(api_key))
return FleetSummaryResponse(
accounts=build_fleet_account_summaries(accounts, include_usage=await _can_view_fleet_usage(api_key))
)


@router.post("/refresh", response_model=FleetRefreshResponse)
async def refresh_fleet_usage(
api_key: ApiKeyData = Security(validate_usage_api_key),
) -> FleetRefreshResponse:
"""Request a bounded usage refresh using codex-lb's normal refresh rules."""

task = asyncio.create_task(
_refresh_fleet_usage_with_owned_session(_visible_account_ids(api_key)),
name="fleet-usage-refresh",
)
try:
return await asyncio.shield(task)
except asyncio.CancelledError:
task.add_done_callback(_log_cancelled_refresh_task_exception)
raise


async def _refresh_fleet_usage_with_owned_session(visible_account_ids: list[str] | None) -> FleetRefreshResponse:
async with get_background_session() as session:
accounts_repo = AccountsRepository(session)
usage_repo = UsageRepository(session)
additional_usage_repo = AdditionalUsageRepository(session)
accounts = (
await accounts_repo.list_accounts_by_ids(visible_account_ids, refresh_existing=True)
if visible_account_ids is not None
else await accounts_repo.list_accounts(refresh_existing=True)
)
eligible_accounts = [account for account in accounts if account.status not in _REFRESH_SKIP_STATUSES]
latest_primary = await usage_repo.latest_by_account(window="primary", account_ids=visible_account_ids)
usage_written = await UsageUpdater(
usage_repo,
accounts_repo,
additional_usage_repo,
).refresh_accounts(eligible_accounts, latest_primary)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run fleet refresh with a task-owned DB session

When a client disconnects/cancels POST /api/fleet/refresh while an upstream usage fetch is in flight, UsageUpdater.refresh_accounts runs the per-account refresh through the module-level singleflight using asyncio.shield, so that task can continue after this endpoint unwinds. Because the repositories passed here all share the surrounding get_background_session() context, its finalizer rolls back/closes the same AsyncSession while the surviving task may still write usage rows, token metadata, or status updates through it, leading to lost refresh work or broken/leaked pooled connections. Use a session owned by the shielded refresh task or ensure the singleflight is awaited/cancelled before leaving the context.

Useful? React with 👍 / 👎.

if usage_written:
await get_rate_limit_headers_cache().invalidate()
get_account_selection_cache().invalidate()
return FleetRefreshResponse(
usage_written=usage_written,
account_count=len(accounts),
attempted_count=len(eligible_accounts),
generated_at=utcnow(),
)


def _log_cancelled_refresh_task_exception(task: asyncio.Task[FleetRefreshResponse]) -> None:
if task.cancelled():
return
try:
exc = task.exception()
except asyncio.CancelledError:
return
if exc is not None:
logger.error(
"Fleet usage refresh failed after request cancellation",
exc_info=(type(exc), exc, exc.__traceback__),
)
36 changes: 36 additions & 0 deletions app/modules/fleet/mappers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from __future__ import annotations

from app.modules.accounts.schemas import AccountSummary
from app.modules.fleet.schemas import FleetAccountSummary, FleetWindowSummary


def fleet_account_summary_from_account(account: AccountSummary, *, include_usage: bool = True) -> FleetAccountSummary:
"""Project a dashboard account into the minimal fleet payload."""

usage = account.usage
return FleetAccountSummary(
account_id=account.account_id,
display_name=account.display_name,
email=account.email,
status=account.status,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hide usage-derived status when quota visibility is disabled

When include_usage is false because the API key lacks account_pool_usage or hide_upstream_quota_from_api_keys is enabled, this still forwards AccountSummary.status. That status is computed by the accounts summary mapper from the latest usage rows, so an otherwise active account with primary or secondary usage at 100% is serialized as rate_limited/quota_exceeded while the window fields are nulled, leaking the hidden quota state through the new fleet endpoint.

Useful? React with 👍 / 👎.

plan_type=account.plan_type,
primary=FleetWindowSummary(
remaining_percent=usage.primary_remaining_percent if include_usage and usage is not None else None,
reset_at=account.reset_at_primary if include_usage else None,
window_minutes=account.window_minutes_primary if include_usage else None,
),
secondary=FleetWindowSummary(
remaining_percent=usage.secondary_remaining_percent if include_usage and usage is not None else None,
reset_at=account.reset_at_secondary if include_usage else None,
window_minutes=account.window_minutes_secondary if include_usage else None,
),
last_refresh_at=account.last_refresh_at if include_usage else None,
)


def build_fleet_account_summaries(
accounts: list[AccountSummary],
*,
include_usage: bool = True,
) -> list[FleetAccountSummary]:
return [fleet_account_summary_from_account(account, include_usage=include_usage) for account in accounts]
40 changes: 40 additions & 0 deletions app/modules/fleet/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from __future__ import annotations

from datetime import datetime

from pydantic import Field

from app.modules.shared.schemas import DashboardModel


class FleetWindowSummary(DashboardModel):
"""One minimal capacity window for a single account."""

remaining_percent: float | None = None
reset_at: datetime | None = None
window_minutes: int | None = None


class FleetAccountSummary(DashboardModel):
"""Non-sensitive capacity projection for fleet consumers."""

account_id: str
display_name: str
email: str
status: str
plan_type: str
primary: FleetWindowSummary
secondary: FleetWindowSummary
last_refresh_at: datetime | None = None


class FleetSummaryResponse(DashboardModel):
accounts: list[FleetAccountSummary] = Field(default_factory=list)


class FleetRefreshResponse(DashboardModel):
ok: bool = True
usage_written: bool
account_count: int
attempted_count: int
generated_at: datetime
2 changes: 2 additions & 0 deletions openspec/changes/add-fleet-refresh-endpoint/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-01
23 changes: 23 additions & 0 deletions openspec/changes/add-fleet-refresh-endpoint/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## Why

External local dashboards need a truthful way to distinguish a fresh read of codex-lb state from an upstream usage refresh attempt. The current live fleet view can read a summary, but upstream usage refresh remains background-only, so a manual refresh button in a sibling dashboard cannot ask codex-lb to refresh and report what happened.

## What Changes

- Add API-key-authenticated `GET /api/fleet/summary` for minimal per-account capacity state.
- Add API-key-authenticated `POST /api/fleet/refresh` that runs the existing usage refresh path outside proxy request selection.
- Return a minimal non-sensitive refresh outcome with `usageWritten`, `accountCount`, `attemptedCount`, and `generatedAt`.
- Preserve existing usage-refresh freshness, cooldown, paused, reauth-required, and deactivated-account rules.

## Capabilities

### New Capabilities

- `fleet-summary`: expose minimal account capacity for trusted local fleet consumers and let them request bounded refresh attempts.

## Impact

- **Backend**: new `app/modules/fleet` API, schemas, and mappers; router registration in `app/main.py`.
- **Specs**: new `fleet-summary` capability.
- **Tests**: integration coverage for auth, minimal projection, sensitive-field omission, and refresh outcome shape.
- **No database migration**: no schema changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
## ADDED Requirements

### Requirement: Fleet summary requires API key authentication

The system SHALL expose `GET /api/fleet/summary` for trusted local fleet consumers. The route MUST require a valid Bearer API key even when global proxy API-key authentication is disabled.

#### Scenario: Missing fleet summary key is rejected

- **WHEN** a client calls `GET /api/fleet/summary` without a Bearer token
- **THEN** the system returns 401
- **AND** no account summary payload is returned

#### Scenario: Valid fleet summary key returns account capacity

- **WHEN** a client calls `GET /api/fleet/summary` with a valid Bearer API key
- **THEN** the response includes `accounts[]`
- **AND** each account includes `accountId`, `displayName`, `email`, `status`, `planType`, `primary`, `secondary`, and `lastRefreshAt`
- **AND** each window includes `remainingPercent`, `resetAt`, and `windowMinutes`

### Requirement: Fleet summary excludes sensitive data

Fleet summary responses MUST NOT include OAuth token material, auth token status, raw credit balances, request-cost detail, additional quota detail, or deactivation reasons.

#### Scenario: Sensitive fields are omitted

- **WHEN** a valid client calls `GET /api/fleet/summary`
- **THEN** no response object includes token fields, `auth`, credit-balance fields, request usage, additional quotas, or deactivation reasons

### Requirement: Fleet refresh requests existing usage refresh policy

The system SHALL expose `POST /api/fleet/refresh` for trusted local fleet consumers. The route MUST require a valid Bearer API key even when global proxy API-key authentication is disabled. The route MUST request a usage refresh through codex-lb's existing usage refresh machinery and MUST NOT refresh inside proxy account selection.

The route MUST preserve existing usage-refresh rules for disabled refresh, fresh samples, auth cooldowns, paused accounts, reauth-required accounts, and deactivated accounts.

#### Scenario: Fleet refresh returns minimal outcome

- **WHEN** a valid client calls `POST /api/fleet/refresh`
- **THEN** the response includes `ok: true`, `usageWritten`, `accountCount`, `attemptedCount`, and `generatedAt`
- **AND** the response does not include account credentials or token material

#### Scenario: Fleet refresh skips unsafe account states

- **GIVEN** active and paused accounts exist
- **WHEN** a valid client calls `POST /api/fleet/refresh`
- **THEN** active accounts are eligible for the refresh attempt
- **AND** paused, reauth-required, and deactivated accounts are not attempted
17 changes: 17 additions & 0 deletions openspec/changes/add-fleet-refresh-endpoint/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## 1. Specification

- [x] 1.1 Add `fleet-summary` OpenSpec capability delta for summary and refresh behavior.

## 2. Backend API

- [x] 2.1 Add a minimal fleet summary response shape.
- [x] 2.2 Add API-key-authenticated `GET /api/fleet/summary`.
- [x] 2.3 Add API-key-authenticated `POST /api/fleet/refresh`.
- [x] 2.4 Reuse existing usage refresh machinery and preserve refresh policy rules.
- [x] 2.5 Register the fleet router.

## 3. Tests

- [x] 3.1 Add integration tests for missing/invalid API key rejection.
- [x] 3.2 Add integration tests for minimal projection and sensitive-field omission.
- [x] 3.3 Add integration tests for bounded refresh response shape.
Loading