diff --git a/apps/gateway/src/lib/costs.spec.ts b/apps/gateway/src/lib/costs.spec.ts index c7c4e5235b..d1d251516e 100644 --- a/apps/gateway/src/lib/costs.spec.ts +++ b/apps/gateway/src/lib/costs.spec.ts @@ -2296,3 +2296,123 @@ describe("shouldBillCancelledRequests", () => { expect(shouldBillCancelledRequests()).toBe(false); }); }); + +describe("peak / off-peak time-of-day pricing (DeepSeek)", () => { + beforeEach(() => { + vi.mocked(mockGetEffectiveDiscount).mockImplementation(async () => ({ + discount: "0", + source: "none", + })); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const setTime = (iso: string) => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date(iso)); + }; + + it("bills off-peak rates outside the peak window", async () => { + setTime("2026-08-17T12:00:00Z"); // 12:00 UTC — off-peak + + const flash = await calculateCosts( + "deepseek-v4-flash", + "deepseek", + null, + 2_000_000, + 1_000_000, + 1_000_000, + ); + expect(flash.inputCost).toBeCloseTo(0.22); + expect(flash.outputCost).toBeCloseTo(0.66); + expect(flash.cachedInputCost).toBeCloseTo(0.007); + + const pro = await calculateCosts( + "deepseek-v4-pro", + "deepseek", + null, + 2_000_000, + 1_000_000, + 1_000_000, + ); + expect(pro.inputCost).toBeCloseTo(0.66); + expect(pro.outputCost).toBeCloseTo(1.98); + expect(pro.cachedInputCost).toBeCloseTo(0.022); + }); + + it("bills peak rates inside the peak window", async () => { + setTime("2026-08-17T02:00:00Z"); // 02:00 UTC — peak (01:00-04:00) + + const flash = await calculateCosts( + "deepseek-v4-flash", + "deepseek", + null, + 2_000_000, + 1_000_000, + 1_000_000, + ); + expect(flash.inputCost).toBeCloseTo(0.44); + expect(flash.outputCost).toBeCloseTo(1.32); + expect(flash.cachedInputCost).toBeCloseTo(0.014); + + const pro = await calculateCosts( + "deepseek-v4-pro", + "deepseek", + null, + 2_000_000, + 1_000_000, + 1_000_000, + ); + expect(pro.inputCost).toBeCloseTo(1.32); + expect(pro.outputCost).toBeCloseTo(3.96); + expect(pro.cachedInputCost).toBeCloseTo(0.044); + }); + + it("bills off-peak rates at the first window boundary (04:00 UTC)", async () => { + setTime("2026-08-17T04:00:00Z"); + + const flash = await calculateCosts( + "deepseek-v4-flash", + "deepseek", + null, + 1_000_000, + 0, + null, + ); + expect(flash.inputCost).toBeCloseTo(0.22); + }); + + it("bills peak rates in the second window (06:00-10:00 UTC)", async () => { + setTime("2026-08-17T08:00:00Z"); + + const flash = await calculateCosts( + "deepseek-v4-flash", + "deepseek", + null, + 1_000_000, + 0, + null, + ); + expect(flash.inputCost).toBeCloseTo(0.44); + }); + + it("bills base (regular flat) rates before effectiveAt", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date("2026-08-13T08:00:00Z")); // before effectiveAt + + const flash = await calculateCosts( + "deepseek-v4-flash", + "deepseek", + null, + 2_000_000, + 1_000_000, + 1_000_000, + ); + // The base (regular) flat prices apply before effectiveAt + expect(flash.inputCost).toBeCloseTo(0.14); // base: 0.14e-6 * 1M (prompt - cached = 1M) + expect(flash.outputCost).toBeCloseTo(0.28); // base: 0.28e-6 * 1M + expect(flash.cachedInputCost).toBeCloseTo(0.0028); // base: 0.0028e-6 * 1M + }); +}); diff --git a/apps/gateway/src/lib/costs.ts b/apps/gateway/src/lib/costs.ts index be72e12c44..bb87ae96d3 100644 --- a/apps/gateway/src/lib/costs.ts +++ b/apps/gateway/src/lib/costs.ts @@ -14,6 +14,7 @@ import { type ToolCall, expandAllProviderRegions, getSupportedServiceTiers, + resolveTimeBasedPricing, } from "@llmgateway/models"; /** @@ -536,12 +537,16 @@ export async function calculateCosts( // Set completion tokens to 0 if not available (but still calculate input costs) calculatedCompletionTokens ??= 0; - // Get pricing based on token count (supports tiered pricing) + // Resolve peak/off-peak time-of-day pricing (DeepSeek first-party): use the + // peak rates while the current UTC hour is inside the mapping's peak window, + // the off-peak base rates otherwise. Tier selection below then overrides by + // token count for mappings that price by context length. + const timeBasedPricing = resolveTimeBasedPricing(providerInfo); const pricing = getPricingForTokenCount( providerInfo.pricingTiers, - providerInfo.inputPrice ?? "0", - providerInfo.outputPrice ?? "0", - providerInfo.cachedInputPrice, + timeBasedPricing.inputPrice, + timeBasedPricing.outputPrice, + timeBasedPricing.cachedInputPrice, providerInfo.cacheReadInputPrice, providerInfo.cacheWriteInputPrice, providerInfo.cacheWriteInputPrice1h, diff --git a/packages/actions/src/get-cheapest-from-available-providers.ts b/packages/actions/src/get-cheapest-from-available-providers.ts index 34f247ee6d..67ce700aca 100644 --- a/packages/actions/src/get-cheapest-from-available-providers.ts +++ b/packages/actions/src/get-cheapest-from-available-providers.ts @@ -10,6 +10,7 @@ import { type AvailableModelProvider, type ModelWithPricing, type ProviderModelMapping, + resolveTimeBasedPricing, } from "@llmgateway/models"; import { randomFloat, randomInt } from "@llmgateway/shared/random"; import { @@ -333,20 +334,29 @@ function getPerSecondBillingKeys( export function getProviderSelectionPrice( providerInfo: - | Pick< + | (Pick< ProviderModelMapping, | "inputPrice" | "outputPrice" | "perSecondPrice" | "perImagePrice" | "requestPrice" - > + > & + Partial>) | undefined, videoPricing?: VideoPricingContext, + now: Date = new Date(), ): Decimal { - const inputPrice = providerInfo?.inputPrice; - const outputPrice = providerInfo?.outputPrice; const requestPrice = providerInfo?.requestPrice; + // Resolve peak/off-peak time-of-day pricing (DeepSeek first-party): token + // rates vary by UTC hour once the mapping's peakPricing is effective, so + // routing must rank with the same rates billing uses. + const timeBasedPricing = providerInfo + ? resolveTimeBasedPricing(providerInfo, now) + : undefined; + const inputPrice = timeBasedPricing?.inputPrice ?? providerInfo?.inputPrice; + const outputPrice = + timeBasedPricing?.outputPrice ?? providerInfo?.outputPrice; const hasAnyTokenPrice = inputPrice !== undefined || outputPrice !== undefined; const hasPositiveTokenPrice = @@ -399,7 +409,8 @@ type ProviderSelectionPriceInfo = AvailableModelProvider & | "perSecondPrice" | "perImagePrice" | "requestPrice" - >; + > & + Partial>; export async function getDiscountedProviderSelectionPrice( providerInfo: ProviderSelectionPriceInfo | undefined, diff --git a/packages/actions/src/models.spec.ts b/packages/actions/src/models.spec.ts index 93f095bf10..d68fe5d316 100644 --- a/packages/actions/src/models.spec.ts +++ b/packages/actions/src/models.spec.ts @@ -1313,6 +1313,132 @@ describe("getCheapestFromAvailableProviders", () => { ).toBe(0.375 / 1e6); }); + describe("time-of-day pricing in provider selection", () => { + // Mirrors the DeepSeek V4 Pro first-party mapping: peak 01:00-04:00 and + // 06:00-10:00 UTC at double the off-peak rates, base (regular) flat + // rates before effectiveAt. + const deepseekLikeMapping = { + inputPrice: "0.435e-6", + outputPrice: "0.87e-6", + cachedInputPrice: "0.003625e-6", + peakPricing: { + effectiveAt: "2026-08-16T16:00:00Z", + peak: { + inputPrice: "1.32e-6", + outputPrice: "3.96e-6", + cachedInputPrice: "0.044e-6", + }, + offPeak: { + inputPrice: "0.66e-6", + outputPrice: "1.98e-6", + cachedInputPrice: "0.022e-6", + }, + hoursUtc: [ + [1, 4], + [6, 10], + ] as [number, number][], + }, + }; + + it("returns the peak average during peak hours", () => { + expect( + getProviderSelectionPrice( + deepseekLikeMapping, + undefined, + new Date("2026-08-17T02:00:00Z"), + ).toNumber(), + ).toBe((1.32e-6 + 3.96e-6) / 2); + }); + + it("returns the off-peak average outside peak hours", () => { + expect( + getProviderSelectionPrice( + deepseekLikeMapping, + undefined, + new Date("2026-08-17T12:00:00Z"), + ).toNumber(), + ).toBe((0.66e-6 + 1.98e-6) / 2); + }); + + it("returns the base average before effectiveAt", () => { + expect( + getProviderSelectionPrice( + deepseekLikeMapping, + undefined, + new Date("2026-08-15T12:00:00Z"), + ).toNumber(), + ).toBe((0.435e-6 + 0.87e-6) / 2); + }); + + it("returns the plain average for a mapping without peakPricing", () => { + expect( + getProviderSelectionPrice({ + inputPrice: "0.5e-6", + outputPrice: "1.5e-6", + }).toNumber(), + ).toBe(1e-6); + }); + + it("ranks with peak rates through full provider selection", async () => { + // The deepseek mapping's peak window covers every hour and + // effectiveAt is in the past, so the selection price is the peak + // rate regardless of wall clock. At the raw base rates deepseek's + // average (0.375e-6) beats openai's (0.75e-6); with peak rates + // applied deepseek's average rises to 1.125e-6 and openai wins — + // so selecting openai proves time-of-day pricing was applied. + const alwaysPeakModel = { + id: "always-peak-model", + name: "Always Peak Model", + family: "openai" as const, + providers: [ + { + providerId: "openai" as const, + externalId: "always-peak", + inputPrice: "0.5e-6", + outputPrice: "1.0e-6", + streaming: true as const, + }, + { + providerId: "deepseek" as const, + externalId: "always-peak", + inputPrice: "0.25e-6", + outputPrice: "0.5e-6", + peakPricing: { + effectiveAt: "2020-01-01T00:00:00Z", + peak: { + inputPrice: "0.75e-6", + outputPrice: "1.5e-6", + }, + offPeak: { + inputPrice: "0.25e-6", + outputPrice: "0.5e-6", + }, + hoursUtc: [[0, 24]] as [number, number][], + }, + streaming: true as const, + }, + ], + }; + + const equalPriority = resolveRoutingConfig( + { providerPriorities: { openai: 1, deepseek: 1 } }, + buildProviderPriorityDefaults(), + ); + + const result = await getCheapestFromAvailableProviders( + alwaysPeakModel.providers, + alwaysPeakModel, + { routingConfig: equalPriority }, + ); + + expect(result?.provider.providerId).toBe("openai"); + const deepseekScore = result?.metadata.providerScores.find( + (s) => s.providerId === "deepseek", + ); + expect(deepseekScore?.price).toBe((0.75e-6 + 1.5e-6) / 2); + }); + }); + describe("cache support weighting", () => { const cacheTestModel = { id: "cache-test-model", diff --git a/packages/models/src/helpers.spec.ts b/packages/models/src/helpers.spec.ts new file mode 100644 index 0000000000..aaf5454f25 --- /dev/null +++ b/packages/models/src/helpers.spec.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; + +import { resolveTimeBasedPricing } from "./helpers.js"; + +import type { ProviderModelMapping } from "./models.js"; + +const peakPricedMapping = { + inputPrice: "0.14e-6", + outputPrice: "0.28e-6", + cachedInputPrice: "0.0028e-6", + peakPricing: { + effectiveAt: "2026-08-16T16:00:00Z", + peak: { + inputPrice: "0.44e-6", + outputPrice: "1.32e-6", + cachedInputPrice: "0.014e-6", + }, + offPeak: { + inputPrice: "0.22e-6", + outputPrice: "0.66e-6", + cachedInputPrice: "0.007e-6", + }, + hoursUtc: [ + [1, 4], + [6, 10], + ], + }, +} satisfies Pick< + ProviderModelMapping, + "inputPrice" | "outputPrice" | "cachedInputPrice" | "peakPricing" +>; + +const at = (iso: string) => new Date(iso); + +describe("resolveTimeBasedPricing", () => { + it("returns the base prices unchanged when the mapping has no peakPricing", () => { + const mapping = { + inputPrice: "0.14e-6", + outputPrice: "0.28e-6", + cachedInputPrice: "0.0028e-6", + } satisfies Pick< + ProviderModelMapping, + "inputPrice" | "outputPrice" | "cachedInputPrice" + >; + + expect( + resolveTimeBasedPricing(mapping, at("2026-08-17T02:00:00Z")), + ).toEqual({ + inputPrice: "0.14e-6", + outputPrice: "0.28e-6", + cachedInputPrice: "0.0028e-6", + }); + }); + + it.each([ + ["01:00", "2026-08-17T01:00:00Z"], + ["03:59", "2026-08-17T03:59:00Z"], + ["06:00", "2026-08-17T06:00:00Z"], + ["09:59", "2026-08-17T09:59:00Z"], + ])("applies peak rates during peak hours (%s)", (_label, iso) => { + expect(resolveTimeBasedPricing(peakPricedMapping, at(iso))).toEqual({ + inputPrice: "0.44e-6", + outputPrice: "1.32e-6", + cachedInputPrice: "0.014e-6", + }); + }); + + it.each([ + ["00:59", "2026-08-17T00:59:00Z"], + ["04:00", "2026-08-17T04:00:00Z"], + ["05:59", "2026-08-17T05:59:00Z"], + ["10:00", "2026-08-17T10:00:00Z"], + ["23:59", "2026-08-17T23:59:00Z"], + ])("applies off-peak rates outside the peak window (%s)", (_label, iso) => { + expect(resolveTimeBasedPricing(peakPricedMapping, at(iso))).toEqual({ + inputPrice: "0.22e-6", + outputPrice: "0.66e-6", + cachedInputPrice: "0.007e-6", + }); + }); + + const effectiveAtMapping = { + inputPrice: "0.14e-6", + outputPrice: "0.28e-6", + cachedInputPrice: "0.0028e-6", + peakPricing: { + effectiveAt: "2026-08-16T16:00:00Z", + peak: { + inputPrice: "0.44e-6", + outputPrice: "1.32e-6", + cachedInputPrice: "0.014e-6", + }, + offPeak: { + inputPrice: "0.22e-6", + outputPrice: "0.66e-6", + cachedInputPrice: "0.007e-6", + }, + hoursUtc: [ + [1, 4], + [6, 10], + ], + }, + } satisfies Pick< + ProviderModelMapping, + "inputPrice" | "outputPrice" | "cachedInputPrice" | "peakPricing" + >; + + it("returns the base (regular) prices before effectiveAt", () => { + expect( + resolveTimeBasedPricing(effectiveAtMapping, at("2026-08-16T15:59:00Z")), + ).toEqual({ + inputPrice: "0.14e-6", + outputPrice: "0.28e-6", + cachedInputPrice: "0.0028e-6", + }); + expect( + resolveTimeBasedPricing(effectiveAtMapping, at("2026-08-16T16:00:00Z")), + ).toEqual({ + inputPrice: "0.22e-6", + outputPrice: "0.66e-6", + cachedInputPrice: "0.007e-6", + }); // at effectiveAt — off-peak rates apply + expect( + resolveTimeBasedPricing(effectiveAtMapping, at("2026-08-16T17:00:00Z")), + ).toEqual({ + inputPrice: "0.22e-6", + outputPrice: "0.66e-6", + cachedInputPrice: "0.007e-6", + }); // off-peak hour on effective date — off-peak rates + expect( + resolveTimeBasedPricing(effectiveAtMapping, at("2026-08-17T02:00:00Z")), + ).toEqual({ + inputPrice: "0.44e-6", + outputPrice: "1.32e-6", + cachedInputPrice: "0.014e-6", + }); // peak hour after effectiveAt — peak rates + }); + + it("returns undefined peak cached rate when peakPricing omits it", () => { + const mapping = { + inputPrice: "0.14e-6", + outputPrice: "0.28e-6", + peakPricing: { + effectiveAt: "2026-08-16T16:00:00Z", + peak: { + inputPrice: "0.44e-6", + outputPrice: "1.32e-6", + }, + offPeak: { + inputPrice: "0.22e-6", + outputPrice: "0.66e-6", + }, + hoursUtc: [[1, 4]], + }, + } satisfies Pick< + ProviderModelMapping, + "inputPrice" | "outputPrice" | "cachedInputPrice" | "peakPricing" + >; + + expect( + resolveTimeBasedPricing(mapping, at("2026-08-17T02:00:00Z")), + ).toEqual({ + inputPrice: "0.44e-6", + outputPrice: "1.32e-6", + cachedInputPrice: undefined, + }); + }); +}); diff --git a/packages/models/src/helpers.ts b/packages/models/src/helpers.ts index 62ba02a17b..8939436e97 100644 --- a/packages/models/src/helpers.ts +++ b/packages/models/src/helpers.ts @@ -194,3 +194,50 @@ const OPENAI_EXPLICIT_PROMPT_CACHE_MODELS = new Set([ export function supportsOpenAIExplicitPromptCache(modelName: string): boolean { return OPENAI_EXPLICIT_PROMPT_CACHE_MODELS.has(modelName); } + +/** + * Resolve the per-token rates that apply to a mapping at a given instant. + * Without `peakPricing`, the mapping's base inputPrice/outputPrice/ + * cachedInputPrice are always returned. With `peakPricing`, the base fields + * (the regular flat rates) apply before `effectiveAt`; on/after it, the + * `peak` rates apply while `now` (UTC) falls inside a peak window and the + * `offPeak` rates otherwise. + */ +export function resolveTimeBasedPricing( + mapping: Pick< + ProviderModelMapping, + "inputPrice" | "outputPrice" | "cachedInputPrice" | "peakPricing" + >, + now: Date = new Date(), +): { + inputPrice: string; + outputPrice: string; + cachedInputPrice: string | undefined; +} { + const peakPricing = mapping.peakPricing; + if (!peakPricing) { + return { + inputPrice: mapping.inputPrice ?? "0", + outputPrice: mapping.outputPrice ?? "0", + cachedInputPrice: mapping.cachedInputPrice, + }; + } + // Before effectiveAt, charge the base (regular flat) prices. + if (now.getTime() < Date.parse(peakPricing.effectiveAt)) { + return { + inputPrice: mapping.inputPrice ?? "0", + outputPrice: mapping.outputPrice ?? "0", + cachedInputPrice: mapping.cachedInputPrice, + }; + } + const hour = now.getUTCHours(); + const isPeak = peakPricing.hoursUtc.some( + ([start, end]) => hour >= start && hour < end, + ); + const tier = isPeak ? peakPricing.peak : peakPricing.offPeak; + return { + inputPrice: tier.inputPrice, + outputPrice: tier.outputPrice, + cachedInputPrice: tier.cachedInputPrice, + }; +} diff --git a/packages/models/src/models.ts b/packages/models/src/models.ts index c9320c44bb..0006a3d91b 100644 --- a/packages/models/src/models.ts +++ b/packages/models/src/models.ts @@ -317,6 +317,66 @@ export interface ProviderModelMapping { * Tiers should be sorted by upToTokens in ascending order. */ pricingTiers?: PricingTier[]; + /** + * Peak/off-peak time-of-day pricing. The mapping's base + * inputPrice/outputPrice/cachedInputPrice are the regular flat prices, + * billed before `effectiveAt` (and always when `peakPricing` is absent). + * On/after `effectiveAt`, `peak` applies while the current UTC hour falls + * inside `hoursUtc` and `offPeak` applies otherwise. Only DeepSeek's + * first-party API uses this today — peak 01:00-04:00 and 06:00-10:00 UTC + * at double the off-peak rates, effective 2026-08-16. + */ + peakPricing?: { + /** + * ISO-8601 instant when peak/off-peak pricing takes effect. Before + * this date the mapping's base inputPrice/outputPrice/cachedInputPrice + * (the regular flat rates) apply. + */ + effectiveAt: string; + /** + * Prices charged during peak hours (on/after effectiveAt). + */ + peak: { + /** + * Price per input token in USD during peak hours. + */ + inputPrice: Price; + /** + * Price per output token in USD during peak hours. + */ + outputPrice: Price; + /** + * Price per cached input token in USD during peak hours. When + * unset, billing falls back to `inputPrice`, matching base-price + * behavior. + */ + cachedInputPrice?: Price; + }; + /** + * Prices charged during off-peak hours (on/after effectiveAt). + */ + offPeak: { + /** + * Price per input token in USD during off-peak hours. + */ + inputPrice: Price; + /** + * Price per output token in USD during off-peak hours. + */ + outputPrice: Price; + /** + * Price per cached input token in USD during off-peak hours. When + * unset, billing falls back to `inputPrice`, matching base-price + * behavior. + */ + cachedInputPrice?: Price; + }; + /** + * Peak hours in UTC as half-open [start, end) hour ranges (0-23). All + * hours outside these ranges are off-peak. + */ + hoursUtc: readonly [start: number, end: number][]; + }; /** * Maximum context window size in tokens */ diff --git a/packages/models/src/models/deepseek.ts b/packages/models/src/models/deepseek.ts index 7838d35acc..d77c995632 100644 --- a/packages/models/src/models/deepseek.ts +++ b/packages/models/src/models/deepseek.ts @@ -261,9 +261,30 @@ export const deepseekModels = [ { providerId: "deepseek", externalId: "deepseek-v4-pro", + // Base fields are the regular flat rates, billed before + // effectiveAt (2026-08-16 16:00 UTC). On/after, peak hours + // (01:00-04:00 and 06:00-10:00 UTC) bill at the peak rates + // below, all other hours at the offPeak rates. inputPrice: "0.435e-6", outputPrice: "0.87e-6", cachedInputPrice: "0.003625e-6", + peakPricing: { + effectiveAt: "2026-08-16T16:00:00Z", + peak: { + inputPrice: "1.32e-6", + outputPrice: "3.96e-6", + cachedInputPrice: "0.044e-6", + }, + offPeak: { + inputPrice: "0.66e-6", + outputPrice: "1.98e-6", + cachedInputPrice: "0.022e-6", + }, + hoursUtc: [ + [1, 4], + [6, 10], + ], + }, requestPrice: "0", contextSize: 1050000, maxOutput: 393216, @@ -500,9 +521,30 @@ export const deepseekModels = [ { providerId: "deepseek", externalId: "deepseek-v4-flash", + // Base fields are the regular flat rates, billed before + // effectiveAt (2026-08-16 16:00 UTC). On/after, peak hours + // (01:00-04:00 and 06:00-10:00 UTC) bill at the peak rates + // below, all other hours at the offPeak rates. inputPrice: "0.14e-6", outputPrice: "0.28e-6", cachedInputPrice: "0.0028e-6", + peakPricing: { + effectiveAt: "2026-08-16T16:00:00Z", + peak: { + inputPrice: "0.44e-6", + outputPrice: "1.32e-6", + cachedInputPrice: "0.014e-6", + }, + offPeak: { + inputPrice: "0.22e-6", + outputPrice: "0.66e-6", + cachedInputPrice: "0.007e-6", + }, + hoursUtc: [ + [1, 4], + [6, 10], + ], + }, requestPrice: "0", contextSize: 1050000, maxOutput: 393216,