Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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."""
74 changes: 74 additions & 0 deletions app/modules/fleet/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from __future__ import annotations

from fastapi import APIRouter, Depends, Security

from app.core.auth.dependencies import set_dashboard_error_format, validate_usage_api_key
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

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


@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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Honor API-key quota visibility for fleet summary

For API keys deliberately configured not to see upstream quota data, this new path bypasses the existing visibility controls: /v1/usage parses api_key.usage_sections and suppresses account_pool_usage when hide_upstream_quota_from_api_keys is enabled, but GET /api/fleet/summary returns per-account remaining percentages and reset times for the same key. When usageSections excludes account_pool_usage/upstream_limits or the global privacy toggle is on, callers can recover the hidden quota details by calling this endpoint instead, so the summary should apply the same gates or require a separate explicit permission.

Useful? React with 👍 / 👎.



@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."""

visible_account_ids = _visible_account_ids(api_key)
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(),
)
32 changes: 32 additions & 0 deletions app/modules/fleet/mappers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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) -> 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 usage is not None else None,
reset_at=account.reset_at_primary,
window_minutes=account.window_minutes_primary,
),
secondary=FleetWindowSummary(
remaining_percent=usage.secondary_remaining_percent if usage is not None else None,
reset_at=account.reset_at_secondary,
window_minutes=account.window_minutes_secondary,
),
last_refresh_at=account.last_refresh_at,
)


def build_fleet_account_summaries(accounts: list[AccountSummary]) -> list[FleetAccountSummary]:
return [fleet_account_summary_from_account(account) 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