diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a47ef..fbd9753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Refresh display pricing for the current Command Code model catalog, remove expired Qwen promotional rates, add current free and discounted models, and require review when temporary prices expire. - Use the host-provided `pi-ai` and `pi-coding-agent` core packages instead of installing private runtime copies, including for local and out-of-store development checkouts. ## 0.4.4 - 2026-08-03 diff --git a/index.ts b/index.ts index 4b67b15..70fb759 100644 --- a/index.ts +++ b/index.ts @@ -20,57 +20,13 @@ import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } f import { calculateCommandCodeCost } from "./src/cost.ts" import { DEFAULT_MODELS_URL, loadCommandCodeModels } from "./src/models.ts" import { getApiKey, login, refreshToken } from "./src/oauth.ts" +import { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts" const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE const MODELS_URL = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL const MODELS_CACHE_PATH = process.env.COMMANDCODE_MODELS_CACHE ?? join(getAgentDir(), "commandcode-models.json") -type CommandCodeModelCost = { - input: number - output: number - cacheRead: number - cacheWrite: number -} - -const ZERO_MODEL_COST: CommandCodeModelCost = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -} - -// The Provider API supplies the current model list. Keep known display pricing -// here until the Provider API exposes prices directly. -const MODEL_COSTS: Record = { - "claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "claude-haiku-4-5-20251001": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - "gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, - "gpt-5.3-codex": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 }, - "gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, - "google/gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, - "google/gemini-3.1-flash-lite": { input: 0.25, output: 1.5, cacheRead: 0.03, cacheWrite: 0 }, - // 4× usage deal: 75% off (permanent, no expiry) - "deepseek/deepseek-v4-pro": { input: 0.435, output: 0.87, cacheRead: 0.003625, cacheWrite: 0 }, - "deepseek/deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.028, cacheWrite: 0 }, - "moonshotai/Kimi-K2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 }, - "moonshotai/Kimi-K2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 }, - "zai-org/GLM-5.1": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "zai-org/GLM-5": { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 }, - "MiniMaxAI/MiniMax-M2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "MiniMaxAI/MiniMax-M2.5": { input: 0.27, output: 0.95, cacheRead: 0.03, cacheWrite: 0 }, - "Qwen/Qwen3.6-Max-Preview": { input: 1.3, output: 7.8, cacheRead: 0.26, cacheWrite: 1.63 }, - "Qwen/Qwen3.6-Plus": { input: 0.5, output: 3, cacheRead: 0.1, cacheWrite: 0 }, - // 2× usage deal: 50% off through June 22, 2026 - "Qwen/Qwen3.7-Max": { input: 1.25, output: 3.75, cacheRead: 0.25, cacheWrite: 1.56 }, - "stepfun/Step-3.5-Flash": { input: 0.1, output: 0.3, cacheRead: 0.02, cacheWrite: 0 }, - "xiaomi/mimo-v2.5-pro": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "xiaomi/mimo-v2.5": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, -} - const streamCommandCode = createStreamCommandCode({ createStream: () => new AssistantMessageEventStream(), calculateCost: calculateCommandCodeCost, diff --git a/src/cost.ts b/src/cost.ts index 2be736c..55c1bd0 100644 --- a/src/cost.ts +++ b/src/cost.ts @@ -10,10 +10,22 @@ import type { ModelLike, Usage } from "./types.ts" export function calculateCommandCodeCost(model: ModelLike, usage: Usage): void { - usage.cost.input = (model.cost.input / 1_000_000) * usage.input - usage.cost.output = (model.cost.output / 1_000_000) * usage.output - usage.cost.cacheRead = (model.cost.cacheRead / 1_000_000) * usage.cacheRead - usage.cost.cacheWrite = (model.cost.cacheWrite * usage.cacheWrite) / 1_000_000 + const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite + let rates = model.cost + let matchedThreshold = -1 + for (const tier of model.cost.tiers ?? []) { + if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) { + rates = tier + matchedThreshold = tier.inputTokensAbove + } + } + + const longWrite = usage.cacheWrite1h ?? 0 + const shortWrite = usage.cacheWrite - longWrite + usage.cost.input = (rates.input / 1_000_000) * usage.input + usage.cost.output = (rates.output / 1_000_000) * usage.output + usage.cost.cacheRead = (rates.cacheRead / 1_000_000) * usage.cacheRead + usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1_000_000 usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite } diff --git a/src/pricing.ts b/src/pricing.ts new file mode 100644 index 0000000..aa801c0 --- /dev/null +++ b/src/pricing.ts @@ -0,0 +1,226 @@ +export interface CommandCodeModelCostRates { + input: number + output: number + cacheRead: number + cacheWrite: number +} + +export interface CommandCodeModelCostTier extends CommandCodeModelCostRates { + inputTokensAbove: number +} + +export interface CommandCodeModelCost extends CommandCodeModelCostRates { + tiers?: readonly CommandCodeModelCostTier[] +} + +export interface TemporaryPricing { + models: readonly string[] + expiresOn: string + description: string +} + +export const PRICING_SOURCE_URL = "https://commandcode.ai/docs/resources/pricing-limits" +export const PRICING_LAST_VERIFIED = "2026-08-04" + +export const ZERO_MODEL_COST: CommandCodeModelCost = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +} + +/** + * Display prices in USD per million tokens. + * + * Context-dependent rates use pi's request-wide input pricing tiers. The + * highest threshold exceeded by input + cache reads + cache writes applies to + * the full request. The Command Code usage page remains authoritative for the + * amount billed for an individual request. + */ +export const MODEL_COSTS: Readonly> = { + // Free models + "poolside/laguna-s-2.1-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + "inclusionai/ling-3.0-flash-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + + // Open and open-weight models + "tencent/hy3-paid": { input: 0.14, output: 0.58, cacheRead: 0.035, cacheWrite: 0 }, + "moonshotai/Kimi-K3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, + "moonshotai/Kimi-K2.7-Code": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, + "moonshotai/Kimi-K2.7-Code-Highspeed": { + input: 1.9, + output: 8, + cacheRead: 0.38, + cacheWrite: 0, + }, + "moonshotai/Kimi-K2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 }, + "moonshotai/Kimi-K2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 }, + "zai-org/GLM-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, + "zai-org/GLM-5.2-Fast": { input: 3, output: 10.25, cacheRead: 0.5, cacheWrite: 0 }, + "zai-org/GLM-5.1": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, + "zai-org/GLM-5": { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 }, + "MiniMaxAI/MiniMax-M3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, + "MiniMaxAI/MiniMax-M2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, + "MiniMaxAI/MiniMax-M2.5": { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0 }, + // Permanent 75% discount. + "deepseek/deepseek-v4-pro": { + input: 0.435, + output: 0.87, + cacheRead: 0.003625, + cacheWrite: 0, + }, + "deepseek/deepseek-v4-flash": { + input: 0.14, + output: 0.28, + cacheRead: 0.0028, + cacheWrite: 0, + }, + "Qwen/Qwen3.8-Max": { input: 2, output: 6, cacheRead: 0.25, cacheWrite: 2.5 }, + "Qwen/Qwen3.7-Max": { input: 2.5, output: 7.5, cacheRead: 0.5, cacheWrite: 3.13 }, + "Qwen/Qwen3.7-Plus": { + input: 0.4, + output: 1.6, + cacheRead: 0.08, + cacheWrite: 0.5, + tiers: [ + { + inputTokensAbove: 256_000, + input: 1.2, + output: 4.8, + cacheRead: 0.24, + cacheWrite: 1.5, + }, + ], + }, + "Qwen/Qwen3.7-Flash": { + input: 0.03, + output: 0.13, + cacheRead: 0.006, + cacheWrite: 0.038, + tiers: [ + { + inputTokensAbove: 32_000, + input: 0.1, + output: 0.4, + cacheRead: 0.02, + cacheWrite: 0.125, + }, + { + inputTokensAbove: 256_000, + input: 0.2, + output: 0.8, + cacheRead: 0.04, + cacheWrite: 0.25, + }, + ], + }, + "Qwen/Qwen3.6-Max-Preview": { + input: 1.3, + output: 7.8, + cacheRead: 0.26, + cacheWrite: 1.63, + }, + "Qwen/Qwen3.6-Plus": { input: 0.5, output: 3, cacheRead: 0.1, cacheWrite: 0 }, + "stepfun/Step-3.7-Flash": { input: 0.2, output: 1.15, cacheRead: 0.04, cacheWrite: 0 }, + "stepfun/Step-3.5-Flash": { input: 0.1, output: 0.3, cacheRead: 0.02, cacheWrite: 0 }, + // Permanent discounted rates. + "xiaomi/mimo-v2.5-pro": { input: 0.435, output: 0.87, cacheRead: 0.0036, cacheWrite: 0 }, + "xiaomi/mimo-v2.5": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + "nvidia/nemotron-3-ultra-550b-a55b": { + input: 0.6, + output: 2.4, + cacheRead: 0.12, + cacheWrite: 0, + }, + "sakana/fugu-ultra": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, + "thinkingmachines/inkling": { input: 1, output: 4.05, cacheRead: 0.17, cacheWrite: 0 }, + "thinkingmachines/inkling-small": { + input: 0.5, + output: 1.2, + cacheRead: 0.1, + cacheWrite: 0, + }, + "meta/muse-spark-1.1": { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, + + // Anthropic + // Introductory pricing through 2026-08-31. + "claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, + "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + "claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, + "claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + "claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + "claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + "claude-haiku-4-5-20251001": { + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + + // OpenAI + "gpt-5.6-sol": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, + // Discounted rates through 2026-08-14. + "gpt-5.6-terra": { + input: 1, + output: 6, + cacheRead: 0.1, + cacheWrite: 1.25, + tiers: [ + { + inputTokensAbove: 272_000, + input: 2, + output: 9, + cacheRead: 0.2, + cacheWrite: 2.5, + }, + ], + }, + "gpt-5.6-luna": { + input: 0.1, + output: 0.6, + cacheRead: 0.01, + cacheWrite: 0.125, + tiers: [ + { + inputTokensAbove: 272_000, + input: 0.2, + output: 0.9, + cacheRead: 0.02, + cacheWrite: 0.25, + }, + ], + }, + "gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, + "gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, + "gpt-5.3-codex": { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 }, + "gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, + + // Google and xAI + "google/gemini-3.6-flash": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, + "google/gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, + "google/gemini-3.5-flash-lite": { + input: 0.3, + output: 2.5, + cacheRead: 0.03, + cacheWrite: 0, + }, + "google/gemini-3.1-flash-lite": { + input: 0.25, + output: 1.5, + cacheRead: 0.03, + cacheWrite: 0, + }, + "xai/grok-4.5": { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, +} + +export const TEMPORARY_PRICING: readonly TemporaryPricing[] = [ + { + models: ["gpt-5.6-terra", "gpt-5.6-luna"], + expiresOn: "2026-08-14", + description: "50% promotional rates", + }, + { + models: ["claude-sonnet-5"], + expiresOn: "2026-08-31", + description: "introductory pricing", + }, +] diff --git a/src/types.ts b/src/types.ts index d2a9abe..c8db5a9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,7 @@ export interface Usage { output: number cacheRead: number cacheWrite: number + cacheWrite1h?: number totalTokens: number cost: UsageCost } @@ -50,13 +51,21 @@ export interface AssistantMessageLike { timestamp: number } -export interface ModelCost { +export interface ModelCostRates { input: number output: number cacheRead: number cacheWrite: number } +export interface ModelCostTier extends ModelCostRates { + inputTokensAbove: number +} + +export interface ModelCost extends ModelCostRates { + tiers?: readonly ModelCostTier[] +} + export interface ModelLike { id: string api: unknown diff --git a/tests/fixtures/commandcode-model-ids.json b/tests/fixtures/commandcode-model-ids.json new file mode 100644 index 0000000..f0974cd --- /dev/null +++ b/tests/fixtures/commandcode-model-ids.json @@ -0,0 +1,57 @@ +{ + "fetchedAt": "2026-08-04T10:12:57.953Z", + "source": "https://api.commandcode.ai/provider/v1/models", + "modelIds": [ + "claude-sonnet-5", + "claude-sonnet-4-6", + "claude-fable-5", + "claude-opus-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-haiku-4-5-20251001", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.4", + "gpt-5.3-codex", + "gpt-5.4-mini", + "deepseek/deepseek-v4-pro", + "deepseek/deepseek-v4-flash", + "moonshotai/Kimi-K3", + "moonshotai/Kimi-K2.7-Code", + "moonshotai/Kimi-K2.7-Code-Highspeed", + "moonshotai/Kimi-K2.6", + "moonshotai/Kimi-K2.5", + "zai-org/GLM-5.2", + "zai-org/GLM-5.2-Fast", + "zai-org/GLM-5.1", + "zai-org/GLM-5", + "MiniMaxAI/MiniMax-M3", + "MiniMaxAI/MiniMax-M2.7", + "MiniMaxAI/MiniMax-M2.5", + "xiaomi/mimo-v2.5-pro", + "xiaomi/mimo-v2.5", + "Qwen/Qwen3.8-Max", + "Qwen/Qwen3.7-Max", + "Qwen/Qwen3.7-Plus", + "Qwen/Qwen3.7-Flash", + "Qwen/Qwen3.6-Max-Preview", + "Qwen/Qwen3.6-Plus", + "stepfun/Step-3.7-Flash", + "stepfun/Step-3.5-Flash", + "tencent/hy3-paid", + "google/gemini-3.6-flash", + "google/gemini-3.5-flash", + "google/gemini-3.5-flash-lite", + "google/gemini-3.1-flash-lite", + "sakana/fugu-ultra", + "nvidia/nemotron-3-ultra-550b-a55b", + "thinkingmachines/inkling", + "thinkingmachines/inkling-small", + "poolside/laguna-s-2.1-free", + "inclusionai/ling-3.0-flash-free", + "meta/muse-spark-1.1", + "xai/grok-4.5" + ] +} diff --git a/tests/fixtures/commandcode-pricing.json b/tests/fixtures/commandcode-pricing.json new file mode 100644 index 0000000..deaee75 --- /dev/null +++ b/tests/fixtures/commandcode-pricing.json @@ -0,0 +1,67 @@ +{ + "verifiedAt": "2026-08-04", + "source": "https://commandcode.ai/docs/resources/pricing-limits", + "tierPolicy": "Use request-wide input tiers; the highest threshold exceeded by input plus cache tokens applies to the full request.", + "tiers": { + "Qwen/Qwen3.7-Plus": [[256000, 1.2, 4.8, 0.24, 1.5]], + "Qwen/Qwen3.7-Flash": [ + [32000, 0.1, 0.4, 0.02, 0.125], + [256000, 0.2, 0.8, 0.04, 0.25] + ], + "gpt-5.6-terra": [[272000, 2, 9, 0.2, 2.5]], + "gpt-5.6-luna": [[272000, 0.2, 0.9, 0.02, 0.25]] + }, + "costs": { + "poolside/laguna-s-2.1-free": [0, 0, 0, 0], + "inclusionai/ling-3.0-flash-free": [0, 0, 0, 0], + "tencent/hy3-paid": [0.14, 0.58, 0.035, 0], + "moonshotai/Kimi-K3": [3, 15, 0.3, 0], + "moonshotai/Kimi-K2.7-Code": [0.95, 4, 0.19, 0], + "moonshotai/Kimi-K2.7-Code-Highspeed": [1.9, 8, 0.38, 0], + "moonshotai/Kimi-K2.6": [0.95, 4, 0.16, 0], + "moonshotai/Kimi-K2.5": [0.6, 3, 0.1, 0], + "zai-org/GLM-5.2": [1.4, 4.4, 0.26, 0], + "zai-org/GLM-5.2-Fast": [3, 10.25, 0.5, 0], + "zai-org/GLM-5.1": [1.4, 4.4, 0.26, 0], + "zai-org/GLM-5": [1, 3.2, 0.2, 0], + "MiniMaxAI/MiniMax-M3": [0.3, 1.2, 0.06, 0], + "MiniMaxAI/MiniMax-M2.7": [0.3, 1.2, 0.06, 0], + "MiniMaxAI/MiniMax-M2.5": [0.3, 1.2, 0.03, 0], + "deepseek/deepseek-v4-pro": [0.435, 0.87, 0.003625, 0], + "deepseek/deepseek-v4-flash": [0.14, 0.28, 0.0028, 0], + "Qwen/Qwen3.8-Max": [2, 6, 0.25, 2.5], + "Qwen/Qwen3.7-Max": [2.5, 7.5, 0.5, 3.13], + "Qwen/Qwen3.7-Plus": [0.4, 1.6, 0.08, 0.5], + "Qwen/Qwen3.7-Flash": [0.03, 0.13, 0.006, 0.038], + "Qwen/Qwen3.6-Max-Preview": [1.3, 7.8, 0.26, 1.63], + "Qwen/Qwen3.6-Plus": [0.5, 3, 0.1, 0], + "stepfun/Step-3.7-Flash": [0.2, 1.15, 0.04, 0], + "stepfun/Step-3.5-Flash": [0.1, 0.3, 0.02, 0], + "xiaomi/mimo-v2.5-pro": [0.435, 0.87, 0.0036, 0], + "xiaomi/mimo-v2.5": [0.14, 0.28, 0.0028, 0], + "nvidia/nemotron-3-ultra-550b-a55b": [0.6, 2.4, 0.12, 0], + "sakana/fugu-ultra": [5, 30, 0.5, 0], + "thinkingmachines/inkling": [1, 4.05, 0.17, 0], + "thinkingmachines/inkling-small": [0.5, 1.2, 0.1, 0], + "meta/muse-spark-1.1": [1.25, 4.25, 0.15, 0], + "claude-sonnet-5": [2, 10, 0.2, 2.5], + "claude-sonnet-4-6": [3, 15, 0.3, 3.75], + "claude-fable-5": [10, 50, 1, 12.5], + "claude-opus-5": [5, 25, 0.5, 6.25], + "claude-opus-4-8": [5, 25, 0.5, 6.25], + "claude-opus-4-7": [5, 25, 0.5, 6.25], + "claude-haiku-4-5-20251001": [1, 5, 0.1, 1.25], + "gpt-5.6-sol": [5, 30, 0.5, 6.25], + "gpt-5.6-terra": [1, 6, 0.1, 1.25], + "gpt-5.6-luna": [0.1, 0.6, 0.01, 0.125], + "gpt-5.5": [5, 30, 0.5, 0], + "gpt-5.4": [2.5, 15, 0.25, 0], + "gpt-5.3-codex": [2, 8, 0.5, 0], + "gpt-5.4-mini": [0.75, 4.5, 0.075, 0], + "google/gemini-3.6-flash": [1.5, 7.5, 0.15, 0], + "google/gemini-3.5-flash": [1.5, 9, 0.15, 0], + "google/gemini-3.5-flash-lite": [0.3, 2.5, 0.03, 0], + "google/gemini-3.1-flash-lite": [0.25, 1.5, 0.03, 0], + "xai/grok-4.5": [2, 6, 0.5, 0] + } +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 8428cc9..f4b3f65 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -79,7 +79,7 @@ export function makeModel(overrides: Partial = {}): ModelLike { api: "commandcode-custom", provider: "commandcode", maxTokens: 384_000, - cost: { input: 0.14, output: 0.28, cacheRead: 0.028, cacheWrite: 0 }, + cost: { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, ...overrides, } } diff --git a/tests/test-cost.ts b/tests/test-cost.ts index f1503ab..ae7f730 100644 --- a/tests/test-cost.ts +++ b/tests/test-cost.ts @@ -13,13 +13,17 @@ import { describe, it } from "node:test" import { calculateCommandCodeCost } from "../src/cost.ts" import type { Usage } from "../src/types.ts" -interface CostTable { +interface CostRates { input: number output: number cacheRead: number cacheWrite: number } +interface CostTable extends CostRates { + tiers?: Array +} + const COST_FIXTURES: Record = { "zero-cost-model": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, @@ -29,7 +33,17 @@ const COST_FIXTURES: Record = { cacheRead: 0.003625, cacheWrite: 0, }, - "Qwen/Qwen3.7-Max": { input: 1.25, output: 3.75, cacheRead: 0.25, cacheWrite: 1.56 }, + "Qwen/Qwen3.7-Max": { input: 2.5, output: 7.5, cacheRead: 0.5, cacheWrite: 3.13 }, + "Qwen/Qwen3.7-Flash": { + input: 0.03, + output: 0.13, + cacheRead: 0.006, + cacheWrite: 0.038, + tiers: [ + { inputTokensAbove: 32_000, input: 0.1, output: 0.4, cacheRead: 0.02, cacheWrite: 0.125 }, + { inputTokensAbove: 256_000, input: 0.2, output: 0.8, cacheRead: 0.04, cacheWrite: 0.25 }, + ], + }, "gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, } @@ -51,6 +65,14 @@ function commandCodeModel(id: string, cost: CostTable) { } } +function assertClose(actual: number, expected: number) { + assert.ok( + Math.abs(actual - expected) <= + Number.EPSILON * Math.max(1, Math.abs(actual), Math.abs(expected)), + `expected ${actual} to be close to ${expected}`, + ) +} + function freshUsage(tokens: (typeof USAGE_CASES)[number]): Usage { return { ...tokens, @@ -60,10 +82,20 @@ function freshUsage(tokens: (typeof USAGE_CASES)[number]): Usage { } function expectedCost(cost: CostTable, tokens: (typeof USAGE_CASES)[number]): Usage["cost"] { - const input = (cost.input / 1_000_000) * tokens.input - const output = (cost.output / 1_000_000) * tokens.output - const cacheRead = (cost.cacheRead / 1_000_000) * tokens.cacheRead - const cacheWrite = (cost.cacheWrite * tokens.cacheWrite) / 1_000_000 + const inputTokens = tokens.input + tokens.cacheRead + tokens.cacheWrite + let rates: CostRates = cost + let matchedThreshold = -1 + for (const tier of cost.tiers ?? []) { + if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) { + rates = tier + matchedThreshold = tier.inputTokensAbove + } + } + + const input = (rates.input / 1_000_000) * tokens.input + const output = (rates.output / 1_000_000) * tokens.output + const cacheRead = (rates.cacheRead / 1_000_000) * tokens.cacheRead + const cacheWrite = (rates.cacheWrite * tokens.cacheWrite) / 1_000_000 return { input, output, @@ -91,6 +123,51 @@ describe("calculateCommandCodeCost()", () => { } }) + it("applies the highest request-wide input tier above its threshold", () => { + const model = commandCodeModel("Qwen/Qwen3.7-Flash", COST_FIXTURES["Qwen/Qwen3.7-Flash"]) + + const atThreshold = freshUsage({ + input: 32_000, + output: 1_000, + cacheRead: 0, + cacheWrite: 0, + }) + calculateCommandCodeCost(model, atThreshold) + assertClose(atThreshold.cost.input, (0.03 * 32_000) / 1_000_000) + + const aboveFirstTier = freshUsage({ + input: 30_000, + output: 1_000, + cacheRead: 2_001, + cacheWrite: 0, + }) + calculateCommandCodeCost(model, aboveFirstTier) + assertClose(aboveFirstTier.cost.input, (0.1 * 30_000) / 1_000_000) + assertClose(aboveFirstTier.cost.cacheRead, (0.02 * 2_001) / 1_000_000) + + const aboveHighestTier = freshUsage({ + input: 100_000, + output: 1_000, + cacheRead: 156_001, + cacheWrite: 0, + }) + calculateCommandCodeCost(model, aboveHighestTier) + assertClose(aboveHighestTier.cost.input, (0.2 * 100_000) / 1_000_000) + assertClose(aboveHighestTier.cost.output, (0.8 * 1_000) / 1_000_000) + }) + + it("prices one-hour cache writes at twice the active input rate", () => { + const model = commandCodeModel("claude-sonnet-4-6", COST_FIXTURES["claude-sonnet-4-6"]) + const usage = freshUsage({ input: 0, output: 0, cacheRead: 0, cacheWrite: 1_000 }) + usage.cacheWrite1h = 400 + + calculateCommandCodeCost(model, usage) + + const expectedShortWrite = (3.75 * 600) / 1_000_000 + const expectedLongWrite = (3 * 2 * 400) / 1_000_000 + assertClose(usage.cost.cacheWrite, expectedShortWrite + expectedLongWrite) + }) + it("writes the total as the sum of all cost components", () => { const model = commandCodeModel("claude-sonnet-4-6", COST_FIXTURES["claude-sonnet-4-6"]) const usage = freshUsage({ input: 1_000, output: 500, cacheRead: 10_000, cacheWrite: 2_000 }) diff --git a/tests/test-pricing.ts b/tests/test-pricing.ts index 3d4e995..a782a73 100644 --- a/tests/test-pricing.ts +++ b/tests/test-pricing.ts @@ -1,90 +1,177 @@ import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" import { describe, it } from "node:test" -// MODEL_COSTS is a module-level const in index.ts. We verify the pricing -// overlay by importing the map through a dedicated re-export so tests don't -// need to spin up the full extension. -// -// To keep the test self-contained without importing the full extension (which -// requires ExtensionAPI), we read the source and extract the constant at -// runtime. A cleaner approach would be a dedicated src/pricing.ts module, -// but for now we verify the known cost entries directly. +import { + MODEL_COSTS, + PRICING_LAST_VERIFIED, + PRICING_SOURCE_URL, + TEMPORARY_PRICING, +} from "../src/pricing.ts" -import { readFileSync } from "node:fs" -import { resolve, dirname } from "node:path" -import { fileURLToPath } from "node:url" +interface ModelCatalogSnapshot { + fetchedAt: string + source: string + modelIds: string[] +} -const __dirname = dirname(fileURLToPath(import.meta.url)) -const indexSource = readFileSync(resolve(__dirname, "..", "index.ts"), "utf-8") +interface PricingSnapshot { + verifiedAt: string + source: string + tierPolicy: string + tiers: Record + costs: Record +} -// Extract MODEL_COSTS object from index.ts source using a simple parse. -// The map is written as a Record -// so we eval it in a sandboxed context. -const match = indexSource.match( - /const MODEL_COSTS:\s*Record\s*=\s*\{([\s\S]*?)\n\}/, -) -assert.ok(match, "MODEL_COSTS constant should exist in index.ts") +const fixtureUrl = new URL("./fixtures/commandcode-model-ids.json", import.meta.url) +const fixture = JSON.parse(await readFile(fixtureUrl, "utf-8")) as ModelCatalogSnapshot +const pricingFixtureUrl = new URL("./fixtures/commandcode-pricing.json", import.meta.url) +const pricingFixture = JSON.parse(await readFile(pricingFixtureUrl, "utf-8")) as PricingSnapshot +const freeModels = new Set(["poolside/laguna-s-2.1-free", "inclusionai/ling-3.0-flash-free"]) -// Parse the cost entries from the extracted block. -const costBlock = match[1] -const entries: Record = {} -for (const line of costBlock.split("\n")) { - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith("//")) continue - const entryMatch = trimmed.match(/^"([^"]+)":\s*\{\s*input:\s*([\d.]+),\s*output:\s*([\d.]+)/) - if (entryMatch) { - entries[entryMatch[1]] = { - input: Number(entryMatch[2]), - output: Number(entryMatch[3]), - } - } +function assertCost( + modelId: string, + expected: { input: number; output: number; cacheRead: number; cacheWrite: number }, +) { + const cost = MODEL_COSTS[modelId] + assert.ok(cost, `${modelId} should have pricing`) + assert.deepEqual( + { + input: cost.input, + output: cost.output, + cacheRead: cost.cacheRead, + cacheWrite: cost.cacheWrite, + }, + expected, + `${modelId} base pricing should match the source`, + ) } describe("MODEL_COSTS pricing overlay", () => { - it("covers known Command Code models with non-zero pricing", () => { - const knownModels = [ - "deepseek/deepseek-v4-flash", - "deepseek/deepseek-v4-pro", - "claude-sonnet-4-6", - "claude-opus-4-7", - "Qwen/Qwen3.7-Max", - "gpt-5.5", - "stepfun/Step-3.5-Flash", - ] + it("covers the current Command Code model catalog snapshot", () => { + assert.equal(fixture.source, "https://api.commandcode.ai/provider/v1/models") + assert.match(fixture.fetchedAt, /^2026-08-04T/) - for (const id of knownModels) { - const cost = entries[id] - assert.ok(cost, `MODEL_COSTS should include "${id}"`) - assert.ok(cost.input > 0, `"${id}" input cost should be > 0`) - assert.ok(cost.output > 0, `"${id}" output cost should be > 0`) - } + const catalogIds = [...fixture.modelIds].sort() + const pricedIds = Object.keys(MODEL_COSTS).sort() + assert.deepEqual(pricedIds, catalogIds) }) - it("includes promotional pricing notes in comments", () => { - // The DeepSeek V4 Pro 4× deal and Qwen 3.7 Max 2× deal should be - // documented in the source comments. - assert.ok( - costBlock.includes("4× usage deal") || costBlock.includes("75% off"), - "DeepSeek V4 Pro promotional pricing should be documented", - ) - assert.ok( - costBlock.includes("2× usage deal") || costBlock.includes("50% off"), - "Qwen 3.7 Max promotional pricing should be documented", + it("matches the verified official pricing snapshot", () => { + assert.equal(pricingFixture.verifiedAt, PRICING_LAST_VERIFIED) + assert.equal(pricingFixture.source, PRICING_SOURCE_URL) + assert.match(pricingFixture.tierPolicy, /request-wide input tiers/) + + const expected = Object.fromEntries( + Object.entries(pricingFixture.costs).map( + ([modelId, [input, output, cacheRead, cacheWrite]]) => [ + modelId, + { + input, + output, + cacheRead, + cacheWrite, + ...(pricingFixture.tiers[modelId] + ? { + tiers: pricingFixture.tiers[modelId].map( + ([inputTokensAbove, tierInput, tierOutput, tierCacheRead, tierCacheWrite]) => ({ + inputTokensAbove, + input: tierInput, + output: tierOutput, + cacheRead: tierCacheRead, + cacheWrite: tierCacheWrite, + }), + ), + } + : {}), + }, + ], + ), ) + assert.deepEqual(MODEL_COSTS, expected) + }) + + it("uses non-zero prices except for models documented as free", () => { + for (const [modelId, cost] of Object.entries(MODEL_COSTS)) { + assert.ok(cost.input >= 0, `${modelId} input cost should be non-negative`) + assert.ok(cost.output >= 0, `${modelId} output cost should be non-negative`) + assert.ok(cost.cacheRead >= 0, `${modelId} cache-read cost should be non-negative`) + assert.ok(cost.cacheWrite >= 0, `${modelId} cache-write cost should be non-negative`) + + const allZero = Object.values(cost).every((value) => value === 0) + assert.equal( + allZero, + freeModels.has(modelId), + `${modelId} free-model status should be explicit`, + ) + } + }) + + it("matches corrected official rates", () => { + assertCost("deepseek/deepseek-v4-flash", { + input: 0.14, + output: 0.28, + cacheRead: 0.0028, + cacheWrite: 0, + }) + assertCost("Qwen/Qwen3.7-Max", { + input: 2.5, + output: 7.5, + cacheRead: 0.5, + cacheWrite: 3.13, + }) + assertCost("xiaomi/mimo-v2.5-pro", { + input: 0.435, + output: 0.87, + cacheRead: 0.0036, + cacheWrite: 0, + }) + assertCost("MiniMaxAI/MiniMax-M2.5", { + input: 0.3, + output: 1.2, + cacheRead: 0.03, + cacheWrite: 0, + }) + }) + + it("uses the documented base rates for context-dependent models", () => { + assertCost("Qwen/Qwen3.7-Plus", { + input: 0.4, + output: 1.6, + cacheRead: 0.08, + cacheWrite: 0.5, + }) + assertCost("Qwen/Qwen3.7-Flash", { + input: 0.03, + output: 0.13, + cacheRead: 0.006, + cacheWrite: 0.038, + }) + assertCost("gpt-5.6-terra", { + input: 1, + output: 6, + cacheRead: 0.1, + cacheWrite: 1.25, + }) + }) + + it("tracks pricing provenance", () => { + assert.equal(PRICING_SOURCE_URL, "https://commandcode.ai/docs/resources/pricing-limits") + assert.equal(PRICING_LAST_VERIFIED, "2026-08-04") }) - it("has cache pricing for models that support it", () => { - // Claude models should have non-zero cacheRead and cacheWrite costs. - const claudeModels = ["claude-sonnet-4-6", "claude-opus-4-7"] - for (const id of claudeModels) { - const fullEntryMatch = costBlock.match( - new RegExp( - `"${id.replace(/\//g, "\\\\")}":\\s*\\{[^}]+cacheRead:\\s*([\\d.]+)[^}]+cacheWrite:\\s*([\\d.]+)`, - ), + it("fails once temporary pricing needs review", () => { + const today = new Date().toISOString().slice(0, 10) + for (const pricing of TEMPORARY_PRICING) { + assert.match(pricing.expiresOn, /^\d{4}-\d{2}-\d{2}$/) + assert.ok(pricing.models.length > 0) + assert.ok( + pricing.expiresOn >= today, + `${pricing.description} for ${pricing.models.join(", ")} expired on ${pricing.expiresOn}; refresh MODEL_COSTS`, ) - assert.ok(fullEntryMatch, `"${id}" should have cacheRead and cacheWrite fields`) - assert.ok(Number(fullEntryMatch[1]) > 0, `"${id}" cacheRead should be > 0`) - assert.ok(Number(fullEntryMatch[2]) > 0, `"${id}" cacheWrite should be > 0`) + for (const modelId of pricing.models) { + assert.ok(MODEL_COSTS[modelId], `${modelId} should have a temporary price entry`) + } } }) })