Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
efc5c96
Implement billing
charlesvien Mar 17, 2026
2b12246
wip
charlesvien Mar 19, 2026
db58198
settings refactor for plans
charlesvien Mar 19, 2026
8bb3478
Add getPostHogUrl util for region-aware URLs
charlesvien Mar 19, 2026
478fe12
Move getCloudUrlFromRegion to shared/utils/urls
charlesvien Mar 19, 2026
41e9fd9
Move CloudRegion and REGION_LABELS to shared/types/regions
charlesvien Mar 20, 2026
3842e34
Add upgrade confirmation dialog and plan features
charlesvien Mar 20, 2026
c26fcaf
Move getPostHogUrl to renderer utils to fix typecheck
charlesvien Mar 20, 2026
7316ef4
plans
charlesvien Mar 20, 2026
313273a
Auto-provision free seat on auth init if none exists
charlesvien Mar 20, 2026
996f578
reset
charlesvien Mar 20, 2026
fd48757
rebase
charlesvien Apr 1, 2026
b20e654
Update useOnboardingFlow.ts
charlesvien Apr 8, 2026
7e5d296
Gate free seat provisioning on billing feature flag
charlesvien Apr 8, 2026
69b8f8a
Wire up gateway usage endpoint in Plan & Usage settings
charlesvien Apr 8, 2026
c7df406
remove dead initializeOAuth code
charlesvien Apr 8, 2026
b835293
Move seat fetching to auth session hook and use async client
charlesvien Apr 8, 2026
efabdcf
Switch usage display from USD amounts to percent
charlesvien Apr 13, 2026
4f2ceb0
lint
charlesvien Apr 13, 2026
2249486
Fix broken imports in GitHubConnectionBanner
charlesvien Apr 13, 2026
60d6e15
Gate seat auto-provisioning behind posthog-code-billing flag
charlesvien Apr 16, 2026
40bed9b
stop prefixing v in version in the update banner
charlesvien Apr 16, 2026
55390f1
Restore org picker step in onboarding flow
charlesvien Apr 16, 2026
7a357fb
Add project picker to org onboarding step
charlesvien Apr 16, 2026
9056ba6
Fix org step spacing and project dropdown theming
charlesvien Apr 16, 2026
5178ced
Update free plan to show local and cloud execution
charlesvien Apr 16, 2026
7a0b551
Show usage percentages to 2 decimal places
charlesvien Apr 16, 2026
23b6bf9
Add tooltip on unlimited usage asterisk
charlesvien Apr 16, 2026
3073a6f
Gate seat provisioning behind billing flag and send user_distinct_id
charlesvien Apr 16, 2026
8cb0af1
Fix billing review issues across stores and settings
charlesvien Apr 16, 2026
62678f9
Remove dead selectedPlan and selectedOrgId from authStore
charlesvien Apr 16, 2026
47a7eb2
Guard seat store actions behind billing feature flag
charlesvien Apr 17, 2026
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CloudRegion } from "@shared/types/oauth";
import type { CloudRegion } from "@shared/types/regions";
import { eq } from "drizzle-orm";
import { inject, injectable } from "inversify";
import { MAIN_TOKENS } from "../../di/tokens";
Expand Down
8 changes: 3 additions & 5 deletions apps/code/src/main/services/auth/service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import {
getCloudUrlFromRegion,
OAUTH_SCOPE_VERSION,
} from "@shared/constants/oauth";
import type { CloudRegion } from "@shared/types/oauth";
import { OAUTH_SCOPE_VERSION } from "@shared/constants/oauth";
import type { CloudRegion } from "@shared/types/regions";
import { type BackoffOptions, sleepWithBackoff } from "@shared/utils/backoff";
import { getCloudUrlFromRegion } from "@shared/utils/urls";
import { powerMonitor } from "electron";
import { inject, injectable, postConstruct, preDestroy } from "inversify";
import type { IAuthPreferenceRepository } from "../../db/repositories/auth-preference-repository";
Expand Down
2 changes: 1 addition & 1 deletion apps/code/src/main/services/github-integration/service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getCloudUrlFromRegion } from "@shared/constants/oauth";
import { getCloudUrlFromRegion } from "@shared/utils/urls";
import { shell } from "electron";
import { injectable } from "inversify";
import { logger } from "../../utils/logger";
Expand Down
2 changes: 1 addition & 1 deletion apps/code/src/main/services/linear-integration/service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getCloudUrlFromRegion } from "@shared/constants/oauth.js";
import { getCloudUrlFromRegion } from "@shared/utils/urls.js";
import { shell } from "electron";
import { injectable } from "inversify";
import { logger } from "../../utils/logger.js";
Expand Down
17 changes: 17 additions & 0 deletions apps/code/src/main/services/llm-gateway/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,20 @@ export interface AnthropicErrorResponse {
code?: string;
};
}

export const usageBucketSchema = z.object({
used_percent: z.number(),
resets_in_seconds: z.number(),
exceeded: z.boolean(),
});

export const usageOutput = z.object({
product: z.string(),
user_id: z.number(),
sustained: usageBucketSchema,
burst: usageBucketSchema,
is_rate_limited: z.boolean(),
});

export type UsageBucket = z.infer<typeof usageBucketSchema>;
export type UsageOutput = z.infer<typeof usageOutput>;
42 changes: 35 additions & 7 deletions apps/code/src/main/services/llm-gateway/service.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { getLlmGatewayUrl } from "@posthog/agent/posthog-api";
import {
getGatewayUsageUrl,
getLlmGatewayUrl,
} from "@posthog/agent/posthog-api";
import { net } from "electron";
import { inject, injectable } from "inversify";
import { MAIN_TOKENS } from "../../di/tokens";
import { logger } from "../../utils/logger";
import type { AuthService } from "../auth/service";
import type {
AnthropicErrorResponse,
AnthropicMessagesRequest,
AnthropicMessagesResponse,
LlmMessage,
PromptOutput,
import {
type AnthropicErrorResponse,
type AnthropicMessagesRequest,
type AnthropicMessagesResponse,
type LlmMessage,
type PromptOutput,
type UsageOutput,
usageOutput,
} from "./schemas";

const log = logger.scope("llm-gateway");
Expand Down Expand Up @@ -134,4 +139,27 @@ export class LlmGatewayService {
},
};
}

async fetchUsage(): Promise<UsageOutput> {
const auth = await this.authService.getValidAccessToken();
const usageUrl = getGatewayUsageUrl(auth.apiHost);

log.debug("Fetching usage from gateway", { url: usageUrl });

const response = await this.authService.authenticatedFetch(
net.fetch,
usageUrl,
);

if (!response.ok) {
throw new LlmGatewayError(
`Failed to fetch usage: HTTP ${response.status}`,
"usage_error",
undefined,
response.status,
);
}

return usageOutput.parse(await response.json());
}
}
2 changes: 1 addition & 1 deletion apps/code/src/main/services/oauth/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import * as crypto from "node:crypto";
import * as http from "node:http";
import type { Socket } from "node:net";
import {
getCloudUrlFromRegion,
getOauthClientIdFromRegion,
OAUTH_SCOPES,
} from "@shared/constants/oauth";
import { getCloudUrlFromRegion } from "@shared/utils/urls";
import { shell } from "electron";
import { inject, injectable } from "inversify";
import { MAIN_TOKENS } from "../../di/tokens";
Expand Down
10 changes: 9 additions & 1 deletion apps/code/src/main/trpc/routers/llm-gateway.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { container } from "../../di/container";
import { MAIN_TOKENS } from "../../di/tokens";
import { promptInput, promptOutput } from "../../services/llm-gateway/schemas";
import {
promptInput,
promptOutput,
usageOutput,
} from "../../services/llm-gateway/schemas";
import type { LlmGatewayService } from "../../services/llm-gateway/service";
import { publicProcedure, router } from "../trpc";

Expand All @@ -18,4 +22,8 @@ export const llmGatewayRouter = router({
model: input.model,
}),
),

usage: publicProcedure
.output(usageOutput)
.query(() => getService().fetchUsage()),
});
192 changes: 158 additions & 34 deletions apps/code/src/renderer/api/posthogClient.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort";
import { type PermissionMode } from "@posthog/agent/execution-mode";
import type { PermissionMode } from "@posthog/agent/execution-mode";
import type {
ActionabilityJudgmentArtefact,
AvailableSuggestedReviewer,
Expand All @@ -24,11 +24,29 @@ import type {
TaskRun,
} from "@shared/types";
import type { CloudRunSource, PrAuthorshipMode } from "@shared/types/cloud";
import type { SeatData } from "@shared/types/seat";
import { SEAT_PRODUCT_KEY } from "@shared/types/seat";
import type { StoredLogEntry } from "@shared/types/session-events";
import { logger } from "@utils/logger";
import { buildApiFetcher } from "./fetcher";
import { createApiClient, type Schemas } from "./generated";

export class SeatSubscriptionRequiredError extends Error {
redirectUrl: string;
constructor(redirectUrl: string) {
super("Billing subscription required");
this.name = "SeatSubscriptionRequiredError";
this.redirectUrl = redirectUrl;
}
}

export class SeatPaymentFailedError extends Error {
constructor(message?: string) {
super(message ?? "Payment failed");
this.name = "SeatPaymentFailedError";
}
}

const log = logger.scope("posthog-client");

export type McpRecommendedServer = Schemas.RecommendedServer;
Expand Down Expand Up @@ -1178,39 +1196,6 @@ export class PostHogAPIClient {
return await response.json();
}

/**
* Get billing information for a specific organization.
*/
async getOrgBilling(orgId: string): Promise<{
has_active_subscription: boolean;
customer_id: string | null;
}> {
const url = new URL(
`${this.api.baseUrl}/api/organizations/${orgId}/billing/`,
);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: `/api/organizations/${orgId}/billing/`,
});

if (!response.ok) {
throw new Error(
`Failed to fetch organization billing: ${response.statusText}`,
);
}

const data = await response.json();
return {
has_active_subscription:
typeof data.has_active_subscription === "boolean"
? data.has_active_subscription
: false,
customer_id:
typeof data.customer_id === "string" ? data.customer_id : null,
};
}

async getSignalReports(
params?: SignalReportsQueryParams,
): Promise<SignalReportsResponse> {
Expand Down Expand Up @@ -1741,6 +1726,145 @@ export class PostHogAPIClient {
}
}

async getMySeat(): Promise<SeatData | null> {
try {
const url = new URL(`${this.api.baseUrl}/api/seats/me/`);
url.searchParams.set("product_key", SEAT_PRODUCT_KEY);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: "/api/seats/me/",
});
return (await response.json()) as SeatData;
} catch (error) {
if (this.isFetcherStatusError(error, 404)) {
return null;
}
throw error;
}
}

async createSeat(planKey: string): Promise<SeatData> {
try {
const user = await this.getCurrentUser();
const distinctId = user.distinct_id;
if (!distinctId) {
throw new Error("Cannot create seat: user has no distinct_id");
}
const url = new URL(`${this.api.baseUrl}/api/seats/`);
const response = await this.api.fetcher.fetch({
method: "post",
url,
path: "/api/seats/",
overrides: {
body: JSON.stringify({
product_key: SEAT_PRODUCT_KEY,
plan_key: planKey,
user_distinct_id: distinctId,
}),
},
});
return (await response.json()) as SeatData;
} catch (error) {
this.throwSeatError(error);
}
}

async upgradeSeat(planKey: string): Promise<SeatData> {
try {
const url = new URL(`${this.api.baseUrl}/api/seats/me/`);
const response = await this.api.fetcher.fetch({
method: "patch",
url,
path: "/api/seats/me/",
overrides: {
body: JSON.stringify({
product_key: SEAT_PRODUCT_KEY,
plan_key: planKey,
}),
},
});
return (await response.json()) as SeatData;
} catch (error) {
this.throwSeatError(error);
}
}

async cancelSeat(): Promise<void> {
try {
const url = new URL(`${this.api.baseUrl}/api/seats/me/`);
url.searchParams.set("product_key", SEAT_PRODUCT_KEY);
await this.api.fetcher.fetch({
method: "delete",
url,
path: "/api/seats/me/",
});
} catch (error) {
if (this.isFetcherStatusError(error, 204)) {
return;
}
this.throwSeatError(error);
}
}

async reactivateSeat(): Promise<SeatData> {
try {
const url = new URL(`${this.api.baseUrl}/api/seats/me/reactivate/`);
const response = await this.api.fetcher.fetch({
method: "post",
url,
path: "/api/seats/me/reactivate/",
overrides: {
body: JSON.stringify({ product_key: SEAT_PRODUCT_KEY }),
},
});
return (await response.json()) as SeatData;
} catch (error) {
this.throwSeatError(error);
}
}

private isFetcherStatusError(error: unknown, status: number): boolean {
return error instanceof Error && error.message.includes(`[${status}]`);
}

private parseFetcherError(error: unknown): {
status: number;
body: Record<string, unknown>;
} | null {
if (!(error instanceof Error)) return null;
const match = error.message.match(/\[(\d+)\]\s*(.*)/);
if (!match) return null;
try {
return {
status: Number.parseInt(match[1], 10),
body: JSON.parse(match[2]) as Record<string, unknown>,
};
} catch {
return { status: Number.parseInt(match[1], 10), body: {} };
}
}

private throwSeatError(error: unknown): never {
const parsed = this.parseFetcherError(error);

if (parsed) {
if (
parsed.status === 400 &&
typeof parsed.body.redirect_url === "string"
) {
throw new SeatSubscriptionRequiredError(parsed.body.redirect_url);
}
if (parsed.status === 402) {
const message =
typeof parsed.body.error === "string" ? parsed.body.error : undefined;
throw new SeatPaymentFailedError(message);
}
}

throw error;
}

/**
* Check if a feature flag is enabled for the current project.
* Returns true if the flag exists and is active, false otherwise.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { Callout, Flex, Spinner, Text, Theme } from "@radix-ui/themes";
import codeLogo from "@renderer/assets/images/code.svg";
import logomark from "@renderer/assets/images/logomark.svg";
import { trpcClient } from "@renderer/trpc/client";
import { REGION_LABELS } from "@shared/constants/oauth";
import type { CloudRegion } from "@shared/types/oauth";
import type { CloudRegion } from "@shared/types/regions";
import { REGION_LABELS } from "@shared/types/regions";
import { RegionSelect } from "./RegionSelect";

export const getErrorMessage = (error: unknown) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Flex, Select, Text } from "@radix-ui/themes";
import { IS_DEV } from "@shared/constants/environment";
import type { CloudRegion } from "@shared/types/oauth";
import type { CloudRegion } from "@shared/types/regions";
import { useState } from "react";

interface RegionSelectProps {
Expand Down
2 changes: 1 addition & 1 deletion apps/code/src/renderer/features/auth/hooks/authClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { PostHogAPIClient } from "@renderer/api/posthogClient";
import { trpcClient } from "@renderer/trpc/client";
import { getCloudUrlFromRegion } from "@shared/constants/oauth";
import { getCloudUrlFromRegion } from "@shared/utils/urls";
import { useMemo } from "react";
import {
type AuthState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"
import { resetSessionService } from "@features/sessions/service/service";
import { trpcClient } from "@renderer/trpc/client";
import { ANALYTICS_EVENTS } from "@shared/types/analytics";
import type { CloudRegion } from "@shared/types/oauth";
import type { CloudRegion } from "@shared/types/regions";
import { useNavigationStore } from "@stores/navigationStore";
import { useMutation } from "@tanstack/react-query";
import { track } from "@utils/analytics";
Expand Down
Loading
Loading