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 packages/agents-usage/src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export const LOCAL_USAGE_PROVIDER_DESCRIPTORS: readonly UsageProviderDescriptor[
// Cookie login for live Go/Zen meters; local auth.json only gates the plan badge.
mechanism: "cookie",
needsLogin: true,
needsBrowserSessionForUsage: true,
windowIds: ["session-5h", "weekly", "monthly"],
},
];
Expand Down
2 changes: 2 additions & 0 deletions packages/agents-usage/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ export interface UsageProviderDescriptor {
mechanism: UsageMechanism;
/** True when a token/cookie may need to be captured via a login flow. */
needsLogin: boolean;
/** True when a local credential can exist but usage meters still need a browser session. */
needsBrowserSessionForUsage?: boolean;
/** A cookie-backed provider also accepts a pasted API key. */
apiKeyFallback?: boolean;
windowIds: UsageWindowId[];
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/components/providers/usageProviders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { AgentInstanceConfigMap } from "@/shared/contracts";
import {
hasRailUsage,
isClaudeUsageProvider,
needsBrowserSessionForUsage,
pickUsageRings,
resolveDisplayedProviders,
supportsApiKeyLogin,
Expand Down Expand Up @@ -49,6 +50,11 @@ describe("usageProviders", () => {
expect(supportsBrowserLogin("qwen")).toBe(true);
});

it("identifies providers whose empty local snapshot still needs browser usage auth", () => {
expect(needsBrowserSessionForUsage("opencode")).toBe(true);
expect(needsBrowserSessionForUsage("grok")).toBe(false);
});

it("adds Claude profile providers after the base Claude provider", () => {
const providers = usageProvidersForAgentInstances(agentInstances);
const claudeIndex = providers.findIndex((provider) => provider.id === "claude");
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/components/providers/usageProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ export function supportsApiKeyLogin(providerId: string): boolean {
);
}

/** True when the provider's plan badge can be known locally but its meters need browser auth. */
export function needsBrowserSessionForUsage(providerId: string): boolean {
return USAGE_PROVIDER_BY_ID.get(baseAgentKind(providerId))?.needsBrowserSessionForUsage === true;
}

/** Providers whose windows share one reset clock (one header countdown, no per-window resets). */
export function usesSharedWindowReset(providerId: string): boolean {
return rendererMeta(providerId)?.sharedWindowReset === true;
Expand Down
52 changes: 52 additions & 0 deletions src/renderer/components/providers/useUsageProviderLogin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ function okSnapshot(providerId: string): UsageSnapshot {
} as UsageSnapshot;
}

function localGoSnapshot(): UsageSnapshot {
return {
providerId: "opencode",
status: "ok",
plan: "Go",
windows: [],
fetchedAt: 2,
} as UsageSnapshot;
}

describe("useUsageProviderLogin", () => {
beforeEach(() => {
bridgeMock.isRemoteSession.mockReturnValue(false);
Expand All @@ -67,6 +77,48 @@ describe("useUsageProviderLogin", () => {
expect(result.current.canSignIn).toBe(true);
});

it("offers OpenCode browser login when local Go auth has no web meters", () => {
useProviderUsageStore.getState().mergeSnapshot(localGoSnapshot());

const { result } = renderHook(() => useUsageProviderLogin("opencode"));

expect(result.current.canBrowserSignIn).toBe(true);
expect(result.current.canSignOut).toBe(false);
});

it("keeps OpenCode sign-out available after a browser session is stored", () => {
useProviderUsageStore.getState().mergeSnapshot(localGoSnapshot());
useUsageLoginStateStore.getState().setStored("opencode", true);

const { result } = renderHook(() => useUsageProviderLogin("opencode"));

expect(result.current.canBrowserSignIn).toBe(false);
expect(result.current.canSignOut).toBe(true);
});

it("clears the stored OpenCode session and refreshes after sign-out", async () => {
bridgeMock.clearUsageLogin.mockResolvedValue({ ok: true });
bridgeMock.refreshProviderUsage.mockResolvedValue({
snapshots: [authMissingSnapshot("opencode")],
fromCache: false,
});
useProviderUsageStore.getState().mergeSnapshot(localGoSnapshot());
useUsageLoginStateStore.getState().setStored("opencode", true);

const { result } = renderHook(() => useUsageProviderLogin("opencode"));

await act(async () => {
await result.current.handleSignOut();
});

expect(bridgeMock.clearUsageLogin).toHaveBeenCalledWith({ providerId: "opencode" });
expect(useUsageLoginStateStore.getState().stored.opencode).toBe(false);
expect(bridgeMock.refreshProviderUsage).toHaveBeenCalledWith({
providerIds: ["opencode"],
force: true,
});
});

it("offers browser-session and API-key paths for Alibaba Token Plan", () => {
useProviderUsageStore.getState().mergeSnapshot(authMissingSnapshot("qwen"));

Expand Down
19 changes: 17 additions & 2 deletions src/renderer/components/providers/useUsageProviderLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import {
useUsageLoginStateStore,
} from "@/renderer/state/usageLoginStateStore";
import { refreshAndMergeProviderUsage } from "./refreshProviderUsageSnapshot";
import { supportsApiKeyLogin, supportsBrowserLogin } from "./usageProviders";
import {
needsBrowserSessionForUsage,
supportsApiKeyLogin,
supportsBrowserLogin,
} from "./usageProviders";

/**
* Sign-in / sign-out flow for a usage provider, shared by the usage panel card
Expand All @@ -32,8 +36,19 @@ export function useUsageProviderLogin(id: string) {
// by another path (e.g. Copilot's OAuth/CLI token) has no stored cookie session
// yet is signed in — offering "Sign in" there is wrong.
const sessionRejected = snapshot?.status === "auth-missing";
// OpenCode can report a local Go plan before its browser session is captured,
// but the usage meters are only available through that web session. Keep the
// sign-in action visible for the empty-meter state, including a cached snapshot
// from before the browser session was captured.
const needsUsageSession =
!hasStoredSession &&
needsBrowserSessionForUsage(id) &&
snapshot?.status === "ok" &&
snapshot.windows.length === 0;
const canSignIn =
supportsLogin && snapshot?.status !== "ok" && (!hasStoredSession || sessionRejected);
supportsLogin &&
(snapshot?.status !== "ok" || needsUsageSession) &&
(!hasStoredSession || sessionRejected);
const canBrowserSignIn = canSignIn && isBrowserLogin;
const canApiKeySignIn = canSignIn && isApiKeyLogin;
const canSignOut = supportsLogin && hasStoredSession;
Expand Down