Skip to content
Open
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
4 changes: 2 additions & 2 deletions src/server/management/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import type { OcxClaudeCodeConfig, OcxClaudeDesktopProfile, OcxConfig, OcxCustom
import type { DesktopProfileModel } from "../../claude/desktop-profile";
import { drainAndShutdown } from "../lifecycle";
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
import { estimateComboCost, estimateRequestCost, effectiveServiceTier, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
import { estimateComboCost, estimateRequestCost, serviceTierContext, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
import type { PersistedUsageAttempt } from "../../usage/log";
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
import { applySystemEnvToggle } from "../system-env";
Expand Down Expand Up @@ -124,7 +124,7 @@ export function unavailableCostReason(entry: MetricSource): MetricUnavailableRea
}

export function costResult(entry: MetricSource): CostResult {
const tier = effectiveServiceTier(entry);
const tier = serviceTierContext(entry);
const estimate = entry.attempts?.length
? estimateComboCost(entry.attempts, undefined, tier)
: estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier });
Expand Down
Binary file modified src/usage/cost.ts
Binary file not shown.
111 changes: 111 additions & 0 deletions src/usage/expected-prices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export interface ExpectedPriceOverlay {
}

const GEMINI_31_PRO: Cost4 = { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 };
const GPT56_SOL: Cost4 = { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 };
const GPT56_TERRA: Cost4 = { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 };
const GPT56_LUNA: Cost4 = { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 };
const GEMINI_36_FLASH: Cost4 = { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 };
const MINIMAX_M21_HIGHSPEED: Cost4 = { input: 0.6, output: 2.4, cacheRead: 0.03, cacheWrite: 0.375 };
const KIMI_K3: Cost4 = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3 };
Expand All @@ -50,6 +53,7 @@ const ANTHROPIC_PRICING = "https://platform.claude.com/docs/en/about-claude/pric

const GEMINI_PRICING = "https://ai.google.dev/gemini-api/docs/pricing (2026-07-22); cacheWrite=0: storage is billed per-hour, not per-token";
const MINIMAX_PRICING = "https://platform.minimax.io/docs/guides/pricing-paygo";
const OPENAI_GPT56_PRICING = "https://developers.openai.com/api/docs/pricing";
const DEEPSEEK_PRICING = "https://api-docs.deepseek.com/quick_start/pricing-details-usd; V4 Flash alias transition scheduled 2026-07-24 — re-verify after";
// Kimi official tables publish input/output/cache-hit only; cacheWrite is mapped to the
// cache-miss input price (Kimi auto-caches with no separate write billing). 2026-07-20 re-verified.
Expand Down Expand Up @@ -77,6 +81,14 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [
// base model's standard rate per the official Billing FAQ).
{ provider: "google-antigravity", modelId: "gemini-3.6-flash", cost4: GEMINI_36_FLASH, source: `collapsed base ID ${GEMINI_PRICING}`, verifiedAt: "2026-07-22", status: "verified" },
{ provider: "google-antigravity", modelId: "gemini-3.1-pro", cost4: GEMINI_31_PRO, source: `collapsed base ID ${GEMINI_PRICING}`, verifiedAt: "2026-07-22", status: "verified" },
// OpenAI GPT-5.6 `-pro` virtual selections. The virtual resolver keeps the SELECTED id in
// the usage log and records the wire model separately, and cost resolution deliberately
// does not fall back through resolvedModel — so without these rows every `-pro` request
// resolved to null and rendered no cost estimate at all (#908 audit, runtime-verified).
// Pro reasoning bills at the base model's published API rate; the suffix is an effort knob.
{ provider: "openai-apikey", modelId: "gpt-5.6-sol-pro", cost4: GPT56_SOL, source: `collapsed base ID ${OPENAI_GPT56_PRICING}`, verifiedAt: "2026-08-03", status: "verified-derived" },
{ provider: "openai-apikey", modelId: "gpt-5.6-terra-pro", cost4: GPT56_TERRA, source: `collapsed base ID ${OPENAI_GPT56_PRICING}`, verifiedAt: "2026-08-03", status: "verified-derived" },
{ provider: "openai-apikey", modelId: "gpt-5.6-luna-pro", cost4: GPT56_LUNA, source: `collapsed base ID ${OPENAI_GPT56_PRICING}`, verifiedAt: "2026-08-03", status: "verified-derived" },
Comment on lines +89 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply Fast multipliers to newly priced Pro aliases

These rows make the *-pro selections priceable while their selected suffix remains in the usage log, but applyPriorityMultiplier() consequently looks up names such as gpt-5.6-sol-pro in PRIORITY_MULTIPLIERS, which only contains the base slugs. The API-key provider still sends service_tier=priority after rewriting the virtual selection to its base wire model, so a response-confirmed Fast Sol Pro request with 200K input and 20K output is reported as $1.60 instead of the $3.20 Fast rate. Resolve the multiplier through the virtual model's base ID, or derive corresponding alias entries and cover them with a Fast regression test.

AGENTS.md reference: src/AGENTS.md:L18-L18

Useful? React with 👍 / 👎.

{ provider: "google-antigravity", modelId: "gemini-3.1-pro-low", cost4: GEMINI_31_PRO, source: `derived: gemini-3.1-pro (<=200k tier) ${GEMINI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" },
{ provider: "google-antigravity", modelId: "gemini-3.1-pro-high", cost4: GEMINI_31_PRO, source: `derived: gemini-3.1-pro (<=200k tier) ${GEMINI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" },
{ provider: "google-antigravity", modelId: "gemini-pro-agent", cost4: GEMINI_31_PRO, source: `wire id for gemini-3.1-pro high ${GEMINI_PRICING}`, verifiedAt: "2026-07-23", status: "verified-derived" },
Expand Down Expand Up @@ -162,3 +174,102 @@ export const PRIORITY_MULTIPLIERS: Readonly<Record<string, number>> = {
export function resolvePriorityMultiplier(modelId: string): number {
return PRIORITY_MULTIPLIERS[modelId] ?? 1;
}

/**
* Long-context pricing tiers (#908). Several vendors reprice the ENTIRE request
* once the prompt crosses a published input-token threshold, so a flat Cost4
* cannot express it.
*
* The threshold is measured on RAW `usage.inputTokens` (total prompt size,
* including cache reads/writes) — never on normalized billable input, which has
* already had cache tokens subtracted. A 280k prompt with a 200k cache read has
* 80k billable input and still crosses OpenAI's 272k boundary; deciding after
* normalization would under-bill exactly the cache-heavy long requests.
*
* Rules are exact provider+model matches. No case folding: the jawcode bundle
* carries BOTH `minimax-m3` and `MiniMax-M3` at different rates, so folding
* would select the wrong base row. No model-level fallback: routed resellers
* (Cursor, OpenRouter) share model slugs but price independently.
*/
export interface ContextTier {
provider: string;
modelId: string;
/** Long rates apply once raw input tokens pass this boundary. */
thresholdInputTokens: number;
/** true = `>=` threshold (xAI), false = `>` threshold (OpenAI, MiniMax). */
inclusive: boolean;
/** Per-field factor from the short rate to the published long rate. */
multiplier: Cost4;
source: string;
verifiedAt: string;
}

/**
* OpenAI GPT-5.6: "Prompts with >272K input tokens are priced at 2x input and
* 1.5x output for the full request." Cached input and cache writes also double,
* per the published short/long columns.
*/
const OPENAI_LONG_CONTEXT: Cost4 = { input: 2, output: 1.5, cacheRead: 2, cacheWrite: 2 };
/** xAI and MiniMax double every rate uniformly past their thresholds. */
const UNIFORM_DOUBLE: Cost4 = { input: 2, output: 2, cacheRead: 2, cacheWrite: 2 };

const OPENAI_PRICING_DOC = "https://developers.openai.com/api/docs/pricing";
const OPENAI_GPT56_CONTEXT_MODELS = [
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
Comment on lines +217 to +220

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include the generic GPT-5.6 alias in context tiers

The OpenAI API catalog exposes gpt-5.6, and the repository's API contract records that this upstream alias routes to Sol, but this exact-match list begins at gpt-5.6-sol. Consequently, findContextTier() returns no rule for API-key requests selecting the generic alias, so prompts above 272K still use the short rate—for example, 300K input plus 20K output remains $2.10 instead of $3.90. Include gpt-5.6, preferably by deriving this list from the canonical provider registry rather than maintaining another independent model list.

AGENTS.md reference: src/AGENTS.md:L18-L18

Useful? React with 👍 / 👎.

// Virtual `-pro` selections keep their own id in usage logs (the wire model is
// recorded separately), so they need their own rows or they silently skip the tier.
"gpt-5.6-sol-pro",
"gpt-5.6-terra-pro",
"gpt-5.6-luna-pro",
];

export const CONTEXT_TIERS: readonly ContextTier[] = [
...["openai", "openai-apikey"].flatMap(provider =>
OPENAI_GPT56_CONTEXT_MODELS.map((modelId): ContextTier => ({
Comment on lines +228 to +230

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the user-visible long-context pricing rules

This registry changes the estimated amounts displayed by the Logs and Usage surfaces whenever OpenAI, xAI, or MiniMax prompts cross the new thresholds, including the non-obvious Fast-mode exclusivity rule, but the commit contains no docs-site/ update explaining those calculations. Add the thresholds and estimation behavior to the English documentation and keep the translated versions consistent so users can interpret the changed dashboard totals.

AGENTS.md reference: AGENTS.md:L212-L213

Useful? React with 👍 / 👎.

provider,
modelId,
thresholdInputTokens: 272_000,
inclusive: false,
multiplier: OPENAI_LONG_CONTEXT,
source: OPENAI_PRICING_DOC,
verifiedAt: "2026-08-03",
})),
),
{
provider: "xai",
modelId: "grok-4.5",
thresholdInputTokens: 200_000,
inclusive: true,
multiplier: UNIFORM_DOUBLE,
source: "https://docs.x.ai/developers/pricing",
verifiedAt: "2026-08-03",
},
...["minimax", "minimax-cn"].map((provider): ContextTier => ({
provider,
modelId: "MiniMax-M3",
thresholdInputTokens: 512_000,
inclusive: false,
multiplier: UNIFORM_DOUBLE,
source: "https://platform.minimax.io/docs/guides/pricing-paygo",
verifiedAt: "2026-08-03",
})),
];

/** Exact provider+model context-tier lookup. No fuzzy matching, no case folding. */
export function findContextTier(
provider: string,
modelId: string,
tiers: readonly ContextTier[] = CONTEXT_TIERS,
): ContextTier | undefined {
return tiers.find(tier => tier.provider === provider && tier.modelId === modelId);
}

/** Whether a raw input-token count crosses the tier's published boundary. */
export function isLongContext(tier: ContextTier, rawInputTokens: number): boolean {
if (!Number.isFinite(rawInputTokens)) return false;
return tier.inclusive
? rawInputTokens >= tier.thresholdInputTokens
: rawInputTokens > tier.thresholdInputTokens;
}
8 changes: 4 additions & 4 deletions src/usage/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { baseProviderLabel } from "../providers/label";
import { canonicalAntigravityUsageModel } from "../providers/antigravity-models";
import { usageDisplayTotalTokens } from "./totals";
import type { PersistedUsageEntry, UsageStatus } from "./log";
import { estimateComboCost, estimateRequestCost, effectiveServiceTier } from "./cost";
import { estimateComboCost, estimateRequestCost, serviceTierContext } from "./cost";

export type UsageRange = "7d" | "30d" | "all";
export type UsageSurface = "all" | "codex" | "claude" | "grok";
Expand Down Expand Up @@ -286,7 +286,7 @@ function addEstimatedCost(
totals.unmeteredRequests += 1;
return;
}
const tier = effectiveServiceTier(entry);
const tier = serviceTierContext(entry);
const estimate = entry.attempts?.length
? estimateComboCost(entry.attempts, undefined, tier)
: estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier });
Expand Down Expand Up @@ -415,7 +415,7 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage
}
// Accumulate per-model estimated cost
for (const entry of entries) {
const tier = effectiveServiceTier(entry);
const tier = serviceTierContext(entry);
const estimate = entry.attempts?.length
? estimateComboCost(entry.attempts, undefined, tier)
: estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier });
Expand Down Expand Up @@ -524,7 +524,7 @@ function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): Us
}
}
for (const entry of entries) {
const tier = effectiveServiceTier(entry);
const tier = serviceTierContext(entry);
const estimate = entry.attempts?.length
? estimateComboCost(entry.attempts, undefined, tier)
: estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier });
Expand Down
Loading
Loading