fix(usage): use authoritative OpenCode Go quotas
This commit is contained in:
@@ -2,6 +2,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- OpenCode Go usage now comes from the official `GET /zen/go/v1/usage` endpoint (rolling 5h / weekly / monthly percent windows with server-computed resets) instead of synthesizing dollar estimates from OMP-observed request costs, so `/usage` reflects spend made outside OMP and the hardcoded $12/$30/$60 caps are gone. The usage probe now validates credentials (401 invalid key, 403 lapsed Go subscription), and a new ranking strategy routes multi-key pools by rolling/weekly headroom while keeping the monthly window display-only (an exhausted monthly can still serve requests via the console "Use balance" fallback).
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the observed-request-cost machinery that existed only to power the OpenCode Go estimate: `AuthStorage.recordUsageCost`, the store `recordUsageCosts`/`listUsageCosts` hooks, `UsageFetchContext.listUsageCosts`, the `UsageCostHistoryEntry`/`UsageCostHistoryQuery` types, and the `usage_cost_history` schema and statements. Existing unused tables are left intact rather than deleting local data during startup.
|
||||
|
||||
## [17.2.15] - 2026-08-12
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -36,8 +36,6 @@ import type {
|
||||
CredentialRankingContext,
|
||||
CredentialRankingStrategy,
|
||||
ObservedUsageEntry,
|
||||
UsageCostHistoryEntry,
|
||||
UsageCostHistoryQuery,
|
||||
UsageCredential,
|
||||
UsageFetchContext,
|
||||
UsageFetchParams,
|
||||
@@ -66,7 +64,7 @@ import {
|
||||
listCodexResetCredits,
|
||||
pickSoonestExpiringCredit,
|
||||
} from "./usage/openai-codex-reset";
|
||||
import { opencodeGoUsageProvider } from "./usage/opencode-go";
|
||||
import { opencodeGoRankingStrategy, opencodeGoUsageProvider } from "./usage/opencode-go";
|
||||
import { syntheticUsageProvider } from "./usage/synthetic";
|
||||
import { umansUsageProvider } from "./usage/umans";
|
||||
import { xaiOauthUsageProvider } from "./usage/xai-oauth";
|
||||
@@ -454,10 +452,6 @@ export interface AuthCredentialStore {
|
||||
* skipped — the broker host records into its own database instead.
|
||||
*/
|
||||
recordUsageSnapshots?(entries: UsageHistoryEntry[]): void;
|
||||
/** Append observed request costs for providers without upstream usage APIs. */
|
||||
recordUsageCosts?(entries: UsageCostHistoryEntry[]): void;
|
||||
/** Read observed request costs, oldest first. */
|
||||
listUsageCosts?(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[];
|
||||
/** Read recorded usage-limit snapshots, oldest first. */
|
||||
listUsageHistory?(query?: UsageHistoryQuery): UsageHistoryEntry[];
|
||||
/**
|
||||
@@ -698,6 +692,10 @@ const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 10_000;
|
||||
const USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES: Partial<Record<Provider, number>> = {
|
||||
"google-antigravity": 2,
|
||||
zai: 2,
|
||||
// v2: retires cached reports from the OMP-observed spend estimator (dollar
|
||||
// units) now that limits come from the upstream percent-based `/usage`
|
||||
// endpoint; the 24h last-good retention would otherwise keep serving them.
|
||||
"opencode-go": 2,
|
||||
// v2: cache identity gained an `org:` component so two subscriptions on one
|
||||
// account email stop sharing a slot. v3 retires parsed reports created before
|
||||
// Anthropic extra-usage rows existed; header ingestion can otherwise keep
|
||||
@@ -1072,6 +1070,7 @@ const DEFAULT_RANKING_STRATEGIES = new Map<Provider, CredentialRankingStrategy>(
|
||||
["anthropic", claudeRankingStrategy],
|
||||
["google-antigravity", antigravityRankingStrategy],
|
||||
["zai", zaiRankingStrategy],
|
||||
["opencode-go", opencodeGoRankingStrategy],
|
||||
]);
|
||||
|
||||
function resolveDefaultRankingStrategy(provider: Provider): CredentialRankingStrategy | undefined {
|
||||
@@ -3175,7 +3174,6 @@ export class AuthStorage {
|
||||
const report = await providerImpl.fetchUsage(params, {
|
||||
fetch: this.#usageFetch,
|
||||
logger: this.#usageLogger,
|
||||
listUsageCosts: query => this.#store.listUsageCosts?.(query) ?? [],
|
||||
});
|
||||
// Attribute the report to the credential's organization. The orgId and
|
||||
// orgName fallbacks apply independently: Claude's usage endpoint stamps
|
||||
@@ -3297,42 +3295,6 @@ export class AuthStorage {
|
||||
return this.#store.listUsageHistory?.(query) ?? [];
|
||||
}
|
||||
|
||||
/** Record one observed provider request cost for later local usage aggregation. */
|
||||
recordUsageCost(
|
||||
provider: Provider,
|
||||
costUsd: number,
|
||||
options?: { sessionId?: string; recordedAt?: number; baseUrl?: string },
|
||||
): boolean {
|
||||
if (!Number.isFinite(costUsd) || costUsd <= 0) return false;
|
||||
const record = this.#store.recordUsageCosts;
|
||||
if (!record) return false;
|
||||
const credential = this.#resolveObservedUsageCredential(provider, options?.sessionId);
|
||||
if (!credential) return false;
|
||||
const entry: UsageCostHistoryEntry = {
|
||||
recordedAt: options?.recordedAt ?? Date.now(),
|
||||
provider,
|
||||
accountKey: this.#buildUsageCacheIdentity(credential),
|
||||
costUsd,
|
||||
};
|
||||
try {
|
||||
record.call(this.#store, [entry]);
|
||||
const cacheKey = this.#buildUsageReportCacheKey({
|
||||
provider,
|
||||
credential,
|
||||
baseUrl: options?.baseUrl,
|
||||
});
|
||||
const existing = this.#usageCache.getStale<UsageReport | null>(cacheKey);
|
||||
this.#usageCache.set(cacheKey, { value: existing?.value ?? null, expiresAt: Date.now() - 1 });
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.#usageLogger?.debug("usage cost record failed", {
|
||||
provider,
|
||||
error: String(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward one completed request's usage to the store's observer hook.
|
||||
* Broker-backed stores batch these into per-install reports so the broker
|
||||
@@ -3383,28 +3345,6 @@ export class AuthStorage {
|
||||
return this.#store.getClientUsageSummary?.(sinceMs) ?? { clients: [] };
|
||||
}
|
||||
|
||||
#resolveObservedUsageCredential(provider: Provider, sessionId?: string): UsageCredential | undefined {
|
||||
const entries = this.#getStoredCredentials(provider);
|
||||
const sessionCredential = this.#getSessionCredential(provider, sessionId);
|
||||
if (sessionCredential) {
|
||||
const credential = entries[sessionCredential.index]?.credential;
|
||||
if (credential) {
|
||||
return credential.type === "api_key"
|
||||
? { type: "api_key", apiKey: credential.key }
|
||||
: this.#buildUsageCredential(credential);
|
||||
}
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
const credential = entries[0]!.credential;
|
||||
return credential.type === "api_key"
|
||||
? { type: "api_key", apiKey: credential.key }
|
||||
: this.#buildUsageCredential(credential);
|
||||
}
|
||||
const envKey = getEnvApiKey(provider);
|
||||
if (envKey) return { type: "api_key", apiKey: envKey };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
ingestUsageHeaders(
|
||||
provider: Provider,
|
||||
headers: Record<string, string>,
|
||||
@@ -4101,7 +4041,6 @@ export class AuthStorage {
|
||||
const ctx: UsageFetchContext = {
|
||||
fetch: this.#usageFetch,
|
||||
logger: this.#usageLogger,
|
||||
listUsageCosts: query => this.#store.listUsageCosts?.(query) ?? [],
|
||||
};
|
||||
|
||||
const results: CredentialHealthResult[] = [];
|
||||
|
||||
@@ -25,8 +25,6 @@ import type {
|
||||
ClientProviderUsage,
|
||||
ClientUsageReport,
|
||||
ClientUsageSummary,
|
||||
UsageCostHistoryEntry,
|
||||
UsageCostHistoryQuery,
|
||||
UsageHistoryEntry,
|
||||
UsageHistoryQuery,
|
||||
} from "../usage";
|
||||
@@ -387,8 +385,6 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
#releaseCredentialRefreshLeaseStmt: Statement;
|
||||
#credentialBlockReconcileAfter: Map<string, number> = new Map();
|
||||
#insertUsageHistoryStmt: Statement;
|
||||
#insertUsageCostStmt: Statement;
|
||||
#listUsageCostsStmt: Statement;
|
||||
#lastUsageHistoryStmt: Statement;
|
||||
#listUsageHistoryStmt: Statement;
|
||||
#updateUsageHistoryStmt: Statement;
|
||||
@@ -516,12 +512,6 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
this.#listUsageHistoryStmt = this.#db.prepare(
|
||||
"SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at FROM usage_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) ORDER BY recorded_at ASC",
|
||||
);
|
||||
this.#insertUsageCostStmt = this.#db.prepare(
|
||||
"INSERT INTO usage_cost_history (recorded_at, provider, account_key, cost_usd) VALUES (?, ?, ?, ?)",
|
||||
);
|
||||
this.#listUsageCostsStmt = this.#db.prepare(
|
||||
"SELECT recorded_at, provider, account_key, cost_usd FROM usage_cost_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) AND (? IS NULL OR account_key = ?) ORDER BY recorded_at ASC",
|
||||
);
|
||||
}
|
||||
|
||||
static async open(dbPath: string = getAgentDbPath()): Promise<SqliteAuthCredentialStore> {
|
||||
@@ -634,14 +624,6 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
resets_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_history_series ON usage_history(provider, account_key, limit_id, recorded_at);
|
||||
CREATE TABLE IF NOT EXISTS usage_cost_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
recorded_at INTEGER NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
account_key TEXT NOT NULL,
|
||||
cost_usd REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_cost_history_lookup ON usage_cost_history(provider, account_key, recorded_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_history_recorded ON usage_history(recorded_at);
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
install_id TEXT PRIMARY KEY,
|
||||
@@ -1769,42 +1751,6 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
recordUsageCosts(entries: UsageCostHistoryEntry[]): void {
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
this.#insertUsageCostStmt.run(entry.recordedAt, entry.provider, entry.accountKey, entry.costUsd);
|
||||
}
|
||||
} catch {
|
||||
// Cost history is best-effort; never break request persistence.
|
||||
}
|
||||
}
|
||||
|
||||
listUsageCosts(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[] {
|
||||
try {
|
||||
const provider = query?.provider ?? null;
|
||||
const accountKey = query?.accountKey ?? null;
|
||||
const rows = this.#listUsageCostsStmt.all(
|
||||
query?.sinceMs ?? 0,
|
||||
provider,
|
||||
provider,
|
||||
accountKey,
|
||||
accountKey,
|
||||
) as Array<{
|
||||
recorded_at: number;
|
||||
provider: string;
|
||||
account_key: string;
|
||||
cost_usd: number;
|
||||
}>;
|
||||
return rows.map(row => ({
|
||||
recordedAt: row.recorded_at,
|
||||
provider: row.provider as Provider,
|
||||
accountKey: row.account_key,
|
||||
costUsd: row.cost_usd,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
recordClientUsage(report: ClientUsageReport): void {
|
||||
const now = Date.now();
|
||||
@@ -2051,8 +1997,6 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
this.#lastUsageHistoryStmt.finalize();
|
||||
this.#listUsageHistoryStmt.finalize();
|
||||
this.#updateUsageHistoryStmt.finalize();
|
||||
this.#insertUsageCostStmt.finalize();
|
||||
this.#listUsageCostsStmt.finalize();
|
||||
this.#updateIfMatchesStmt.finalize();
|
||||
this.#updateIfMatchesWithLeaseStmt.finalize();
|
||||
this.#deleteIfMatchesWithLeaseStmt.finalize();
|
||||
|
||||
@@ -166,24 +166,6 @@ export interface UsageHistoryQuery {
|
||||
/** Inclusive lower bound on {@link UsageHistoryEntry.recordedAt} (epoch ms). */
|
||||
sinceMs?: number;
|
||||
}
|
||||
/** One observed provider request cost, attributed to the credential that made it. */
|
||||
export interface UsageCostHistoryEntry {
|
||||
/** Epoch ms the request completed. */
|
||||
recordedAt: number;
|
||||
provider: Provider;
|
||||
/** Stable credential identity key (account/email/project/secret derived). */
|
||||
accountKey: string;
|
||||
/** Estimated request cost in USD. */
|
||||
costUsd: number;
|
||||
}
|
||||
|
||||
/** Filter for reading observed request costs. */
|
||||
export interface UsageCostHistoryQuery {
|
||||
provider?: string;
|
||||
accountKey?: string;
|
||||
/** Inclusive lower bound on {@link UsageCostHistoryEntry.recordedAt} (epoch ms). */
|
||||
sinceMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated request usage a client observed for one (provider, model) pair.
|
||||
@@ -346,8 +328,6 @@ export interface UsageFetchContext {
|
||||
fetch: FetchImpl;
|
||||
logger?: UsageLogger;
|
||||
retryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
|
||||
/** Observed request-cost history for providers without upstream usage APIs. */
|
||||
listUsageCosts?: (query?: UsageCostHistoryQuery) => UsageCostHistoryEntry[];
|
||||
}
|
||||
|
||||
/** Provider implementation for fetching usage information. */
|
||||
|
||||
@@ -1,88 +1,181 @@
|
||||
import type { UsageCostHistoryEntry, UsageLimit, UsageProvider, UsageWindow } from "../usage";
|
||||
import { ProviderHttpError } from "../error";
|
||||
import type {
|
||||
CredentialRankingStrategy,
|
||||
UsageFetchContext,
|
||||
UsageFetchParams,
|
||||
UsageLimit,
|
||||
UsageProvider,
|
||||
UsageReport,
|
||||
UsageStatus,
|
||||
UsageWindow,
|
||||
} from "../usage";
|
||||
import { isRecord } from "../utils";
|
||||
import { DAY_MS, HOUR_MS } from "./shared";
|
||||
|
||||
const OPENCODE_GO_PROVIDER = "opencode-go";
|
||||
const OPENCODE_GO_LIMITS = [
|
||||
{ id: "rolling-5h", label: "5 Hour", durationMs: 5 * HOUR_MS, limitUsd: 12 },
|
||||
{ id: "weekly", label: "Weekly", durationMs: 7 * DAY_MS, limitUsd: 30 },
|
||||
{ id: "monthly", label: "Monthly", durationMs: 30 * DAY_MS, limitUsd: 60 },
|
||||
const DEFAULT_ENDPOINT = "https://opencode.ai/zen/go";
|
||||
const USAGE_PATH = "/v1/usage";
|
||||
|
||||
/**
|
||||
* `GET /zen/go/v1/usage` response windows. The route is first-party but
|
||||
* undocumented (`anomalyco/opencode` `packages/console/app/src/routes/zen/go/v1/usage.ts`)
|
||||
* and its shape changed once on merge day, so each window is decoded
|
||||
* defensively and malformed windows are skipped rather than failing the report.
|
||||
*
|
||||
* Per window: `status` is `"ok" | "rate-limited"`, `percent` is a floored,
|
||||
* clamped integer 0-100, and `resetsAt` is an ISO timestamp computed server
|
||||
* side. The monthly window anchors on the subscription anniversary — not a
|
||||
* 30-day rolling span — so it deliberately carries no `durationMs`.
|
||||
*/
|
||||
const OPENCODE_GO_WINDOWS = [
|
||||
{ key: "rolling", limitId: "rolling-5h", windowId: "5h", label: "5 Hour", durationMs: 5 * HOUR_MS },
|
||||
{ key: "weekly", limitId: "weekly", windowId: "7d", label: "Weekly", durationMs: 7 * DAY_MS },
|
||||
{ key: "monthly", limitId: "monthly", windowId: "monthly", label: "Monthly", durationMs: undefined },
|
||||
] as const;
|
||||
|
||||
function sumWindowCosts(entries: UsageCostHistoryEntry[], sinceMs: number): { used: number; resetsAt?: number } {
|
||||
let used = 0;
|
||||
let firstRecordedAt: number | undefined;
|
||||
for (const entry of entries) {
|
||||
if (entry.recordedAt < sinceMs) continue;
|
||||
used += entry.costUsd;
|
||||
if (firstRecordedAt === undefined || entry.recordedAt < firstRecordedAt) {
|
||||
firstRecordedAt = entry.recordedAt;
|
||||
}
|
||||
}
|
||||
return { used, resetsAt: firstRecordedAt };
|
||||
function normalizeBaseUrl(baseUrl?: string): string {
|
||||
if (!baseUrl?.trim()) return DEFAULT_ENDPOINT;
|
||||
// Strip a trailing `/v1` (models.json carries both `zen/go` and
|
||||
// `zen/go/v1` base URLs) so the usage path doesn't double it, while
|
||||
// preserving any path-mounted gateway prefix.
|
||||
const withoutTrailingSlash = baseUrl.trim().replace(/\/+$/, "");
|
||||
return withoutTrailingSlash.replace(/\/v1$/i, "") || DEFAULT_ENDPOINT;
|
||||
}
|
||||
|
||||
function resolveStatus(usedFraction: number): UsageLimit["status"] {
|
||||
function resolveStatus(windowStatus: unknown, usedFraction: number): UsageStatus {
|
||||
if (windowStatus === "rate-limited") return "exhausted";
|
||||
if (usedFraction >= 1) return "exhausted";
|
||||
if (usedFraction >= 0.8) return "warning";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function buildWindowLimit(
|
||||
limit: (typeof OPENCODE_GO_LIMITS)[number],
|
||||
entries: UsageCostHistoryEntry[],
|
||||
nowMs: number,
|
||||
): UsageLimit {
|
||||
const sinceMs = nowMs - limit.durationMs;
|
||||
const windowCost = sumWindowCosts(entries, sinceMs);
|
||||
const used = Number(windowCost.used.toFixed(6));
|
||||
const usedFraction = used / limit.limitUsd;
|
||||
const window: UsageWindow = {
|
||||
id: limit.id,
|
||||
label: limit.label,
|
||||
durationMs: limit.durationMs,
|
||||
};
|
||||
if (windowCost.resetsAt !== undefined) {
|
||||
window.resetsAt = windowCost.resetsAt + limit.durationMs;
|
||||
}
|
||||
function buildWindowLimit(descriptor: (typeof OPENCODE_GO_WINDOWS)[number], payload: unknown): UsageLimit | undefined {
|
||||
if (!isRecord(payload)) return undefined;
|
||||
const percent = payload.percent;
|
||||
if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0) return undefined;
|
||||
const usedFraction = percent / 100;
|
||||
const window: UsageWindow = { id: descriptor.windowId, label: descriptor.label };
|
||||
if (descriptor.durationMs !== undefined) window.durationMs = descriptor.durationMs;
|
||||
const resetsAtMs = typeof payload.resetsAt === "string" ? Date.parse(payload.resetsAt) : Number.NaN;
|
||||
if (Number.isFinite(resetsAtMs)) window.resetsAt = resetsAtMs;
|
||||
return {
|
||||
id: limit.id,
|
||||
label: `${limit.label} limit`,
|
||||
id: descriptor.limitId,
|
||||
label: `${descriptor.label} limit`,
|
||||
scope: {
|
||||
provider: OPENCODE_GO_PROVIDER,
|
||||
windowId: limit.id,
|
||||
windowId: descriptor.windowId,
|
||||
shared: true,
|
||||
},
|
||||
window,
|
||||
amount: {
|
||||
used,
|
||||
limit: limit.limitUsd,
|
||||
remaining: Math.max(0, limit.limitUsd - used),
|
||||
used: percent,
|
||||
usedFraction,
|
||||
remainingFraction: Math.max(0, 1 - usedFraction),
|
||||
unit: "usd",
|
||||
unit: "percent",
|
||||
},
|
||||
status: resolveStatus(usedFraction),
|
||||
status: resolveStatus(payload.status, usedFraction),
|
||||
};
|
||||
}
|
||||
|
||||
async function readUpstreamErrorMessage(response: Response): Promise<string | undefined> {
|
||||
try {
|
||||
const payload = (await response.json()) as unknown;
|
||||
if (!isRecord(payload) || !isRecord(payload.error)) return undefined;
|
||||
return typeof payload.error.message === "string" ? payload.error.message : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOpenCodeGoUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
|
||||
if (params.provider !== OPENCODE_GO_PROVIDER) return null;
|
||||
const credential = params.credential;
|
||||
if (credential.type !== "api_key" || !credential.apiKey) return null;
|
||||
|
||||
const url = `${normalizeBaseUrl(params.baseUrl)}${USAGE_PATH}`;
|
||||
let payload: unknown;
|
||||
try {
|
||||
const response = await ctx.fetch(url, {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: `Bearer ${credential.apiKey}`,
|
||||
},
|
||||
signal: params.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
// 401 (missing/invalid key) and 403 (no Go subscription) must throw
|
||||
// so checkCredentials flags the credential as ok:false rather than
|
||||
// ok:null (unknown). Other non-ok statuses are transient — return
|
||||
// null so the cached last-good report serves through them.
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const detail = await readUpstreamErrorMessage(response);
|
||||
throw new ProviderHttpError(
|
||||
`OpenCode Go usage endpoint returned ${response.status}${detail ? `: ${detail}` : ""}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
ctx.logger?.warn("OpenCode Go usage fetch failed", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
payload = (await response.json()) as unknown;
|
||||
} catch (error) {
|
||||
if (error instanceof ProviderHttpError) throw error;
|
||||
ctx.logger?.warn("OpenCode Go usage fetch error", { error: String(error) });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isRecord(payload) || !isRecord(payload.usage)) {
|
||||
ctx.logger?.warn("OpenCode Go usage response had no usage object");
|
||||
return null;
|
||||
}
|
||||
const usage = payload.usage;
|
||||
const limits: UsageLimit[] = [];
|
||||
for (const descriptor of OPENCODE_GO_WINDOWS) {
|
||||
const limit = buildWindowLimit(descriptor, usage[descriptor.key]);
|
||||
if (limit) limits.push(limit);
|
||||
}
|
||||
if (limits.length === 0) return null;
|
||||
|
||||
return {
|
||||
provider: OPENCODE_GO_PROVIDER,
|
||||
fetchedAt: Date.now(),
|
||||
limits,
|
||||
metadata: {
|
||||
planType: "OpenCode Go",
|
||||
endpoint: url,
|
||||
},
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
|
||||
export const opencodeGoUsageProvider: UsageProvider = {
|
||||
id: OPENCODE_GO_PROVIDER,
|
||||
fetchUsage: fetchOpenCodeGoUsage,
|
||||
supports: params => params.provider === OPENCODE_GO_PROVIDER && params.credential.type === "api_key",
|
||||
validatesCredentials: false,
|
||||
async fetchUsage(params, ctx) {
|
||||
if (params.provider !== OPENCODE_GO_PROVIDER || params.credential.type !== "api_key") return null;
|
||||
const nowMs = Date.now();
|
||||
const sinceMs = nowMs - OPENCODE_GO_LIMITS[OPENCODE_GO_LIMITS.length - 1]!.durationMs;
|
||||
const entries =
|
||||
ctx.listUsageCosts?.({ provider: OPENCODE_GO_PROVIDER, accountKey: params.accountKey, sinceMs }) ?? [];
|
||||
return {
|
||||
provider: OPENCODE_GO_PROVIDER,
|
||||
fetchedAt: nowMs,
|
||||
limits: OPENCODE_GO_LIMITS.map(limit => buildWindowLimit(limit, entries, nowMs)),
|
||||
notes: ["OMP-observed spend only; OpenCode usage outside OMP is not included."],
|
||||
metadata: {
|
||||
planType: "OpenCode Go",
|
||||
source: "omp-observed-request-costs",
|
||||
},
|
||||
};
|
||||
validatesCredentials: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Multi-key pools rank by real headroom on the rolling and weekly windows.
|
||||
*
|
||||
* The monthly window is deliberately display-only: an exhausted monthly can
|
||||
* still serve requests when the account's console "Use balance" fallback is
|
||||
* enabled, and the usage endpoint does not report that flag — blocking on it
|
||||
* would bench a working key until the subscription anniversary. Hard monthly
|
||||
* failures still rotate credentials via the `401 Insufficient balance`
|
||||
* usage-limit classification ([#3169](https://github.com/can1357/oh-my-pi/issues/3169)).
|
||||
*/
|
||||
export const opencodeGoRankingStrategy: CredentialRankingStrategy = {
|
||||
findWindowLimits: report => ({
|
||||
primary: report.limits.find(limit => limit.id === "rolling-5h"),
|
||||
secondary: report.limits.find(limit => limit.id === "weekly"),
|
||||
}),
|
||||
scopeLimits: report => report.limits.filter(limit => limit.id !== "monthly"),
|
||||
windowDefaults: {
|
||||
primaryMs: 5 * HOUR_MS,
|
||||
secondaryMs: 7 * DAY_MS,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from "@oh-my-pi/pi-ai/auth-storage";
|
||||
import type { UsageProvider } from "@oh-my-pi/pi-ai/usage";
|
||||
import * as claudeUsage from "@oh-my-pi/pi-ai/usage/claude";
|
||||
import { opencodeGoUsageProvider } from "@oh-my-pi/pi-ai/usage/opencode-go";
|
||||
import { ollamaCloudUsageProvider } from "@oh-my-pi/pi-ai/usage/ollama";
|
||||
|
||||
function oauthRow(id: number, email: string, opts?: { expired?: boolean }): StoredAuthCredential {
|
||||
const credential: AuthCredential = {
|
||||
@@ -402,13 +402,13 @@ describe("AuthStorage.checkCredentials", () => {
|
||||
it("does not mark local-only usage providers healthy without upstream validation", async () => {
|
||||
const apiKeyRow: StoredAuthCredential = {
|
||||
id: 12,
|
||||
provider: "opencode-go",
|
||||
credential: { type: "api_key", key: "sk-opencode-go" },
|
||||
provider: "ollama-cloud",
|
||||
credential: { type: "api_key", key: "sk-ollama-cloud" },
|
||||
disabledCause: null,
|
||||
};
|
||||
const store = makeStore([apiKeyRow]);
|
||||
const storage = new AuthStorage(store, {
|
||||
usageProviderResolver: provider => (provider === "opencode-go" ? opencodeGoUsageProvider : undefined),
|
||||
usageProviderResolver: provider => (provider === "ollama-cloud" ? ollamaCloudUsageProvider : undefined),
|
||||
});
|
||||
await storage.reload();
|
||||
|
||||
|
||||
@@ -188,15 +188,33 @@ describe("AuthStorage usage history recording", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode Go usage from observed request costs", () => {
|
||||
describe("OpenCode Go usage via the upstream endpoint", () => {
|
||||
let store: SqliteAuthCredentialStore;
|
||||
let storage: AuthStorage;
|
||||
let fetchCalls: Array<{ url: string; headers: Record<string, string> }>;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchCalls = [];
|
||||
store = new SqliteAuthCredentialStore(new Database(":memory:"));
|
||||
storage = new AuthStorage(store, {
|
||||
usageProviderResolver: provider =>
|
||||
provider === "opencode-go" ? opencodeGoUsage.opencodeGoUsageProvider : undefined,
|
||||
usageFetch: (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
fetchCalls.push({
|
||||
url: String(input),
|
||||
headers: (init?.headers as Record<string, string>) ?? {},
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
usage: {
|
||||
rolling: { status: "ok", percent: 12, resetsAt: "2026-08-12T15:09:04.847Z" },
|
||||
weekly: { status: "ok", percent: 8, resetsAt: "2026-08-17T00:00:00.847Z" },
|
||||
monthly: { status: "rate-limited", percent: 100, resetsAt: "2026-08-19T00:31:53.847Z" },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}) as unknown as typeof fetch,
|
||||
});
|
||||
await storage.reload();
|
||||
await storage.set("opencode-go", { type: "api_key", key: "opencode-go-key" });
|
||||
@@ -208,50 +226,30 @@ describe("OpenCode Go usage from observed request costs", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns zero-dollar OpenCode Go limits for a fresh key", async () => {
|
||||
it("fetches percent-based limits for a stored API key and records history rows", async () => {
|
||||
const reports = await storage.fetchUsageReports();
|
||||
|
||||
expect(fetchCalls).toHaveLength(1);
|
||||
expect(fetchCalls[0]?.url).toBe("https://opencode.ai/zen/go/v1/usage");
|
||||
expect(fetchCalls[0]?.headers.authorization).toBe("Bearer opencode-go-key");
|
||||
|
||||
const report = reports?.find(candidate => candidate.provider === "opencode-go");
|
||||
expect(report?.limits.map(limit => [limit.id, limit.amount.used, limit.amount.limit])).toEqual([
|
||||
["rolling-5h", 0, 12],
|
||||
["weekly", 0, 30],
|
||||
["monthly", 0, 60],
|
||||
expect(report?.limits.map(limit => [limit.id, limit.amount.used, limit.status])).toEqual([
|
||||
["rolling-5h", 12, "ok"],
|
||||
["weekly", 8, "ok"],
|
||||
["monthly", 100, "exhausted"],
|
||||
]);
|
||||
expect(report?.limits.map(limit => limit.scope.windowId)).toEqual(["5h", "7d", "monthly"]);
|
||||
expect(report?.limits.find(limit => limit.id === "monthly")?.window?.resetsAt).toBe(
|
||||
Date.parse("2026-08-19T00:31:53.847Z"),
|
||||
);
|
||||
|
||||
// Fresh reports append durable usage-history rows per limit window.
|
||||
const rows = store.listUsageHistory({ provider: "opencode-go" });
|
||||
expect(rows.map(row => [row.limitId, row.usedFraction])).toEqual([
|
||||
["rolling-5h", 0.12],
|
||||
["weekly", 0.08],
|
||||
["monthly", 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it("refreshes cached OpenCode Go limits after recording new observed spend", async () => {
|
||||
const nowMs = Date.parse("2026-06-18T12:00:00Z");
|
||||
setSystemTime(new Date(nowMs));
|
||||
|
||||
const initialReports = await storage.fetchUsageReports();
|
||||
const initial = initialReports?.find(candidate => candidate.provider === "opencode-go");
|
||||
expect(initial?.limits.find(limit => limit.id === "rolling-5h")?.amount.used).toBe(0);
|
||||
|
||||
storage.recordUsageCost("opencode-go", 3, { recordedAt: nowMs });
|
||||
|
||||
const refreshedReports = await storage.fetchUsageReports();
|
||||
const refreshed = refreshedReports?.find(candidate => candidate.provider === "opencode-go");
|
||||
expect(refreshed?.limits.find(limit => limit.id === "rolling-5h")?.amount.used).toBe(3);
|
||||
});
|
||||
|
||||
it("aggregates one key's observed spend into OpenCode Go cap windows", async () => {
|
||||
const nowMs = Date.parse("2026-06-18T12:00:00Z");
|
||||
setSystemTime(new Date(nowMs));
|
||||
storage.recordUsageCost("opencode-go", 4, { recordedAt: nowMs - HOUR });
|
||||
storage.recordUsageCost("opencode-go", 7, { recordedAt: nowMs - 6 * HOUR });
|
||||
storage.recordUsageCost("opencode-go", 11, { recordedAt: nowMs - 10 * 24 * HOUR });
|
||||
storage.recordUsageCost("opencode-go", 13, { recordedAt: nowMs - 31 * 24 * HOUR });
|
||||
|
||||
const reports = await storage.fetchUsageReports();
|
||||
const report = reports?.find(candidate => candidate.provider === "opencode-go");
|
||||
if (!report) throw new Error("expected opencode-go usage report");
|
||||
|
||||
const usedByLimit = new Map(report.limits.map(limit => [limit.id, limit.amount.used]));
|
||||
expect(usedByLimit.get("rolling-5h")).toBe(4);
|
||||
expect(usedByLimit.get("weekly")).toBe(11);
|
||||
expect(usedByLimit.get("monthly")).toBe(22);
|
||||
|
||||
const fiveHour = report.limits.find(limit => limit.id === "rolling-5h");
|
||||
expect(fiveHour?.window?.resetsAt).toBe(nowMs - HOUR + 5 * HOUR);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { FetchImpl } from "@oh-my-pi/pi-ai/types";
|
||||
import { opencodeGoRankingStrategy, opencodeGoUsageProvider } from "../src/usage/opencode-go";
|
||||
|
||||
const DEFAULT_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
|
||||
|
||||
/** Live capture from `GET /zen/go/v1/usage`, 2026-08-12. */
|
||||
function usagePayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
usage: {
|
||||
rolling: { status: "ok", percent: 12, resetsAt: "2026-08-12T15:09:04.847Z" },
|
||||
weekly: { status: "ok", percent: 8, resetsAt: "2026-08-17T00:00:00.847Z" },
|
||||
monthly: { status: "rate-limited", percent: 100, resetsAt: "2026-08-19T00:31:53.847Z" },
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeFetch(payload: unknown, status = 200): FetchImpl {
|
||||
const fn = async () =>
|
||||
new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
return fn as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function fetchRecorder(
|
||||
calls: Array<{ url: string; headers: Record<string, string> }>,
|
||||
payload: unknown,
|
||||
status = 200,
|
||||
): FetchImpl {
|
||||
const fn = async (input: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({
|
||||
url: String(input),
|
||||
headers: (init?.headers as Record<string, string>) ?? {},
|
||||
});
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
return fn as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("opencode-go usage provider", () => {
|
||||
it("parses the three windows into percent limits with canonical window ids", async () => {
|
||||
const report = await opencodeGoUsageProvider.fetchUsage(
|
||||
{ provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } },
|
||||
{ fetch: fakeFetch(usagePayload()) },
|
||||
);
|
||||
expect(report).not.toBeNull();
|
||||
expect(report?.limits.map(limit => [limit.id, limit.scope.windowId, limit.amount.used])).toEqual([
|
||||
["rolling-5h", "5h", 12],
|
||||
["weekly", "7d", 8],
|
||||
["monthly", "monthly", 100],
|
||||
]);
|
||||
const rolling = report?.limits.find(limit => limit.id === "rolling-5h");
|
||||
expect(rolling?.amount.usedFraction).toBeCloseTo(0.12, 5);
|
||||
expect(rolling?.amount.unit).toBe("percent");
|
||||
expect(rolling?.window?.durationMs).toBe(5 * 3_600_000);
|
||||
expect(rolling?.window?.resetsAt).toBe(Date.parse("2026-08-12T15:09:04.847Z"));
|
||||
// Monthly anchors on the subscription anniversary, not a 30d span.
|
||||
const monthly = report?.limits.find(limit => limit.id === "monthly");
|
||||
expect(monthly?.window?.durationMs).toBeUndefined();
|
||||
expect(monthly?.window?.resetsAt).toBe(Date.parse("2026-08-19T00:31:53.847Z"));
|
||||
});
|
||||
|
||||
it("maps rate-limited windows to exhausted and high usage to warning", async () => {
|
||||
const report = await opencodeGoUsageProvider.fetchUsage(
|
||||
{ provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } },
|
||||
{
|
||||
fetch: fakeFetch(
|
||||
usagePayload({
|
||||
rolling: { status: "ok", percent: 85, resetsAt: "2026-08-12T15:09:04.847Z" },
|
||||
}),
|
||||
),
|
||||
},
|
||||
);
|
||||
expect(report?.limits.find(limit => limit.id === "rolling-5h")?.status).toBe("warning");
|
||||
expect(report?.limits.find(limit => limit.id === "weekly")?.status).toBe("ok");
|
||||
expect(report?.limits.find(limit => limit.id === "monthly")?.status).toBe("exhausted");
|
||||
});
|
||||
|
||||
it("sends Authorization: Bearer <key> to the fixed usage route", async () => {
|
||||
const calls: Array<{ url: string; headers: Record<string, string> }> = [];
|
||||
await opencodeGoUsageProvider.fetchUsage(
|
||||
{ provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } },
|
||||
{ fetch: fetchRecorder(calls, usagePayload()) },
|
||||
);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.url).toBe(DEFAULT_USAGE_URL);
|
||||
expect(calls[0]?.headers.authorization).toBe("Bearer sk-test");
|
||||
});
|
||||
|
||||
it("normalizes both catalog baseUrl forms onto the usage route", async () => {
|
||||
for (const baseUrl of ["https://opencode.ai/zen/go", "https://opencode.ai/zen/go/v1"]) {
|
||||
const calls: Array<{ url: string; headers: Record<string, string> }> = [];
|
||||
await opencodeGoUsageProvider.fetchUsage(
|
||||
{ provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" }, baseUrl },
|
||||
{ fetch: fetchRecorder(calls, usagePayload()) },
|
||||
);
|
||||
expect(calls[0]?.url).toBe(DEFAULT_USAGE_URL);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on 401 with the upstream error message so checkCredentials flags the key", async () => {
|
||||
await expect(
|
||||
opencodeGoUsageProvider.fetchUsage(
|
||||
{ provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } },
|
||||
{
|
||||
fetch: fakeFetch({ type: "error", error: { type: "AuthError", message: "Unauthorized" } }, 401),
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/401.*Unauthorized/);
|
||||
});
|
||||
|
||||
it("throws on 403 so lapsed Go subscriptions surface in credential health", async () => {
|
||||
await expect(
|
||||
opencodeGoUsageProvider.fetchUsage(
|
||||
{ provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } },
|
||||
{
|
||||
fetch: fakeFetch(
|
||||
{ type: "error", error: { type: "EntitlementError", message: "OpenCode Go subscription required." } },
|
||||
403,
|
||||
),
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/403.*subscription required/);
|
||||
});
|
||||
|
||||
it("returns null on a transient non-auth HTTP failure (500)", async () => {
|
||||
const report = await opencodeGoUsageProvider.fetchUsage(
|
||||
{ provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } },
|
||||
{ fetch: fakeFetch({ message: "internal server error" }, 500) },
|
||||
);
|
||||
expect(report).toBeNull();
|
||||
});
|
||||
|
||||
it("skips malformed windows and returns null when no window parses", async () => {
|
||||
const partial = await opencodeGoUsageProvider.fetchUsage(
|
||||
{ provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } },
|
||||
{
|
||||
fetch: fakeFetch(usagePayload({ rolling: { status: "ok", percent: "abc" }, weekly: null })),
|
||||
},
|
||||
);
|
||||
expect(partial?.limits.map(limit => limit.id)).toEqual(["monthly"]);
|
||||
|
||||
const empty = await opencodeGoUsageProvider.fetchUsage(
|
||||
{ provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } },
|
||||
{ fetch: fakeFetch({ usage: {} }) },
|
||||
);
|
||||
expect(empty).toBeNull();
|
||||
|
||||
const noUsage = await opencodeGoUsageProvider.fetchUsage(
|
||||
{ provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } },
|
||||
{ fetch: fakeFetch({}) },
|
||||
);
|
||||
expect(noUsage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("opencode-go ranking strategy", () => {
|
||||
it("ranks on rolling/weekly and keeps the monthly window display-only", async () => {
|
||||
const report = await opencodeGoUsageProvider.fetchUsage(
|
||||
{ provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } },
|
||||
{ fetch: fakeFetch(usagePayload()) },
|
||||
);
|
||||
if (!report) throw new Error("expected report");
|
||||
|
||||
const windows = opencodeGoRankingStrategy.findWindowLimits(report);
|
||||
expect(windows.primary?.id).toBe("rolling-5h");
|
||||
expect(windows.secondary?.id).toBe("weekly");
|
||||
|
||||
// Exhausted monthly (recoverable via the console "Use balance"
|
||||
// fallback) must not enter credential-wide exhaustion checks.
|
||||
const scoped = opencodeGoRankingStrategy.scopeLimits?.(report);
|
||||
expect(scoped?.map(limit => limit.id)).toEqual(["rolling-5h", "weekly"]);
|
||||
});
|
||||
});
|
||||
@@ -14,24 +14,24 @@ import { type } from "@oh-my-pi/omptype";
|
||||
import { usageReportSchema } from "@oh-my-pi/pi-ai";
|
||||
import { usageResponseSchema } from "@oh-my-pi/pi-ai/auth-broker/wire-schemas";
|
||||
|
||||
const DISCLAIMER = "OMP-observed spend only; OpenCode usage outside OMP is not included.";
|
||||
const PROVIDER_NOTE = "Usage data can be delayed by up to five minutes.";
|
||||
|
||||
function reportWithNotes() {
|
||||
return {
|
||||
provider: "opencode-go",
|
||||
provider: "anthropic",
|
||||
fetchedAt: Date.now(),
|
||||
limits: [
|
||||
{
|
||||
id: "rolling-5h",
|
||||
label: "5 Hour limit",
|
||||
scope: { provider: "opencode-go", windowId: "rolling-5h" },
|
||||
window: { id: "rolling-5h", label: "5 Hour", durationMs: 5 * 3_600_000 },
|
||||
amount: { used: 3, limit: 12, remaining: 9, usedFraction: 0.25, remainingFraction: 0.75, unit: "usd" },
|
||||
id: "anthropic:5h",
|
||||
label: "5 Hour",
|
||||
scope: { provider: "anthropic", windowId: "5h" },
|
||||
window: { id: "5h", label: "5 Hour", durationMs: 5 * 3_600_000 },
|
||||
amount: { usedFraction: 0.25, remainingFraction: 0.75, unit: "percent" },
|
||||
status: "ok",
|
||||
},
|
||||
],
|
||||
notes: [DISCLAIMER],
|
||||
metadata: { planType: "OpenCode Go" },
|
||||
notes: [PROVIDER_NOTE],
|
||||
metadata: { planType: "Pro" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("usage report notes wire schema", () => {
|
||||
it("usageReportSchema accepts report-level notes and preserves them", () => {
|
||||
const validated = usageReportSchema(reportWithNotes());
|
||||
expect(validated).not.toBeInstanceOf(type.errors);
|
||||
expect(validated).toHaveProperty("notes", [DISCLAIMER]);
|
||||
expect(validated).toHaveProperty("notes", [PROVIDER_NOTE]);
|
||||
});
|
||||
|
||||
it("usageResponseSchema preserves report-level notes through the broker reject gate", () => {
|
||||
@@ -52,6 +52,6 @@ describe("usage report notes wire schema", () => {
|
||||
expect(validated).toHaveProperty("reports");
|
||||
if (validated instanceof type.errors) throw new Error("expected valid response");
|
||||
const reports = validated.reports;
|
||||
expect(reports[0]).toHaveProperty("notes", [DISCLAIMER]);
|
||||
expect(reports[0]).toHaveProperty("notes", [PROVIDER_NOTE]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- `/usage`, `omp usage`, and the status line now show authoritative OpenCode Go quota from the official `GET /zen/go/v1/usage` endpoint — including usage made outside OMP — instead of dollar estimates summed from OMP-observed request costs. The status line renders all three windows (`5h` / `7d` / `mo`), and the per-turn cost recording special case for `opencode-go` sessions is gone along with the "OMP-observed spend only" disclaimer.
|
||||
|
||||
## [17.2.15] - 2026-08-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1446,10 +1446,15 @@ export class StatusLineComponent implements Component {
|
||||
};
|
||||
sevenDayTier = tier || undefined;
|
||||
}
|
||||
// Conservatively gate monthly status-line rendering to Cursor for now —
|
||||
// Copilot/OpenCode also emit monthly windows, but their multi-bucket
|
||||
// shape needs a dedicated selector before we surface `mo N%` for them.
|
||||
if (activeProvider === "cursor" && (windowId === "monthly" || windowId === "30d")) {
|
||||
// Monthly rendering is gated to providers with a single monthly
|
||||
// bucket (Cursor's priority selector picks its personal rail;
|
||||
// OpenCode Go emits exactly one). Copilot also emits monthly
|
||||
// windows, but its multi-bucket shape needs a dedicated selector
|
||||
// before we surface `mo N%` for it.
|
||||
if (
|
||||
(activeProvider === "cursor" || activeProvider === "opencode-go") &&
|
||||
(windowId === "monthly" || windowId === "30d")
|
||||
) {
|
||||
const priority = cursorMonthlyPriority(l.id);
|
||||
const shouldReplace =
|
||||
!monthly ||
|
||||
|
||||
@@ -667,8 +667,9 @@ const usageSegment: StatusLineSegment = {
|
||||
}
|
||||
if (u.monthly) {
|
||||
const pct = u.monthly.percent;
|
||||
// Cursor-only today (normalize gates monthly to provider === "cursor").
|
||||
// Cursor's web dashboard floors included-usage percents (1.88 → "1% used").
|
||||
// Cursor and OpenCode Go (normalize gates monthly to those providers).
|
||||
// Both floor used percents upstream (Cursor's dashboard shows 1.88 →
|
||||
// "1% used"; OpenCode's endpoint already emits floored integers).
|
||||
const pctText = theme.fg(pickUsageColor(pct), `${Math.floor(pct)}%`);
|
||||
const reset =
|
||||
u.monthly.resetHours !== undefined
|
||||
|
||||
@@ -2590,13 +2590,6 @@ export class AgentSession {
|
||||
this.#maintenance.skipPostTurnMaintenanceAssistantTimestamp = assistantMsg.timestamp;
|
||||
}
|
||||
await this.#recovery.onAssistantSettledSuccessfully(assistantMsg);
|
||||
if (assistantMsg.provider === "opencode-go") {
|
||||
this.#modelRegistry.authStorage.recordUsageCost(assistantMsg.provider, assistantMsg.usage.cost.total, {
|
||||
sessionId: this.#activeProviderSessionId(),
|
||||
recordedAt: assistantMsg.timestamp,
|
||||
baseUrl: this.#modelRegistry.getProviderBaseUrl?.(assistantMsg.provider),
|
||||
});
|
||||
}
|
||||
// Broker deployments: report this request's burn so the broker can
|
||||
// attribute token usage per install. No-op with a local auth store.
|
||||
this.#modelRegistry.authStorage.recordObservedUsage({
|
||||
|
||||
@@ -400,13 +400,31 @@ describe("usage status-line segment", () => {
|
||||
expect(content).not.toContain("90%");
|
||||
});
|
||||
|
||||
it("does not render monthly usage for non-Cursor providers", async () => {
|
||||
it("renders all three OpenCode Go windows including monthly", async () => {
|
||||
const now = Date.now();
|
||||
const component = makeComponent(
|
||||
[
|
||||
{
|
||||
provider: "opencode-go",
|
||||
limits: [
|
||||
{ id: "opencode-go:usd:monthly", scope: { windowId: "monthly" }, amount: { usedFraction: 0.42 } },
|
||||
{
|
||||
id: "rolling-5h",
|
||||
scope: { windowId: "5h" },
|
||||
window: { id: "5h", durationMs: 5 * 3_600_000, resetsAt: now + 90 * 60_000 },
|
||||
amount: { used: 12, usedFraction: 0.12, unit: "percent" },
|
||||
},
|
||||
{
|
||||
id: "weekly",
|
||||
scope: { windowId: "7d" },
|
||||
window: { id: "7d", durationMs: 7 * 86_400_000, resetsAt: now + 100 * 3_600_000 },
|
||||
amount: { used: 8, usedFraction: 0.08, unit: "percent" },
|
||||
},
|
||||
{
|
||||
id: "monthly",
|
||||
scope: { windowId: "monthly" },
|
||||
window: { id: "monthly", resetsAt: now + 160 * 3_600_000 },
|
||||
amount: { used: 42, usedFraction: 0.42, unit: "percent" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -417,6 +435,29 @@ describe("usage status-line segment", () => {
|
||||
await flushUsageRefresh();
|
||||
const content = stripVTControlCharacters(component.getTopBorder(200).content);
|
||||
|
||||
expect(content).toContain("5h");
|
||||
expect(content).toContain("12%");
|
||||
expect(content).toContain("7d");
|
||||
expect(content).toContain("8%");
|
||||
expect(content).toContain("mo");
|
||||
expect(content).toContain("42%");
|
||||
});
|
||||
|
||||
it("does not render monthly usage for providers outside the single-bucket gate", async () => {
|
||||
const component = makeComponent(
|
||||
[
|
||||
{
|
||||
provider: "github-copilot",
|
||||
limits: [{ id: "copilot:premium", scope: { windowId: "monthly" }, amount: { usedFraction: 0.42 } }],
|
||||
},
|
||||
],
|
||||
{ provider: "github-copilot" },
|
||||
);
|
||||
|
||||
component.refreshUsageInBackground();
|
||||
await flushUsageRefresh();
|
||||
const content = stripVTControlCharacters(component.getTopBorder(200).content);
|
||||
|
||||
expect(content).not.toContain("mo");
|
||||
expect(content).not.toContain("42%");
|
||||
});
|
||||
|
||||
@@ -422,29 +422,29 @@ describe("formatUsageBreakdown", () => {
|
||||
});
|
||||
|
||||
it("renders provider-level notes once per provider, not duplicated per account or limit", () => {
|
||||
const disclaimer = "OMP-observed spend only; OpenCode usage outside OMP is not included.";
|
||||
const providerNote = "Usage data can be delayed by up to five minutes.";
|
||||
const multiAccount = [
|
||||
makeReport(
|
||||
"opencode-go",
|
||||
"anthropic",
|
||||
"acct-a@example.test",
|
||||
[makeLimit({ id: "5 Hour", usedFraction: 0.3, durationMs: FIVE_HOURS, windowId: "5h" })],
|
||||
[disclaimer],
|
||||
[providerNote],
|
||||
),
|
||||
makeReport(
|
||||
"opencode-go",
|
||||
"anthropic",
|
||||
"acct-b@example.test",
|
||||
[makeLimit({ id: "5 Hour", usedFraction: 0.6, durationMs: FIVE_HOURS, windowId: "5h" })],
|
||||
[disclaimer],
|
||||
[providerNote],
|
||||
),
|
||||
];
|
||||
const text = stripVTControlCharacters(formatUsageBreakdown(multiAccount, [], Date.now()));
|
||||
// The disclaimer appears exactly once, not once per account or limit.
|
||||
const occurrences = text.split(disclaimer).length - 1;
|
||||
// The provider note appears exactly once, not once per account or limit.
|
||||
const occurrences = text.split(providerNote).length - 1;
|
||||
expect(occurrences).toBe(1);
|
||||
// It appears above the per-account rows, not inline with a limit line.
|
||||
const disclaimerIdx = text.indexOf(disclaimer);
|
||||
const noteIdx = text.indexOf(providerNote);
|
||||
const firstLimitIdx = text.indexOf("5 Hour");
|
||||
expect(disclaimerIdx).toBeLessThan(firstLimitIdx);
|
||||
expect(noteIdx).toBeLessThan(firstLimitIdx);
|
||||
});
|
||||
|
||||
it("renders Antigravity weekly windows in the usage breakdown", () => {
|
||||
|
||||
@@ -49,23 +49,23 @@ function report(provider: string, email: string, limits: UsageReport["limits"],
|
||||
|
||||
describe("renderUsageReports (#3268 TUI aggregate)", () => {
|
||||
it("renders provider-wide UsageReport.notes exactly once for multiple accounts", () => {
|
||||
const disclaimer = "OMP-observed spend only; OpenCode usage outside OMP is not included.";
|
||||
const providerNote = "Usage data can be delayed by up to five minutes.";
|
||||
const reports: UsageReport[] = [
|
||||
report(
|
||||
"opencode-go",
|
||||
"github-copilot",
|
||||
"acct-a@example.test",
|
||||
[limit("5 Hour limit", "rolling-5h", 5 * HOUR, 0.3)],
|
||||
[disclaimer],
|
||||
[providerNote],
|
||||
),
|
||||
report(
|
||||
"opencode-go",
|
||||
"github-copilot",
|
||||
"acct-b@example.test",
|
||||
[limit("5 Hour limit", "rolling-5h", 5 * HOUR, 0.6)],
|
||||
[disclaimer],
|
||||
[providerNote],
|
||||
),
|
||||
];
|
||||
const text = stripVTControlCharacters(renderUsageReports(reports, theme, Date.now(), 120));
|
||||
const occurrences = text.split(disclaimer).length - 1;
|
||||
const occurrences = text.split(providerNote).length - 1;
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user