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
36 changes: 36 additions & 0 deletions packages/internal/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,42 @@ This package has minimal runtime dependencies that get bundled:
- `consola` - Logging
- `valibot` - Schema validation

## Pricing Implementation Notes

### Tiered Pricing Support

LiteLLM supports tiered pricing for large context window models. Not all models use tiered pricing:

**Models WITH tiered pricing:**

- **Claude/Anthropic models**: 200k token threshold
- Fields: `input_cost_per_token_above_200k_tokens`, `output_cost_per_token_above_200k_tokens`
- Cache fields: `cache_creation_input_token_cost_above_200k_tokens`, `cache_read_input_token_cost_above_200k_tokens`
- ✅ Currently implemented in cost calculation logic

- **Gemini models**: 128k token threshold
- Fields: `input_cost_per_token_above_128k_tokens`, `output_cost_per_token_above_128k_tokens`
- ⚠️ Schema supports these fields but calculation logic NOT implemented
- Would require different threshold handling if Gemini support is added

**Models WITHOUT tiered pricing:**

- **GPT/OpenAI models**: Flat rate pricing (no token-based tiers)
- Note: OpenAI has "tier levels" but these are for API rate limits, not pricing

### ⚠️ IMPORTANT for Future Development

When adding support for new models:

1. **Check if the model has tiered pricing** in LiteLLM's schema
2. **Verify the threshold value** (200k for Claude, 128k for Gemini, etc.)
3. **Update calculation logic** if threshold differs from currently implemented 200k
4. **Add comprehensive tests** for boundary conditions at the threshold
5. **Document the pricing structure** in relevant CLAUDE.md files
6. **If cache-specific rates are missing**, fall back to the corresponding input rates (base and above-threshold) to avoid under-charging cached tokens

The current implementation in `pricing.ts` only handles 200k threshold. Adding models with different thresholds would require refactoring the `calculateTieredCost` helper function.

## Code Style

Follow the same conventions as the main ccusage package:
Expand Down
241 changes: 221 additions & 20 deletions packages/internal/src/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@ import * as v from 'valibot';
export const LITELLM_PRICING_URL
= 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';

/**
* Default token threshold for tiered pricing in 1M context window models.
* LiteLLM's pricing schema hard-codes this threshold in field names
* (e.g., `input_cost_per_token_above_200k_tokens`).
* The threshold parameter in calculateTieredCost allows flexibility for
* future models that may use different thresholds.
*/
const DEFAULT_TIERED_THRESHOLD = 200_000;

