Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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."""
59 changes: 59 additions & 0 deletions app/modules/fleet/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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.dependencies import AccountsContext, get_accounts_context
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=[Security(validate_usage_api_key), Depends(set_dashboard_error_format)],

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 Preserve API-key scope in fleet handlers

For deployments that issue account-scoped API keys to clients, this router-level auth dependency only validates the token and discards the ApiKeyData, so both fleet handlers operate as an unscoped key: GET /api/fleet/summary lists every account/email and POST /api/fleet/refresh refreshes every eligible account instead of just assignedAccountIds. The existing API-key contract treats non-empty assigned_account_ids as account scope, so these handlers should receive the key data and filter accounts/usage queries before returning or refreshing.

Useful? React with 👍 / 👎.

)

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


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

accounts = await context.service.list_accounts()
return FleetSummaryResponse(accounts=build_fleet_account_summaries(accounts))


@router.post("/refresh", response_model=FleetRefreshResponse)
async def refresh_fleet_usage(
context: AccountsContext = Depends(get_accounts_context),
) -> FleetRefreshResponse:
"""Request a bounded usage refresh using codex-lb's normal refresh rules."""

usage_repo = UsageRepository(context.session)
additional_usage_repo = AdditionalUsageRepository(context.session)
accounts = await context.repository.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")
usage_written = await UsageUpdater(
usage_repo,
context.repository,
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.

P1 Badge Use a background session for shielded refreshes

When POST /api/fleet/refresh is cancelled or times out while an upstream fetch is in flight, UsageUpdater.refresh_accounts() runs the per-account work inside _USAGE_REFRESH_SINGLEFLIGHT.run() via an asyncio.shielded task. Because the repos passed here all share the request-scoped context.session, FastAPI can close or roll back that session while the shielded task keeps using it, leading to failed refreshes or checked-out connections not returning under disconnects; create and own a background session inside the refresh task instead.

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