diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6f51af4fb..728c8ddff 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Added + +- Added Synthetic (synthetic.new) usage provider: `/usage` now reports the rolling 5-hour request limit and weekly credit quota via `GET /v2/quotas`, including per-tick regeneration rates in the window labels. +- Added optional `UsageWindow.resetLabel` so rolling windows can render their countdown with an accurate verb (e.g. "tick in 12m" / "regen in 51m" instead of "resets in") — both quota windows on Synthetic regenerate incrementally rather than hard-resetting. + ### Fixed - Fixed GitHub Copilot OpenAI-compatible requests being rejected when the session's native OpenAI service tier was set to `priority` ([#5160](https://github.com/can1357/oh-my-pi/pull/5160) by [@audreyt](https://github.com/audreyt)). diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index ae36ea537..69f17b77e 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -57,6 +57,7 @@ import { listCodexResetCredits, } from "./usage/openai-codex-reset"; import { opencodeGoUsageProvider } from "./usage/opencode-go"; +import { syntheticUsageProvider } from "./usage/synthetic"; import { zaiRankingStrategy, zaiUsageProvider } from "./usage/zai"; const USAGE_RANKING_METRIC_EPSILON = 1e-9; @@ -596,6 +597,7 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [ opencodeGoUsageProvider, githubCopilotUsageProvider, cursorUsageProvider, + syntheticUsageProvider, ]; const DEFAULT_USAGE_PROVIDER_MAP = new Map( diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index e5599d876..e97ecb10a 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -39,6 +39,7 @@ export * from "./usage/ollama"; export * from "./usage/openai-codex"; export * from "./usage/openai-codex-reset"; export * from "./usage/opencode-go"; +export * from "./usage/synthetic"; export * from "./usage/zai"; export * from "./utils/anthropic-auth"; export * from "./utils/event-stream"; diff --git a/packages/ai/src/usage.ts b/packages/ai/src/usage.ts index a04979fbf..4c7cb85cf 100644 --- a/packages/ai/src/usage.ts +++ b/packages/ai/src/usage.ts @@ -20,6 +20,12 @@ export interface UsageWindow { durationMs?: number; /** Absolute reset timestamp in milliseconds since epoch. */ resetsAt?: number; + /** + * Verb rendered before the {@link resetsAt} countdown (e.g. "tick", "regen"). + * Defaults to "resets" — override for rolling windows where the timestamp is + * an incremental regeneration step rather than a full window reset. + */ + resetLabel?: string; } /** Quantitative usage data. */ @@ -189,6 +195,7 @@ export const usageWindowSchema = type({ label: "string", "durationMs?": "number", "resetsAt?": "number", + "resetLabel?": "string", }); export const usageAmountSchema = type({ diff --git a/packages/ai/src/usage/synthetic.ts b/packages/ai/src/usage/synthetic.ts new file mode 100644 index 000000000..f19e772a6 --- /dev/null +++ b/packages/ai/src/usage/synthetic.ts @@ -0,0 +1,180 @@ +import type { + UsageAmount, + UsageFetchContext, + UsageFetchParams, + UsageLimit, + UsageProvider, + UsageReport, + UsageStatus, + UsageWindow, +} from "../usage"; +import { isRecord } from "../utils"; + +const QUOTAS_URL = "https://api.synthetic.new/v2/quotas"; +const FIVE_HOUR_MS = 5 * 60 * 60 * 1000; +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +function parseDollarAmount(value: unknown): number | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.replace(/^\$/, "").trim(); + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function parseIsoMs(value: unknown): number | undefined { + if (typeof value !== "string" || !value) return undefined; + const ms = Date.parse(value); + return Number.isFinite(ms) ? ms : undefined; +} + +function buildUsageAmount(args: { + used: number | undefined; + limit: number | undefined; + remaining: number | undefined; + usedFraction: number | undefined; + unit: UsageAmount["unit"]; +}): UsageAmount { + let usedFraction = args.usedFraction; + if (usedFraction === undefined && args.used !== undefined && args.limit !== undefined && args.limit > 0) { + usedFraction = Math.min(args.used / args.limit, 1); + } + const remainingFraction = usedFraction !== undefined ? Math.max(1 - usedFraction, 0) : undefined; + return { + ...(args.used !== undefined ? { used: args.used } : {}), + ...(args.limit !== undefined ? { limit: args.limit } : {}), + ...(args.remaining !== undefined ? { remaining: args.remaining } : {}), + ...(usedFraction !== undefined ? { usedFraction } : {}), + ...(remainingFraction !== undefined ? { remainingFraction } : {}), + unit: args.unit, + }; +} + +function getUsageStatus(usedFraction: number | undefined): UsageStatus | undefined { + if (usedFraction === undefined) return undefined; + if (usedFraction >= 1) return "exhausted"; + if (usedFraction >= 0.9) return "warning"; + return "ok"; +} + +function parseRollingFiveHourLimit(raw: unknown, provider: UsageFetchParams["provider"]): UsageLimit | null { + if (!isRecord(raw)) return null; + const remaining = typeof raw.remaining === "number" ? raw.remaining : undefined; + const max = typeof raw.max === "number" ? raw.max : undefined; + const limited = raw.limited === true; + const nextTickAt = parseIsoMs(raw.nextTickAt); + const tickPercent = typeof raw.tickPercent === "number" ? raw.tickPercent : undefined; + + if (remaining === undefined && max === undefined) return null; + + const used = max !== undefined && remaining !== undefined ? max - remaining : undefined; + const regenPercent = tickPercent !== undefined ? Number((tickPercent * 100).toFixed(2)) : undefined; + const window: UsageWindow = { + id: "5h", + label: regenPercent !== undefined ? `5h Rolling · regen ${regenPercent}%/tick` : "5 Hour Rolling", + durationMs: FIVE_HOUR_MS, + ...(nextTickAt !== undefined ? { resetsAt: nextTickAt, resetLabel: "tick" } : {}), + }; + const amount = buildUsageAmount({ + used, + limit: max, + remaining, + usedFraction: undefined, + unit: "requests", + }); + const status: UsageStatus = limited ? "exhausted" : (getUsageStatus(amount.usedFraction) ?? "ok"); + return { + id: "synthetic:requests:5h", + label: "Synthetic 5h Rolling Limit", + scope: { provider, windowId: "5h", shared: true }, + window, + amount, + status, + }; +} + +function parseWeeklyTokenLimit(raw: unknown, provider: UsageFetchParams["provider"]): UsageLimit | null { + if (!isRecord(raw)) return null; + const remainingCredits = parseDollarAmount(raw.remainingCredits); + const maxCredits = parseDollarAmount(raw.maxCredits); + const percentRemaining = typeof raw.percentRemaining === "number" ? raw.percentRemaining : undefined; + const nextRegenAt = parseIsoMs(raw.nextRegenAt); + + if (remainingCredits === undefined && maxCredits === undefined) return null; + + const usedFraction = + percentRemaining !== undefined ? Math.min(Math.max(1 - percentRemaining / 100, 0), 1) : undefined; + const used = usedFraction !== undefined && maxCredits !== undefined ? usedFraction * maxCredits : undefined; + const nextRegenCredits = parseDollarAmount(raw.nextRegenCredits); + const window: UsageWindow = { + id: "7d", + label: nextRegenCredits !== undefined ? `7d Rolling · regen $${nextRegenCredits.toFixed(2)}/tick` : "Weekly", + durationMs: WEEK_MS, + ...(nextRegenAt !== undefined ? { resetsAt: nextRegenAt, resetLabel: "regen" } : {}), + }; + const amount = buildUsageAmount({ + used, + limit: maxCredits, + remaining: remainingCredits, + usedFraction, + unit: "usd", + }); + return { + id: "synthetic:usd:7d", + label: "Synthetic Weekly Credit Limit", + scope: { provider, windowId: "7d", shared: true }, + window, + amount, + status: getUsageStatus(amount.usedFraction), + }; +} + +async function fetchSyntheticUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise { + if (params.provider !== "synthetic") return null; + const credential = params.credential; + if (credential.type !== "api_key" || !credential.apiKey) return null; + + let payload: unknown = null; + try { + const response = await ctx.fetch(QUOTAS_URL, { + headers: { + Authorization: `Bearer ${credential.apiKey}`, + "Content-Type": "application/json", + }, + signal: params.signal, + }); + if (!response.ok) { + ctx.logger?.warn("Synthetic usage fetch failed", { status: response.status, statusText: response.statusText }); + return null; + } + payload = await response.json(); + } catch (error) { + ctx.logger?.warn("Synthetic usage fetch error", { error: String(error) }); + return null; + } + + if (!isRecord(payload)) return null; + + const limits: UsageLimit[] = []; + + const fiveHour = parseRollingFiveHourLimit(payload.rollingFiveHourLimit, params.provider); + if (fiveHour) limits.push(fiveHour); + + const weekly = parseWeeklyTokenLimit(payload.weeklyTokenLimit, params.provider); + if (weekly) limits.push(weekly); + + if (limits.length === 0) return null; + + return { + provider: params.provider, + fetchedAt: Date.now(), + limits, + metadata: { endpoint: QUOTAS_URL }, + raw: payload, + }; +} + +export const syntheticUsageProvider: UsageProvider = { + id: "synthetic", + fetchUsage: fetchSyntheticUsage, + supports: params => params.provider === "synthetic" && params.credential.type === "api_key", +}; diff --git a/packages/ai/test/synthetic-usage.test.ts b/packages/ai/test/synthetic-usage.test.ts new file mode 100644 index 000000000..66fdc97b1 --- /dev/null +++ b/packages/ai/test/synthetic-usage.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, mock } from "bun:test"; + +// pi-natives requires a built Rust nightly .node addon — mock it so the +// @oh-my-pi/pi-utils barrel (which re-exports procmgr/ptree) doesn't crash. +mock.module("@oh-my-pi/pi-natives", () => ({ + Process: class {}, + ProcessStatus: { Running: "running", Exited: "exited" }, + Shell: class {}, + PtySession: class {}, +})); + +import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; +import type { UsageFetchContext, UsageFetchParams } from "@oh-my-pi/pi-ai/usage"; +import { syntheticUsageProvider } from "@oh-my-pi/pi-ai/usage/synthetic"; + +const FULL_FIXTURE = { + subscription: { limit: 500, requests: 12, renewsAt: "2026-07-10T11:33:46.399Z" }, + search: { hourly: { limit: 250, requests: 0, renewsAt: "2026-07-10T07:33:46.399Z" } }, + freeToolCalls: { limit: 0, requests: 0, renewsAt: "2026-07-11T06:33:46.405Z" }, + weeklyTokenLimit: { + nextRegenAt: "2026-07-10T08:17:04.000Z", + percentRemaining: 7.615, + maxCredits: "$24.00", + remainingCredits: "$1.82", + nextRegenCredits: "$0.48", + }, + rollingFiveHourLimit: { + nextTickAt: "2026-07-10T06:46:05.000Z", + tickPercent: 0.05, + remaining: 500, + max: 500, + limited: false, + }, +}; + +function makeCredential(): UsageFetchParams["credential"] { + return { + type: "api_key", + apiKey: "synthetic-test-key", + }; +} + +function makeCtx(payload: unknown, status = 200): UsageFetchContext { + const fetch: FetchImpl = async () => { + return new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }); + }; + return { fetch }; +} + +function makeCtxThrow(): UsageFetchContext { + const fetch: FetchImpl = async () => { + throw new Error("Network error"); + }; + return { fetch }; +} + +describe("synthetic usage provider", () => { + it("happy path: full fixture returns exactly 2 limits — 5h rolling and weekly credits", async () => { + const report = await syntheticUsageProvider.fetchUsage!( + { provider: "synthetic", credential: makeCredential(), signal: undefined }, + makeCtx(FULL_FIXTURE), + ); + + expect(report).not.toBeNull(); + expect(report!.limits).toHaveLength(2); + expect(report!.limits.map(l => l.id)).toEqual(["synthetic:requests:5h", "synthetic:usd:7d"]); + expect(report!.limits.map(l => l.label)).toEqual(["Synthetic 5h Rolling Limit", "Synthetic Weekly Credit Limit"]); + expect(report!.limits.map(l => l.scope.windowId)).toEqual(["5h", "7d"]); + expect(report!.limits.map(l => l.window?.durationMs)).toEqual([5 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000]); + }); + + it("5h rolling limit: used/remaining math (500 max, 500 remaining → 0 used, status ok)", async () => { + const report = await syntheticUsageProvider.fetchUsage!( + { provider: "synthetic", credential: makeCredential(), signal: undefined }, + makeCtx(FULL_FIXTURE), + ); + + const fiveHour = report!.limits.find(l => l.id === "synthetic:requests:5h")!; + expect(fiveHour.amount.remaining).toBe(500); + expect(fiveHour.amount.limit).toBe(500); + expect(fiveHour.amount.used).toBe(0); + expect(fiveHour.amount.usedFraction).toBe(0); + expect(fiveHour.status).toBe("ok"); + }); + + it("5h rolling limit: regen rate folded into window label, tick time drives resetsAt", async () => { + const report = await syntheticUsageProvider.fetchUsage!( + { provider: "synthetic", credential: makeCredential(), signal: undefined }, + makeCtx(FULL_FIXTURE), + ); + + const fiveHour = report!.limits.find(l => l.id === "synthetic:requests:5h")!; + // Single-line rendering: no notes; tickPercent 0.05 → "5%" in the window label. + expect(fiveHour.notes).toBeUndefined(); + expect(fiveHour.window?.label).toContain("regen 5%/tick"); + // nextTickAt drives the countdown, rendered as "tick in …" (not a window reset). + expect(fiveHour.window?.resetsAt).toBe(Date.parse("2026-07-10T06:46:05.000Z")); + expect(fiveHour.window?.resetLabel).toBe("tick"); + }); + + it("weekly USD limit: parses dollar amounts, uses percentRemaining for usedFraction", async () => { + const report = await syntheticUsageProvider.fetchUsage!( + { provider: "synthetic", credential: makeCredential(), signal: undefined }, + makeCtx(FULL_FIXTURE), + ); + + const weekly = report!.limits.find(l => l.id === "synthetic:usd:7d")!; + expect(weekly.amount.limit).toBeCloseTo(24.0); + expect(weekly.amount.remaining).toBeCloseTo(1.82); + // usedFraction = 1 - 7.615/100 ≈ 0.92385 + expect(weekly.amount.usedFraction).toBeCloseTo(1 - 7.615 / 100, 5); + expect(weekly.amount.unit).toBe("usd"); + // usedFraction ~0.924 ≥ 0.9 → "warning" + expect(weekly.status).toBe("warning"); + // Weekly credits regenerate incrementally too: "regen in …" + $/tick in the label. + expect(weekly.window?.resetLabel).toBe("regen"); + expect(weekly.window?.label).toContain("regen $0.48/tick"); + expect(weekly.window?.resetsAt).toBe(Date.parse("2026-07-10T08:17:04.000Z")); + }); + + it("docs-minimal response (subscription-only) → null (no surfaceable limits)", async () => { + const minimal = { + subscription: { limit: 100, requests: 5, renewsAt: "2026-08-01T00:00:00.000Z" }, + }; + const report = await syntheticUsageProvider.fetchUsage!( + { provider: "synthetic", credential: makeCredential(), signal: undefined }, + makeCtx(minimal), + ); + // subscription is dropped; no 5h or weekly data → nothing to show + expect(report).toBeNull(); + }); + + it("limited: true on rollingFiveHourLimit → status is exhausted", async () => { + const payload = { + ...FULL_FIXTURE, + rollingFiveHourLimit: { ...FULL_FIXTURE.rollingFiveHourLimit, remaining: 0, limited: true }, + }; + const report = await syntheticUsageProvider.fetchUsage!( + { provider: "synthetic", credential: makeCredential(), signal: undefined }, + makeCtx(payload), + ); + + const fiveHour = report!.limits.find(l => l.id === "synthetic:requests:5h")!; + expect(fiveHour.status).toBe("exhausted"); + }); + + it("non-synthetic provider → returns null", async () => { + const report = await syntheticUsageProvider.fetchUsage!( + { provider: "openai", credential: makeCredential(), signal: undefined }, + makeCtx(FULL_FIXTURE), + ); + expect(report).toBeNull(); + }); + + it("non-api_key credential → returns null", async () => { + const report = await syntheticUsageProvider.fetchUsage!( + { provider: "synthetic", credential: { type: "oauth" } as UsageFetchParams["credential"], signal: undefined }, + makeCtx(FULL_FIXTURE), + ); + expect(report).toBeNull(); + }); + + it("HTTP error response → returns null", async () => { + const report = await syntheticUsageProvider.fetchUsage!( + { provider: "synthetic", credential: makeCredential(), signal: undefined }, + makeCtx({ message: "Unauthorized" }, 401), + ); + expect(report).toBeNull(); + }); + + it("network error / thrown fetch → returns null", async () => { + const report = await syntheticUsageProvider.fetchUsage!( + { provider: "synthetic", credential: makeCredential(), signal: undefined }, + makeCtxThrow(), + ); + expect(report).toBeNull(); + }); + + it("malformed JSON payload → returns null", async () => { + const fetch: FetchImpl = async () => { + return new Response("not-json{{{{", { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + const report = await syntheticUsageProvider.fetchUsage!( + { provider: "synthetic", credential: makeCredential(), signal: undefined }, + { fetch }, + ); + expect(report).toBeNull(); + }); + + it("empty object payload → returns null", async () => { + const report = await syntheticUsageProvider.fetchUsage!( + { provider: "synthetic", credential: makeCredential(), signal: undefined }, + makeCtx({}), + ); + expect(report).toBeNull(); + }); +}); diff --git a/packages/coding-agent/src/cli/usage-cli.ts b/packages/coding-agent/src/cli/usage-cli.ts index 52df8bd1e..38302f536 100644 --- a/packages/coding-agent/src/cli/usage-cli.ts +++ b/packages/coding-agent/src/cli/usage-cli.ts @@ -409,7 +409,7 @@ function formatLimitLine(limit: UsageLimit, labelWidth: number, nowMs: number): const details: string[] = [describeAmount(limit)]; const resetsAt = limit.window?.resetsAt; if (resetsAt !== undefined && resetsAt > nowMs) { - details.push(`resets in ${formatDuration(resetsAt - nowMs)}`); + details.push(`${limit.window?.resetLabel ?? "resets"} in ${formatDuration(resetsAt - nowMs)}`); } const lines = [ ` ${STATUS_COLOR[status]("●")} ${padded} ${renderBar(limit)} ${chalk.dim(details.join(" · "))}`, diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index b9a98fba9..5db5d352b 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1636,17 +1636,24 @@ function formatAggregateAmount(limits: UsageLimit[]): string { } function resolveResetRange(limits: UsageLimit[], nowMs: number): string | null { - const absolute = limits - .map(limit => limit.window?.resetsAt) - .filter((value): value is number => value !== undefined && Number.isFinite(value) && value > nowMs); - if (absolute.length === 0) return null; - const offsets = absolute.map(value => value - nowMs); + const windows = limits + .map(limit => limit.window) + .filter( + (window): window is NonNullable => + window?.resetsAt !== undefined && Number.isFinite(window.resetsAt) && window.resetsAt > nowMs, + ); + if (windows.length === 0) return null; + // Use the shared verb when every contributing window agrees (e.g. all "tick"); + // mixed or absent labels fall back to the generic "resets". + const labels = new Set(windows.map(window => window.resetLabel ?? "resets")); + const verb = labels.size === 1 ? [...labels][0]! : "resets"; + const offsets = windows.map(window => window.resetsAt! - nowMs); const minReset = Math.min(...offsets); const maxReset = Math.max(...offsets); if (maxReset - minReset > 60_000) { - return `resets in ${formatDuration(minReset)}–${formatDuration(maxReset)}`; + return `${verb} in ${formatDuration(minReset)}–${formatDuration(maxReset)}`; } - return `resets in ${formatDuration(minReset)}`; + return `${verb} in ${formatDuration(minReset)}`; } /** * Compact one-line quota summary for a single advisor's provider. 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 34794e259..88f999b4c 100644 --- a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts +++ b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts @@ -124,7 +124,9 @@ function renderUsageReports( ); lines.push(` ${renderAsciiBar(limit.amount.usedFraction)}`); if (limit.window?.resetsAt && limit.window.resetsAt > nowMs) { - lines.push(` resets in ${formatDuration(limit.window.resetsAt - nowMs)}`); + lines.push( + ` ${limit.window.resetLabel ?? "resets"} in ${formatDuration(limit.window.resetsAt - nowMs)}`, + ); } if (limit.notes && limit.notes.length > 0) lines.push(