Skip to content
Open
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
59 changes: 45 additions & 14 deletions src/server/routes/usage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Hono } from "hono";

import { createLogger } from "@/shared";
import { config, createLogger } from "@/shared";

const logger = createLogger("usage");

Expand Down Expand Up @@ -75,17 +75,48 @@ async function queryClaudeUsage() {
}

/**
* Usage route group. Serves Claude usage / credentials data.
* Options for wiring usage routes in tests or alternate runtime contexts.
*/
export const usageRoutes = new Hono().get("/claude", async (c) => {
try {
const usage = await queryClaudeUsage();
return c.json({ usage });
} catch (err) {
logger.error({ err }, "failed to read Claude usage");
return c.json(
{ error: err instanceof Error ? err.message : "unknown error" },
500,
);
}
});
export interface UsageRoutesOptions {
/** Returns the currently configured default agent type. */
getActiveAgentType?: () => string;
/** Reads Claude usage data from the underlying provider. */
queryUsage?: typeof queryClaudeUsage;
}

/**
* Creates the usage route group. The Claude endpoint only reads Claude
* credentials when the active agent is Claude.
*/
export function createUsageRoutes(options: UsageRoutesOptions = {}) {
const getActiveAgentType =
options.getActiveAgentType ?? (() => config.agents.default.type);
const queryUsage = options.queryUsage ?? queryClaudeUsage;

return new Hono().get("/claude", async (c) => {
const activeAgentType = getActiveAgentType();
if (activeAgentType !== "claude") {
return c.json({
usage: null,
unavailable: {
active_agent_type: activeAgentType,
reason:
"Claude usage is only available when the active agent is Claude.",
},
});
}

try {
const usage = await queryUsage();
return c.json({ usage });
} catch (err) {
logger.error({ err }, "failed to read Claude usage");
return c.json(
{ error: err instanceof Error ? err.message : "unknown error" },
500,
);
}
});
}

export const usageRoutes = createUsageRoutes();
63 changes: 63 additions & 0 deletions tests/server/routes/usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";

import { createUsageRoutes } from "@/server/routes/usage";

describe("usageRoutes", () => {
test("queries Claude usage when the active agent is Claude", async () => {
let queried = false;
const usage = {
five_hour: {
utilization: 0.25,
resets_at: null,
},
seven_day: {
utilization: 0.5,
resets_at: "2026-06-07T12:00:00Z",
},
extra_usage: {
is_enabled: false as const,
monthly_limit: null,
used_credits: null,
utilization: null,
},
};
const routes = createUsageRoutes({
getActiveAgentType: () => "claude",
queryUsage: async () => {
queried = true;
return usage;
},
});

const response = await routes.request("/claude");
const body = await response.json();

expect(response.status).toBe(200);
expect(queried).toBe(true);
expect(body).toEqual({ usage });
});

test("does not query Claude usage when the active agent is not Claude", async () => {
let queried = false;
const routes = createUsageRoutes({
getActiveAgentType: () => "codex",
queryUsage: async () => {
queried = true;
throw new Error("should not query Claude usage");
},
});

const response = await routes.request("/claude");
const body = await response.json();

expect(response.status).toBe(200);
expect(queried).toBe(false);
expect(body).toEqual({
usage: null,
unavailable: {
active_agent_type: "codex",
reason: "Claude usage is only available when the active agent is Claude.",
},
});
});
});
14 changes: 13 additions & 1 deletion web/src/app/usage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@ function formatResetsAt(resetsAt: string): string {

function UsagePage() {
const {
data: usage,
data: usageStatus,
isLoading,
isRefetching,
refetch,
dataUpdatedAt,
} = useClaudeUsage();
const usage = usageStatus?.usage ?? null;
const unavailable = usageStatus?.unavailable ?? null;

const lastUpdatedLabel =
dataUpdatedAt != null
Expand All @@ -58,6 +60,16 @@ function UsagePage() {
<CardTitle>Claude usage limits</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-6">
{unavailable ? (
<p className="rounded-md border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
Claude usage is unavailable because the active agent is{" "}
<span className="font-medium text-foreground">
{unavailable.active_agent_type}
</span>
.
</p>
) : null}

{/* Current session (five_hour) */}
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Current session</h3>
Expand Down
25 changes: 23 additions & 2 deletions web/src/lib/api/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,9 @@ export function useSoulMemoryUpdate() {

// --- Usage ---

/**
* Claude usage data returned by the usage endpoint.
*/
export interface ClaudeUsage {
five_hour: {
utilization: number;
Expand All @@ -279,6 +282,14 @@ export interface ClaudeUsage {
};
}

/**
* Explains why Claude usage data cannot be shown for the active configuration.
*/
export interface ClaudeUsageUnavailable {
active_agent_type: string;
reason: string;
}

/**
* Fetches Claude usage data from /api/usage/claude.
*/
Expand All @@ -288,14 +299,24 @@ export function useClaudeUsage() {
queryFn: async () => {
const res = await api.usage.claude.$get();
const json = (await res.json()) as {
usage?: ClaudeUsage;
usage?: ClaudeUsage | null;
unavailable?: ClaudeUsageUnavailable;
error?: string;
};
if (!res.ok) {
throw new Error(json.error ?? "Failed to fetch usage");
}
if (json.unavailable) {
return {
usage: null,
unavailable: json.unavailable,
};
}
if (!json.usage) throw new Error("Invalid usage response");
return json.usage;
return {
usage: json.usage,
unavailable: null,
};
},
});
}