Skip to content
Closed
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
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { createNotificationsRouter } from "./notifications";
import { createPermissionsRouter } from "./permissions";
import { createPortsRouter } from "./ports";
import { createProjectsRouter } from "./projects";
import { createProviderUsageRouter } from "./provider-usage";
import { createResourceMetricsRouter } from "./resource-metrics";
import { createRingtoneRouter } from "./ringtone";
import { createSettingsRouter } from "./settings";
Expand Down Expand Up @@ -49,6 +50,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
notifications: createNotificationsRouter(getWindow),
permissions: createPermissionsRouter(),
ports: createPortsRouter(),
providerUsage: createProviderUsageRouter(),
resourceMetrics: createResourceMetricsRouter(),
menu: createMenuRouter(),
external: createExternalRouter(),
Expand Down
45 changes: 45 additions & 0 deletions apps/desktop/src/lib/trpc/routers/provider-usage.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { z } from "zod";

const percentSchema = z.number().finite().min(0).max(100);

export const usageWindowSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
usedPercent: percentSchema,
remainingPercent: percentSchema,
resetAt: z.number().int().positive().nullable(),
windowSeconds: z.number().int().positive().nullable(),
});

export const providerUsageAccountSchema = z.object({
id: z.string().min(1),
providerId: z.enum(["claude", "codex"]),
profileName: z.string().min(1),
accountLabel: z.string().nullable(),
planLabel: z.string().nullable(),
isActive: z.boolean(),
status: z.enum(["ok", "cached", "no-data", "error"]),
statusMessage: z.string().nullable(),
windows: z.array(usageWindowSchema),
});

export const providerUsageSchema = z.object({
providerId: z.enum(["claude", "codex"]),
providerName: z.enum(["Claude", "Codex"]),
status: z.enum(["ok", "not-configured", "unavailable"]),
accountLabel: z.string().nullable(),
activeAccountId: z.string().nullable(),
accounts: z.array(providerUsageAccountSchema),
windows: z.array(usageWindowSchema),
errorMessage: z.string().nullable(),
});

export const providerUsageSnapshotSchema = z.object({
providers: z.array(providerUsageSchema),
collectedAt: z.number().int().positive(),
});

export type UsageWindow = z.infer<typeof usageWindowSchema>;
export type ProviderUsageAccount = z.infer<typeof providerUsageAccountSchema>;
export type ProviderUsage = z.infer<typeof providerUsageSchema>;
export type ProviderUsageSnapshot = z.infer<typeof providerUsageSnapshotSchema>;
205 changes: 205 additions & 0 deletions apps/desktop/src/lib/trpc/routers/provider-usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { describe, expect, test } from "bun:test";
import { createProviderUsageRouter } from "./provider-usage";
import type { ProviderUsageSnapshot } from "./provider-usage.schema";

const snapshot: ProviderUsageSnapshot = {
providers: [
{
providerId: "claude",
providerName: "Claude",
status: "ok",
accountLabel: "Max",
activeAccountId: "claude:active",
accounts: [
{
id: "claude:active",
providerId: "claude",
profileName: "active",
accountLabel: "Max",
planLabel: "Max",
isActive: true,
status: "ok",
statusMessage: null,
windows: [],
},
],
windows: [],
errorMessage: null,
},
],
collectedAt: 1_000,
};

