Skip to content
Merged
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
10 changes: 10 additions & 0 deletions apps/mobile/src/features/usage/UsageRouteScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ vi.mock("./UsageLimitsPooled", () => ({ UsageLimitsSection: () => null }));
vi.mock("./usageProviders", () => ({
PROVIDER_LABEL: { opencode: "OpenCode", codex: "Codex" },
useProviderColors: () => ({ opencode: "#000", codex: "#fff" }),
useUsageMixColors: () => ({
input: "#111",
cacheRead: "#222",
cacheWrite: "#333",
output: "#444",
other: "#555",
standard: "#666",
fast: "#777",
ultrafast: "#888",
}),
}));

import { UsageRouteScreen } from "./UsageRouteScreen";
Expand Down
76 changes: 75 additions & 1 deletion apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,15 @@ import { UsageLimitsSection } from "./UsageLimitsPooled";
import { ControlPillMenu } from "../../components/ControlPill";
import { SymbolView } from "../../components/AppSymbol";
import type { UsageChartMetric } from "./usageChartData";
import { PROVIDER_LABEL, useProviderColors } from "./usageProviders";
import {
costTypeSegments,
hasFasterSpeedCost,
SPEED_COST_FOOTNOTE,
speedCostSegments,
visibleCostSegments,
type CostMixSegment,
} from "./usageCostMix";
import { PROVIDER_LABEL, useProviderColors, useUsageMixColors } from "./usageProviders";

type UsageTab = "usage" | "limits";
const TAB_OPTIONS = [
Expand Down Expand Up @@ -336,6 +344,7 @@ export function UsageRouteScreen() {
/>
<ProviderSection merged={merged} metric={metric} />
<TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
<CostSection merged={merged} />
<ModelsSection merged={merged} />
</>
)}
Expand Down Expand Up @@ -597,6 +606,71 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H
);
}

function CostSection(props: { readonly merged: MergedUsage }) {
const { categoryCost, speedCost } = props.merged;
const colors = useUsageMixColors();
if (props.merged.costUsd <= 0) return null;

return (
<SettingsSection title="Cost">
<ShareBar label="By type" segments={costTypeSegments(categoryCost, colors)} />
{hasFasterSpeedCost(speedCost) ? (
<View className="border-t border-border-subtle">
<ShareBar
label="By speed"
segments={speedCostSegments(speedCost, colors)}
aside={`${formatUsd(speedCost.premium)} premium`}
footnote={SPEED_COST_FOOTNOTE}
/>
</View>
) : null}
</SettingsSection>
);
}

/** One part-to-whole cost bar with its legend. Empty segments are left out. */
function ShareBar(props: {
readonly label: string;
readonly segments: readonly CostMixSegment[];
readonly aside?: string;
readonly footnote?: string;
}) {
const visible = visibleCostSegments(props.segments);
if (visible.length === 0) return null;

return (
<View className="gap-3 p-4">
<View className="flex-row items-baseline justify-between gap-3">
<Text className="text-sm text-foreground-muted">{props.label}</Text>
{props.aside ? (
<Text className="text-sm tabular-nums text-foreground-muted">{props.aside}</Text>
) : null}
</View>
<View className="h-2 flex-row gap-0.5">
{visible.map((segment) => (
<View
key={segment.label}
className="h-full rounded-sm"
style={{ flex: segment.value, backgroundColor: segment.color }}
/>
))}
</View>
<View className="flex-row flex-wrap gap-x-4 gap-y-1.5">
{visible.map((segment) => (
<View key={segment.label} className="flex-row items-center gap-1.5">
<View className="size-2 rounded-sm" style={{ backgroundColor: segment.color }} />
<Text className="text-sm text-foreground-muted">{segment.label}</Text>
<Text className="text-sm tabular-nums text-foreground">{formatUsd(segment.value)}</Text>
</View>
))}
</View>
{props.footnote ? (
<Text className="text-xs text-foreground-muted">{props.footnote}</Text>
) : null}
</View>
);
}

function MetricCell(props: {
readonly label: string;
readonly value: string;
Expand Down
52 changes: 52 additions & 0 deletions apps/mobile/src/features/usage/usageCostMix.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vite-plus/test";

import {
costTypeSegments,
hasFasterSpeedCost,
speedCostSegments,
visibleCostSegments,
type CostMixColors,
} from "./usageCostMix";

const colors: CostMixColors = {
input: "input",
cacheRead: "cacheRead",
cacheWrite: "cacheWrite",
output: "output",
other: "other",
standard: "standard",
fast: "fast",
ultrafast: "ultrafast",
};

describe("usage cost mix", () => {
it("splits cost by type and hides sub-cent unsplit rounding", () => {
const cost = { input: 1, cacheRead: 2, cacheWrite: 0, output: 4, unsplit: 0.004 };

expect(
visibleCostSegments(costTypeSegments(cost, colors)).map(({ label, value }) => [label, value]),
).toEqual([
["Input", 1],
["Cache read", 2],
["Output", 4],
]);
expect(visibleCostSegments(costTypeSegments({ ...cost, unsplit: 5 }, colors)).at(-1)).toEqual({
label: "Other",
value: 5,
color: "other",
});
});

it("shows the speed bar only when some cost ran faster than standard", () => {
const standardOnly = { standard: 9, fast: 0, ultrafast: 0, premium: 0 };
const mixed = { standard: 9, fast: 6, ultrafast: 2, premium: 4 };

expect(hasFasterSpeedCost(standardOnly)).toBe(false);
expect(hasFasterSpeedCost(mixed)).toBe(true);
expect(speedCostSegments(mixed, colors)).toEqual([
{ label: "Standard", value: 9, color: "standard" },
{ label: "Fast", value: 6, color: "fast" },
{ label: "Ultrafast", value: 2, color: "ultrafast" },
]);
});
});
66 changes: 66 additions & 0 deletions apps/mobile/src/features/usage/usageCostMix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Shapes merged cost into the part-to-whole segments the Usage cost section
* draws: by token type, and by request speed.
*
* @module usageCostMix
*/
import type { CategoryCost, SpeedCost } from "@t3tools/shared/usageMerge";

export interface CostMixSegment {
readonly label: string;
readonly value: number;
readonly color: string;
}

export interface CostMixColors {
readonly input: string;
readonly cacheRead: string;
readonly cacheWrite: string;
readonly output: string;
readonly other: string;
readonly standard: string;
readonly fast: string;
readonly ultrafast: string;
}

export function costTypeSegments(
cost: CategoryCost,
colors: CostMixColors,
): readonly CostMixSegment[] {
return [
{ label: "Input", value: cost.input, color: colors.input },
{ label: "Cache read", value: cost.cacheRead, color: colors.cacheRead },
{ label: "Cache write", value: cost.cacheWrite, color: colors.cacheWrite },
{ label: "Output", value: cost.output, color: colors.output },
// Reported cost with no rates to split it, or from older servers. Below a
// cent it is rounding, not usage.
{ label: "Other", value: cost.unsplit >= 0.005 ? cost.unsplit : 0, color: colors.other },
];
}

export function speedCostSegments(
cost: SpeedCost,
colors: CostMixColors,
): readonly CostMixSegment[] {
return [
{ label: "Standard", value: cost.standard, color: colors.standard },
{ label: "Fast", value: cost.fast, color: colors.fast },
{ label: "Ultrafast", value: cost.ultrafast, color: colors.ultrafast },
];
}

/** Servers from before the speed split count all of their cost as standard. */
export const SPEED_COST_FOOTNOTE =
"Servers that predate speed tracking count all cost as Standard.";

/** The speed bar only appears once some cost ran faster than standard. */
export function hasFasterSpeedCost(cost: SpeedCost): boolean {
return cost.fast + cost.ultrafast > 0;
}

/** Empty segments are left out of a bar and its legend. */
export function visibleCostSegments(
segments: readonly CostMixSegment[],
): readonly CostMixSegment[] {
return segments.filter((segment) => segment.value > 0);
}
21 changes: 21 additions & 0 deletions apps/mobile/src/features/usage/usageProviders.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { UsageProviderKind } from "@t3tools/contracts";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import type { CostMixColors } from "./usageCostMix";

/**
* Series and table order. The chart stacks providers from the bottom in this
Expand Down Expand Up @@ -35,3 +36,23 @@ export function useProviderColors(): Record<UsageProviderKind, string> {
opencode: "#8b5cf6",
};
}

/**
* Neutral steps for cost and token mixes, so they never borrow a provider's
* color. Matches the web steps: oklab mixes of the codex ink into the
* background, above the 15 ΔE separation floor for adjacent segments.
*/
export function useUsageMixColors(): CostMixColors {
const { themeAppearance: scheme } = useAppearancePreferences();
const dark = scheme === "dark";
return {
input: dark ? "#737373" : "#848484",
cacheRead: dark ? "#282828" : "#c0c0c0",
cacheWrite: dark ? "#949494" : "#6d6d6d",
output: dark ? "#e6e6e6" : "#3c3c43",
other: dark ? "#494949" : "#a3a3a3",
standard: dark ? "#313131" : "#b8b8b8",
fast: dark ? "#838383" : "#797979",
ultrafast: dark ? "#e6e6e6" : "#3c3c43",
};
}
Loading
Loading