/**
* LiteLLM Model Pricing Schema
*
* ⚠️ TIERED PRICING NOTE:
* Different models use different token thresholds for tiered pricing:
* - Claude/Anthropic: 200k tokens (implemented in calculateTieredCost)
* - Gemini: 128k tokens (schema fields only, NOT implemented in calculations)
* - GPT/OpenAI: No tiered pricing (flat rate)
*
* When adding support for new models:
* 1. Check if model has tiered pricing in LiteLLM data
* 2. Verify the threshold value
* 3. Update calculateTieredCost logic if threshold differs from 200k
* 4. Add tests for tiered pricing boundaries
*/
export const liteLLMModelPricingSchema = v.object({
input_cost_per_token: v.optional(v.number()),
output_cost_per_token: v.optional(v.number()),
Expand All @@ -12,6 +36,14 @@ export const liteLLMModelPricingSchema = v.object({
max_tokens: v.optional(v.number()),
max_input_tokens: v.optional(v.number()),
max_output_tokens: v.optional(v.number()),
// Claude/Anthropic: 1M context window pricing (200k threshold)
input_cost_per_token_above_200k_tokens: v.optional(v.number()),
output_cost_per_token_above_200k_tokens: v.optional(v.number()),
cache_creation_input_token_cost_above_200k_tokens: v.optional(v.number()),
cache_read_input_token_cost_above_200k_tokens: v.optional(v.number()),
// Gemini: Tiered pricing (128k threshold) - NOT implemented in calculations
input_cost_per_token_above_128k_tokens: v.optional(v.number()),
output_cost_per_token_above_128k_tokens: v.optional(v.number()),
});

export type LiteLLMModelPricing = v.InferOutput<typeof liteLLMModelPricingSchema>;
Expand Down Expand Up @@ -202,6 +234,17 @@ export class LiteLLMPricingFetcher implements Disposable {
);
}

/**
* Calculate the total cost for token usage based on model pricing
*
* Supports tiered pricing for 1M context window models where tokens
* above a threshold (default 200k) are charged at a different rate.
* Handles all token types: input, output, cache creation, and cache read.
*
* @param tokens - Token counts for different types
* @param pricing - Model pricing information from LiteLLM
* @returns Total cost in USD
*/
calculateCostFromPricing(
tokens: {
input_tokens: number;
Expand All @@ -211,31 +254,73 @@ export class LiteLLMPricingFetcher implements Disposable {
},
pricing: LiteLLMModelPricing,
): number {
let cost = 0;
/**
* Calculate cost with tiered pricing for 1M context window models
*
* @param totalTokens - Total number of tokens to calculate cost for
* @param basePrice - Price per token for tokens up to the threshold
* @param tieredPrice - Price per token for tokens above the threshold
* @param threshold - Token threshold for tiered pricing (default 200k)
* @returns Total cost applying tiered pricing when applicable
*
* @example
* // 300k tokens with base price $3/M and tiered price $6/M
* calculateTieredCost(300_000, 3e-6, 6e-6)
* // Returns: (200_000 * 3e-6) + (100_000 * 6e-6) = $1.2
*/
const calculateTieredCost = (
totalTokens: number | undefined,
basePrice: number | undefined,
tieredPrice: number | undefined,
threshold: number = DEFAULT_TIERED_THRESHOLD,
): number => {
if (totalTokens == null || totalTokens <= 0) {
return 0;
}

if (pricing.input_cost_per_token != null) {
cost += tokens.input_tokens * pricing.input_cost_per_token;
}
if (totalTokens > threshold && tieredPrice != null) {
const tokensBelowThreshold = Math.min(totalTokens, threshold);
const tokensAboveThreshold = Math.max(0, totalTokens - threshold);

if (pricing.output_cost_per_token != null) {
cost += tokens.output_tokens * pricing.output_cost_per_token;
}
let tieredCost = tokensAboveThreshold * tieredPrice;
if (basePrice != null) {
tieredCost += tokensBelowThreshold * basePrice;
}
return tieredCost;
}

if (
tokens.cache_creation_input_tokens != null
&& pricing.cache_creation_input_token_cost != null
) {
cost
+= tokens.cache_creation_input_tokens
* pricing.cache_creation_input_token_cost;
}
if (basePrice != null) {
return totalTokens * basePrice;
}

if (tokens.cache_read_input_tokens != null && pricing.cache_read_input_token_cost != null) {
cost
+= tokens.cache_read_input_tokens * pricing.cache_read_input_token_cost;
}
return 0;
};

return cost;
const inputCost = calculateTieredCost(
tokens.input_tokens,
pricing.input_cost_per_token,
pricing.input_cost_per_token_above_200k_tokens,
);

const outputCost = calculateTieredCost(
tokens.output_tokens,
pricing.output_cost_per_token,
pricing.output_cost_per_token_above_200k_tokens,
);

const cacheCreationCost = calculateTieredCost(
tokens.cache_creation_input_tokens,
pricing.cache_creation_input_token_cost,
pricing.cache_creation_input_token_cost_above_200k_tokens,
);

const cacheReadCost = calculateTieredCost(
tokens.cache_read_input_tokens,
pricing.cache_read_input_token_cost,
pricing.cache_read_input_token_cost_above_200k_tokens,
);

return inputCost + outputCost + cacheCreationCost + cacheReadCost;
}

async calculateCostFromTokens(
Expand Down Expand Up @@ -303,5 +388,121 @@ if (import.meta.vitest != null) {

expect(cost).toBeCloseTo((1000 * 1.25e-6) + (500 * 1e-5) + (200 * 1.25e-7));
});

it('calculates tiered pricing for tokens exceeding 200k threshold (300k input, 250k output, 300k cache creation, 250k cache read)', async () => {
using fetcher = new LiteLLMPricingFetcher({
offline: true,
offlineLoader: async () => ({
'anthropic/claude-4-sonnet-20250514': {
input_cost_per_token: 3e-6,
output_cost_per_token: 1.5e-5,
input_cost_per_token_above_200k_tokens: 6e-6,
output_cost_per_token_above_200k_tokens: 2.25e-5,
cache_creation_input_token_cost: 3.75e-6,
cache_read_input_token_cost: 3e-7,
cache_creation_input_token_cost_above_200k_tokens: 7.5e-6,
cache_read_input_token_cost_above_200k_tokens: 6e-7,
},
}),
});

// Test comprehensive scenario with all token types above 200k threshold
const cost = await Result.unwrap(fetcher.calculateCostFromTokens({
input_tokens: 300_000,
output_tokens: 250_000,
cache_creation_input_tokens: 300_000,
cache_read_input_tokens: 250_000,
}, 'anthropic/claude-4-sonnet-20250514'));

const expectedCost
= (200_000 * 3e-6) + (100_000 * 6e-6) // input
+ (200_000 * 1.5e-5) + (50_000 * 2.25e-5) // output
+ (200_000 * 3.75e-6) + (100_000 * 7.5e-6) // cache creation
+ (200_000 * 3e-7) + (50_000 * 6e-7); // cache read
expect(cost).toBeCloseTo(expectedCost);
});

it('uses standard pricing for 300k/250k tokens when model lacks tiered pricing', async () => {
using fetcher = new LiteLLMPricingFetcher({
offline: true,
offlineLoader: async () => ({
'gpt-5': {
input_cost_per_token: 1e-6,
output_cost_per_token: 2e-6,
},
}),
});

// Should use normal pricing for all tokens
const cost = await Result.unwrap(fetcher.calculateCostFromTokens({
input_tokens: 300_000,
output_tokens: 250_000,
}, 'gpt-5'));

expect(cost).toBeCloseTo((300_000 * 1e-6) + (250_000 * 2e-6));
});

it('correctly applies pricing at 200k boundary (200k uses base, 200,001 uses tiered, 0 returns 0)', async () => {
using fetcher = new LiteLLMPricingFetcher({
offline: true,
offlineLoader: async () => ({
'claude-4-sonnet-20250514': {
input_cost_per_token: 3e-6,
input_cost_per_token_above_200k_tokens: 6e-6,
},
}),
});

// Test with exactly 200k tokens (should use only base price)
const cost200k = await Result.unwrap(fetcher.calculateCostFromTokens({
input_tokens: 200_000,
output_tokens: 0,
}, 'claude-4-sonnet-20250514'));
expect(cost200k).toBeCloseTo(200_000 * 3e-6);

// Test with 200,001 tokens (should use tiered pricing for 1 token)
const cost200k1 = await Result.unwrap(fetcher.calculateCostFromTokens({
input_tokens: 200_001,
output_tokens: 0,
}, 'claude-4-sonnet-20250514'));
expect(cost200k1).toBeCloseTo((200_000 * 3e-6) + (1 * 6e-6));

// Test with 0 tokens (should return 0)
const costZero = await Result.unwrap(fetcher.calculateCostFromTokens({
input_tokens: 0,
output_tokens: 0,
}, 'claude-4-sonnet-20250514'));
expect(costZero).toBe(0);
});

it('charges only for tokens above 200k when base price is missing (300k→100k charged, 100k→0 charged)', async () => {
using fetcher = new LiteLLMPricingFetcher({
offline: true,
offlineLoader: async () => ({
'theoretical-model': {
// No base price, only tiered pricing
input_cost_per_token_above_200k_tokens: 6e-6,
output_cost_per_token_above_200k_tokens: 2.25e-5,
},
}),
});

// Test with 300k tokens - should only charge for tokens above 200k
const cost = await Result.unwrap(fetcher.calculateCostFromTokens({
input_tokens: 300_000,
output_tokens: 250_000,
}, 'theoretical-model'));

// Only 100k input tokens above 200k are charged
// Only 50k output tokens above 200k are charged
expect(cost).toBeCloseTo((100_000 * 6e-6) + (50_000 * 2.25e-5));

// Test with tokens below threshold - should return 0 (no base price)
const costBelow = await Result.unwrap(fetcher.calculateCostFromTokens({
input_tokens: 100_000,
output_tokens: 100_000,
}, 'theoretical-model'));
expect(costBelow).toBe(0);
});
});
}
Loading