describe("providerUsage router", () => {
test("returns a validated snapshot and forwards forced refresh", async () => {
let force = false;
const usageRouter = createProviderUsageRouter(async (options) => {
force = options?.force ?? false;
return snapshot;
});
const caller = usageRouter.createCaller({});

expect(await caller.getSnapshot({ force: true })).toEqual(snapshot);
expect(force).toBe(true);
});

test("strips fields outside the renderer contract", async () => {
const usageRouter = createProviderUsageRouter(async () => {
return {
...snapshot,
providers: snapshot.providers.map((provider) => ({
...provider,
accessToken: "must-never-reach-renderer",
accounts: provider.accounts.map((account) => ({
...account,
accessToken: "must-never-reach-renderer",
})),
})),
} as unknown as ProviderUsageSnapshot;
});
const caller = usageRouter.createCaller({});

const result = await caller.getSnapshot();
expect(result.providers[0]).not.toHaveProperty("accessToken");
expect(result.providers[0]?.accounts[0]).not.toHaveProperty("accessToken");
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
expect(JSON.stringify(result)).not.toContain("must-never-reach-renderer");
});

test("exposes provider accounts but strips credential material from account rows", async () => {
const usageRouter = createProviderUsageRouter(async () => {
return {
providers: [
{
providerId: "codex",
providerName: "Codex",
status: "ok",
accountLabel: "person@example.com",
activeAccountId: "codex:person",
windows: [],
errorMessage: null,
accounts: [
{
id: "codex:person",
providerId: "codex",
profileName: "person",
accountLabel: "person@example.com",
planLabel: "pro",
isActive: true,
status: "cached",
statusMessage: "cached 4m ago",
windows: [],
authJson: "must-never-reach-renderer",
},
],
},
],
collectedAt: 1_000,
} as unknown as ProviderUsageSnapshot;
});
const caller = usageRouter.createCaller({});

const result = await caller.getSnapshot();

expect(result.providers[0]?.accounts).toEqual([
{
id: "codex:person",
providerId: "codex",
profileName: "person",
accountLabel: "person@example.com",
planLabel: "pro",
isActive: true,
status: "cached",
statusMessage: "cached 4m ago",
windows: [],
},
]);
expect(JSON.stringify(result)).not.toContain("authJson");
expect(JSON.stringify(result)).not.toContain("must-never-reach-renderer");
});

test("exposes Codex account mutations through the provider usage router", async () => {
const calls: string[] = [];
const usageRouter = createProviderUsageRouter({
collect: async (options) => {
calls.push(options?.force ? "collect:force" : "collect");
return snapshot;
},
importCurrentCodex: async () => {
calls.push("import");
return {
profileName: "person-example-com",
authJson: "must-never-reach-renderer",
} as unknown as { profileName: string };
},
addCodexAccount: async () => {
calls.push("add");
return {
profileName: "team-example-com",
authJson: "must-never-reach-renderer",
} as unknown as { profileName: string };
},
switchCodexProfile: async (profileName) => {
calls.push(`switch:${profileName}`);
return {
profileName,
authJson: "must-never-reach-renderer",
} as unknown as { profileName: string };
},
});
const caller = usageRouter.createCaller({});

await expect(caller.importCurrentCodex()).resolves.toEqual({
profileName: "person-example-com",
});
await expect(caller.addCodexAccount()).resolves.toEqual({
profileName: "team-example-com",
});
await expect(
caller.switchCodexProfile({ profileName: "team" }),
).resolves.toEqual({ profileName: "team" });
expect(calls).toEqual([
"import",
"collect:force",
"add",
"collect:force",
"switch:team",
"collect:force",
]);
});

test("does not fail a successful Codex mutation when forced refresh fails", async () => {
const usageRouter = createProviderUsageRouter({
collect: async () => {
throw new Error("refresh failed");
},
importCurrentCodex: async () => ({
profileName: "person-example-com",
}),
});
const caller = usageRouter.createCaller({});

await expect(caller.importCurrentCodex()).resolves.toEqual({
profileName: "person-example-com",
});
});

test("rejects unsafe Codex profile names before switching accounts", async () => {
const calls: string[] = [];
const usageRouter = createProviderUsageRouter({
collect: async () => snapshot,
switchCodexProfile: async (profileName) => {
calls.push(profileName);
return { profileName };
},
});
const caller = usageRouter.createCaller({});

await expect(
caller.switchCodexProfile({ profileName: "../active" }),
).rejects.toThrow();
await expect(
caller.switchCodexProfile({ profileName: "/tmp/active" }),
).rejects.toThrow();
expect(calls).toEqual([]);
});
});
105 changes: 105 additions & 0 deletions apps/desktop/src/lib/trpc/routers/provider-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { collectProviderUsage } from "main/lib/provider-usage";
import { codexProfileStore } from "main/lib/provider-usage/providers/codex-profiles";
import { z } from "zod";
import { publicProcedure, router } from "..";
import {
type ProviderUsageSnapshot,
providerUsageSnapshotSchema,
} from "./provider-usage.schema";

interface CollectProviderUsageOptions {
force?: boolean;
}

type ProviderUsageCollector = (
options?: CollectProviderUsageOptions,
) => Promise<ProviderUsageSnapshot>;

interface ProviderUsageRouterDependencies {
collect: ProviderUsageCollector;
importCurrentCodex: () => Promise<{ profileName: string }>;
addCodexAccount: () => Promise<{ profileName: string }>;
switchCodexProfile: (profileName: string) => Promise<{ profileName: string }>;
}

const getSnapshotInputSchema = z
.object({
force: z.boolean().optional(),
})
.optional();

const switchCodexProfileInputSchema = z.object({
profileName: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
});

const codexProfileMutationOutputSchema = z.object({
profileName: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
});

const defaultDependencies: ProviderUsageRouterDependencies = {
collect: collectProviderUsage,
importCurrentCodex: async () => {
const profile = await codexProfileStore.importActive();
return { profileName: profile.profileName };
},
addCodexAccount: async () => {
const profile = await codexProfileStore.addViaIsolatedLogin();
return { profileName: profile.profileName };
},
switchCodexProfile: async (profileName) => {
const profile = await codexProfileStore.activate(profileName);
return { profileName: profile.profileName };
},
};

function resolveDependencies(
dependencies:
| ProviderUsageCollector
| Partial<ProviderUsageRouterDependencies>,
): ProviderUsageRouterDependencies {
if (typeof dependencies === "function") {
return { ...defaultDependencies, collect: dependencies };
}
return { ...defaultDependencies, ...dependencies };
}

export const createProviderUsageRouter = (
dependencies:
| ProviderUsageCollector
| Partial<ProviderUsageRouterDependencies> = defaultDependencies,
) => {
const resolved = resolveDependencies(dependencies);
async function refreshAfterMutation<T extends { profileName: string }>(
mutation: () => Promise<T>,
): Promise<T> {
const result = await mutation();
await resolved.collect({ force: true }).catch((error) => {
console.warn(
"[provider-usage] Failed to refresh after account update:",
error,
);
});
return result;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

return router({
getSnapshot: publicProcedure
.input(getSnapshotInputSchema)
.output(providerUsageSnapshotSchema)
.query(({ input }) => resolved.collect(input)),
importCurrentCodex: publicProcedure
.output(codexProfileMutationOutputSchema)
.mutation(() => refreshAfterMutation(resolved.importCurrentCodex)),
addCodexAccount: publicProcedure
.output(codexProfileMutationOutputSchema)
.mutation(() => refreshAfterMutation(resolved.addCodexAccount)),
switchCodexProfile: publicProcedure
.input(switchCodexProfileInputSchema)
.output(codexProfileMutationOutputSchema)
.mutation(({ input }) =>
refreshAfterMutation(() =>
resolved.switchCodexProfile(input.profileName),
),
),
});
};
Loading