Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The format is intentionally lightweight and human-readable. Group entries by rel

- Added richer client usage reporting in `GET /api/stats` and the dashboard, including per-client tokens, failures, success rate, and aggregate client totals
- Added a second wave of AI-native starter templates for Agno, Semantic Kernel, Haystack, Mastra, and Google ADK
- Added client highlight summaries to `GET /api/stats` and the built-in dashboard for top request, token, cost, failure, and latency signals

### Changed

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ FoundryGate gives OpenClaw, n8n, CLI tools, and custom apps one local endpoint a
- Single local endpoint for many upstreams: cloud providers, proxy providers, and local workers can sit behind the same base URL.
- OpenAI-compatible runtime: chat completions, model discovery, image generation, and image editing use familiar OpenAI-style paths.
- Better routing than simple first-match proxying: policies, static rules, heuristics, client profiles, hooks, and route-fit scoring all participate.
- Strong operator visibility: `/health`, provider inventory, route previews, traces, stats, update checks, and dashboard views are built in.
- Strong operator visibility: `/health`, provider inventory, route previews, traces, stats, update checks, and dashboard views are built in, including per-client usage highlights.
- Practical rollout controls: fallback chains, maintenance windows, rollout rings, provider scopes, and post-update verification gates are already there.
- Copy/paste onboarding: OpenClaw, n8n, CLI, delegated-agent traffic, provider templates, and env starter files ship with the repo.

Expand Down
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ curl -fsS 'http://127.0.0.1:8090/api/providers?capability=image_generation'

### `GET /api/stats`

Returns aggregate request counters, token usage, per-client breakdowns, aggregate client totals, cost data, and operator-action summaries.
Returns aggregate request counters, token usage, per-client breakdowns, aggregate client totals, client highlight summaries, cost data, and operator-action summaries.

