mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🤖 feat: Add GPT-5.6 (Sol/Terra/Luna) OpenAI Models (#14206)
* ✨ feat: Add GPT-5.6 (Sol/Terra/Luna) OpenAI models Adds the GPT-5.6 family (GA 2026-07-09) across the context, output, pricing, cache, premium, and default-model maps, mirroring gpt-5.5. - gpt-5.6 (Sol alias), gpt-5.6-terra, gpt-5.6-luna - 1.05M context / 128K output for all tiers - Standard + long-context (>272K input) tiered pricing and cache rates * 🐛 fix: Bill GPT-5.6 cache writes at documented 1.25x input surcharge OpenAI prices GPT-5.6 cache writes above the base input rate (Sol $6.25, Terra $3.125, Luna $1.25 per 1M vs $5/$2.50/$1 input). Correct the cacheTokenValues write rates so explicit prompt-caching usage is billed and reported accurately, and lock the surcharge with a test. * ✨ feat: Expose GPT-5.6 max reasoning effort + long-context cache premium Folds in the two deferred Codex findings: 1. Add `max` to the OpenAI `ReasoningEffort` enum and the reasoning_effort parameter options/labels so GPT-5.6 (Sol/Terra/Luna) can request its documented highest reasoning setting. Backend passthrough and zod validation pick it up via the nativeEnum schema. 2. Apply the long-context (>272K input) premium to cache tokens. Adds `premiumCacheTokenValues` + `getPremiumCacheRate`, threads `inputTokenCount` into `getCacheMultiplier`, and wires it through both structured-spend paths. Covers the gpt-5.4/5.5/5.6 family whose cache write/read previously stayed at flat base rates on long-context calls. * 🐛 fix: Bill GPT-5.6 cache writes + map max effort for OpenRouter Claude Addresses Codex round-3 findings: 1. (P1) splitUsage only read `cache_creation`/`cache_creation_input_tokens`, so OpenAI GPT-5.6's `cache_write_tokens` fell into inputOnly and billed at the input rate instead of the 1.25x write rate. Extend UsageMetadata and single-source the cache-creation read to also recognize `cache_write_tokens` (nested and top-level). 2. (P2) `max` was exposed via OpenRouter (spreads OpenAI settings) but the adaptive-Claude verbosity map had no `max`, so it was silently dropped. Map max -> 'max' verbosity. * 🐛 fix: Forward GPT-5.6 cache_write_tokens into emitted usage Local Codex review (P1): the cache-write fix reached balance billing (splitUsage/getCacheCreationTokens) but not the emitted-usage pipeline. ModelEndHandler built the emitted event's cache_creation from only cache_creation/cache_creation_input_tokens, so GPT-5.6 cache_write_tokens were dropped and aggregateEmittedUsage classified them as ordinary input — displayed/persisted cost undercounted and disagreed with the balance charge. Fold cache_write_tokens (nested + top-level) into the emitted cache_creation.
This commit is contained in:
parent
6e8f44d72b
commit
b753da163e
17 changed files with 320 additions and 18 deletions
|
|
@ -131,10 +131,14 @@ class ModelEndHandler {
|
|||
this.collectedUsage.push(taggedUsage);
|
||||
|
||||
if (this.emitUsage) {
|
||||
/** Normalize Anthropic/Bedrock-style top-level cache fields into details */
|
||||
/** Normalize Anthropic/Bedrock top-level and OpenAI GPT-5.6
|
||||
* `cache_write_tokens` cache fields into details so the emitted/persisted
|
||||
* usage cost matches what billing charges (getCacheCreationTokens). */
|
||||
const cache_creation =
|
||||
taggedUsage.input_token_details?.cache_creation ??
|
||||
taggedUsage.cache_creation_input_tokens;
|
||||
taggedUsage.input_token_details?.cache_write_tokens ??
|
||||
taggedUsage.cache_creation_input_tokens ??
|
||||
taggedUsage.cache_write_tokens;
|
||||
const cache_read =
|
||||
taggedUsage.input_token_details?.cache_read ?? taggedUsage.cache_read_input_tokens;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -258,6 +258,20 @@ describe('getModelMaxTokens', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('should return correct tokens for gpt-5.6 matches', () => {
|
||||
expect(getModelMaxTokens('gpt-5.6')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5.6']);
|
||||
expect(getModelMaxTokens('gpt-5.6-sol')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5.6']);
|
||||
expect(getModelMaxTokens('openai/gpt-5.6')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['gpt-5.6'],
|
||||
);
|
||||
expect(getModelMaxTokens('gpt-5.6-terra')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['gpt-5.6-terra'],
|
||||
);
|
||||
expect(getModelMaxTokens('gpt-5.6-luna-2026-07-09')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['gpt-5.6-luna'],
|
||||
);
|
||||
});
|
||||
|
||||
test('should return correct tokens for Anthropic models', () => {
|
||||
const models = [
|
||||
'claude-2.1',
|
||||
|
|
@ -577,6 +591,9 @@ describe('getModelMaxTokens', () => {
|
|||
'gpt-5.4-pro',
|
||||
'gpt-5.5',
|
||||
'gpt-5.5-pro',
|
||||
'gpt-5.6',
|
||||
'gpt-5.6-terra',
|
||||
'gpt-5.6-luna',
|
||||
'gpt-5-mini',
|
||||
'gpt-5-nano',
|
||||
'gpt-5-pro',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface GetCacheMultiplierParams {
|
|||
cacheType: 'write' | 'read';
|
||||
model?: string;
|
||||
endpointTokenConfig?: EndpointTokenConfig;
|
||||
inputTokenCount?: number;
|
||||
}
|
||||
|
||||
export interface PricingFns {
|
||||
|
|
@ -124,11 +125,19 @@ function calculateStructuredTokenValue(
|
|||
inputTokenCount,
|
||||
});
|
||||
const writeMultiplier =
|
||||
pricing.getCacheMultiplier({ cacheType: 'write', model, endpointTokenConfig }) ??
|
||||
inputMultiplier;
|
||||
pricing.getCacheMultiplier({
|
||||
cacheType: 'write',
|
||||
model,
|
||||
endpointTokenConfig,
|
||||
inputTokenCount,
|
||||
}) ?? inputMultiplier;
|
||||
const readMultiplier =
|
||||
pricing.getCacheMultiplier({ cacheType: 'read', model, endpointTokenConfig }) ??
|
||||
inputMultiplier;
|
||||
pricing.getCacheMultiplier({
|
||||
cacheType: 'read',
|
||||
model,
|
||||
endpointTokenConfig,
|
||||
inputTokenCount,
|
||||
}) ?? inputMultiplier;
|
||||
|
||||
const inputAbs = Math.abs(txData.inputTokens ?? 0);
|
||||
const writeAbs = Math.abs(txData.writeTokens ?? 0);
|
||||
|
|
|
|||
|
|
@ -1609,6 +1609,41 @@ describe('computeUsageCostUSD', () => {
|
|||
);
|
||||
expect(cost).toBeCloseTo((1000 * 3 + 2000 * 3.75 + 10000 * 0.3 + 500 * 15) / 1e6);
|
||||
});
|
||||
|
||||
it('routes nested cache_write_tokens to the write bucket, not the input rate', () => {
|
||||
/** OpenAI GPT-5.6 reports cache writes as `cache_write_tokens`. Those 2000
|
||||
* tokens must bill at the write rate (3.75), not fold into input (3);
|
||||
* inputOnly = 13000 - 2000 write - 10000 read = 1000. */
|
||||
const cost = computeUsageCostUSD(
|
||||
{
|
||||
input_tokens: 13000,
|
||||
output_tokens: 500,
|
||||
model: 'gpt-5.6',
|
||||
provider: 'openAI',
|
||||
input_token_details: { cache_read: 10000, cache_write_tokens: 2000 },
|
||||
},
|
||||
pricing,
|
||||
);
|
||||
expect(cost).toBeCloseTo((1000 * 3 + 2000 * 3.75 + 10000 * 0.3 + 500 * 15) / 1e6);
|
||||
});
|
||||
|
||||
it('routes top-level cache_write_tokens to the write bucket alongside the premium input tier', () => {
|
||||
/** Top-level `cache_write_tokens` (Chat/Responses flattened shape) is also
|
||||
* recognized; inputOnly = 280000 - 5000 - 15000 = 260000, above the premium
|
||||
* threshold so input/completion price at the premium tier (8 / 40). */
|
||||
const cost = computeUsageCostUSD(
|
||||
{
|
||||
input_tokens: 280000,
|
||||
output_tokens: 500,
|
||||
model: 'gpt-5.6',
|
||||
provider: 'openAI',
|
||||
input_token_details: { cache_read: 15000 },
|
||||
cache_write_tokens: 5000,
|
||||
},
|
||||
pricing,
|
||||
);
|
||||
expect(cost).toBeCloseTo((260000 * 8 + 5000 * 3.75 + 15000 * 0.3 + 500 * 40) / 1e6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateEmittedUsage', () => {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,22 @@ type SpendStructuredTokensFn = (
|
|||
tokenUsage: StructuredTokenUsage,
|
||||
) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Cache-creation (write) tokens across provider shapes: langchain's
|
||||
* `input_token_details.cache_creation`, Anthropic's `cache_creation_input_tokens`,
|
||||
* and OpenAI GPT-5.6+'s `cache_write_tokens` (nested or top-level). Kept in one
|
||||
* place so the completion-token and billing splits never diverge.
|
||||
*/
|
||||
function getCacheCreationTokens(usage: UsageMetadata): number {
|
||||
return (
|
||||
Number(usage.input_token_details?.cache_creation) ||
|
||||
Number(usage.input_token_details?.cache_write_tokens) ||
|
||||
Number(usage.cache_creation_input_tokens) ||
|
||||
Number(usage.cache_write_tokens) ||
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `completionTokens` for billing, repairing providers whose
|
||||
* `usage_metadata.output_tokens` undercounts.
|
||||
|
|
@ -70,10 +86,7 @@ function resolveCompletionTokens(usage: UsageMetadata): number {
|
|||
// Subset providers fold cache into input_tokens, so their adjustment is 0.
|
||||
const cacheRead =
|
||||
Number(usage.input_token_details?.cache_read) || Number(usage.cache_read_input_tokens) || 0;
|
||||
const cacheCreation =
|
||||
Number(usage.input_token_details?.cache_creation) ||
|
||||
Number(usage.cache_creation_input_tokens) ||
|
||||
0;
|
||||
const cacheCreation = getCacheCreationTokens(usage);
|
||||
const cacheAdjustment = inputTokensIncludesCache(usage.provider) ? 0 : cacheRead + cacheCreation;
|
||||
|
||||
if (total > input + output + cacheAdjustment) {
|
||||
|
|
@ -94,10 +107,7 @@ interface SplitUsage {
|
|||
}
|
||||
|
||||
function splitUsage(usage: UsageMetadata): SplitUsage {
|
||||
const cacheCreation =
|
||||
Number(usage.input_token_details?.cache_creation) ||
|
||||
Number(usage.cache_creation_input_tokens) ||
|
||||
0;
|
||||
const cacheCreation = getCacheCreationTokens(usage);
|
||||
const cacheRead =
|
||||
Number(usage.input_token_details?.cache_read) || Number(usage.cache_read_input_tokens) || 0;
|
||||
const rawInput = Number(usage.input_tokens) || 0;
|
||||
|
|
|
|||
|
|
@ -833,6 +833,23 @@ describe('getOpenAILLMConfig', () => {
|
|||
expect(result.llmConfig).toHaveProperty('verbosity', 'max');
|
||||
});
|
||||
|
||||
it('should map OpenRouter adaptive Claude max effort to max verbosity', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
streaming: true,
|
||||
useOpenRouter: true,
|
||||
modelOptions: {
|
||||
model: 'anthropic/claude-sonnet-4.6',
|
||||
reasoning_effort: 'max' as ReasoningEffort,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning', {
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.llmConfig).toHaveProperty('verbosity', 'max');
|
||||
});
|
||||
|
||||
it('should preserve extra-high OpenRouter verbosity for future adaptive Claude models', () => {
|
||||
const result = getOpenAILLMConfig({
|
||||
apiKey: 'test-api-key',
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ const openRouterAnthropicVerbosityByEffort: Record<
|
|||
medium: 'medium',
|
||||
high: 'high',
|
||||
xhigh: 'xhigh',
|
||||
max: 'max',
|
||||
};
|
||||
|
||||
function isStringVerbosity(value: unknown): value is string {
|
||||
|
|
|
|||
|
|
@ -202,12 +202,20 @@ export interface UsageMetadata {
|
|||
cache_creation?: number;
|
||||
/** Tokens read from cache */
|
||||
cache_read?: number;
|
||||
/** OpenAI GPT-5.6+ cache-write tokens (billed above the input rate) */
|
||||
cache_write_tokens?: number;
|
||||
};
|
||||
/**
|
||||
* Anthropic-style cache creation tokens.
|
||||
* Present for Claude models. Mutually exclusive with input_token_details.
|
||||
*/
|
||||
cache_creation_input_tokens?: number;
|
||||
/**
|
||||
* OpenAI GPT-5.6+ cache-write tokens, reported at the top level of
|
||||
* `prompt_tokens_details`/`input_tokens_details`. Distinct from cached
|
||||
* (read) tokens and billed at a premium over the input rate.
|
||||
*/
|
||||
cache_write_tokens?: number;
|
||||
/**
|
||||
* Anthropic-style cache read tokens.
|
||||
* Present for Claude models. Mutually exclusive with input_token_details.
|
||||
|
|
|
|||
|
|
@ -31,3 +31,19 @@ describe('getModelMaxOutputTokens partial-override fallback', () => {
|
|||
expect(fallback).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gpt-5.6 tiers', () => {
|
||||
it('resolves 1.05M context and 128K output for every tier and the sol alias', () => {
|
||||
for (const model of ['gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']) {
|
||||
expect(getModelMaxTokens(model, EModelEndpoint.openAI)).toBe(1050000);
|
||||
expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe(128000);
|
||||
}
|
||||
});
|
||||
|
||||
it('matches the longest tier key over the shorter gpt-5 pattern', () => {
|
||||
expect(getModelMaxTokens('openai/gpt-5.6-terra-2026-07-09', EModelEndpoint.openAI)).toBe(
|
||||
1050000,
|
||||
);
|
||||
expect(getModelMaxTokens('gpt-5', EModelEndpoint.openAI)).toBe(400000);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ const openAIModels = {
|
|||
'gpt-5.4-nano': 400000,
|
||||
'gpt-5.5': 1050000,
|
||||
'gpt-5.5-pro': 1050000,
|
||||
'gpt-5.6': 1050000,
|
||||
'gpt-5.6-terra': 1050000,
|
||||
'gpt-5.6-luna': 1050000,
|
||||
'chat-latest': 400000,
|
||||
'gpt-5-mini': 400000,
|
||||
'gpt-5-nano': 400000,
|
||||
|
|
@ -433,6 +436,9 @@ export const modelMaxOutputs = {
|
|||
'gpt-5.4-nano': 128000,
|
||||
'gpt-5.5': 128000,
|
||||
'gpt-5.5-pro': 128000,
|
||||
'gpt-5.6': 128000,
|
||||
'gpt-5.6-terra': 128000,
|
||||
'gpt-5.6-luna': 128000,
|
||||
'chat-latest': 128000,
|
||||
'gpt-5-mini': 128000,
|
||||
'gpt-5-nano': 128000,
|
||||
|
|
|
|||
|
|
@ -2011,6 +2011,9 @@ export const alternateName = {
|
|||
};
|
||||
|
||||
const sharedOpenAIModels = [
|
||||
'gpt-5.6',
|
||||
'gpt-5.6-terra',
|
||||
'gpt-5.6-luna',
|
||||
'gpt-5.5',
|
||||
'gpt-5.5-pro',
|
||||
'chat-latest',
|
||||
|
|
|
|||
|
|
@ -244,6 +244,7 @@ const openAIParams: Record<string, SettingDefinition> = {
|
|||
ReasoningEffort.medium,
|
||||
ReasoningEffort.high,
|
||||
ReasoningEffort.xhigh,
|
||||
ReasoningEffort.max,
|
||||
],
|
||||
enumMappings: {
|
||||
[ReasoningEffort.unset]: 'com_ui_auto',
|
||||
|
|
@ -253,6 +254,7 @@ const openAIParams: Record<string, SettingDefinition> = {
|
|||
[ReasoningEffort.medium]: 'com_ui_medium',
|
||||
[ReasoningEffort.high]: 'com_ui_high',
|
||||
[ReasoningEffort.xhigh]: 'com_ui_xhigh',
|
||||
[ReasoningEffort.max]: 'com_ui_max',
|
||||
},
|
||||
optionType: 'model',
|
||||
columnSpan: 4,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import {
|
||||
AnthropicEffort,
|
||||
ReasoningEffort,
|
||||
googleSettings,
|
||||
anthropicSettings,
|
||||
compactGoogleSchema,
|
||||
eAnthropicEffortSchema,
|
||||
eReasoningEffortSchema,
|
||||
} from './schemas';
|
||||
|
||||
describe('anthropicSettings', () => {
|
||||
|
|
@ -567,3 +569,20 @@ describe('AnthropicEffort', () => {
|
|||
expect(() => eAnthropicEffortSchema.parse('ultra')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReasoningEffort', () => {
|
||||
it('exposes max as the highest OpenAI reasoning effort, after xhigh', () => {
|
||||
expect(ReasoningEffort.max).toBe('max');
|
||||
const keys = Object.keys(ReasoningEffort);
|
||||
expect(keys.indexOf('max')).toBeGreaterThan(keys.indexOf('xhigh'));
|
||||
});
|
||||
|
||||
it('accepts max through the zod schema', () => {
|
||||
expect(eReasoningEffortSchema.parse('max')).toBe('max');
|
||||
expect(eReasoningEffortSchema.parse(ReasoningEffort.max)).toBe('max');
|
||||
});
|
||||
|
||||
it('still rejects unknown effort values', () => {
|
||||
expect(() => eReasoningEffortSchema.parse('ultra')).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ export enum ReasoningEffort {
|
|||
medium = 'medium',
|
||||
high = 'high',
|
||||
xhigh = 'xhigh',
|
||||
max = 'max',
|
||||
}
|
||||
|
||||
export enum ReasoningParameterFormat {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ type CacheMultiplierParams = {
|
|||
cacheType?: 'write' | 'read';
|
||||
model?: string;
|
||||
endpointTokenConfig?: Record<string, Record<string, number>>;
|
||||
inputTokenCount?: number;
|
||||
};
|
||||
|
||||
/** Fields read/written by the internal token value calculators */
|
||||
|
|
@ -142,10 +143,15 @@ export function createTransactionMethods(
|
|||
cacheType: 'write',
|
||||
model,
|
||||
endpointTokenConfig: etConfig,
|
||||
inputTokenCount,
|
||||
}) ?? inputMultiplier;
|
||||
const readMultiplier =
|
||||
txMethods.getCacheMultiplier({ cacheType: 'read', model, endpointTokenConfig: etConfig }) ??
|
||||
inputMultiplier;
|
||||
txMethods.getCacheMultiplier({
|
||||
cacheType: 'read',
|
||||
model,
|
||||
endpointTokenConfig: etConfig,
|
||||
inputTokenCount,
|
||||
}) ?? inputMultiplier;
|
||||
|
||||
txn.rateDetail = {
|
||||
input: inputMultiplier,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
tokenValues,
|
||||
cacheTokenValues,
|
||||
premiumTokenValues,
|
||||
premiumCacheTokenValues,
|
||||
defaultRate,
|
||||
} from './tx';
|
||||
import { matchModelName, findMatchingPattern } from './test-helpers';
|
||||
|
|
@ -449,6 +450,26 @@ describe('getMultiplier', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should return the correct multiplier for gpt-5.6 tiers', () => {
|
||||
for (const model of ['gpt-5.6', 'gpt-5.6-terra', 'gpt-5.6-luna']) {
|
||||
expect(getValueKey(model)).toBe(model);
|
||||
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(tokenValues[model].prompt);
|
||||
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(tokenValues[model].completion);
|
||||
expect(getCacheMultiplier({ model, cacheType: 'write' })).toBe(cacheTokenValues[model].write);
|
||||
expect(getCacheMultiplier({ model, cacheType: 'read' })).toBe(cacheTokenValues[model].read);
|
||||
}
|
||||
expect(getValueKey('gpt-5.6-sol')).toBe('gpt-5.6');
|
||||
expect(getMultiplier({ model: 'openai/gpt-5.6-terra', tokenType: 'completion' })).toBe(
|
||||
tokenValues['gpt-5.6-terra'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should bill gpt-5.6 cache writes at the documented 1.25x input surcharge', () => {
|
||||
for (const model of ['gpt-5.6', 'gpt-5.6-terra', 'gpt-5.6-luna']) {
|
||||
expect(cacheTokenValues[model].write).toBeCloseTo(tokenValues[model].prompt * 1.25);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return the correct multiplier for gpt-4o', () => {
|
||||
const valueKey = getValueKey('gpt-4o-2024-08-06');
|
||||
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
|
||||
|
|
@ -2688,5 +2709,74 @@ describe('Premium Token Pricing', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GPT-5.6 Long-Context Premium Pricing', () => {
|
||||
const tiers = ['gpt-5.6', 'gpt-5.6-terra', 'gpt-5.6-luna'];
|
||||
|
||||
it('should define a premium entry above standard rates for every tier', () => {
|
||||
for (const model of tiers) {
|
||||
const premiumEntry = premiumTokenValues[model];
|
||||
expect(premiumEntry).toBeDefined();
|
||||
expect(premiumEntry.threshold).toBe(272000);
|
||||
expect(premiumEntry.prompt).toBe(tokenValues[model].prompt * 2);
|
||||
expect(premiumEntry.completion).toBe(tokenValues[model].completion * 1.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('should bill standard rates at or below threshold and premium rates above', () => {
|
||||
for (const model of tiers) {
|
||||
const { threshold, prompt, completion } = premiumTokenValues[model];
|
||||
expect(getMultiplier({ model, tokenType: 'prompt', inputTokenCount: threshold })).toBe(
|
||||
tokenValues[model].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model, tokenType: 'prompt', inputTokenCount: threshold + 1 })).toBe(
|
||||
prompt,
|
||||
);
|
||||
expect(
|
||||
getMultiplier({ model, tokenType: 'completion', inputTokenCount: threshold + 1 }),
|
||||
).toBe(completion);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Long-Context Premium Cache Pricing', () => {
|
||||
const premiumCacheModels = Object.keys(premiumCacheTokenValues);
|
||||
|
||||
it('should scale cache write/read by the same long-context multiplier as input', () => {
|
||||
for (const model of premiumCacheModels) {
|
||||
const inputRatio = premiumTokenValues[model].prompt / tokenValues[model].prompt;
|
||||
expect(premiumCacheTokenValues[model].write).toBeCloseTo(
|
||||
cacheTokenValues[model].write * inputRatio,
|
||||
);
|
||||
expect(premiumCacheTokenValues[model].read).toBeCloseTo(
|
||||
cacheTokenValues[model].read * inputRatio,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return premium cache rates above threshold and standard rates at/below', () => {
|
||||
for (const model of premiumCacheModels) {
|
||||
const { threshold } = premiumCacheTokenValues[model];
|
||||
for (const cacheType of ['write', 'read'] as const) {
|
||||
expect(getCacheMultiplier({ model, cacheType, inputTokenCount: threshold + 1 })).toBe(
|
||||
premiumCacheTokenValues[model][cacheType],
|
||||
);
|
||||
expect(getCacheMultiplier({ model, cacheType, inputTokenCount: threshold })).toBe(
|
||||
cacheTokenValues[model][cacheType],
|
||||
);
|
||||
expect(getCacheMultiplier({ model, cacheType })).toBe(cacheTokenValues[model][cacheType]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should not apply premium cache rates to models without a premium cache entry', () => {
|
||||
for (const model of ['gpt-5-mini', 'gpt-5.4-mini', 'gpt-4o']) {
|
||||
expect(premiumCacheTokenValues[model]).toBeUndefined();
|
||||
expect(getCacheMultiplier({ model, cacheType: 'read', inputTokenCount: 5_000_000 })).toBe(
|
||||
getCacheMultiplier({ model, cacheType: 'read' }),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cross-package sync validation tests (tokens.ts ↔ tx.ts) moved to
|
||||
// packages/api tests since they require maxTokensMap from @librechat/api.
|
||||
|
|
|
|||
|
|
@ -134,6 +134,9 @@ export const tokenValues: Record<string, { prompt: number; completion: number }>
|
|||
'gpt-5.4-nano': { prompt: 0.2, completion: 1.25 },
|
||||
'gpt-5.5': { prompt: 5, completion: 30 },
|
||||
'gpt-5.5-pro': { prompt: 30, completion: 180 },
|
||||
'gpt-5.6': { prompt: 5, completion: 30 },
|
||||
'gpt-5.6-terra': { prompt: 2.5, completion: 15 },
|
||||
'gpt-5.6-luna': { prompt: 1, completion: 6 },
|
||||
'chat-latest': { prompt: 5, completion: 30 },
|
||||
'gpt-5-chat-latest': { prompt: 1.25, completion: 10 },
|
||||
'gpt-5.1-chat-latest': { prompt: 1.25, completion: 10 },
|
||||
|
|
@ -329,6 +332,9 @@ export const cacheTokenValues: Record<string, { write: number; read: number }> =
|
|||
'gpt-5.4-mini': { write: 0.75, read: 0.075 },
|
||||
'gpt-5.4-nano': { write: 0.2, read: 0.02 },
|
||||
'gpt-5.5': { write: 5, read: 0.5 },
|
||||
'gpt-5.6': { write: 6.25, read: 0.5 },
|
||||
'gpt-5.6-terra': { write: 3.125, read: 0.25 },
|
||||
'gpt-5.6-luna': { write: 1.25, read: 0.1 },
|
||||
'chat-latest': { write: 5, read: 0.5 },
|
||||
'gpt-5-chat-latest': { write: 1.25, read: 0.125 },
|
||||
'gpt-5.1-chat-latest': { write: 1.25, read: 0.125 },
|
||||
|
|
@ -376,6 +382,26 @@ export const premiumTokenValues: Record<
|
|||
'gpt-5.4-pro': { threshold: 272000, prompt: 60, completion: 270 },
|
||||
'gpt-5.5': { threshold: 272000, prompt: 10, completion: 45 },
|
||||
'gpt-5.5-pro': { threshold: 272000, prompt: 60, completion: 270 },
|
||||
'gpt-5.6': { threshold: 272000, prompt: 10, completion: 45 },
|
||||
'gpt-5.6-terra': { threshold: 272000, prompt: 5, completion: 22.5 },
|
||||
'gpt-5.6-luna': { threshold: 272000, prompt: 2, completion: 9 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Premium (tiered) cache pricing for models whose cache rates change once the
|
||||
* prompt crosses the long-context threshold. Cache write/read scale by the same
|
||||
* multiplier the long-context tier applies to input (e.g. 2x for the gpt-5.x
|
||||
* family), so these mirror `premiumTokenValues` on the cache dimension.
|
||||
*/
|
||||
export const premiumCacheTokenValues: Record<
|
||||
string,
|
||||
{ threshold: number; write: number; read: number }
|
||||
> = {
|
||||
'gpt-5.4': { threshold: 272000, write: 5, read: 0.5 },
|
||||
'gpt-5.5': { threshold: 272000, write: 10, read: 1 },
|
||||
'gpt-5.6': { threshold: 272000, write: 12.5, read: 1 },
|
||||
'gpt-5.6-terra': { threshold: 272000, write: 6.25, read: 0.5 },
|
||||
'gpt-5.6-luna': { threshold: 272000, write: 2.5, read: 0.2 },
|
||||
};
|
||||
|
||||
export function createTxMethods(
|
||||
|
|
@ -424,12 +450,14 @@ export function createTxMethods(
|
|||
model,
|
||||
endpoint,
|
||||
endpointTokenConfig,
|
||||
inputTokenCount,
|
||||
}: {
|
||||
valueKey?: string;
|
||||
cacheType?: 'write' | 'read';
|
||||
model?: string;
|
||||
endpoint?: string;
|
||||
endpointTokenConfig?: Record<string, Record<string, number>>;
|
||||
inputTokenCount?: number | null;
|
||||
}) => number | null;
|
||||
defaultRate: number;
|
||||
cacheTokenValues: Record<
|
||||
|
|
@ -552,8 +580,28 @@ export function createTxMethods(
|
|||
return tokenValues[valueKey]?.[tokenType] ?? defaultRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if premium (tiered) cache pricing applies and returns the premium rate.
|
||||
*/
|
||||
function getPremiumCacheRate(
|
||||
valueKey: string,
|
||||
cacheType: 'write' | 'read',
|
||||
inputTokenCount?: number | null,
|
||||
): number | null {
|
||||
if (inputTokenCount == null) {
|
||||
return null;
|
||||
}
|
||||
const premiumEntry = premiumCacheTokenValues[valueKey];
|
||||
if (!premiumEntry || inputTokenCount <= premiumEntry.threshold) {
|
||||
return null;
|
||||
}
|
||||
return premiumEntry[cacheType] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the cache multiplier for a given value key and token type.
|
||||
* When `inputTokenCount` crosses a model's long-context threshold, the
|
||||
* premium cache rate applies instead of the standard one.
|
||||
*/
|
||||
function getCacheMultiplier({
|
||||
valueKey,
|
||||
|
|
@ -561,12 +609,14 @@ export function createTxMethods(
|
|||
model,
|
||||
endpoint,
|
||||
endpointTokenConfig,
|
||||
inputTokenCount,
|
||||
}: {
|
||||
valueKey?: string;
|
||||
cacheType?: 'write' | 'read';
|
||||
model?: string;
|
||||
endpoint?: string;
|
||||
endpointTokenConfig?: Record<string, Record<string, number>>;
|
||||
inputTokenCount?: number | null;
|
||||
}): number | null {
|
||||
if (endpointTokenConfig && model) {
|
||||
const modelConfig = endpointTokenConfig[model];
|
||||
|
|
@ -578,7 +628,11 @@ export function createTxMethods(
|
|||
}
|
||||
|
||||
if (valueKey && cacheType) {
|
||||
return cacheTokenValues[valueKey]?.[cacheType] ?? null;
|
||||
return (
|
||||
getPremiumCacheRate(valueKey, cacheType, inputTokenCount) ??
|
||||
cacheTokenValues[valueKey]?.[cacheType] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
if (!cacheType || !model) {
|
||||
|
|
@ -590,7 +644,11 @@ export function createTxMethods(
|
|||
return null;
|
||||
}
|
||||
|
||||
return cacheTokenValues[valueKey]?.[cacheType] ?? null;
|
||||
return (
|
||||
getPremiumCacheRate(valueKey, cacheType, inputTokenCount) ??
|
||||
cacheTokenValues[valueKey]?.[cacheType] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue