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
9 changes: 9 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,15 @@ export const GO_MAX_LOG_ENTRIES = 2000;
export const GO_SESSION_IDLE_MS = 2 * 60 * 60 * 1000;
export const GO_MAX_SESSIONS = 50;

// ─── Single-free-account fair-use policy (1 free account per install) ────────

/** globalState key listing fingerprints currently treated as free accounts. */
export const FREE_ACCOUNTS_STATE_KEY = "opencodego.freeAccounts.v1";
/** globalState key mapping a fingerprint to the last time it was confirmed paid (ms). */
export const PAID_ACCOUNTS_STATE_KEY = "opencodego.paidAccounts.v1";
/** How long a "confirmed paid" status stays valid before the account is treated as free again. */
export const PAID_CONFIRMATION_TTL_MS = 24 * 60 * 60 * 1000;

// ─── Usage display options ────────────────────────────────────────────────────

/**
Expand Down
19 changes: 19 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
secretKeyFor,
} from "./config";
import { GoUsageTracker } from "./usage/tracker";
import { removeAccount, shouldBlockFreeUsage } from "./usage/freeAccountPolicy";
import { loadFreeAccountPolicy, persistFreeAccountPolicy } from "./usage/freeAccountStorage";
import { buildUsageQuickPickItems } from "./usage/formatting";
import { PROVIDERS } from "./provider/definitions";
import { OpenCodeProvider } from "./provider/OpenCodeProvider";
Expand Down Expand Up @@ -201,6 +203,15 @@ export function activate(context: vscode.ExtensionContext) {
const summary = tracker.getSummary();
const items = buildUsageQuickPickItems(summary, tracker.hasServerUsage, usageRollingMeterVisible());

// Warn when free usage is blocked by the single-free-account policy.
if (shouldBlockFreeUsage(loadFreeAccountPolicy(context), Date.now())) {
items.unshift({
label: "$(warning) Multiple free accounts detected",
detail: "Free models are disabled until you keep only one free account (or add a paid subscription).",
alwaysShow: true,
});
}

// All-time usage in the current workspace (from the OpenCode CLI
// history) β€” replaces the old "Latest Session (est)" estimate row.
if (usageCodebaseRowVisible()) {
Expand Down Expand Up @@ -317,6 +328,14 @@ export function activate(context: vscode.ExtensionContext) {
ctx.globalState.update(`opencodego.usageBaseline.v1.${fp}`, {});
ctx.globalState.update(`opencodego.sessionCosts.v1.${fp}`, []);

// Drop the deleted profile from the free-account policy so the
// free-account count drops back toward the allowed single account.
const policy = loadFreeAccountPolicy(ctx);
const nextPolicy = removeAccount(policy, fp);
if (nextPolicy !== policy) {
persistFreeAccountPolicy(ctx, nextPolicy);
}

const remaining = readProfiles(ctx).filter((p) => p.fingerprint !== fp);
await writeProfiles(ctx, remaining);
setProfilesCache(remaining);
Expand Down
43 changes: 43 additions & 0 deletions src/provider/OpenCodeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ import {
} from "../usage/dashboard";
import { formatCacheHitRatio } from "../usage/usage";
import { estimateCost } from "../usage/pricing";
import { countFreeAccounts, markFreeUsage, markPaid, shouldBlockFreeUsage } from "../usage/freeAccountPolicy";
import { freeAccountPolicyDiagnostics, loadFreeAccountPolicy, persistFreeAccountPolicy } from "../usage/freeAccountStorage";
import { keyFingerprint } from "../usage/usageProfile";
import { resolveResponseApiKey } from "../apiKeyResolution";
import { clearOpenCodeModelMetadataCache, getOpenCodeModelMetadata } from "../models/metadataFetcher";
import { convertMessage, normalizeMessages, trimOldImagesFromHistoryInPlace } from "./messages";
Expand Down Expand Up @@ -475,6 +478,10 @@ export class OpenCodeProvider implements vscode.LanguageModelChatProvider<OpenCo
`- credentialInSecretStorage: ${String(hasStoredApiKey)}`,
`- modelSelectionError: ${modelSelectionError ?? "none"}`,
"",
"## Free-Account Policy",
"",
...freeAccountPolicyDiagnostics(this.context),
"",
"## Recent Requests",
"",
...this.recentTransportDiagnosticsLines(),
Expand Down Expand Up @@ -705,6 +712,32 @@ export class OpenCodeProvider implements vscode.LanguageModelChatProvider<OpenCo
}

const rawModelId = model.rawModelId ?? resolveRawModelId(model.id);

// ── Single-free-account fair-use policy ───────────────────────────────────
// Free quota is per person. Once two free accounts are known on this
// install, free-model usage is blocked until the user keeps only one free
// account (or the extras become paid). Paid accounts are always allowed.
const fingerprint = keyFingerprint(apiKey);
const nowMs = Date.now();
const freePolicy = loadFreeAccountPolicy(this.context);
if (isFreeModel(rawModelId)) {
const nextPolicy = markFreeUsage(freePolicy, fingerprint, nowMs);
if (shouldBlockFreeUsage(nextPolicy, nowMs)) {
persistFreeAccountPolicy(this.context, nextPolicy);
const freeCount = countFreeAccounts(nextPolicy, nowMs);
this.log(`[free-account] blocked free-model request for profile ${fingerprint} (${String(freeCount)} free accounts)`);
throw new OpenCodeRequestError(
"OpenCode free-account limit reached",
`Only ONE free OpenCode account is allowed per install β€” ${String(freeCount)} free accounts are configured. ` +
`Keep a single free account: delete the extra ones via the "OpenCode Go: Delete Profile" command, ` +
`or add a paid subscription to continue using free models.`,
);
}
if (nextPolicy !== freePolicy) {
persistFreeAccountPolicy(this.context, nextPolicy);
}
}

const convertedMessages = await Promise.all(
messages.map((message) => convertMessage(message, this.reasoningContentByToolCallId, rawModelId)),
);
Expand Down Expand Up @@ -860,6 +893,16 @@ export class OpenCodeProvider implements vscode.LanguageModelChatProvider<OpenCo
const requestHeaders = buildOpenCodeRequestHeaders(messages, options, rawModelId);
const outputChannel = this.getOutputChannel();
const onTransportSummary = (summary: TransportRequestSummary) => {
// A successful paid (non-free) model request confirms a paid account and
// exempts it from the single-free-account limit (multiple paid accounts
// are allowed). Only requests with actual token usage count as success.
if (!isFreeModel(summary.modelId) && (summary.promptTokens !== undefined || summary.completionTokens !== undefined)) {
const paidPolicy = loadFreeAccountPolicy(this.context);
const nextPaidPolicy = markPaid(paidPolicy, keyFingerprint(apiKey), Date.now());
if (nextPaidPolicy !== paidPolicy) {
persistFreeAccountPolicy(this.context, nextPaidPolicy);
}
}
// Compute credits for VS Code session cost (1 credit = $0.01).
// VS Code reads usage.copilotCredits from the LanguageModelDataPart
// to accumulate session cost. We mutate the summary object directly
Expand Down
129 changes: 129 additions & 0 deletions src/test/freeAccountPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { PAID_CONFIRMATION_TTL_MS } from "../config.js";
import {
countFreeAccounts,
emptyFreeAccountPolicy,
fromStored,
isPaid,
markFreeUsage,
markPaid,
removeAccount,
shouldBlockFreeUsage,
toStored,
unmarkPaid,
} from "../usage/freeAccountPolicy.js";

const NOW = 1_700_000_000_000;

describe("freeAccountPolicy", () => {
it("always allows a single free account", () => {
let p = emptyFreeAccountPolicy();
p = markFreeUsage(p, "A", NOW);
assert.equal(countFreeAccounts(p, NOW), 1);
assert.equal(shouldBlockFreeUsage(p, NOW), false);
});

it("blocks free usage once a second free account is seen", () => {
let p = emptyFreeAccountPolicy();
p = markFreeUsage(p, "A", NOW);
p = markFreeUsage(p, "B", NOW);
assert.equal(countFreeAccounts(p, NOW), 2);
assert.equal(shouldBlockFreeUsage(p, NOW), true);
});

it("does not double-count the same fingerprint", () => {
let p = emptyFreeAccountPolicy();
p = markFreeUsage(p, "A", NOW);
p = markFreeUsage(p, "A", NOW);
assert.equal(countFreeAccounts(p, NOW), 1);
assert.equal(shouldBlockFreeUsage(p, NOW), false);
});

it("exempts confirmed paid accounts from the free limit", () => {
let p = emptyFreeAccountPolicy();
p = markFreeUsage(p, "A", NOW); // free account A
p = markPaid(p, "B", NOW); // paid account B
p = markFreeUsage(p, "B", NOW); // B uses a free model but is paid β†’ not counted
assert.equal(countFreeAccounts(p, NOW), 1);
assert.equal(shouldBlockFreeUsage(p, NOW), false);
});

it("allows multiple active paid accounts even when they use free models", () => {
let p = emptyFreeAccountPolicy();
p = markPaid(p, "P1", NOW);
p = markPaid(p, "P2", NOW);
p = markFreeUsage(p, "P1", NOW);
p = markFreeUsage(p, "P2", NOW);
assert.equal(countFreeAccounts(p, NOW), 0);
assert.equal(shouldBlockFreeUsage(p, NOW), false);
});

it("re-classifies a lapsed paid account as free after the confirmation TTL", () => {
let p = emptyFreeAccountPolicy();
p = markPaid(p, "P", NOW); // paid while the subscription was active
const later = NOW + PAID_CONFIRMATION_TTL_MS + 1; // sub lapsed
assert.equal(isPaid(p, "P", later), false);
p = markFreeUsage(p, "P", later); // now uses a free model
assert.equal(countFreeAccounts(p, later), 1);
});

it("blocks two lapsed paid accounts once both are used as free", () => {
let p = emptyFreeAccountPolicy();
p = markPaid(p, "P1", NOW);
p = markPaid(p, "P2", NOW);
const later = NOW + PAID_CONFIRMATION_TTL_MS + 1;
p = markFreeUsage(p, "P1", later);
p = markFreeUsage(p, "P2", later);
assert.equal(countFreeAccounts(p, later), 2);
assert.equal(shouldBlockFreeUsage(p, later), true);
});

it("renews paid status on fresh paid confirmation (subscription renewed)", () => {
let p = emptyFreeAccountPolicy();
p = markPaid(p, "P", NOW);
const later = NOW + PAID_CONFIRMATION_TTL_MS + 1;
p = markPaid(p, "P", later); // renewed
p = markFreeUsage(p, "P", later);
assert.equal(isPaid(p, "P", later), true);
assert.equal(countFreeAccounts(p, later), 0);
});

it("unmarkPaid drops paid confirmation (Go endpoint reports no subscription)", () => {
let p = emptyFreeAccountPolicy();
p = markPaid(p, "P", NOW);
p = unmarkPaid(p, "P");
assert.equal(isPaid(p, "P", NOW), false);
p = markFreeUsage(p, "P", NOW);
assert.equal(countFreeAccounts(p, NOW), 1);
});

it("removing a deleted account drops the count back below the limit", () => {
let p = emptyFreeAccountPolicy();
p = markFreeUsage(p, "A", NOW);
p = markFreeUsage(p, "B", NOW);
assert.equal(shouldBlockFreeUsage(p, NOW), true);
p = removeAccount(p, "B");
assert.equal(countFreeAccounts(p, NOW), 1);
assert.equal(shouldBlockFreeUsage(p, NOW), false);
});

it("round-trips through the persisted shape", () => {
let p = emptyFreeAccountPolicy();
p = markFreeUsage(p, "A", NOW);
p = markPaid(p, "P", NOW);
const stored = toStored(p);
assert.deepEqual(stored.free, ["A"]);
assert.equal(stored.paid["P"], NOW);

const restored = fromStored(stored.free, stored.paid);
assert.equal(countFreeAccounts(restored, NOW), 1);
assert.equal(isPaid(restored, "P", NOW), true);
});

it("ignores malformed persisted data", () => {
const restored = fromStored(["ok", 42 as unknown as string, null as unknown as string], { P: "not-a-number" as unknown as number });
assert.deepEqual([...restored.freeAccounts], ["ok"]);
assert.equal(restored.paidAccounts.size, 0);
});
});
21 changes: 21 additions & 0 deletions src/usage/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
writeProfiles,
type UsageProfile,
} from "./usageProfile";
import { markPaid, unmarkPaid } from "./freeAccountPolicy";
import { loadFreeAccountPolicy, persistFreeAccountPolicy } from "./freeAccountStorage";
import { escapeHtml, formatCount, formatRelativeTime, formatTokenCount, formatUsd } from "../utils";

export let usageStatusBarItem: vscode.StatusBarItem | undefined;
Expand Down Expand Up @@ -344,17 +346,36 @@ export function refreshGoUsageStatusBar(): void {
const apiKey = profileApiKeys.get(activeProfileFingerprint) ?? (await _extensionContext?.secrets.get(secretKeyFor(GO_VENDOR)));
if (!apiKey) return;
const changed = await tracker.syncServerUsage(apiKey);
applyGoSubscriptionToPolicy(tracker, apiKey);
if (changed) refreshGoUsageStatusBar();
})();
}

/**
* Reflect the tracker's last Go-subscription classification in the
* free-account policy: active sub β†’ confirmed paid; no sub β†’ drop paid.
*/
function applyGoSubscriptionToPolicy(tracker: GoUsageTracker, apiKey: string): void {
const subActive = tracker.lastSubscriptionActive;
if (subActive === undefined || !_extensionContext) {
return;
}
const policy = loadFreeAccountPolicy(_extensionContext);
const fingerprint = keyFingerprint(apiKey);
const next = subActive ? markPaid(policy, fingerprint, Date.now()) : unmarkPaid(policy, fingerprint);
if (next !== policy) {
persistFreeAccountPolicy(_extensionContext, next);
}
}

/**
* Fetch server-accurate usage for a key and repaint the status bar when a new
* snapshot arrived. Uses the tracker owning that key (creating its profile on
* first use), so multi-account setups keep per-key meters.
*/
export async function syncTrackerUsage(tracker: GoUsageTracker, apiKey: string): Promise<void> {
const changed = await tracker.syncServerUsage(apiKey);
applyGoSubscriptionToPolicy(tracker, apiKey);
if (changed) refreshGoUsageStatusBar();
}

Expand Down
Loading
Loading