```bash
curl -fsS http://127.0.0.1:8090/api/stats
Expand Down
60 changes: 57 additions & 3 deletions foundrygate/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,49 @@ def _health_summary() -> dict[str, int]:
}


def _client_highlights(client_totals: list[dict[str, Any]]) -> dict[str, dict[str, Any] | None]:
"""Return a small set of client-level highlights for the operator surface."""
if not client_totals:
return {
"top_requests": None,
"top_tokens": None,
"top_cost": None,
"highest_failure_rate": None,
"slowest_client": None,
}

rows = list(client_totals)
failure_rows = [row for row in rows if (row.get("failures") or 0) > 0]

return {
"top_requests": max(
rows, key=lambda row: (row.get("requests") or 0, row.get("total_tokens") or 0)
),
"top_tokens": max(
rows,
key=lambda row: (row.get("total_tokens") or 0, row.get("requests") or 0),
),
"top_cost": max(rows, key=lambda row: (row.get("cost_usd") or 0, row.get("requests") or 0)),
"highest_failure_rate": (
max(
failure_rows,
key=lambda row: (
row.get("success_pct") is not None,
-(row.get("success_pct") or 0),
row.get("failures") or 0,
row.get("requests") or 0,
),
)
if failure_rows
else None
),
"slowest_client": max(
rows,
key=lambda row: (row.get("avg_latency_ms") or 0, row.get("requests") or 0),
),
}


def _rollout_provider_summary(provider_scope: dict[str, Any] | None) -> dict[str, Any]:
"""Return provider-health totals for the configured rollout scope."""
scope = dict(provider_scope or {})
Expand Down Expand Up @@ -907,13 +950,15 @@ async def stats(
"status": operator_status,
"client_tag": client_tag,
}
client_totals = _metrics.get_client_totals(**filters)
return {
"totals": _metrics.get_totals(**filters),
"providers": _metrics.get_provider_summary(**filters),
"modalities": _metrics.get_modality_breakdown(**filters),
"routing": _metrics.get_routing_breakdown(**filters),
"clients": _metrics.get_client_breakdown(**filters),
"client_totals": _metrics.get_client_totals(**filters),
"client_totals": client_totals,
"client_highlights": _client_highlights(client_totals),
"operator_actions": _metrics.get_operator_breakdown(**operator_filters),
"hourly": _metrics.get_hourly_series(24),
"daily": _metrics.get_daily_totals(30),
Expand Down Expand Up @@ -1837,8 +1882,13 @@ def _dashboard_csp() -> str:

const operatorRows = stats.operator_actions || [];
const clientTotalRows = stats.client_totals || [];
const clientHighlights = stats.client_highlights || {};
const latestOperatorEvent = (operatorEvents.events || [])[0] || null;
const topClient = clientTotalRows.length ? clientTotalRows[0] : null;
const topClient = clientHighlights.top_requests || (clientTotalRows.length ? clientTotalRows[0] : null);
const topTokenClient = clientHighlights.top_tokens || null;
const topCostClient = clientHighlights.top_cost || null;
const highestFailureClient = clientHighlights.highest_failure_rate || null;
const slowestClient = clientHighlights.slowest_client || null;
$('#cards').innerHTML = `
<div class="card"><div class="label">Requests</div><div class="value">${fmtTok(totals.total_requests || 0)}</div></div>
<div class="card"><div class="label">Cost</div><div class="value cost">${fmtUsd(totals.total_cost_usd || 0)}</div></div>
Expand All @@ -1849,7 +1899,11 @@ def _dashboard_csp() -> str:
<div class="card"><div class="label">Healthy Providers</div><div class="value">${healthyProviders}/${providers.length}</div><div class="detail">${unhealthyProviders} unhealthy</div></div>
<div class="card"><div class="label">Capability Coverage</div><div class="value">${coverageEntries.length}</div><div class="detail">${coverageEntries.map(([name]) => name).slice(0,3).join(', ') || 'none'}</div></div>
<div class="card"><div class="label">Top Modality</div><div class="value">${esc(topModality)}</div><div class="detail">${modalityRows.length} modality groups</div></div>
<div class="card"><div class="label">Top Client</div><div class="value">${esc(topClient ? (topClient.client_tag || topClient.client_profile || 'generic') : '—')}</div><div class="detail">${topClient ? `${fmtTok(topClient.total_tokens || 0)} tokens / ${fmtUsd(topClient.cost_usd || 0)}` : 'No client traffic yet'}</div></div>
<div class="card"><div class="label">Top Client</div><div class="value">${esc(topClient ? (topClient.client_tag || topClient.client_profile || 'generic') : '—')}</div><div class="detail">${topClient ? `${fmtTok(topClient.requests || 0)} requests / ${fmtTok(topClient.total_tokens || 0)} tokens` : 'No client traffic yet'}</div></div>
<div class="card"><div class="label">Top Token Client</div><div class="value">${esc(topTokenClient ? (topTokenClient.client_tag || topTokenClient.client_profile || 'generic') : '—')}</div><div class="detail">${topTokenClient ? `${fmtTok(topTokenClient.total_tokens || 0)} tokens / ${fmtUsd(topTokenClient.cost_usd || 0)}` : 'No client token data yet'}</div></div>
<div class="card"><div class="label">Top Cost Client</div><div class="value ${topCostClient && (topCostClient.cost_usd || 0) > 0 ? 'cost' : ''}">${esc(topCostClient ? (topCostClient.client_tag || topCostClient.client_profile || 'generic') : '—')}</div><div class="detail">${topCostClient ? `${fmtUsd(topCostClient.cost_usd || 0)} total / ${fmtUsd(topCostClient.cost_per_request_usd || 0)} per request` : 'No client cost data yet'}</div></div>
<div class="card"><div class="label">Highest Failure Client</div><div class="value ${(highestFailureClient && (highestFailureClient.failures || 0) > 0) ? 'err' : ''}">${esc(highestFailureClient ? (highestFailureClient.client_tag || highestFailureClient.client_profile || 'generic') : '—')}</div><div class="detail">${highestFailureClient ? `${fmt(100 - (highestFailureClient.success_pct || 0), 1)}% fail / ${highestFailureClient.failures || 0} failures` : 'No client failures yet'}</div></div>
<div class="card"><div class="label">Slowest Client</div><div class="value">${esc(slowestClient ? (slowestClient.client_tag || slowestClient.client_profile || 'generic') : '—')}</div><div class="detail">${slowestClient ? `${fmtMs(slowestClient.avg_latency_ms || 0)} avg / ${fmtTok(slowestClient.requests || 0)} requests` : 'No client latency data yet'}</div></div>
<div class="card"><div class="label">Release Status</div><div class="value ${(update.alert_level === 'critical' || update.alert_level === 'warning') ? 'err' : update.update_available ? 'cost' : ''}">${esc(update.latest_version || update.current_version || 'n/a')}</div><div class="detail">${update.enabled ? (update.status === 'ok' ? `${esc(update.release_channel || 'stable')} / ${esc(update.update_type || 'current')} / ${esc(update.recommended_action || (update.update_available ? 'Upgrade recommended' : 'No action needed'))}${update.auto_update && update.auto_update.enabled ? ` / ring: ${esc(update.auto_update.rollout_ring || 'early')} / auto: ${esc(update.auto_update.eligible ? 'eligible' : (update.auto_update.blocked_reason || 'blocked'))}` : ''}` : esc(update.recommended_action || 'Update check unavailable')) : 'Update checks disabled'}</div></div>
<div class="card"><div class="label">Operator Actions</div><div class="value">${fmtTok((operatorEvents.events || []).length)}</div><div class="detail">${latestOperatorEvent ? `${esc(latestOperatorEvent.action || 'update-check')} / ${esc(latestOperatorEvent.status || 'unknown')}` : 'No recent operator events'}</div></div>
`;
Expand Down
46 changes: 46 additions & 0 deletions tests/test_api_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,40 @@ def get_routing_breakdown(self, **_kwargs):
def get_client_breakdown(self, **_kwargs):
return []

def get_client_totals(self, **_kwargs):
return [
{
"client_profile": "openclaw",
"client_tag": "agent-alpha",
"requests": 12,
"failures": 1,
"success_pct": 91.7,
"prompt_tokens": 1500,
"compl_tokens": 450,
"total_tokens": 1950,
"cost_usd": 0.1642,
"cost_per_request_usd": 0.0137,
"avg_latency_ms": 620.0,
"modalities": "chat",
"providers": "cloud-default",
},
{
"client_profile": "cli",
"client_tag": "batch-jobs",
"requests": 5,
"failures": 2,
"success_pct": 60.0,
"prompt_tokens": 4200,
"compl_tokens": 1200,
"total_tokens": 5400,
"cost_usd": 0.441,
"cost_per_request_usd": 0.0882,
"avg_latency_ms": 980.0,
"modalities": "chat,image_generation",
"providers": "cloud-default",
},
]

def get_operator_breakdown(self, **_kwargs):
return []

Expand Down Expand Up @@ -183,6 +217,18 @@ def test_dashboard_sets_security_headers(api_client):
assert "sha256-" in csp


def test_stats_includes_client_highlights(api_client):
response = api_client.get("/api/stats")

assert response.status_code == 200
body = response.json()
assert body["client_highlights"]["top_requests"]["client_tag"] == "agent-alpha"
assert body["client_highlights"]["top_tokens"]["client_tag"] == "batch-jobs"
assert body["client_highlights"]["top_cost"]["client_tag"] == "batch-jobs"
assert body["client_highlights"]["highest_failure_rate"]["client_tag"] == "batch-jobs"
assert body["client_highlights"]["slowest_client"]["client_tag"] == "batch-jobs"


def test_route_preview_rejects_large_json_payload(api_client):
response = api_client.post(
"/api/route",
Expand Down
Loading