diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 34c31852ea..e7ebcaffc4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/auth-broker/wire-schemas.ts b/packages/ai/src/auth-broker/wire-schemas.ts index c22c76174b..07c842f35c 100644 --- a/packages/ai/src/auth-broker/wire-schemas.ts +++ b/packages/ai/src/auth-broker/wire-schemas.ts @@ -192,6 +192,7 @@ const arkUsageReportSchema = type({ fetchedAt: "number", limits: usageLimitSchema.array(), "resetCredits?": usageResetCreditsSchema, + "notes?": "string[]", "metadata?": { "[string]": "unknown" }, "raw?": "unknown", }); diff --git a/packages/ai/src/usage.ts b/packages/ai/src/usage.ts index 45771607ea..c0cf013d72 100644 --- a/packages/ai/src/usage.ts +++ b/packages/ai/src/usage.ts @@ -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; 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. diff --git a/packages/ai/src/usage/opencode-go.ts b/packages/ai/src/usage/opencode-go.ts index 6d165bb010..772ff35ec0 100644 --- a/packages/ai/src/usage/opencode-go.ts +++ b/packages/ai/src/usage/opencode-go.ts @@ -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", diff --git a/packages/ai/test/usage-report-notes-schema.test.ts b/packages/ai/test/usage-report-notes-schema.test.ts new file mode 100644 index 0000000000..b31f429856 --- /dev/null +++ b/packages/ai/test/usage-report-notes-schema.test.ts @@ -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]); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 93f794f7fa..cbdfe2707b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 `/` 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 diff --git a/packages/coding-agent/src/cli/usage-cli.ts b/packages/coding-agent/src/cli/usage-cli.ts index e5b94250b3..fe8a7e65d1 100644 --- a/packages/coding-agent/src/cli/usage-cli.ts +++ b/packages/coding-agent/src/cli/usage-cli.ts @@ -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"; @@ -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) diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index e85ba82621..7f6ababbc9 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -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"; @@ -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"; @@ -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; @@ -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 ?? []))]; 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(), + ); } } diff --git a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts index c3c06f35f1..d40c893996 100644 --- a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts +++ b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts @@ -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"; @@ -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; @@ -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(" • ")}`, + ); } } } diff --git a/packages/coding-agent/test/usage-cli.test.ts b/packages/coding-agent/test/usage-cli.test.ts index c6727ff498..490f2d6880 100644 --- a/packages/coding-agent/test/usage-cli.test.ts +++ b/packages/coding-agent/test/usage-cli.test.ts @@ -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", () => {