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
21 changes: 15 additions & 6 deletions docs/tco-calculator.md
Original file line number Diff line number Diff line change
Expand Up @@ -1089,25 +1089,34 @@ and the two can be collapsed into one once both are on master.
no scenario selector and no precision selector (precision stays in auto mode, the
densest measured run set). The interactivity target is a typed number in the same
row as utilization and the license fee.
- **Kimi K3 and GLM 5.2/5.3.** The model selector offers the tab's route allow-list
- **Kimi K3, GLM 5.2/5.3, and MiniMax M3.** The model selector offers the tab's route allow-list
(`MODEL_ROUTE_TAB_MODELS['profit-estimator']` and
`['profit-estimator-per-gigawatt']` in `model-routes.ts`) intersected with the
models that have an agentic run. Each bare path opens on Kimi K3
(`defaultRouteModel(tab)`); `/profit-estimator/kimi-k3` and
`/profit-estimator-per-gigawatt/kimi-k3` are the same pages, `/glm-5-3` is the
GLM 5.2/5.3 page (one data bucket, one slug, as on the rest of the site), aliases
308 to the canonical slug, and any model outside the allow-list 404s. Widening
GLM 5.2/5.3 page (one data bucket, one slug, as on the rest of the site),
`/minimax-m3` is the MiniMax M3 page, aliases 308 to the canonical slug, and any
model outside the allow-list 404s. Widening
the pages to more models is one list edit, a `profitModelDefaults` entry, and
fixture rows.
- **Per-model defaults.** `profitModelDefaults(model)` in `profit-estimator.ts`
gives each model the operating point and price source it opens on, and a model
switch re-seeds both. Kimi K3 opens on 45 tok/s/user and the OpenRouter catalog,
gives each model the operating point, price source, and model license fee it
opens on, and a model switch re-seeds all three. Kimi K3 opens on 45 tok/s/user and the OpenRouter catalog,
where Moonshot's price holds across hosts. GLM 5.2/5.3 opens on 100 tok/s/user
and Z.ai's list price ($1.40 input / $0.26 cached / $4.40 output per M tok):
third-party hosts undercut Z.ai on OpenRouter, so the catalog aggregate would
understate what the lab charges, and Z.ai's own 48 tok/s/user is below the
measured range of the priced SKUs, so 100 is the nearest round point every curve
covers. A model with a list price gets a third Token Price option, `<vendor> list
covers. MiniMax M3 opens on 83 tok/s/user, the speed MiniMax's own API serves
at, and MiniMax's standard list price for calls up to 512K input tokens ($0.30
input / $0.06 cached / $1.20 output per M tok, the permanent 50%-off rate on its
pay-as-you-go page); the OpenRouter aggregate also sits below it. At 83
tok/s/user the B200, B300, GB200, and MI355X agentic curves are priced, and the
H100, H200, MI300X, and MI325X curves top out below it and list as not priced.
MiniMax M3 also opens on a 20% model license fee; every other model opens on
the 30% `DEFAULT_LAB_CUT_PCT`. A
model with a list price gets a third Token Price option, `<vendor> list
price`, next to OpenRouter and Custom; the caption names the source in force and
links the lab's pricing page when the list price is used. Switching to Custom
seeds the three fields from whichever price is in force.
Expand Down
113 changes: 107 additions & 6 deletions packages/app/cypress/e2e/profit-estimator.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
// - defaults are Kimi K3, 45 tok/s/user, 60% utilization, 30% model license fee;
// - GLM 5.2/5.3 has its own defaults, 100 tok/s/user and the Z.ai list price
// ($1.40 / $0.26 cached / $4.40), and a model switch re-seeds both;
// - MiniMax M3 opens on 83 tok/s/user, the MiniMax list price, and a 20% license fee
// ($0.30 / $0.06 cached / $1.20);
// - utilization scales revenue only, so the revenue label moves and the
// TCO segment does not;
// - the SKU legend is the filter for which bars are drawn;
// - a loss is drawn below the zero line as a hatched segment, not as a new colour;
// - the OpenRouter catalog is the default price source and a custom triple
// (input, cached input, output) can replace it;
// - the workload is pinned to agentic traces, so there is no scenario or
// precision selector, the model selector offers Kimi K3 and GLM 5.2/5.3 only,
// and the target interactivity is a typed number, not a slider;
// precision selector, the model selector offers Kimi K3, GLM 5.2/5.3 and
// MiniMax M3 only, and the target interactivity is a typed number, not a slider;
// - the cost provider has a custom $/GPU/hr option with one input per chip;
// - the heading reads like /inference, the subtitle names the utilization, and
// the formula folds away under the chart;
Expand All @@ -21,8 +23,9 @@
import { interceptProfitData } from '../support/profit-fixtures';

// Kimi K3 is the page default; the DeepSeek row proves the page prices the
// routed model, not the first catalog entry. The GLM row sits below Z.ai's
// list price, as the real aggregate does, so the spec can tell the two apart.
// routed model, not the first catalog entry. The GLM and MiniMax rows sit below
// their labs' list prices, as the real aggregates do, so the spec can tell the
// two sources apart.
const OPENROUTER_MODELS = {
data: [
{
Expand All @@ -33,6 +36,10 @@ const OPENROUTER_MODELS = {
id: 'z-ai/glm-5.3',
pricing: { prompt: '0.00000115', completion: '0.0000035', input_cache_read: '0.0000002' },
},
{
id: 'minimax/minimax-m3',
pricing: { prompt: '0.00000023', completion: '0.00000096', input_cache_read: '0.00000005' },
},
{
id: 'deepseek/deepseek-v4-pro-0813',
pricing: { prompt: '0.00000066', completion: '0.00000198', input_cache_read: '0.000000022' },
Expand Down Expand Up @@ -118,9 +125,10 @@ describe('Profit Estimator per GW', () => {
cy.get('[data-testid="profit-precision-selector"]').should('not.exist');
cy.get('[data-testid="profit-model-selector"]').should('contain.text', 'Kimi K3').click();
cy.get('[role="option"]')
.should('have.length', 2)
.should('have.length', 3)
.and('contain.text', 'Kimi K3')
.and('contain.text', 'GLM5.2/GLM5.3');
.and('contain.text', 'GLM5.2/GLM5.3')
.and('contain.text', 'MiniMax M3');
cy.get('body').type('{esc}');
// Kimi K3 has no list price, so the selector offers the catalog and custom only.
cy.get('button#profit-price-source').click();
Expand Down Expand Up @@ -486,6 +494,99 @@ describe('Profit Estimator — GLM 5.2/5.3', () => {
});
});

describe('Profit Estimator — MiniMax M3', () => {
beforeEach(() => {
stubOpenRouter();
cy.viewport(1280, 1000);
});

it('opens /profit-estimator/minimax-m3 on 83 tok/s/user and the MiniMax list price', () => {
cy.visit('/profit-estimator/minimax-m3', { onBeforeLoad: suppressNudges });
chart().should('exist');
cy.location('pathname').should('eq', '/profit-estimator/minimax-m3');
cy.get('[data-testid="profit-target-input"]').should('have.value', '83');
cy.get('[data-testid="profit-lab-cut-input"]').should('have.value', '20');
cy.get('[data-testid="result-context-license-fee"]').should('have.text', '20%');
cy.get('[data-testid="profit-caption"] h2').should(
'contain.text',
'MiniMax M3 428B Agentic Revenue & Profit Estimates per Chip per Hour at P90 83 tok/s/user Interactivity',
);
cy.get('[data-testid="profit-selling-prices"]')
.should('contain.text', 'Input: $0.3')
.and('contain.text', 'Cached Input: $0.06')
.and('contain.text', 'Output: $1.2')
.and('contain.text', '(MiniMax list price)');
cy.get('[data-testid="profit-list-price-source"]')
.should('have.attr', 'href', 'https://platform.minimax.io/docs/guides/pricing-paygo')
.and('contain.text', 'MiniMax');
// The wide curve covers 83 tok/s/user; the H200 curve stops short of it.
chart().find('image.bar-vendor-mark').should('have.length', 4);
chart().should('not.contain.text', 'H200');
cy.get('[data-testid="profit-pricing-notice"]').should('not.exist');

// The catalog stays one click away and reads the MiniMax row, not Kimi's.
cy.get('button#profit-price-source').click();
cy.get('[role="option"]')
.should('have.length', 3)
.and('contain.text', 'OpenRouter')
.and('contain.text', 'MiniMax list price')
.and('contain.text', 'Custom $/M tok');
cy.contains('[role="option"]', 'OpenRouter').click();
cy.get('[data-testid="profit-selling-prices"]')
.should('contain.text', 'Input: $0.23')
.and('contain.text', 'Output: $0.96')
.and('contain.text', '(OpenRouter)');
cy.get('[data-testid="profit-list-price-source"]').should('not.exist');

// Custom seeds from the price in force, here the list price.
cy.get('button#profit-price-source').click();
cy.contains('[role="option"]', 'MiniMax list price').click();
cy.get('button#profit-price-source').click();
cy.contains('[role="option"]', 'Custom $/M tok').click();
cy.get('[data-testid="profit-input-price"]').should('have.value', '0.3');
cy.get('[data-testid="profit-cached-price"]').should('have.value', '0.06');
cy.get('[data-testid="profit-output-price"]').should('have.value', '1.2');
});

it('re-seeds the operating point and price source when switching from GLM', () => {
cy.visit('/profit-estimator-per-gigawatt/glm-5-3', { onBeforeLoad: suppressNudges });
chart().should('exist');
cy.get('[data-testid="profit-target-input"]').should('have.value', '100');
cy.get('[data-testid="profit-lab-cut-input"]').should('have.value', '30');

cy.get('[data-testid="profit-model-selector"]').click();
cy.contains('[role="option"]', 'MiniMax M3').click();
cy.location('pathname').should('eq', '/profit-estimator-per-gigawatt/minimax-m3');
cy.get('[data-testid="profit-caption"] h2')
.should('contain.text', 'MiniMax M3')
.and('contain.text', '83 tok/s/user');
cy.get('[data-testid="profit-target-input"]').should('have.value', '83');
cy.get('[data-testid="profit-lab-cut-input"]').should('have.value', '20');
cy.get('[data-testid="profit-selling-prices"]')
.should('contain.text', 'Input: $0.3')
.and('contain.text', '(MiniMax list price)');

cy.get('[data-testid="profit-model-selector"]').click();
cy.contains('[role="option"]', 'Kimi K3').click();
cy.location('pathname').should('eq', '/profit-estimator-per-gigawatt');
cy.get('[data-testid="profit-target-input"]').should('have.value', '45');
cy.get('[data-testid="profit-lab-cut-input"]').should('have.value', '30');
cy.get('[data-testid="profit-selling-prices"]')
.should('contain.text', 'Input: $0.6')
.and('contain.text', '(OpenRouter)');
});

it('serves the Chinese mirror with the list price named in Chinese', () => {
cy.visit('/zh/profit-estimator-per-gigawatt/minimax-m3', { onBeforeLoad: suppressNudges });
chart().should('exist');
cy.get('[data-testid="profit-target-input"]').should('have.value', '83');
cy.get('[data-testid="profit-selling-prices"]')
.should('contain.text', '输入:$0.3')
.and('contain.text', 'MiniMax 官方定价');
cy.get('button#profit-price-source').should('contain.text', 'MiniMax 官方定价');
});
});

describe('Profit Estimator per GW — Chinese mirror', () => {
it('renders /zh/profit-estimator-per-gigawatt with translated controls and caption', () => {
stubOpenRouter();
Expand Down
8 changes: 5 additions & 3 deletions packages/app/cypress/support/profit-fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Synthetic agentic rows for the `/profit-estimator` e2e spec, one identical
* SKU set per model the estimator serves (Kimi K3 and GLM 5.2/5.3).
* SKU set per model the estimator serves (Kimi K3, GLM 5.2/5.3, MiniMax M3).
*
* The captured API fixtures carry no `agentic_traces` rows, and the estimator
* is pinned to that workload, so the spec intercepts availability and
Expand All @@ -10,17 +10,19 @@
*
* The H200 curve tops out below the 45 tok/s/user default on purpose: the page
* must list it under "Not priced" rather than extrapolate a bar for it. The
* wide curve reaches 130 tok/s/user so GLM's 100 tok/s/user default is a read,
* not a clamp, on every other SKU.
* wide curve reaches 130 tok/s/user so GLM's 100 and MiniMax M3's 83 tok/s/user
* defaults are reads, not clamps, on every other SKU.
*/
import { metricsFor } from './overlay-fixtures';

export const PROFIT_MODEL_DB_KEY = 'kimik3';
export const PROFIT_GLM_DB_KEY = 'glm5.2';
export const PROFIT_MINIMAX_DB_KEY = 'minimaxm3';
/** `?model=` display key the benchmarks request carries → DB key of its rows. */
const PROFIT_DB_KEY_BY_DISPLAY_MODEL: Record<string, string> = {
'Kimi-K3': PROFIT_MODEL_DB_KEY,
'GLM-5.2': PROFIT_GLM_DB_KEY,
'MiniMax-M3': PROFIT_MINIMAX_DB_KEY,
};
const PROFIT_DB_KEYS = Object.values(PROFIT_DB_KEY_BY_DISPLAY_MODEL);
export const PROFIT_DATE = '2026-08-31';
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/app/sitemap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ describe('sitemap locale parity', () => {
for (const route of MODEL_ROUTES) {
// The default model's page canonicalizes to the bare tab path, which
// the dashboard-route loop above already covers. Models a tab does not
// serve (the profit estimator is Kimi K3 and GLM 5.2/5.3 only) 404 and stay out.
// serve (the profit estimator is Kimi K3, GLM 5.2/5.3 and MiniMax M3
// only) 404 and stay out.
const expected = served.has(route.model) && route.model !== defaultRouteModel(tab);
const enPath = modelRoutePath(tab, route.slug);
expect(urls.has(`${SITE_URL}${enPath}`)).toBe(expected);
Expand Down
32 changes: 21 additions & 11 deletions packages/app/src/components/calculator/ProfitEstimatorDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ import { getDisplayLabel } from '@/lib/utils';

import {
clampPercent,
DEFAULT_LAB_CUT_PCT,
DEFAULT_UTILIZATION_PCT,
estimateProfitRows,
listPricingToTokenRevenuePricing,
Expand Down Expand Up @@ -411,6 +410,10 @@ export default function ProfitEstimatorDisplay({
function usePercentField(defaultValue: number, eventName: string) {
const [raw, setRaw] = useState(String(defaultValue));
const [value, setValue] = useState(defaultValue);
const reset = useCallback((next: number) => {
setRaw(String(next));
setValue(next);
}, []);
const onChange = useCallback(
(next: string) => {
setRaw(next);
Expand All @@ -426,7 +429,7 @@ function usePercentField(defaultValue: number, eventName: string) {
setRaw(String(clamped));
track(eventName, { value: clamped });
}, [raw, defaultValue, eventName]);
return { raw, value, onChange, onBlur };
return { raw, value, onChange, onBlur, reset };
}

function ProfitEstimatorInner({
Expand Down Expand Up @@ -455,8 +458,8 @@ function ProfitEstimatorInner({
const { selectedRunDate } = useGlobalFilterRun();
const { availableModels, availabilityRows } = useGlobalFilterAvailability();
// This page is agentic only: the sequence is pinned, and the model list is
// the tab's route allow-list (Kimi K3 and GLM 5.2/5.3) intersected with the models
// that have an agentic-traces run, so the selector never offers a model
// the tab's route allow-list (Kimi K3, GLM 5.2/5.3, MiniMax M3) intersected
// with the models that have an agentic-traces run, so the selector never offers a model
// that would draw an empty chart. If the intersection is still loading or
// empty, the allow-list alone is offered so the selector is never blank.
const selectedSequence = Sequence.AgenticTraces;
Expand Down Expand Up @@ -492,28 +495,35 @@ function ProfitEstimatorInner({
() => profitModelDefaults(selectedModel).interactivity,
);
const [targetRaw, setTargetRaw] = useState<string>(() => String(targetValue));
// Each model has its own operating point and price source (Kimi K3: 45
// tok/s/user on OpenRouter; GLM 5.2/5.3: 100 tok/s/user on the Z.ai list
// price), so a model switch re-seeds both. The ref keeps this to actual
// switches: re-renders with the same model leave the reader's edits alone.
const utilization = usePercentField(DEFAULT_UTILIZATION_PCT, 'profit_utilization_set');
const labCut = usePercentField(
profitModelDefaults(selectedModel).labCutPct,
'profit_lab_cut_set',
);
// Each model has its own operating point, price source, and license fee
// (Kimi K3: 45 tok/s/user on OpenRouter at 30%; GLM 5.2/5.3: 100 tok/s/user
// on the Z.ai list price at 30%; MiniMax M3: 83 tok/s/user on the MiniMax
// list price at 20%), so a model switch re-seeds all three. The ref keeps
// this to actual switches: re-renders with the same model leave the
// reader's edits alone.
const defaultsAppliedFor = useRef<Model>(selectedModel);
const resetLabCut = labCut.reset;
useEffect(() => {
if (defaultsAppliedFor.current === selectedModel) return;
defaultsAppliedFor.current = selectedModel;
const defaults = profitModelDefaults(selectedModel);
setTargetValue(defaults.interactivity);
setTargetRaw(String(defaults.interactivity));
setPriceSource(defaultPriceSource(selectedModel));
}, [selectedModel]);
resetLabCut(defaults.labCutPct);
}, [selectedModel, resetLabCut]);
const listPricing = profitModelDefaults(selectedModel).listPricing;
// A model without a list price cannot stay on 'list' (e.g. the route seeded
// one model and the allow-list swapped it); fall back to the catalog.
const effectivePriceSource: PriceSource =
priceSource === 'list' && !listPricing ? 'openrouter' : priceSource;
const [selectedPercentile, setSelectedPercentile] = useState<Percentile>(initialPercentile);
const [visibilityIntent, setVisibilityIntent] = useState<CalculatorVisibilityIntent | null>(null);
const utilization = usePercentField(DEFAULT_UTILIZATION_PCT, 'profit_utilization_set');
const labCut = usePercentField(DEFAULT_LAB_CUT_PCT, 'profit_lab_cut_set');

const { hardwareConfig, getResults, loading, error, hasData, availableHwKeys } =
useThroughputData(
Expand Down
27 changes: 25 additions & 2 deletions packages/app/src/components/calculator/profit-estimator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Model } from '@/lib/data-mappings';

import {
clampPercent,
DEFAULT_LAB_CUT_PCT,
DEFAULT_PROFIT_INTERACTIVITY,
estimateProfitRows,
estimateSkuProfit,
Expand Down Expand Up @@ -330,16 +331,18 @@ describe('parseTokenPriceInput', () => {
});

describe('profitModelDefaults', () => {
it('opens Kimi K3 on 45 tok/s/user and the OpenRouter catalog', () => {
it('opens Kimi K3 on 45 tok/s/user, the OpenRouter catalog, and a 30% license fee', () => {
expect(profitModelDefaults(Model.Kimi_K3)).toEqual({
interactivity: DEFAULT_PROFIT_INTERACTIVITY,
listPricing: null,
labCutPct: DEFAULT_LAB_CUT_PCT,
});
});

it('opens GLM 5.2/5.3 on 100 tok/s/user and the Z.ai list price', () => {
const defaults = profitModelDefaults(Model.GLM_5_2);
expect(defaults.interactivity).toBe(100);
expect(defaults.labCutPct).toBe(DEFAULT_LAB_CUT_PCT);
expect(defaults.listPricing).toEqual({
vendor: 'Z.ai',
inputPerMillion: 1.4,
Expand All @@ -349,10 +352,30 @@ describe('profitModelDefaults', () => {
});
});

it('falls back to 45 tok/s/user and OpenRouter for models without an entry', () => {
it('opens MiniMax M3 on 83 tok/s/user, the MiniMax list price, and a 20% license fee', () => {
const defaults = profitModelDefaults(Model.MiniMax_M3);
expect(defaults.interactivity).toBe(83);
expect(defaults.labCutPct).toBe(20);
expect(defaults.listPricing).toEqual({
vendor: 'MiniMax',
inputPerMillion: 0.3,
cachedInputPerMillion: 0.06,
outputPerMillion: 1.2,
sourceUrl: 'https://platform.minimax.io/docs/guides/pricing-paygo',
});
expect(listPricingToTokenRevenuePricing(defaults.listPricing!)).toEqual({
source: 'normalized',
inputPerMillion: 0.3,
cachedInputPerMillion: 0.06,
outputPerMillion: 1.2,
});
});

it('falls back to 45 tok/s/user, OpenRouter, and a 30% license fee for models without an entry', () => {
expect(profitModelDefaults(Model.DeepSeek_V4_Pro)).toEqual({
interactivity: DEFAULT_PROFIT_INTERACTIVITY,
listPricing: null,
labCutPct: DEFAULT_LAB_CUT_PCT,
});
});

Expand Down
Loading
Loading