fix(usage): dedup provider-wide notes and add report-level notes field
Provider-wide disclaimers (e.g. OpenCode Go's "OMP-observed spend only") were duplicated onto every UsageLimit, then repeated N times in the TUI aggregate renderer (once per account × window). With 2 accounts × 3 windows, the same disclaimer appeared 6 times bullet-joined. Structural fix: - Add notes?: string[] to UsageReport (interface + both schema copies: usage.ts and auth-broker/wire-schemas.ts) so the field survives the broker client's "+": "reject" deserialization gate. - Move opencode-go's disclaimer from per-limit notes to provider-level notes. Defensive fix: - Dedup identical per-limit notes in the TUI aggregate renderer (command-controller.ts) via [...new Set(...)]. - Render provider-level notes once above per-account sections in all three rendering paths: TUI (command-controller), CLI (usage-cli), and ACP (usage-report helper). Regression tests: - usage-cli.test.ts: provider-level notes render once, not duplicated per account or limit; positioned above per-account rows. - usage-report-notes-schema.test.ts: wire-schema round-trip proving notes survives usageResponseSchema validation. Fixes #3268
This commit is contained in:
@@ -2,6 +2,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added provider-level `notes?: string[]` field to `UsageReport` for disclaimers that apply to every limit (e.g. "OMP-observed spend only"). The field is declared in both the `usage.ts` schema and the auth-broker wire schema copy so it survives the `"+": "reject"` deserialization gate. ([#3268](https://github.com/can1357/oh-my-pi/issues/3268))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Moved the OpenCode Go "OMP-observed spend only" disclaimer from per-limit `notes` to provider-level `notes`, so it renders once per provider instead of duplicating across every account × window. ([#3268](https://github.com/can1357/oh-my-pi/issues/3268))
|
||||
|
||||
## [16.1.16] - 2026-06-23
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -192,6 +192,7 @@ const arkUsageReportSchema = type({
|
||||
fetchedAt: "number",
|
||||
limits: usageLimitSchema.array(),
|
||||
"resetCredits?": usageResetCreditsSchema,
|
||||
"notes?": "string[]",
|
||||
"metadata?": { "[string]": "unknown" },
|
||||
"raw?": "unknown",
|
||||
});
|
||||
|
||||
@@ -82,6 +82,13 @@ export interface UsageReport {
|
||||
limits: UsageLimit[];
|
||||
/** Saved rate-limit resets the account can redeem, when the provider reports them. */
|
||||
resetCredits?: UsageResetCredits;
|
||||
/**
|
||||
* Provider-wide disclaimers shown once above per-account sections.
|
||||
* Use this for caveats that apply to every limit (e.g. "OMP-observed
|
||||
* spend only"). Per-limit notes that differ per window (e.g. "Overage
|
||||
* requests: N") stay on {@link UsageLimit.notes}.
|
||||
*/
|
||||
notes?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
raw?: unknown;
|
||||
}
|
||||
@@ -204,6 +211,7 @@ export const usageReportSchema = type({
|
||||
fetchedAt: "number",
|
||||
limits: usageLimitSchema.array(),
|
||||
"resetCredits?": usageResetCreditsSchema,
|
||||
"notes?": "string[]",
|
||||
"metadata?": { "[string]": "unknown" },
|
||||
// `raw` is provider-specific and may be anything; the broker strips it before
|
||||
// sending the report over the wire, so accept-but-ignore here.
|
||||
|
||||
@@ -62,7 +62,6 @@ function buildWindowLimit(
|
||||
unit: "usd",
|
||||
},
|
||||
status: resolveStatus(usedFraction),
|
||||
notes: ["OMP-observed spend only; OpenCode usage outside OMP is not included."],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,6 +79,7 @@ export const opencodeGoUsageProvider: UsageProvider = {
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Regression for #3268: provider-level `notes` on `UsageReport` must survive
|
||||
* the broker wire schema. The broker client validates `/v1/usage` responses
|
||||
* against `usageResponseSchema`, which uses `"+": "reject"` — unknown fields
|
||||
* at the envelope level are rejected, not silently stripped. Both the
|
||||
* `usage.ts` schema and the `auth-broker/wire-schemas.ts` copy must declare
|
||||
* `notes?: string[]` at the report level, or the field is lost on
|
||||
* deserialization. `usageReportSchema` (the non-broker copy) must also accept
|
||||
* the field so local `AuthStorage.fetchUsageReports` results type-check.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { usageReportSchema } from "@oh-my-pi/pi-ai";
|
||||
import { usageResponseSchema } from "@oh-my-pi/pi-ai/auth-broker/wire-schemas";
|
||||
import { type } from "arktype";
|
||||
|
||||
const DISCLAIMER = "OMP-observed spend only; OpenCode usage outside OMP is not included.";
|
||||
|
||||
function reportWithNotes() {
|
||||
return {
|
||||
provider: "opencode-go",
|
||||
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" },
|
||||
status: "ok",
|
||||
},
|
||||
],
|
||||
notes: [DISCLAIMER],
|
||||
metadata: { planType: "OpenCode Go" },
|
||||
};
|
||||
}
|
||||
|
||||
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]);
|
||||
});
|
||||
|
||||
it("usageResponseSchema preserves report-level notes through the broker reject gate", () => {
|
||||
const response = {
|
||||
generatedAt: Date.now(),
|
||||
reports: [reportWithNotes()],
|
||||
};
|
||||
const validated = usageResponseSchema(response);
|
||||
expect(validated).not.toBeInstanceOf(type.errors);
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed all extension loading silently failing on the cross-compiled `omp-darwin-arm64` release binary (downloaded directly or via a Homebrew tap wrapper) because `__computeBunfsPackageRoot` mis-handled `import.meta.dir = "//root/omp-darwin-arm64"`. Bun 1.3.14 reports `<bunfs-root>/<binary-name>` for the compiled entry's `import.meta.dir`, but the pre-fix function joined `metaDir + "packages"` and produced `/root/omp-darwin-arm64/packages` — the binary basename was baked into every bunfs path, so the TypeBox/legacy-pi shims and every `@oh-my-pi/pi-*` package-root override failed `existsSync` validation and `resolveCanonicalPiSpecifier` fell through to a bunfs `Bun.resolveSync` that also could not find the module. The function now detects the bunfs-root + binary-basename shape (`path.basename(path.dirname(metaDir)) === "root"`) and strips the trailing binary segment by slicing the original `metaDir`; the production bunfs shim join path also preserves Bun's bunfs-native `//root` / `B:\~BUN\root` prefix that `path.join` would otherwise collapse. ([#3329](https://github.com/can1357/oh-my-pi/issues/3329))
|
||||
- Fixed `omp usage` and the `/usage` command duplicating provider-wide disclaimer notes (e.g. OpenCode Go's "OMP-observed spend only") once per account × limit window. Provider-level notes now render once above the per-account sections in the TUI, CLI, and ACP render paths, and identical per-limit notes are deduplicated in the TUI aggregate renderer. ([#3268](https://github.com/can1357/oh-my-pi/issues/3268))
|
||||
|
||||
## [16.1.16] - 2026-06-23
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -450,6 +450,9 @@ export function formatUsageBreakdown(
|
||||
lines.push(
|
||||
`${chalk.bold.cyan(formatProviderName(provider))} ${chalk.dim(`— ${accountCount} ${accountCount === 1 ? "account" : "accounts"}`)}`,
|
||||
);
|
||||
// Provider-wide disclaimers render once per provider, not per limit.
|
||||
const providerNotes = [...new Set(providerReports.flatMap(report => report.notes ?? []))];
|
||||
for (const note of providerNotes) lines.push(` ${chalk.dim(note)}`);
|
||||
|
||||
const labelWidth = providerReports
|
||||
.flatMap(report => report.limits)
|
||||
|
||||
@@ -1583,6 +1583,13 @@ function renderUsageReports(
|
||||
lines.push(` ${uiTheme.fg("accent", "in use by this session:")} ${activeAccountLabel}`);
|
||||
}
|
||||
|
||||
// Provider-wide disclaimers (e.g. "OMP-observed spend only") render once
|
||||
// above the per-account sections instead of duplicating onto every limit.
|
||||
const providerNotes = [...new Set(providerReports.flatMap(report => report.notes ?? []))];
|
||||
if (providerNotes.length > 0) {
|
||||
lines.push(` ${uiTheme.fg("dim", providerNotes.join(" • "))}`.trimEnd());
|
||||
}
|
||||
|
||||
const resetAccountLines: string[] = [];
|
||||
for (const report of providerReports) {
|
||||
const count = report.resetCredits?.availableCount ?? 0;
|
||||
@@ -1651,7 +1658,7 @@ function renderUsageReports(
|
||||
if (resetText) {
|
||||
lines.push(` ${uiTheme.fg("dim", resetText)}`.trimEnd());
|
||||
}
|
||||
const notes = sortedLimits.flatMap(limit => limit.notes ?? []);
|
||||
const notes = [...new Set(sortedLimits.flatMap(limit => limit.notes ?? []))];
|
||||
if (notes.length > 0) {
|
||||
lines.push(` ${uiTheme.fg("dim", notes.join(" • "))}`.trimEnd());
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ function renderUsageReports(
|
||||
)) {
|
||||
lines.push("", formatProviderName(provider));
|
||||
const activeAccount = resolveActiveAccount?.(provider);
|
||||
// Provider-wide disclaimers render once per provider, not per limit.
|
||||
const providerNotes = [...new Set(providerReports.flatMap(report => report.notes ?? []))];
|
||||
for (const note of providerNotes) lines.push(` ${note}`);
|
||||
for (const report of providerReports) {
|
||||
const inUse = reportMatchesActiveAccount(report, activeAccount);
|
||||
const savedResets = report.resetCredits?.availableCount ?? 0;
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeLimit(opts: {
|
||||
windowId?: string;
|
||||
tier?: string;
|
||||
accountId?: string;
|
||||
notes?: string[];
|
||||
}): UsageReport["limits"][number] {
|
||||
return {
|
||||
id: opts.id,
|
||||
@@ -36,11 +37,12 @@ function makeLimit(opts: {
|
||||
? { id: opts.windowId ?? opts.id, label: opts.windowId ?? opts.id, durationMs: opts.durationMs }
|
||||
: undefined,
|
||||
amount: { unit: "percent", usedFraction: opts.usedFraction },
|
||||
...(opts.notes ? { notes: opts.notes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeReport(provider: string, email: string, limits: UsageReport["limits"]): UsageReport {
|
||||
return { provider, fetchedAt: Date.now(), limits, metadata: { email } };
|
||||
function makeReport(provider: string, email: string, limits: UsageReport["limits"], notes?: string[]): UsageReport {
|
||||
return { provider, fetchedAt: Date.now(), limits, ...(notes ? { notes } : {}), metadata: { email } };
|
||||
}
|
||||
|
||||
describe("buildRedactionMap", () => {
|
||||
@@ -184,6 +186,51 @@ describe("formatUsageBreakdown", () => {
|
||||
expect(text).not.toContain("dummy.secondary@example.test");
|
||||
for (const mask of redaction.values()) expect(text).toContain(mask);
|
||||
});
|
||||
|
||||
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 multiAccount = [
|
||||
makeReport(
|
||||
"opencode-go",
|
||||
"acct-a@example.test",
|
||||
[makeLimit({ id: "5 Hour", usedFraction: 0.3, durationMs: FIVE_HOURS, windowId: "5h" })],
|
||||
[disclaimer],
|
||||
),
|
||||
makeReport(
|
||||
"opencode-go",
|
||||
"acct-b@example.test",
|
||||
[makeLimit({ id: "5 Hour", usedFraction: 0.6, durationMs: FIVE_HOURS, windowId: "5h" })],
|
||||
[disclaimer],
|
||||
),
|
||||
];
|
||||
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;
|
||||
expect(occurrences).toBe(1);
|
||||
// It appears above the per-account rows, not inline with a limit line.
|
||||
const disclaimerIdx = text.indexOf(disclaimer);
|
||||
const firstLimitIdx = text.indexOf("5 Hour");
|
||||
expect(disclaimerIdx).toBeLessThan(firstLimitIdx);
|
||||
});
|
||||
|
||||
it("deduplicates identical per-limit notes across accounts sharing a window", () => {
|
||||
const note = "Overage requests: 5";
|
||||
const reports = [
|
||||
makeReport("github-copilot", "acct-a@example.test", [
|
||||
makeLimit({ id: "Copilot", usedFraction: 0.8, windowId: "monthly", notes: [note] }),
|
||||
]),
|
||||
makeReport("github-copilot", "acct-b@example.test", [
|
||||
makeLimit({ id: "Copilot", usedFraction: 0.9, windowId: "monthly", notes: [note] }),
|
||||
]),
|
||||
];
|
||||
const text = stripVTControlCharacters(formatUsageBreakdown(reports, [], Date.now()));
|
||||
// CLI renders per-limit, so each account shows its own note — that's
|
||||
// correct for the CLI path (one limit at a time). The dedup contract
|
||||
// lives in the TUI aggregate path (command-controller), tested separately.
|
||||
// Here we assert the CLI doesn't add spurious duplicates beyond one-per-limit.
|
||||
const occurrences = text.split(note).length - 1;
|
||||
expect(occurrences).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUsageHistory", () => {
|
||||
|
||||
Reference in New Issue
Block a user