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
8 changes: 8 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/ai/src/auth-broker/wire-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ const arkUsageReportSchema = type({
fetchedAt: "number",
limits: usageLimitSchema.array(),
"resetCredits?": usageResetCreditsSchema,
"notes?": "string[]",
"metadata?": { "[string]": "unknown" },
"raw?": "unknown",
});
Expand Down
8 changes: 8 additions & 0 deletions packages/ai/src/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/ai/src/usage/opencode-go.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ function buildWindowLimit(
unit: "usd",
},
status: resolveStatus(usedFraction),
notes: ["OMP-observed spend only; OpenCode usage outside OMP is not included."],
};
}

Expand All @@ -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",
Expand Down
57 changes: 57 additions & 0 deletions packages/ai/test/usage-report-notes-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion packages/coding-agent/src/cli/usage-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
type UsageReport,
type UsageUnit,
} from "@oh-my-pi/pi-ai";
import { formatDuration, formatNumber } from "@oh-my-pi/pi-utils";
import { formatDuration, formatNumber, sanitizeText } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { ModelRegistry } from "../config/model-registry";
import { discoverAuthStorage } from "../sdk";
Expand Down Expand Up @@ -450,6 +450,10 @@ 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(sanitizeText(note.replace(/[\r\n]+/g, " ").replace(/\t/g, " ")))}`);

const labelWidth = providerReports
.flatMap(report => report.limits)
Expand Down
19 changes: 15 additions & 4 deletions packages/coding-agent/src/modes/controllers/command-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type UsageReport,
} from "@oh-my-pi/pi-ai";
import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
import { formatDuration, Snowflake } from "@oh-my-pi/pi-utils";
import { formatDuration, Snowflake, sanitizeText } from "@oh-my-pi/pi-utils";
import { shouldEnableAppendOnlyContext } from "../../config/append-only-context-mode";
import { type LoadedCustomShare, loadCustomShare } from "../../export/custom-share";
import { shareSession } from "../../export/share";
Expand Down Expand Up @@ -44,7 +44,7 @@ import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../sess
import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account";
import { outputMeta } from "../../tools/output-meta";
import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils";
import { replaceTabs } from "../../tools/render-utils";
import { replaceTabs, truncateToWidth } from "../../tools/render-utils";
import { getChangelogPath, parseChangelog } from "../../utils/changelog";
import { copyToClipboard } from "../../utils/clipboard";
import { openPath } from "../../utils/open";
Expand Down Expand Up @@ -1583,6 +1583,15 @@ 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", replaceTabs(truncateToWidth(sanitizeText(providerNotes.map(n => n.replace(/[\r\n]+/g, " ")).join(" • ")), 110)))}`.trimEnd(),
);
}

const resetAccountLines: string[] = [];
for (const report of providerReports) {
const count = report.resetCredits?.availableCount ?? 0;
Expand Down Expand Up @@ -1651,9 +1660,11 @@ 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 ?? []))];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix: This is the only changed path that deduplicates per-limit notes across the TUI aggregate groups, but the added test coverage is in formatUsageBreakdown() and explicitly expects two occurrences for the same note. A regression here would still pass. Please add coverage through /usage/handleUsageCommand (or another exported TUI path) asserting one Overage requests: 5 when two reports share the same provider/window group.

if (notes.length > 0) {
lines.push(` ${uiTheme.fg("dim", notes.join(" • "))}`.trimEnd());
lines.push(
` ${uiTheme.fg("dim", replaceTabs(truncateToWidth(sanitizeText(notes.map(n => n.replace(/[\r\n]+/g, " ")).join(" • ")), 110)))}`.trimEnd(),
);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
import { sanitizeText } from "@oh-my-pi/pi-utils";
import type { OAuthAccountIdentity } from "../../session/auth-storage";
import type { SlashCommandRuntime } from "../types";
import { reportMatchesActiveAccount } from "./active-oauth-account";
Expand Down Expand Up @@ -52,6 +53,10 @@ 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(` ${sanitizeText(note.replace(/[\r\n]+/g, " ").replace(/\t/g, " "))}`);
for (const report of providerReports) {
const inUse = reportMatchesActiveAccount(report, activeAccount);
const savedResets = report.resetCredits?.availableCount ?? 0;
Expand Down Expand Up @@ -83,7 +88,10 @@ function renderUsageReports(
if (limit.window?.resetsAt && limit.window.resetsAt > nowMs) {
lines.push(` resets in ${formatDuration(limit.window.resetsAt - nowMs)}`);
}
if (limit.notes && limit.notes.length > 0) lines.push(` ${limit.notes.join(" • ")}`);
if (limit.notes && limit.notes.length > 0)
lines.push(
` ${limit.notes.map(n => sanitizeText(n.replace(/[\r\n]+/g, " ").replace(/\t/g, " "))).join(" • ")}`,
);
}
}
}
Expand Down
51 changes: 49 additions & 2 deletions packages/coding-agent/test/usage-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function makeLimit(opts: {
windowId?: string;
tier?: string;
accountId?: string;
notes?: string[];
}): UsageReport["limits"][number] {
return {
id: opts.id,
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading