Repository navigation
feat(desktop): add multi-account AI usage meter #5844
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
AlchemistChaos
wants to merge
12
commits into
ParadevAI:main
from
AlchemistChaos:feat/minimal-ai-usage-meter
Closed
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
cf31889
feat(desktop): add minimal AI usage meter
AlchemistChaos 6c64d01
refactor(desktop): align AI usage meter with app patterns
AlchemistChaos 164ab09
fix(desktop): harden AI usage provider access
AlchemistChaos e1f686b
Add email blur toggle to AI usage meter
AlchemistChaos b35f327
feat(desktop): add multi-account AI usage meter
AlchemistChaos 9f620b5
fix(desktop): address AI meter review feedback
AlchemistChaos 7ef2a76
fix(desktop): address usage meter follow-up review
AlchemistChaos a960c34
feat(desktop): add settings toggle for AI usage meter
Kitenite 12957e8
Merge remote-tracking branch 'origin/main' into alchemistchaos/feat/m…
Kitenite bd2c225
feat(desktop): regenerate AI usage meter setting migration on latest …
Kitenite 0ea9a2e
feat(desktop): show AI usage meter in v2 tab bar and fix chat-legacy …
Kitenite e0f4ccc
feat(desktop): move v2 AI usage meter into sidebar footer
Kitenite File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
45 changes: 45 additions & 0 deletions
45
apps/desktop/src/lib/trpc/routers/provider-usage.schema.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
205
apps/desktop/src/lib/trpc/routers/provider-usage.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| 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([]); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
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), | ||
| ), | ||
| ), | ||
| }); | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.