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:
oldschoola
2026-06-23 06:07:24 -07:00
parent 93d730c01d
commit 6c3f35dfef
10 changed files with 140 additions and 4 deletions
+8
View File
@@ -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",
});
+8
View File
@@ -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.
+1 -1
View File
@@ -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]);
});
});
+2
View File
@@ -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;
+49 -2
View File
@@ -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", () => {