From 80e05888bac5263ecf7bbf85c17aa7c98e08360f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 12:49:29 -0700 Subject: [PATCH 1/7] Hide dollar cost estimates for Codex ChatGPT subscription sessions Codex OAuth bills against ChatGPT Plus/Pro, not public per-token rates. Showing those rates as a dollar charge was misleading. Match the coding-plan hide path: suppress $ on the Codex base URL, keep context usage, and let /cost name the reason. --- CHANGELOG.md | 4 +++ src/cost/cost-summary.test.ts | 32 ++++++++++++++++++ src/cost/cost-summary.ts | 1 + src/cost/cost-visibility.test.ts | 56 +++++++++++++++++++++++++++++++- src/cost/cost-visibility.ts | 33 ++++++++++++++++--- 5 files changed, 121 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d62c37c7..9ec7b464a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ### Fixed +- Codex ChatGPT subscription sessions no longer show a public-rate dollar + cost estimate. Context usage and `/cost` still work; `/cost` reports the + hide reason as ChatGPT subscription. Metered OpenAI API endpoints keep + dollar estimates. - Failed sessions with an `error` string in `run.json` are valid resume candidates, not corrupt files. The default picker still shows only running and cancelled sessions; `--force` includes failed and done. A diff --git a/src/cost/cost-summary.test.ts b/src/cost/cost-summary.test.ts index aec9d962d..5bcae978e 100644 --- a/src/cost/cost-summary.test.ts +++ b/src/cost/cost-summary.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from "bun:test"; +import { CODEX_BASE_URL } from "../auth/codex/constants.js"; import { setModelContextWindows } from "../provider/context-window.js"; import { buildCostSummary, @@ -54,6 +55,15 @@ describe("buildCostSummary", () => { expect(summary.costHiddenReason).toBe("coding-plan"); }); + it("hides cost for a Codex ChatGPT subscription base URL", () => { + const summary = buildCostSummary({ + ...baseInput, + modelId: "gpt-5.6-luna", + baseURL: CODEX_BASE_URL, + }); + expect(summary.costHiddenReason).toBe("chatgpt-subscription"); + }); + it("hides cost for a provider marked free", () => { const summary = buildCostSummary({ ...baseInput, providerFree: true }); expect(summary.costHiddenReason).toBe("provider-free"); @@ -102,6 +112,19 @@ describe("formatStatusBarSegments", () => { }); }); + it("omits dollar cost for a Codex ChatGPT subscription session", () => { + const summary = buildCostSummary({ + ...baseInput, + modelId: "gpt-5.6-luna", + baseURL: CODEX_BASE_URL, + totalCost: 1.1897, + formattedCost: "$1.1897", + }); + const segments = formatStatusBarSegments(summary); + expect(segments.costLabel).toBeUndefined(); + expect(segments.contextLabel).toMatch(/^Ctx /); + }); + it("renders an unknown context window as --% rather than 0%", () => { setModelContextWindows({ "test-model": 0 }); const summary = buildCostSummary(baseInput); @@ -142,6 +165,15 @@ describe("formatCostCommandOutput", () => { expect(formatCostCommandOutput(summary)).toContain("Cost: hidden (coding-plan endpoint)"); }); + it("reports the reason cost is hidden for a ChatGPT subscription endpoint", () => { + const summary = buildCostSummary({ + ...baseInput, + modelId: "gpt-5.6-luna", + baseURL: CODEX_BASE_URL, + }); + expect(formatCostCommandOutput(summary)).toContain("Cost: hidden (ChatGPT subscription)"); + }); + it("reports the reason cost is hidden for a provider marked free", () => { const summary = buildCostSummary({ ...baseInput, providerFree: true }); expect(formatCostCommandOutput(summary)).toContain("Cost: hidden (provider marked free)"); diff --git a/src/cost/cost-summary.ts b/src/cost/cost-summary.ts index ae8d54bd5..66132d4af 100644 --- a/src/cost/cost-summary.ts +++ b/src/cost/cost-summary.ts @@ -93,6 +93,7 @@ export function formatStatusBarSegments(summary: CostSummary): StatusBarCostSegm const HIDDEN_REASON_TEXT: Record = { "provider-free": "provider marked free", "coding-plan": "coding-plan endpoint", + "chatgpt-subscription": "ChatGPT subscription", "free-model": "free model", "zero-priced": "zero-priced in the pricing registry", }; diff --git a/src/cost/cost-visibility.test.ts b/src/cost/cost-visibility.test.ts index 7d6f4e58b..975260427 100644 --- a/src/cost/cost-visibility.test.ts +++ b/src/cost/cost-visibility.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "bun:test"; -import { costHiddenReason, isCodingPlanBaseURL, isFreeModelId } from "./cost-visibility.js"; +import { CODEX_BASE_URL } from "../auth/codex/constants.js"; +import { + costHiddenReason, + isChatGPTSubscriptionBaseURL, + isCodingPlanBaseURL, + isFreeModelId, +} from "./cost-visibility.js"; import type { PricingCache } from "./pricing-fetcher.js"; const pricingCache: PricingCache = { @@ -12,6 +18,11 @@ const pricingCache: PricingCache = { cacheReadPricePerToken: 0, }, "free-model": { inputPricePerToken: 0, outputPricePerToken: 0, cacheReadPricePerToken: 0 }, + "gpt-5.6-luna": { + inputPricePerToken: 0.000001, + outputPricePerToken: 0.000008, + cacheReadPricePerToken: 0, + }, }, }; @@ -55,6 +66,29 @@ describe("isCodingPlanBaseURL", () => { }); }); +describe("isChatGPTSubscriptionBaseURL", () => { + it("detects the Codex ChatGPT subscription inference base URL", () => { + expect(isChatGPTSubscriptionBaseURL(CODEX_BASE_URL)).toBe(true); + expect(isChatGPTSubscriptionBaseURL(`${CODEX_BASE_URL}/`)).toBe(true); + expect(isChatGPTSubscriptionBaseURL(`${CODEX_BASE_URL}/codex/responses`)).toBe(true); + }); + + it("does not match the metered OpenAI platform API", () => { + expect(isChatGPTSubscriptionBaseURL("https://api.openai.com/v1")).toBe(false); + }); + + it("does not match chatgpt.com outside the backend-api path", () => { + expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/")).toBe(false); + expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/backend")).toBe(false); + }); + + it("handles undefined and malformed URLs without over-matching", () => { + expect(isChatGPTSubscriptionBaseURL(undefined)).toBe(false); + expect(isChatGPTSubscriptionBaseURL("not a url chatgpt.com/backend-api")).toBe(true); + expect(isChatGPTSubscriptionBaseURL("not a url chatgpt.com/")).toBe(false); + }); +}); + describe("costHiddenReason", () => { it("hides for a manual provider override", () => { expect(costHiddenReason({ modelId: "glm-5.1", providerFree: true, pricingCache })).toBe( @@ -72,6 +106,16 @@ describe("costHiddenReason", () => { ).toBe("coding-plan"); }); + it("hides for a Codex ChatGPT subscription base URL even when the model has public rates", () => { + expect( + costHiddenReason({ + modelId: "gpt-5.6-luna", + baseURL: CODEX_BASE_URL, + pricingCache, + }), + ).toBe("chatgpt-subscription"); + }); + it("hides for a free-named model", () => { expect(costHiddenReason({ modelId: "qwen3:free", pricingCache })).toBe("free-model"); }); @@ -90,6 +134,16 @@ describe("costHiddenReason", () => { ).toBeNull(); }); + it("shows cost for Luna on the metered OpenAI platform API", () => { + expect( + costHiddenReason({ + modelId: "gpt-5.6-luna", + baseURL: "https://api.openai.com/v1", + pricingCache, + }), + ).toBeNull(); + }); + it("shows cost for an unknown model with no signals", () => { expect(costHiddenReason({ modelId: "mystery-model", pricingCache: null })).toBeNull(); }); diff --git a/src/cost/cost-visibility.ts b/src/cost/cost-visibility.ts index 314fdef01..79dff9c78 100644 --- a/src/cost/cost-visibility.ts +++ b/src/cost/cost-visibility.ts @@ -1,3 +1,4 @@ +import { CODEX_BASE_URL } from "../auth/codex/constants.js"; import { lookupModelPricing, type PricingCache } from "./pricing-fetcher.js"; // Free-model naming conventions: OpenRouter appends ":free", some gateways use @@ -24,6 +25,27 @@ export function isCodingPlanBaseURL(baseURL: string | undefined): boolean { } } +// Codex OAuth bills against the user's ChatGPT subscription via +// chatgpt.com/backend-api. Public per-token rates for the same model ids do +// not apply there, so dollar estimates must be suppressed. Matched against +// the canonical Codex base (origin + path prefix) so api.openai.com stays +// metered and a bare chatgpt.com host does not hide costs. +const CHATGPT_SUBSCRIPTION_FALLBACK = /chatgpt\.com\/backend-api(\/|$)/i; + +export function isChatGPTSubscriptionBaseURL(baseURL: string | undefined): boolean { + if (baseURL === undefined) return false; + try { + const url = new URL(baseURL); + const codex = new URL(CODEX_BASE_URL); + if (url.origin !== codex.origin) return false; + const basePath = codex.pathname.replace(/\/$/, ""); + const path = url.pathname.replace(/\/$/, "") || "/"; + return path === basePath || path.startsWith(`${basePath}/`); + } catch { + return CHATGPT_SUBSCRIPTION_FALLBACK.test(baseURL); + } +} + export function isFreeModelByPricing(cache: PricingCache | null, modelId: string): boolean { const pricing = lookupModelPricing(cache, modelId); if (pricing === null) return false; @@ -37,15 +59,18 @@ export interface CostVisibilityInput { pricingCache: PricingCache | null; } -export type CostHiddenReason = "provider-free" | "coding-plan" | "free-model" | "zero-priced"; +export type CostHiddenReason = + "provider-free" | "coding-plan" | "chatgpt-subscription" | "free-model" | "zero-priced"; // Non-null when the dollar cost should be suppressed: a manual provider -// override, a coding-plan endpoint, a free-named model, or a model the pricing -// registry reports as zero-cost. The reason is carried to the display so /cost -// can say which condition hid the figure. +// override, a coding-plan endpoint, a ChatGPT/Codex subscription endpoint, a +// free-named model, or a model the pricing registry reports as zero-cost. The +// reason is carried to the display so /cost can say which condition hid the +// figure. export function costHiddenReason(input: CostVisibilityInput): CostHiddenReason | null { if (input.providerFree === true) return "provider-free"; if (isCodingPlanBaseURL(input.baseURL)) return "coding-plan"; + if (isChatGPTSubscriptionBaseURL(input.baseURL)) return "chatgpt-subscription"; if (isFreeModelId(input.modelId)) return "free-model"; return isFreeModelByPricing(input.pricingCache, input.modelId) ? "zero-priced" : null; } From 4b343cbaacd2ff5bc87558cb99c535b2d3427550 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 14:21:11 -0700 Subject: [PATCH 2/7] Hide Codex costs from live provider identity not launch URL /model updates providerName without rewriting baseURL, so a URL-only hide was stale after API to Codex switches. Live Codex identity hides; a present non-Codex identity shows; URL match remains the no-name fallback. --- CHANGELOG.md | 12 +++++--- src/cost/cost-summary.test.ts | 30 +++++++++++++------- src/cost/cost-summary.ts | 15 ++++++---- src/cost/cost-visibility.test.ts | 47 ++++++++++++++++++++++++++++++-- src/cost/cost-visibility.ts | 38 ++++++++++++++++++-------- src/tui/runner.ts | 1 + 6 files changed, 110 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3b0745f7..a3e37dc83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ## [Unreleased] +### Fixed + +- Codex ChatGPT subscription sessions no longer show a public-rate dollar + cost estimate. Hide follows the live provider identity after `/model` + switches, not the launch base URL. Context usage and `/cost` still work; + `/cost` reports the cost as covered by ChatGPT subscription. Metered + OpenAI API endpoints keep dollar estimates. + ## [0.3.11] - 2026-08-31 ### Changed @@ -37,10 +45,6 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ### Fixed -- Codex ChatGPT subscription sessions no longer show a public-rate dollar - cost estimate. Context usage and `/cost` still work; `/cost` reports the - hide reason as ChatGPT subscription. Metered OpenAI API endpoints keep - dollar estimates. - Failed sessions with an `error` string in `run.json` are valid resume candidates, not corrupt files. A truly unreadable session id prints one recovery line; parse diagnostics go to the structured log, not the diff --git a/src/cost/cost-summary.test.ts b/src/cost/cost-summary.test.ts index 5bcae978e..adf05948d 100644 --- a/src/cost/cost-summary.test.ts +++ b/src/cost/cost-summary.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { CODEX_BASE_URL } from "../auth/codex/constants.js"; import { setModelContextWindows } from "../provider/context-window.js"; import { buildCostSummary, @@ -55,11 +54,12 @@ describe("buildCostSummary", () => { expect(summary.costHiddenReason).toBe("coding-plan"); }); - it("hides cost for a Codex ChatGPT subscription base URL", () => { + it("hides cost for a Codex ChatGPT subscription identity", () => { const summary = buildCostSummary({ ...baseInput, modelId: "gpt-5.6-luna", - baseURL: CODEX_BASE_URL, + providerName: "codex/default", + baseURL: "https://api.openai.com/v1", }); expect(summary.costHiddenReason).toBe("chatgpt-subscription"); }); @@ -116,13 +116,15 @@ describe("formatStatusBarSegments", () => { const summary = buildCostSummary({ ...baseInput, modelId: "gpt-5.6-luna", - baseURL: CODEX_BASE_URL, + providerName: "codex/default", + baseURL: "https://api.openai.com/v1", totalCost: 1.1897, formattedCost: "$1.1897", }); - const segments = formatStatusBarSegments(summary); - expect(segments.costLabel).toBeUndefined(); - expect(segments.contextLabel).toMatch(/^Ctx /); + expect(formatStatusBarSegments(summary)).toEqual({ + contextLabel: "Ctx 16%", + contextPercentUsed: 16, + }); }); it("renders an unknown context window as --% rather than 0%", () => { @@ -165,13 +167,21 @@ describe("formatCostCommandOutput", () => { expect(formatCostCommandOutput(summary)).toContain("Cost: hidden (coding-plan endpoint)"); }); - it("reports the reason cost is hidden for a ChatGPT subscription endpoint", () => { + it("reports ChatGPT subscription coverage instead of a hidden dollar figure", () => { const summary = buildCostSummary({ ...baseInput, modelId: "gpt-5.6-luna", - baseURL: CODEX_BASE_URL, + providerName: "codex/default", + baseURL: "https://api.openai.com/v1", }); - expect(formatCostCommandOutput(summary)).toContain("Cost: hidden (ChatGPT subscription)"); + expect(formatCostCommandOutput(summary)).toBe( + [ + "Model: gpt-5.6-luna", + "Cost: covered by ChatGPT subscription (not billed per token)", + "Tokens: 1000 in / 500 out / 200 cache-read", + "Context: 64000/400000 (16%)", + ].join("\n"), + ); }); it("reports the reason cost is hidden for a provider marked free", () => { diff --git a/src/cost/cost-summary.ts b/src/cost/cost-summary.ts index 66132d4af..df608b7d9 100644 --- a/src/cost/cost-summary.ts +++ b/src/cost/cost-summary.ts @@ -9,6 +9,7 @@ import type { PricingCache } from "./pricing-fetcher.js"; export interface CostSummaryInput { modelId: string; baseURL?: string | undefined; + providerName?: string | undefined; providerFree?: boolean | undefined; pricingCache: PricingCache | null; totalCost: number; @@ -45,6 +46,7 @@ export function buildCostSummary(input: CostSummaryInput): CostSummary { costHiddenReason: costHiddenReason({ modelId: input.modelId, baseURL: input.baseURL, + providerName: input.providerName, providerFree: input.providerFree, pricingCache: input.pricingCache, }), @@ -90,21 +92,24 @@ export function formatStatusBarSegments(summary: CostSummary): StatusBarCostSegm }; } -const HIDDEN_REASON_TEXT: Record = { +const HIDDEN_REASON_TEXT: Record, string> = { "provider-free": "provider marked free", "coding-plan": "coding-plan endpoint", - "chatgpt-subscription": "ChatGPT subscription", "free-model": "free model", "zero-priced": "zero-priced in the pricing registry", }; export function formatCostCommandOutput(summary: CostSummary): string { const window = summary.contextWindow > 0 ? String(summary.contextWindow) : "unknown"; - const lines = [ - `Model: ${summary.modelId}`, + const costLine = summary.costHiddenReason === null ? `Cost: ${summary.formattedCost}` - : `Cost: hidden (${HIDDEN_REASON_TEXT[summary.costHiddenReason]})`, + : summary.costHiddenReason === "chatgpt-subscription" + ? "Cost: covered by ChatGPT subscription (not billed per token)" + : `Cost: hidden (${HIDDEN_REASON_TEXT[summary.costHiddenReason]})`; + const lines = [ + `Model: ${summary.modelId}`, + costLine, `Tokens: ${String(summary.inputTokens)} in / ${String(summary.outputTokens)} out / ${String(summary.cacheReadTokens)} cache-read`, `Context: ${String(summary.contextTokens)}/${window} (${formatContextPercentLabel(summary.contextPercentUsed, summary.contextIsEstimate)})`, ]; diff --git a/src/cost/cost-visibility.test.ts b/src/cost/cost-visibility.test.ts index 975260427..82e06cba7 100644 --- a/src/cost/cost-visibility.test.ts +++ b/src/cost/cost-visibility.test.ts @@ -80,12 +80,30 @@ describe("isChatGPTSubscriptionBaseURL", () => { it("does not match chatgpt.com outside the backend-api path", () => { expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/")).toBe(false); expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/backend")).toBe(false); + expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/backend-api-v2")).toBe(false); }); - it("handles undefined and malformed URLs without over-matching", () => { + it("matches the backend-api path case-insensitively", () => { + expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/BACKEND-API")).toBe(true); + expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/Backend-Api/codex/responses")).toBe( + true, + ); + }); + + it("matches query and hash via pathname, not as part of the path prefix", () => { + expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/backend-api?foo=1")).toBe(true); + expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/backend-api#section")).toBe(true); + }); + + it("does not match http against the https Codex origin", () => { + expect(isChatGPTSubscriptionBaseURL("http://chatgpt.com/backend-api")).toBe(false); + }); + + it("rejects undefined, unanchored substrings, and lookalike hosts", () => { expect(isChatGPTSubscriptionBaseURL(undefined)).toBe(false); - expect(isChatGPTSubscriptionBaseURL("not a url chatgpt.com/backend-api")).toBe(true); - expect(isChatGPTSubscriptionBaseURL("not a url chatgpt.com/")).toBe(false); + expect(isChatGPTSubscriptionBaseURL("not a url chatgpt.com/backend-api")).toBe(false); + expect(isChatGPTSubscriptionBaseURL("notchatgpt.com/backend-api")).toBe(false); + expect(isChatGPTSubscriptionBaseURL("chatgpt.com/backend-api")).toBe(true); }); }); @@ -116,6 +134,28 @@ describe("costHiddenReason", () => { ).toBe("chatgpt-subscription"); }); + it("hides on live Codex provider identity even when baseURL is still the metered API", () => { + expect( + costHiddenReason({ + modelId: "gpt-5.6-luna", + providerName: "codex/default", + baseURL: "https://api.openai.com/v1", + pricingCache, + }), + ).toBe("chatgpt-subscription"); + }); + + it("shows cost on live non-Codex identity even when baseURL is still the ChatGPT backend", () => { + expect( + costHiddenReason({ + modelId: "gpt-5.6-luna", + providerName: "openai", + baseURL: CODEX_BASE_URL, + pricingCache, + }), + ).toBeNull(); + }); + it("hides for a free-named model", () => { expect(costHiddenReason({ modelId: "qwen3:free", pricingCache })).toBe("free-model"); }); @@ -138,6 +178,7 @@ describe("costHiddenReason", () => { expect( costHiddenReason({ modelId: "gpt-5.6-luna", + providerName: "openai", baseURL: "https://api.openai.com/v1", pricingCache, }), diff --git a/src/cost/cost-visibility.ts b/src/cost/cost-visibility.ts index 79dff9c78..faa1c2ea5 100644 --- a/src/cost/cost-visibility.ts +++ b/src/cost/cost-visibility.ts @@ -1,4 +1,5 @@ import { CODEX_BASE_URL } from "../auth/codex/constants.js"; +import { isCodexProviderName } from "../config/codex-providers.js"; import { lookupModelPricing, type PricingCache } from "./pricing-fetcher.js"; // Free-model naming conventions: OpenRouter appends ":free", some gateways use @@ -30,17 +31,21 @@ export function isCodingPlanBaseURL(baseURL: string | undefined): boolean { // not apply there, so dollar estimates must be suppressed. Matched against // the canonical Codex base (origin + path prefix) so api.openai.com stays // metered and a bare chatgpt.com host does not hide costs. -const CHATGPT_SUBSCRIPTION_FALLBACK = /chatgpt\.com\/backend-api(\/|$)/i; +const CODEX_BASE = new URL(CODEX_BASE_URL); +const CODEX_ORIGIN = CODEX_BASE.origin; +const CODEX_PATH = CODEX_BASE.pathname.replace(/\/$/, "").toLowerCase(); +// Host-anchored: a scheme or start of string must precede chatgpt.com so +// notchatgpt.com/backend-api never matches. Query/hash after the path still +// count. Unparseable noise that merely contains the substring does not. +const CHATGPT_SUBSCRIPTION_FALLBACK = /(?:^|\/\/)chatgpt\.com\/backend-api(?:\/|$|\?|#)/i; export function isChatGPTSubscriptionBaseURL(baseURL: string | undefined): boolean { if (baseURL === undefined) return false; try { const url = new URL(baseURL); - const codex = new URL(CODEX_BASE_URL); - if (url.origin !== codex.origin) return false; - const basePath = codex.pathname.replace(/\/$/, ""); - const path = url.pathname.replace(/\/$/, "") || "/"; - return path === basePath || path.startsWith(`${basePath}/`); + if (url.origin !== CODEX_ORIGIN) return false; + const path = url.pathname.replace(/\/$/, "").toLowerCase() || "/"; + return path === CODEX_PATH || path.startsWith(`${CODEX_PATH}/`); } catch { return CHATGPT_SUBSCRIPTION_FALLBACK.test(baseURL); } @@ -54,6 +59,10 @@ export function isFreeModelByPricing(cache: PricingCache | null, modelId: string export interface CostVisibilityInput { baseURL?: string | undefined; + // Live /model identity. When set, it wins over a stale launch baseURL for + // the ChatGPT-subscription hide: Codex names hide even on api.openai.com, + // non-Codex names show even on CODEX_BASE_URL. Undefined falls back to URL. + providerName?: string | undefined; modelId: string; providerFree?: boolean | undefined; pricingCache: PricingCache | null; @@ -62,15 +71,22 @@ export interface CostVisibilityInput { export type CostHiddenReason = "provider-free" | "coding-plan" | "chatgpt-subscription" | "free-model" | "zero-priced"; +function isChatGPTSubscriptionSession(input: CostVisibilityInput): boolean { + if (input.providerName !== undefined) { + return isCodexProviderName(input.providerName); + } + return isChatGPTSubscriptionBaseURL(input.baseURL); +} + // Non-null when the dollar cost should be suppressed: a manual provider -// override, a coding-plan endpoint, a ChatGPT/Codex subscription endpoint, a -// free-named model, or a model the pricing registry reports as zero-cost. The -// reason is carried to the display so /cost can say which condition hid the -// figure. +// override, a coding-plan endpoint, a ChatGPT/Codex subscription (live +// provider identity, else Codex URL), a free-named model, or a model the +// pricing registry reports as zero-cost. The reason is carried to the +// display so /cost can say which condition hid the figure. export function costHiddenReason(input: CostVisibilityInput): CostHiddenReason | null { if (input.providerFree === true) return "provider-free"; if (isCodingPlanBaseURL(input.baseURL)) return "coding-plan"; - if (isChatGPTSubscriptionBaseURL(input.baseURL)) return "chatgpt-subscription"; + if (isChatGPTSubscriptionSession(input)) return "chatgpt-subscription"; if (isFreeModelId(input.modelId)) return "free-model"; return isFreeModelByPricing(input.pricingCache, input.modelId) ? "zero-priced" : null; } diff --git a/src/tui/runner.ts b/src/tui/runner.ts index bd2efa1cc..2c4a57389 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2086,6 +2086,7 @@ export async function runTUI(initialConfig: Config): Promise { const summary = buildCostSummary({ modelId: config.model, baseURL: config.baseURL, + providerName: config.providerName, pricingCache, totalCost, formattedCost: formatCost(totalCost), From 4e9df5e665c9a82d59ce3eca429f552b61ae510d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 14:41:19 -0700 Subject: [PATCH 3/7] Refresh prompt cost when the live model switches --- src/tui/runner-host.test.ts | 56 ++++++++++++++++++++++++++++++++++++- src/tui/runner-host.ts | 9 ++++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index 9b23bd310..321412773 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -6,7 +6,12 @@ import type { KeyEvent } from "@opentui/core"; import type { CostSummary } from "../cost/cost-summary.js"; import type { SubAgentSession } from "../subagent/session-store.js"; import { createHarness } from "./harness.js"; -import { acceptOverlaySelection, closeInsetOverlay, runOverlayAction } from "./shell.js"; +import { + acceptOverlaySelection, + closeInsetOverlay, + moveOverlaySelection, + runOverlayAction, +} from "./shell.js"; import { mountRunnerHost, observeSessionFromSubAgents, @@ -443,6 +448,55 @@ describe("bottom border cost run", () => { } }); + test("selecting a Codex model hides prompt $ without waiting for inference", async () => { + const harness = await createHarness({ width: 80, height: 24 }); + let provider = "xai"; + const host = await mountRunnerHost({ + title: "test", + eventEmitter: new EventEmitter(), + send: () => {}, + interrupt: () => {}, + providers: { + xai: { models: ["grok-4"] }, + "codex/abk-labs": { models: ["gpt-5.5"] }, + }, + onModelSelect: (id) => { + const sep = id.indexOf(":"); + if (sep <= 0) return; + provider = id.slice(0, sep); + }, + commands: [], + onCommand: () => {}, + chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, + subAgentSessions: () => [], + createRenderer: async () => harness.renderer, + readCostSummary: () => ({ + ...fakeCostSummary(), + costHiddenReason: provider.startsWith("codex/") ? "chatgpt-subscription" : null, + }), + showPromptCost: () => true, + }); + try { + expect(ruleOf(host.shell.promptBottomRule)).toContain("$0.42"); + + expect(host.openSurface("models")).toBe(true); + const items = host.shell.overlayItems; + const codexIndex = items.findIndex((label) => label.includes("codex/abk-labs")); + expect(codexIndex).toBeGreaterThanOrEqual(0); + moveOverlaySelection(host.shell, codexIndex); + acceptOverlaySelection(host.shell); + + expect(provider).toBe("codex/abk-labs"); + expect(ruleOf(host.shell.promptBottomRule)).not.toContain("$0.42"); + expect(host.shell.costContext?.costLabel ?? null).toBeNull(); + expect(ruleOf(host.shell.promptBottomRule)).toContain("10%"); + } finally { + host.dispose(); + harness.destroy(); + } + }); + test("session.clear paints the context meter unknown immediately", async () => { const harness = await createHarness({ width: 80, height: 24 }); const emitter = new EventEmitter(); diff --git a/src/tui/runner-host.ts b/src/tui/runner-host.ts index 2e7a6eca5..f97cc6205 100644 --- a/src/tui/runner-host.ts +++ b/src/tui/runner-host.ts @@ -104,9 +104,9 @@ export interface RunnerHostDeps { */ readonly modelLabel?: () => PromptActionBarModelLabelInput; /** - * Live cost/context source for the bottom border's meter. Read on mount and - * again after every completed inference turn, so the meter tracks usage - * without a timer of its own. + * Live cost/context source for the bottom border's meter. Read on mount, after + * every completed inference turn, and after a live model pick so hide/show + * follows the new identity without waiting for the next inference. */ readonly readCostSummary?: () => CostSummary | undefined; /** @@ -248,6 +248,9 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise const onModelSelect = (id: string): void => { deps.onModelSelect(id); if (readModelLabel) setPromptModelLabel(host.shell, readModelLabel()); + // Identity is already applied (deps.onModelSelect). Re-read so Codex + // hides $ (and a metered provider shows it) without waiting for inference. + pushCostContext(); }; const cwd = deps.cwd ?? process.cwd(); const host = await mountProductHost({ From 9825ef15344108d34e27e3016302f0159926792e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 19:12:08 -0700 Subject: [PATCH 4/7] Hide coding-plan costs from live provider identity not launch URL /model updates providerName without rewriting baseURL, so a URL-only coding-plan hide was stale after switches onto or off Z.AI. Live zai identity hides; a present non-zai identity shows; URL match remains the no-name fallback. Also pin the Codex-to-metered prompt $ refresh. --- CHANGELOG.md | 7 +++-- src/cost/cost-summary.test.ts | 19 +++++++++++++ src/cost/cost-visibility.test.ts | 31 ++++++++++++++++++++ src/cost/cost-visibility.ts | 30 ++++++++++++++----- src/tui/runner-host.test.ts | 49 ++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e37dc83..2b3d14b52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename - Codex ChatGPT subscription sessions no longer show a public-rate dollar cost estimate. Hide follows the live provider identity after `/model` - switches, not the launch base URL. Context usage and `/cost` still work; - `/cost` reports the cost as covered by ChatGPT subscription. Metered - OpenAI API endpoints keep dollar estimates. + switches, not the launch base URL. Coding-plan (Z.AI) hide uses the same + live-identity rule. Context usage and `/cost` still work; `/cost` + reports Codex cost as covered by ChatGPT subscription. Metered OpenAI + API endpoints keep dollar estimates. ## [0.3.11] - 2026-08-31 diff --git a/src/cost/cost-summary.test.ts b/src/cost/cost-summary.test.ts index adf05948d..3463328a2 100644 --- a/src/cost/cost-summary.test.ts +++ b/src/cost/cost-summary.test.ts @@ -54,6 +54,25 @@ describe("buildCostSummary", () => { expect(summary.costHiddenReason).toBe("coding-plan"); }); + it("hides cost for a live coding-plan identity even when baseURL is still metered", () => { + const summary = buildCostSummary({ + ...baseInput, + providerName: "zai", + baseURL: "https://api.openai.com/v1", + }); + expect(summary.costHiddenReason).toBe("coding-plan"); + }); + + it("shows cost for a live metered identity even when baseURL is still a coding-plan endpoint", () => { + const summary = buildCostSummary({ + ...baseInput, + modelId: "glm-5.1", + providerName: "openai", + baseURL: "https://api.z.ai/api/coding/paas/v4", + }); + expect(summary.costHiddenReason).toBeNull(); + }); + it("hides cost for a Codex ChatGPT subscription identity", () => { const summary = buildCostSummary({ ...baseInput, diff --git a/src/cost/cost-visibility.test.ts b/src/cost/cost-visibility.test.ts index 82e06cba7..f46b8053b 100644 --- a/src/cost/cost-visibility.test.ts +++ b/src/cost/cost-visibility.test.ts @@ -5,6 +5,7 @@ import { costHiddenReason, isChatGPTSubscriptionBaseURL, isCodingPlanBaseURL, + isCodingPlanProviderName, isFreeModelId, } from "./cost-visibility.js"; import type { PricingCache } from "./pricing-fetcher.js"; @@ -66,6 +67,14 @@ describe("isCodingPlanBaseURL", () => { }); }); +describe("isCodingPlanProviderName", () => { + it("matches the first-class Z.AI Coding Plan catalog id", () => { + expect(isCodingPlanProviderName("zai")).toBe(true); + expect(isCodingPlanProviderName("openai")).toBe(false); + expect(isCodingPlanProviderName("codex/default")).toBe(false); + }); +}); + describe("isChatGPTSubscriptionBaseURL", () => { it("detects the Codex ChatGPT subscription inference base URL", () => { expect(isChatGPTSubscriptionBaseURL(CODEX_BASE_URL)).toBe(true); @@ -124,6 +133,28 @@ describe("costHiddenReason", () => { ).toBe("coding-plan"); }); + it("hides on live coding-plan provider identity even when baseURL is still the metered API", () => { + expect( + costHiddenReason({ + modelId: "glm-5.1", + providerName: "zai", + baseURL: "https://api.openai.com/v1", + pricingCache, + }), + ).toBe("coding-plan"); + }); + + it("shows cost on live non-coding-plan identity even when baseURL is still a coding-plan endpoint", () => { + expect( + costHiddenReason({ + modelId: "glm-5.1", + providerName: "openai", + baseURL: "https://api.z.ai/api/coding/paas/v4", + pricingCache, + }), + ).toBeNull(); + }); + it("hides for a Codex ChatGPT subscription base URL even when the model has public rates", () => { expect( costHiddenReason({ diff --git a/src/cost/cost-visibility.ts b/src/cost/cost-visibility.ts index faa1c2ea5..9d7616c7f 100644 --- a/src/cost/cost-visibility.ts +++ b/src/cost/cost-visibility.ts @@ -26,6 +26,12 @@ export function isCodingPlanBaseURL(baseURL: string | undefined): boolean { } } +// First-class Z.AI Coding Plan catalog id. Live /model identity uses this +// name, not the launch baseURL, so a switch onto or off zai updates $ now. +export function isCodingPlanProviderName(name: string): boolean { + return name === "zai"; +} + // Codex OAuth bills against the user's ChatGPT subscription via // chatgpt.com/backend-api. Public per-token rates for the same model ids do // not apply there, so dollar estimates must be suppressed. Matched against @@ -60,8 +66,10 @@ export function isFreeModelByPricing(cache: PricingCache | null, modelId: string export interface CostVisibilityInput { baseURL?: string | undefined; // Live /model identity. When set, it wins over a stale launch baseURL for - // the ChatGPT-subscription hide: Codex names hide even on api.openai.com, - // non-Codex names show even on CODEX_BASE_URL. Undefined falls back to URL. + // ChatGPT-subscription and coding-plan hides: Codex names hide even on + // api.openai.com, zai names hide even on a metered URL; a present + // non-matching name shows even when launch URL would hide. Undefined + // falls back to URL. providerName?: string | undefined; modelId: string; providerFree?: boolean | undefined; @@ -71,6 +79,13 @@ export interface CostVisibilityInput { export type CostHiddenReason = "provider-free" | "coding-plan" | "chatgpt-subscription" | "free-model" | "zero-priced"; +function isCodingPlanSession(input: CostVisibilityInput): boolean { + if (input.providerName !== undefined) { + return isCodingPlanProviderName(input.providerName); + } + return isCodingPlanBaseURL(input.baseURL); +} + function isChatGPTSubscriptionSession(input: CostVisibilityInput): boolean { if (input.providerName !== undefined) { return isCodexProviderName(input.providerName); @@ -79,13 +94,14 @@ function isChatGPTSubscriptionSession(input: CostVisibilityInput): boolean { } // Non-null when the dollar cost should be suppressed: a manual provider -// override, a coding-plan endpoint, a ChatGPT/Codex subscription (live -// provider identity, else Codex URL), a free-named model, or a model the -// pricing registry reports as zero-cost. The reason is carried to the -// display so /cost can say which condition hid the figure. +// override, a coding-plan session (live zai identity, else /coding URL), a +// ChatGPT/Codex subscription (live provider identity, else Codex URL), a +// free-named model, or a model the pricing registry reports as zero-cost. +// The reason is carried to the display so /cost can say which condition hid +// the figure. export function costHiddenReason(input: CostVisibilityInput): CostHiddenReason | null { if (input.providerFree === true) return "provider-free"; - if (isCodingPlanBaseURL(input.baseURL)) return "coding-plan"; + if (isCodingPlanSession(input)) return "coding-plan"; if (isChatGPTSubscriptionSession(input)) return "chatgpt-subscription"; if (isFreeModelId(input.modelId)) return "free-model"; return isFreeModelByPricing(input.pricingCache, input.modelId) ? "zero-priced" : null; diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index 321412773..fe023ae32 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -497,6 +497,55 @@ describe("bottom border cost run", () => { } }); + test("selecting a metered model from Codex shows prompt $ without waiting for inference", async () => { + const harness = await createHarness({ width: 80, height: 24 }); + let provider = "codex/abk-labs"; + const host = await mountRunnerHost({ + title: "test", + eventEmitter: new EventEmitter(), + send: () => {}, + interrupt: () => {}, + providers: { + "codex/abk-labs": { models: ["gpt-5.5"] }, + xai: { models: ["grok-4"] }, + }, + onModelSelect: (id) => { + const sep = id.indexOf(":"); + if (sep <= 0) return; + provider = id.slice(0, sep); + }, + commands: [], + onCommand: () => {}, + chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, + subAgentSessions: () => [], + createRenderer: async () => harness.renderer, + readCostSummary: () => ({ + ...fakeCostSummary(), + costHiddenReason: provider.startsWith("codex/") ? "chatgpt-subscription" : null, + }), + showPromptCost: () => true, + }); + try { + expect(ruleOf(host.shell.promptBottomRule)).not.toContain("$0.42"); + + expect(host.openSurface("models")).toBe(true); + const items = host.shell.overlayItems; + const meteredIndex = items.findIndex((label) => label.includes("[xai]")); + expect(meteredIndex).toBeGreaterThanOrEqual(0); + moveOverlaySelection(host.shell, meteredIndex); + acceptOverlaySelection(host.shell); + + expect(provider).toBe("xai"); + expect(ruleOf(host.shell.promptBottomRule)).toContain("$0.42"); + expect(host.shell.costContext?.costLabel ?? null).toBe("$0.42"); + expect(ruleOf(host.shell.promptBottomRule)).toContain("10%"); + } finally { + host.dispose(); + harness.destroy(); + } + }); + test("session.clear paints the context meter unknown immediately", async () => { const harness = await createHarness({ width: 80, height: 24 }); const emitter = new EventEmitter(); From 62015d45762c6b15a9e8571f5453a2ff64212f77 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 19:44:46 -0700 Subject: [PATCH 5/7] Hide coding-plan cost for zai instance names First-class connect writes kind/slug (zai/default, zai/work). Matching only the bare catalog id skipped those names, so a coding-plan session kept showing dollars when identity was present. --- src/cost/cost-visibility.test.ts | 39 ++++++++++++++++++++++++++++++++ src/cost/cost-visibility.ts | 7 +++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/cost/cost-visibility.test.ts b/src/cost/cost-visibility.test.ts index f46b8053b..a35a58c25 100644 --- a/src/cost/cost-visibility.test.ts +++ b/src/cost/cost-visibility.test.ts @@ -73,6 +73,12 @@ describe("isCodingPlanProviderName", () => { expect(isCodingPlanProviderName("openai")).toBe(false); expect(isCodingPlanProviderName("codex/default")).toBe(false); }); + + it("matches first-class connect instance names", () => { + expect(isCodingPlanProviderName("zai/default")).toBe(true); + expect(isCodingPlanProviderName("zai/work")).toBe(true); + expect(isCodingPlanProviderName("openai/default")).toBe(false); + }); }); describe("isChatGPTSubscriptionBaseURL", () => { @@ -144,6 +150,28 @@ describe("costHiddenReason", () => { ).toBe("coding-plan"); }); + it("hides on a zai instance name even when baseURL is still the metered API", () => { + expect( + costHiddenReason({ + modelId: "glm-5.1", + providerName: "zai/default", + baseURL: "https://api.openai.com/v1", + pricingCache, + }), + ).toBe("coding-plan"); + }); + + it("hides on a zai instance name with a coding-plan URL", () => { + expect( + costHiddenReason({ + modelId: "glm-5.1", + providerName: "zai/default", + baseURL: "https://api.z.ai/api/coding/paas/v4", + pricingCache, + }), + ).toBe("coding-plan"); + }); + it("shows cost on live non-coding-plan identity even when baseURL is still a coding-plan endpoint", () => { expect( costHiddenReason({ @@ -216,6 +244,17 @@ describe("costHiddenReason", () => { ).toBeNull(); }); + it("shows cost for a metered OpenAI instance name", () => { + expect( + costHiddenReason({ + modelId: "gpt-5.6-luna", + providerName: "openai/default", + baseURL: "https://api.openai.com/v1", + pricingCache, + }), + ).toBeNull(); + }); + it("shows cost for an unknown model with no signals", () => { expect(costHiddenReason({ modelId: "mystery-model", pricingCache: null })).toBeNull(); }); diff --git a/src/cost/cost-visibility.ts b/src/cost/cost-visibility.ts index 9d7616c7f..4a87f7637 100644 --- a/src/cost/cost-visibility.ts +++ b/src/cost/cost-visibility.ts @@ -26,10 +26,11 @@ export function isCodingPlanBaseURL(baseURL: string | undefined): boolean { } } -// First-class Z.AI Coding Plan catalog id. Live /model identity uses this -// name, not the launch baseURL, so a switch onto or off zai updates $ now. +// First-class Z.AI Coding Plan catalog id, plus connect instance names +// (`zai/default`, `zai/work`). Live /model identity uses these names, not the +// launch baseURL, so a switch onto or off zai updates $ now. export function isCodingPlanProviderName(name: string): boolean { - return name === "zai"; + return name === "zai" || name.startsWith("zai/"); } // Codex OAuth bills against the user's ChatGPT subscription via From 4a27bec534543ca37a0cda30e958a89923284165 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 20:25:12 -0700 Subject: [PATCH 6/7] Price mixed-session cost per turn instead of live recast A later model switch recast the whole token sink at the live public rate, or claimed ChatGPT subscription coverage for metered turns. Price each turn at the identity in force when those tokens arrived. --- src/cost/cost-summary.test.ts | 71 ++++++++++++++++++ src/cost/cost-summary.ts | 38 ++++++++-- src/cost/session-cost.test.ts | 131 ++++++++++++++++++++++++++++++++++ src/cost/session-cost.ts | 79 ++++++++++++++++++++ src/tui/runner-host.test.ts | 1 + src/tui/runner.ts | 19 +++-- 6 files changed, 328 insertions(+), 11 deletions(-) create mode 100644 src/cost/session-cost.test.ts create mode 100644 src/cost/session-cost.ts diff --git a/src/cost/cost-summary.test.ts b/src/cost/cost-summary.test.ts index 3463328a2..f27411e19 100644 --- a/src/cost/cost-summary.test.ts +++ b/src/cost/cost-summary.test.ts @@ -146,6 +146,32 @@ describe("formatStatusBarSegments", () => { }); }); + it("omits prompt $ on a mixed session while the live identity is Codex", () => { + const summary = buildCostSummary({ + ...baseInput, + modelId: "gpt-5.6-luna", + providerName: "codex/default", + formattedCost: "$0.0070", + totalCost: 0.007, + sessionBillingMix: "mixed", + sessionHiddenReason: "chatgpt-subscription", + }); + expect(formatStatusBarSegments(summary).costLabel).toBeUndefined(); + }); + + it("shows the metered-accumulated $ on a mixed session while the live identity is metered", () => { + const summary = buildCostSummary({ + ...baseInput, + modelId: "glm-5.1", + providerName: "openai", + formattedCost: "$0.0070", + totalCost: 0.007, + sessionBillingMix: "mixed", + sessionHiddenReason: "chatgpt-subscription", + }); + expect(formatStatusBarSegments(summary).costLabel).toBe("$0.0070"); + }); + it("renders an unknown context window as --% rather than 0%", () => { setModelContextWindows({ "test-model": 0 }); const summary = buildCostSummary(baseInput); @@ -218,4 +244,49 @@ describe("formatCostCommandOutput", () => { const summary = buildCostSummary({ ...baseInput, contextIsEstimate: true }); expect(formatCostCommandOutput(summary)).toContain("(~50%)"); }); + + it("prints mixed /cost as the metered portion, not a whole-session subscription", () => { + const summary = buildCostSummary({ + ...baseInput, + modelId: "gpt-5.6-luna", + providerName: "codex/default", + formattedCost: "$0.0070", + totalCost: 0.007, + sessionBillingMix: "mixed", + sessionHiddenReason: "chatgpt-subscription", + }); + const output = formatCostCommandOutput(summary); + expect(output).toContain( + "Cost: $0.0070 (metered portion only; session mixed billed and hidden usage)", + ); + expect(output).not.toContain("covered by ChatGPT subscription"); + }); + + it("prints mixed /cost as the metered portion after switching onto a public-rate model", () => { + const summary = buildCostSummary({ + ...baseInput, + modelId: "glm-5.1", + providerName: "openai", + formattedCost: "$0.0070", + totalCost: 0.007, + sessionBillingMix: "mixed", + sessionHiddenReason: "chatgpt-subscription", + }); + expect(formatCostCommandOutput(summary)).toContain( + "Cost: $0.0070 (metered portion only; session mixed billed and hidden usage)", + ); + }); + + it("keeps hidden-only Codex /cost on the subscription copy", () => { + const summary = buildCostSummary({ + ...baseInput, + modelId: "gpt-5.6-luna", + providerName: "codex/default", + sessionBillingMix: "hidden-only", + sessionHiddenReason: "chatgpt-subscription", + }); + expect(formatCostCommandOutput(summary)).toContain( + "Cost: covered by ChatGPT subscription (not billed per token)", + ); + }); }); diff --git a/src/cost/cost-summary.ts b/src/cost/cost-summary.ts index df608b7d9..aa64984cc 100644 --- a/src/cost/cost-summary.ts +++ b/src/cost/cost-summary.ts @@ -4,6 +4,7 @@ import { contextWindowFor } from "../provider/context-window.js"; import { costHiddenReason, type CostHiddenReason } from "./cost-visibility.js"; +import type { SessionBillingMix } from "./session-cost.js"; import type { PricingCache } from "./pricing-fetcher.js"; export interface CostSummaryInput { @@ -24,10 +25,17 @@ export interface CostSummaryInput { // approximate instead of implying provider-grade precision. The caller // building this input owns the decision; nothing downstream re-derives it. contextIsEstimate: boolean; + // Session mix from the per-turn accumulator. Absent or "none" falls back to + // the live identity for /cost hide copy (fresh launch, post-/clear). + sessionBillingMix?: SessionBillingMix | undefined; + // Hidden reason of the last hidden-identity turn. Used for hidden-only /cost + // copy so a later live metered identity does not rewrite history. + sessionHiddenReason?: CostHiddenReason | null | undefined; } export type CostSummary = CostSummaryInput & { costHiddenReason: CostHiddenReason | null; + sessionBillingMix: SessionBillingMix; contextWindow: number; // Null when the model's context window is unknown (non-positive), so the // display can distinguish "unknown" from a genuine 0% usage. @@ -50,6 +58,7 @@ export function buildCostSummary(input: CostSummaryInput): CostSummary { providerFree: input.providerFree, pricingCache: input.pricingCache, }), + sessionBillingMix: input.sessionBillingMix ?? "none", contextWindow, contextPercentUsed, }; @@ -99,17 +108,32 @@ const HIDDEN_REASON_TEXT: Record