mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: dashboard OAuth provider management
Add OAuth provider management to the Hermes dashboard with full
lifecycle support for Anthropic (PKCE), Nous and OpenAI Codex
(device-code) flows.
## Backend (hermes_cli/web_server.py)
- 6 new API endpoints:
GET /api/providers/oauth — list providers with connection status
POST /api/providers/oauth/{id}/start — initiate PKCE or device-code
POST /api/providers/oauth/{id}/submit — exchange PKCE auth code
GET /api/providers/oauth/{id}/poll/{session} — poll device-code
DELETE /api/providers/oauth/{id} — disconnect provider
DELETE /api/providers/oauth/sessions/{id} — cancel pending session
- OAuth constants imported from anthropic_adapter (no duplication)
- Blocking I/O wrapped in run_in_executor for async safety
- In-memory session store with 15-minute TTL and automatic GC
- Auth token required on all mutating endpoints
## Frontend
- OAuthLoginModal — PKCE (paste auth code) and device-code (poll) flows
- OAuthProvidersCard — status, token preview, connect/disconnect actions
- Toast fix: createPortal to document.body for correct z-index
- App.tsx: skip animation key bump on initial mount (prevent double-mount)
- Integrated into the Env/Keys page
This commit is contained in:
parent
2773b18b56
commit
247929b0dd
11 changed files with 1789 additions and 96 deletions
|
|
@ -1,9 +1,7 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Coins,
|
||||
Cpu,
|
||||
Database,
|
||||
Hash,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
|
|
@ -26,17 +24,6 @@ function formatTokens(n: number): string {
|
|||
return String(n);
|
||||
}
|
||||
|
||||
function formatCost(n: number): string {
|
||||
if (n < 0.01) return `$${n.toFixed(4)}`;
|
||||
return `$${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** Pick the best cost value: actual > estimated > 0 */
|
||||
function bestCost(entry: { estimated_cost: number; actual_cost?: number }): number {
|
||||
if (entry.actual_cost && entry.actual_cost > 0) return entry.actual_cost;
|
||||
return entry.estimated_cost;
|
||||
}
|
||||
|
||||
function formatDate(day: string): string {
|
||||
try {
|
||||
const d = new Date(day + "T00:00:00");
|
||||
|
|
@ -100,9 +87,6 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
const total = d.input_tokens + d.output_tokens;
|
||||
const inputH = Math.round((d.input_tokens / maxTokens) * CHART_HEIGHT_PX);
|
||||
const outputH = Math.round((d.output_tokens / maxTokens) * CHART_HEIGHT_PX);
|
||||
const cacheReadPct = d.cache_read_tokens > 0
|
||||
? Math.round((d.cache_read_tokens / (d.input_tokens + d.cache_read_tokens)) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
key={d.day}
|
||||
|
|
@ -115,9 +99,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
<div className="font-medium">{formatDate(d.day)}</div>
|
||||
<div>Input: {formatTokens(d.input_tokens)}</div>
|
||||
<div>Output: {formatTokens(d.output_tokens)}</div>
|
||||
{cacheReadPct > 0 && <div>Cache hit: {cacheReadPct}%</div>}
|
||||
<div>Total: {formatTokens(total)}</div>
|
||||
{bestCost(d) > 0 && <div>Cost: {formatCost(bestCost(d))}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Input bar */}
|
||||
|
|
@ -168,17 +150,11 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
<th className="text-left py-2 pr-4 font-medium">Date</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Sessions</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Input</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Output</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Cache Hit</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Cost</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Output</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((d) => {
|
||||
const cost = bestCost(d);
|
||||
const cacheHitPct = d.cache_read_tokens > 0 && d.input_tokens > 0
|
||||
? Math.round((d.cache_read_tokens / d.input_tokens) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<tr key={d.day} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
|
||||
<td className="py-2 pr-4 font-medium">{formatDate(d.day)}</td>
|
||||
|
|
@ -186,15 +162,9 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
<td className="text-right py-2 px-4">
|
||||
<span className="text-[#ffe6cb]">{formatTokens(d.input_tokens)}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 px-4">
|
||||
<td className="text-right py-2 pl-4">
|
||||
<span className="text-emerald-400">{formatTokens(d.output_tokens)}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">
|
||||
{cacheHitPct > 0 ? `${cacheHitPct}%` : "—"}
|
||||
</td>
|
||||
<td className="text-right py-2 pl-4 text-muted-foreground">
|
||||
{cost > 0 ? formatCost(cost) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
|
@ -228,8 +198,7 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
|||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<th className="text-left py-2 pr-4 font-medium">Model</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Sessions</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Tokens</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Cost</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Tokens</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -239,14 +208,11 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
|||
<span className="font-mono-ui text-xs">{m.model}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">{m.sessions}</td>
|
||||
<td className="text-right py-2 px-4">
|
||||
<td className="text-right py-2 pl-4">
|
||||
<span className="text-[#ffe6cb]">{formatTokens(m.input_tokens)}</span>
|
||||
{" / "}
|
||||
<span className="text-emerald-400">{formatTokens(m.output_tokens)}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 pl-4 text-muted-foreground">
|
||||
{m.estimated_cost > 0 ? formatCost(m.estimated_cost) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -311,38 +277,26 @@ export default function AnalyticsPage() {
|
|||
|
||||
{data && (
|
||||
<>
|
||||
{/* Summary cards — matches hermes's token model */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<SummaryCard
|
||||
icon={Hash}
|
||||
label="Total Tokens"
|
||||
value={formatTokens(data.totals.total_input + data.totals.total_output)}
|
||||
sub={`${formatTokens(data.totals.total_input)} in / ${formatTokens(data.totals.total_output)} out`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={Database}
|
||||
label="Cache Hit"
|
||||
value={data.totals.total_cache_read > 0
|
||||
? `${Math.round((data.totals.total_cache_read / (data.totals.total_input + data.totals.total_cache_read)) * 100)}%`
|
||||
: "—"}
|
||||
sub={`${formatTokens(data.totals.total_cache_read)} tokens from cache`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={Coins}
|
||||
label="Total Cost"
|
||||
value={formatCost(
|
||||
data.totals.total_actual_cost > 0
|
||||
? data.totals.total_actual_cost
|
||||
: data.totals.total_estimated_cost
|
||||
)}
|
||||
sub={data.totals.total_actual_cost > 0 ? "actual" : `estimated · last ${days}d`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={BarChart3}
|
||||
label="Total Sessions"
|
||||
value={String(data.totals.total_sessions)}
|
||||
sub={`~${(data.totals.total_sessions / days).toFixed(1)}/day avg`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={TrendingUp}
|
||||
label="API Calls"
|
||||
value={String(data.daily.reduce((sum, d) => sum + d.sessions, 0))}
|
||||
sub={`across ${data.by_model.length} models`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bar chart */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue