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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
- Use expanded, collapsible Claude and Codex account sections with Add account below the rows; keep the floating bar's usage footprint when authentication expires.
- Let the tray panel be moved and resized again: drag the strip along its top to move it (the spot is remembered; double-click returns it next to the tray), resize from any edge or corner, and stop it from closing when a resize starts on its outer frame. Its remembered size is now kept in logical pixels, so it fits monitors with different scaling (a size saved by an earlier version is reset to the automatic size once).
- Add Check provider status (Settings > General, on by default as on the Mac). Provider refreshes also read the public status pages of Claude, OpenAI (Codex and OpenAI API), GitHub (Copilot), Cursor, Factory, Augment, Hugging Face and Zoom, and the Google Workspace feed (Gemini and Antigravity). Incidents show as a coloured dot in the Providers sidebar, a line in the tray panel menu and lines in the tray tooltip. In the CLI `--json` output, `status.level` keeps its values, except that a minor incident now reads `degraded` instead of `unknown`.
- Codex usage by session: each conversation in Usage & Spend shows its turn output tokens per second, first-token time, and turn duration from local rollout logs. Expand **Performance details** for P95 latency, the middle-50% throughput range, cached input share, and per-model and reasoning-effort groups; a value appears only once enough completed turns exist, and turns that failed, were interrupted, or have contradictory records are left out (upstream #4304).

## [Windows] 0.70.0 - 2026-10-03

Expand Down
19 changes: 19 additions & 0 deletions apps/desktop-tauri/src/i18n/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,25 @@ export const ALL_LOCALE_KEYS = [
"UsageSpendCustomPricingActive",
"UsageSpendDefaultPricing",
"UsageSpendHourlyActivity",
"UsageSpendPerformanceDetails",
"UsageSpendTurnOutput",
"UsageSpendFirstToken",
"UsageSpendTurnDuration",
"UsageSpendTokensPerSecond",
"UsageSpendSeconds",
"UsageSpendPerformanceCaveat",
"UsageSpendTurnOutputHelp",
"UsageSpendFirstTokenHelp",
"UsageSpendP95Duration",
"UsageSpendP95FirstToken",
"UsageSpendNeedsTwentyTurns",
"UsageSpendMiddleHalfRate",
"UsageSpendNeedsFourTurns",
"UsageSpendCachedInput",
"UsageSpendCacheUnavailable",
"UsageSpendByModelEffort",
"UsageSpendUnknownModel",
"UsageSpendUnknownEffort",
"TokenActivityTitle",
"TokenActivityInLastYear",
"TokenActivityUnavailable",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@ import ProjectConversations, {
CONVERSATION_INITIAL_ROWS,
CONVERSATION_MAX_ROWS,
} from "./ProjectConversations";
import type { CodexWorkspacesSessionUsage } from "../../../types/bridge";
import type {
CodexWorkspacesSessionUsage,
CodexWorkspacesTurnPerformance,
} from "../../../types/bridge";
import type { LocaleKey } from "../../../i18n/keys";

const t = (key: LocaleKey) => key;

function session(index: number, unknownTokens = 0): CodexWorkspacesSessionUsage {
function session(
index: number,
unknownTokens = 0,
turnPerformance: CodexWorkspacesTurnPerformance | null = null,
): CodexWorkspacesSessionUsage {
return {
id: `session-${index}`,
projectId: "project-a",
Expand All @@ -20,6 +27,27 @@ function session(index: number, unknownTokens = 0): CodexWorkspacesSessionUsage
totals: { inputTokens: 10, cachedInputTokens: 0, outputTokens: 0, totalTokens: 10 },
costEstimate: { knownUsd: 100 - index, unknownTokens },
topModel: null,
turnPerformance,
};
}

function performance(overrides: Partial<CodexWorkspacesTurnPerformance> = {}): CodexWorkspacesTurnPerformance {
return {
sampleCount: 9,
outputTokensPerSecond: 45.4,
medianDurationMilliseconds: 8100,
medianFirstTokenMilliseconds: 1200,
firstTokenSampleCount: 9,
details: {
p95DurationMilliseconds: null,
p95FirstTokenMilliseconds: null,
outputRateLowerQuartile: null,
outputRateUpperQuartile: null,
cachedInputFraction: null,
cacheSampleCount: 0,
groups: [],
},
...overrides,
};
}

Expand Down Expand Up @@ -47,4 +75,70 @@ describe("ProjectConversations", () => {
fireEvent.click(screen.getByRole("button", { name: "UsageSpendShowLess" }));
expect(screen.getAllByText(/^#\d+$/)).toHaveLength(CONVERSATION_INITIAL_ROWS);
});

it("shows no performance line for a conversation without timed turns", () => {
render(<ProjectConversations conversations={[session(1)]} t={t} />);

expect(screen.queryByText(/UsageSpendTurnOutput/)).toBeNull();
expect(screen.queryByText("UsageSpendPerformanceDetails")).toBeNull();
});

it("shows turn output, first token and duration with their upstream labels", () => {
render(<ProjectConversations conversations={[session(1, 0, performance())]} t={t} />);

expect(screen.getByText(/UsageSpendTurnOutput: 45\.4 UsageSpendTokensPerSecond/)).toBeTruthy();
expect(screen.getByText(/UsageSpendFirstToken: 1\.2 UsageSpendSeconds/)).toBeTruthy();
expect(screen.getByText(/UsageSpendTurnDuration: 8\.1 UsageSpendSeconds/)).toBeTruthy();
expect(screen.getByTitle("UsageSpendTurnOutputHelp")).toBeTruthy();
expect(screen.getByText("UsageSpendPerformanceDetails")).toBeTruthy();
});

it("shows spread, cache and per-model details when the sample minimums are met", () => {
const rich = performance({
details: {
p95DurationMilliseconds: 19000,
p95FirstTokenMilliseconds: 2500,
outputRateLowerQuartile: 250,
outputRateUpperQuartile: 500,
cachedInputFraction: 0.5,
cacheSampleCount: 4,
groups: [
{
model: "gpt-5.5",
reasoningEffort: "high",
sampleCount: 5,
firstTokenSampleCount: 5,
medianFirstTokenMilliseconds: 1200,
medianDurationMilliseconds: 8100,
outputTokensPerSecond: 40,
},
{
model: null,
reasoningEffort: null,
sampleCount: 4,
firstTokenSampleCount: 0,
medianFirstTokenMilliseconds: null,
medianDurationMilliseconds: 9000,
outputTokensPerSecond: 30,
},
],
},
});
render(<ProjectConversations conversations={[session(1, 0, rich)]} t={t} />);

expect(screen.getByText(/UsageSpendP95Duration: 19\.0 UsageSpendSeconds/)).toBeTruthy();
expect(screen.getByText(/UsageSpendP95FirstToken: 2\.5 UsageSpendSeconds/)).toBeTruthy();
expect(screen.getByText(/UsageSpendMiddleHalfRate: 250\.0–500\.0 UsageSpendTokensPerSecond/)).toBeTruthy();
expect(screen.getByText("UsageSpendCachedInput: 50%")).toBeTruthy();
expect(screen.getByText(/gpt-5\.5 \/ high: 40\.0 UsageSpendTokensPerSecond/)).toBeTruthy();
expect(screen.getByText(/UsageSpendUnknownModel \/ UsageSpendUnknownEffort: 30\.0/)).toBeTruthy();
});

it("explains missing spread and cache evidence instead of showing a number", () => {
render(<ProjectConversations conversations={[session(1, 0, performance())]} t={t} />);

expect(screen.getAllByText(/UsageSpendNeedsTwentyTurns/)).toHaveLength(2);
expect(screen.getByText(/UsageSpendNeedsFourTurns/)).toBeTruthy();
expect(screen.getByText("UsageSpendCachedInput: UsageSpendCacheUnavailable")).toBeTruthy();
});
});
122 changes: 104 additions & 18 deletions apps/desktop-tauri/src/surfaces/settings/tabs/ProjectConversations.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useState } from "react";
import type { LocaleKey } from "../../../i18n/keys";
import type { CodexWorkspacesSessionUsage } from "../../../types/bridge";
import type {
CodexWorkspacesSessionUsage,
CodexWorkspacesTurnPerformance,
} from "../../../types/bridge";

/** Conversations shown before the expand control, and the most ever shown. */
export const CONVERSATION_INITIAL_ROWS = 8;
Expand All @@ -20,23 +23,25 @@ export default function ProjectConversations({ conversations, t }: ProjectConver
return (
<div style={{ display: "grid", gap: 5, marginTop: 9, paddingTop: 8, borderTop: "1px solid var(--border-subtle)" }}>
{visible.map((session, index) => (
<div
key={session.id}
style={{ display: "grid", gridTemplateColumns: "auto minmax(0, 1fr) auto", gap: 10 }}
>
<span className="settings-section__caption" style={{ minWidth: "2.5ch", textAlign: "right" }}>
#{index + 1}
</span>
<span
title={session.displayTitle}
style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{session.displayTitle}
</span>
<span>
{session.costEstimate.unknownTokens > 0 ? "~" : ""}
${session.costEstimate.knownUsd.toFixed(2)}
</span>
<div key={session.id} style={{ display: "grid", gap: 3 }}>
<div style={{ display: "grid", gridTemplateColumns: "auto minmax(0, 1fr) auto", gap: 10 }}>
<span className="settings-section__caption" style={{ minWidth: "2.5ch", textAlign: "right" }}>
#{index + 1}
</span>
<span
title={session.displayTitle}
style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{session.displayTitle}
</span>
<span>
{session.costEstimate.unknownTokens > 0 ? "~" : ""}
${session.costEstimate.knownUsd.toFixed(2)}
</span>
</div>
{session.turnPerformance && (
<TurnPerformance performance={session.turnPerformance} t={t} />
)}
</div>
))}
{capped.length > CONVERSATION_INITIAL_ROWS && (
Expand All @@ -53,3 +58,84 @@ export default function ProjectConversations({ conversations, t }: ProjectConver
</div>
);
}

function formatRate(value: number): string {
return value.toFixed(1);
}

function formatSeconds(milliseconds: number): string {
return (milliseconds / 1000).toFixed(1);
}

function formatOptionalSeconds(milliseconds: number | null, t: (key: LocaleKey) => string): string {
return milliseconds === null ? "—" : `${formatSeconds(milliseconds)} ${t("UsageSpendSeconds")}`;
}

interface TurnPerformanceProps {
performance: CodexWorkspacesTurnPerformance;
t: (key: LocaleKey) => string;
}

/** Observed Codex turn latency and throughput for one conversation (upstream #4304). */
function TurnPerformance({ performance, t }: TurnPerformanceProps) {
const unit = t("UsageSpendTokensPerSecond");
const { details } = performance;
const cached =
details.cachedInputFraction === null
? `${t("UsageSpendCachedInput")}: ${t("UsageSpendCacheUnavailable")}`
: `${t("UsageSpendCachedInput")}: ${Math.round(details.cachedInputFraction * 100)}%`;

return (
<div
className="settings-section__caption"
style={{ paddingLeft: "calc(2.5ch + 10px)", display: "grid", gap: 3 }}
>
<span title={t("UsageSpendTurnOutputHelp")}>
{t("UsageSpendTurnOutput")}: {formatRate(performance.outputTokensPerSecond)} {unit}
{" · "}
<span title={t("UsageSpendFirstTokenHelp")}>
{t("UsageSpendFirstToken")}: {formatOptionalSeconds(performance.medianFirstTokenMilliseconds, t)}
</span>
{" · "}
{t("UsageSpendTurnDuration")}: {formatOptionalSeconds(performance.medianDurationMilliseconds, t)}
</span>
<details>
<summary>{t("UsageSpendPerformanceDetails")}</summary>
<div style={{ display: "grid", gap: 3, paddingTop: 4 }}>
<span>{t("UsageSpendPerformanceCaveat")}</span>
<span>
{t("UsageSpendP95Duration")}:{" "}
{details.p95DurationMilliseconds === null
? t("UsageSpendNeedsTwentyTurns")
: formatOptionalSeconds(details.p95DurationMilliseconds, t)}
</span>
<span>
{t("UsageSpendP95FirstToken")}:{" "}
{details.p95FirstTokenMilliseconds === null
? t("UsageSpendNeedsTwentyTurns")
: formatOptionalSeconds(details.p95FirstTokenMilliseconds, t)}
</span>
<span>
{t("UsageSpendMiddleHalfRate")}:{" "}
{details.outputRateLowerQuartile === null || details.outputRateUpperQuartile === null
? t("UsageSpendNeedsFourTurns")
: `${formatRate(details.outputRateLowerQuartile)}–${formatRate(details.outputRateUpperQuartile)} ${unit}`}
</span>
<span>{cached}</span>
{details.groups.length > 0 && (
<div style={{ display: "grid", gap: 2 }}>
<span>{t("UsageSpendByModelEffort")}</span>
{details.groups.map((group) => (
<span key={`${group.model ?? ""}|${group.reasoningEffort ?? ""}`}>
{group.model ?? t("UsageSpendUnknownModel")} /{" "}
{group.reasoningEffort ?? t("UsageSpendUnknownEffort")}: {formatRate(group.outputTokensPerSecond)}{" "}
{unit}
</span>
))}
</div>
)}
</div>
</details>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -453,5 +453,6 @@ function chatSession(id: string, projectId: string, displayTitle: string): Codex
totals: { inputTokens: 10, cachedInputTokens: 0, outputTokens: 5, totalTokens: 15 },
costEstimate: { knownUsd: 0.5, unknownTokens: 0 },
topModel: "gpt-5",
turnPerformance: null,
};
}
32 changes: 32 additions & 0 deletions apps/desktop-tauri/src/types/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,37 @@ export interface CodexWorkspacesDailyPoint {
estimatedCostUsd: number | null;
}

/** Turns that share one model and reasoning effort (upstream #4304). */
export interface CodexWorkspacesTurnPerformanceGroup {
model: string | null;
reasoningEffort: string | null;
sampleCount: number;
firstTokenSampleCount: number;
medianFirstTokenMilliseconds: number | null;
medianDurationMilliseconds: number;
outputTokensPerSecond: number;
}

export interface CodexWorkspacesTurnPerformanceDetails {
p95DurationMilliseconds: number | null;
p95FirstTokenMilliseconds: number | null;
outputRateLowerQuartile: number | null;
outputRateUpperQuartile: number | null;
cachedInputFraction: number | null;
cacheSampleCount: number;
groups: CodexWorkspacesTurnPerformanceGroup[];
}

/** Native Codex turn latency and throughput for one conversation. */
export interface CodexWorkspacesTurnPerformance {
sampleCount: number;
outputTokensPerSecond: number;
medianDurationMilliseconds: number;
medianFirstTokenMilliseconds: number | null;
firstTokenSampleCount: number;
details: CodexWorkspacesTurnPerformanceDetails;
}

export interface CodexWorkspacesSessionUsage {
id: string;
projectId: string;
Expand All @@ -720,6 +751,7 @@ export interface CodexWorkspacesSessionUsage {
totals: CodexWorkspacesUsageTotals;
costEstimate: CodexWorkspacesCostEstimate;
topModel: string | null;
turnPerformance: CodexWorkspacesTurnPerformance | null;
}

export interface CodexWorkspacesProjectUsage {
Expand Down
2 changes: 1 addition & 1 deletion rust/src/codex_costs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! local-scan aggregation and pricing logic.

mod host_costs;
mod identified_requests;
pub(crate) mod identified_requests;
mod quota_windows;
mod summary_contract;

Expand Down
Loading