mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-14 00:01:53 +00:00
fix: bound uncached remainder by prompt-max(cached,write) and forward compact prompt_cache_key
This commit is contained in:
@@ -270,12 +270,17 @@ type InputTokenDetails struct {
|
|||||||
// which field the upstream reported it in: Claude-derived conversions populate
|
// which field the upstream reported it in: Claude-derived conversions populate
|
||||||
// CachedCreationTokens while OpenAI reports cache_write_tokens natively. Both
|
// CachedCreationTokens while OpenAI reports cache_write_tokens natively. Both
|
||||||
// are billed at the cache-creation price; when both are present the larger
|
// are billed at the cache-creation price; when both are present the larger
|
||||||
// value wins so the same tokens are never double-counted.
|
// value wins so the same tokens are never double-counted. Negative upstream
|
||||||
|
// values are clamped to zero so they can never lower a charge.
|
||||||
func (d InputTokenDetails) CacheCreationTokensTotal() int {
|
func (d InputTokenDetails) CacheCreationTokensTotal() int {
|
||||||
if d.CacheWriteTokens > d.CachedCreationTokens {
|
total := d.CachedCreationTokens
|
||||||
return d.CacheWriteTokens
|
if d.CacheWriteTokens > total {
|
||||||
|
total = d.CacheWriteTokens
|
||||||
}
|
}
|
||||||
return d.CachedCreationTokens
|
if total < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
type OutputTokenDetails struct {
|
type OutputTokenDetails struct {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ func normalizeOpenAIUsage(usage *dto.Usage) {
|
|||||||
if usage.InputTokensDetails != nil {
|
if usage.InputTokensDetails != nil {
|
||||||
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
|
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
|
||||||
usage.PromptTokensDetails.CachedCreationTokens = usage.InputTokensDetails.CachedCreationTokens
|
usage.PromptTokensDetails.CachedCreationTokens = usage.InputTokensDetails.CachedCreationTokens
|
||||||
|
usage.PromptTokensDetails.CacheWriteTokens = usage.InputTokensDetails.CacheWriteTokens
|
||||||
usage.PromptTokensDetails.ImageTokens = usage.InputTokensDetails.ImageTokens
|
usage.PromptTokensDetails.ImageTokens = usage.InputTokensDetails.ImageTokens
|
||||||
usage.PromptTokensDetails.TextTokens = usage.InputTokensDetails.TextTokens
|
usage.PromptTokensDetails.TextTokens = usage.InputTokensDetails.TextTokens
|
||||||
usage.PromptTokensDetails.AudioTokens = usage.InputTokensDetails.AudioTokens
|
usage.PromptTokensDetails.AudioTokens = usage.InputTokensDetails.AudioTokens
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
|||||||
case *dto.OpenAIResponsesRequest:
|
case *dto.OpenAIResponsesRequest:
|
||||||
responsesReq = req
|
responsesReq = req
|
||||||
case *dto.OpenAIResponsesCompactionRequest:
|
case *dto.OpenAIResponsesCompactionRequest:
|
||||||
|
// Only fields documented for POST /v1/responses/compact are forwarded:
|
||||||
|
// model, input, instructions, previous_response_id, prompt_cache_key,
|
||||||
|
// prompt_cache_options, prompt_cache_retention, service_tier.
|
||||||
|
// Undocumented Codex-parity fields (tools, reasoning, text) are parsed
|
||||||
|
// for client compatibility but intentionally not sent upstream.
|
||||||
responsesReq = &dto.OpenAIResponsesRequest{
|
responsesReq = &dto.OpenAIResponsesRequest{
|
||||||
Model: req.Model,
|
Model: req.Model,
|
||||||
Input: req.Input,
|
Input: req.Input,
|
||||||
@@ -47,6 +52,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
|||||||
PreviousResponseID: req.PreviousResponseID,
|
PreviousResponseID: req.PreviousResponseID,
|
||||||
ParallelToolCalls: req.ParallelToolCalls,
|
ParallelToolCalls: req.ParallelToolCalls,
|
||||||
ServiceTier: req.ServiceTier,
|
ServiceTier: req.ServiceTier,
|
||||||
|
PromptCacheKey: req.PromptCacheKey,
|
||||||
PromptCacheOptions: req.PromptCacheOptions,
|
PromptCacheOptions: req.PromptCacheOptions,
|
||||||
PromptCacheRetention: req.PromptCacheRetention,
|
PromptCacheRetention: req.PromptCacheRetention,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
|
|||||||
if oaiUsage.PromptTokensDetails.CacheWriteTokens > 0 {
|
if oaiUsage.PromptTokensDetails.CacheWriteTokens > 0 {
|
||||||
// OpenAI native cache-write usage counts cached and cache-write tokens
|
// OpenAI native cache-write usage counts cached and cache-write tokens
|
||||||
// inside prompt_tokens, while Claude semantics reports input_tokens
|
// inside prompt_tokens, while Claude semantics reports input_tokens
|
||||||
// excluding both; the uncached remainder clamps at zero.
|
// excluding both. Both counts are unadjusted prefixes and may overlap,
|
||||||
|
// so clamp a negative remainder at zero.
|
||||||
inputTokens = oaiUsage.PromptTokens - oaiUsage.PromptTokensDetails.CachedTokens - cacheCreationTokens
|
inputTokens = oaiUsage.PromptTokens - oaiUsage.PromptTokensDetails.CachedTokens - cacheCreationTokens
|
||||||
if inputTokens < 0 {
|
if inputTokens < 0 {
|
||||||
inputTokens = 0
|
inputTokens = 0
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func TestBuildClaudeUsageFromOpenAICacheWriteUsage(t *testing.T) {
|
|||||||
|
|
||||||
require.NotNil(t, usage)
|
require.NotNil(t, usage)
|
||||||
// Claude semantics reports input_tokens excluding cache read/write; the
|
// Claude semantics reports input_tokens excluding cache read/write; the
|
||||||
// remainder 3619-2921-3616 clamps to 0.
|
// overlapping unadjusted prefixes drive the remainder negative, clamp to 0.
|
||||||
assert.Equal(t, 0, usage.InputTokens)
|
assert.Equal(t, 0, usage.InputTokens)
|
||||||
assert.Equal(t, 2921, usage.CacheReadInputTokens)
|
assert.Equal(t, 2921, usage.CacheReadInputTokens)
|
||||||
assert.Equal(t, 3616, usage.CacheCreationInputTokens)
|
assert.Equal(t, 3616, usage.CacheCreationInputTokens)
|
||||||
|
|||||||
@@ -294,9 +294,10 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI cache-write usage can report cached_tokens + cache_write_tokens
|
// OpenAI cache-write usage reports unadjusted prefix counts, so
|
||||||
// exceeding prompt_tokens; the uncached remainder must clamp at zero so
|
// cached_tokens + cache_write_tokens can exceed prompt_tokens and the
|
||||||
// billing never subtracts more than the reported input.
|
// remainder can go negative. Clamp at zero so overlap never turns into
|
||||||
|
// a negative base charge.
|
||||||
if baseTokens.IsNegative() {
|
if baseTokens.IsNegative() {
|
||||||
baseTokens = decimal.Zero
|
baseTokens = decimal.Zero
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -411,8 +411,8 @@ func TestCalculateTextQuotaSummaryBillsOpenAICacheWriteTokens(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("uncached remainder clamps to zero", func(t *testing.T) {
|
t.Run("uncached remainder clamps to zero", func(t *testing.T) {
|
||||||
// Real OpenAI payload shape: cached_tokens + cache_write_tokens exceeds
|
// Real OpenAI payload shape: cached_tokens + cache_write_tokens exceeds
|
||||||
// prompt_tokens, so the uncached remainder must clamp to 0 instead of
|
// prompt_tokens because both are unadjusted prefix counts. The negative
|
||||||
// producing a negative charge component.
|
// remainder must clamp to zero, never turn into a negative base charge.
|
||||||
usage := &dto.Usage{
|
usage := &dto.Usage{
|
||||||
PromptTokens: 3619,
|
PromptTokens: 3619,
|
||||||
CompletionTokens: 36,
|
CompletionTokens: 36,
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenAI cache-write usage reports unadjusted prefix counts, so cr + cc can
|
||||||
|
// exceed the prompt and drive the remainder negative. Clamp at zero.
|
||||||
if p < 0 {
|
if p < 0 {
|
||||||
p = 0
|
p = 0
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -444,7 +444,7 @@ function SidebarGroupAction({
|
|||||||
props: mergeProps<'button'>(
|
props: mergeProps<'button'>(
|
||||||
{
|
{
|
||||||
className: cn(
|
className: cn(
|
||||||
'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
|
'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-muted hover:text-sidebar-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
className
|
className
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -495,13 +495,13 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sidebarMenuButtonVariants = cva(
|
const sidebarMenuButtonVariants = cva(
|
||||||
'peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate',
|
'peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-muted hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-muted data-open:hover:text-sidebar-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground data-active:hover:bg-sidebar-accent data-active:hover:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
default: 'hover:bg-muted hover:text-sidebar-foreground',
|
||||||
outline:
|
outline:
|
||||||
'bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]',
|
'bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-muted hover:text-sidebar-foreground hover:shadow-[0_0_0_1px_var(--muted)]',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-8 text-sm',
|
default: 'h-8 text-sm',
|
||||||
@@ -586,7 +586,7 @@ function SidebarMenuAction({
|
|||||||
props: mergeProps<'button'>(
|
props: mergeProps<'button'>(
|
||||||
{
|
{
|
||||||
className: cn(
|
className: cn(
|
||||||
'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
|
'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-muted hover:text-sidebar-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
showOnHover &&
|
showOnHover &&
|
||||||
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0',
|
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0',
|
||||||
className
|
className
|
||||||
@@ -611,7 +611,7 @@ function SidebarMenuBadge({
|
|||||||
data-slot='sidebar-menu-badge'
|
data-slot='sidebar-menu-badge'
|
||||||
data-sidebar='menu-badge'
|
data-sidebar='menu-badge'
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-sidebar-foreground peer-hover/menu-button:text-sidebar-accent-foreground peer-data-active/menu-button:text-sidebar-accent-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1',
|
'text-sidebar-foreground peer-hover/menu-button:text-sidebar-foreground peer-data-active/menu-button:text-sidebar-accent-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -701,7 +701,7 @@ function SidebarMenuSubButton({
|
|||||||
props: mergeProps<'a'>(
|
props: mergeProps<'a'>(
|
||||||
{
|
{
|
||||||
className: cn(
|
className: cn(
|
||||||
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
|
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-muted hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-active:hover:bg-sidebar-accent data-active:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
|
||||||
className
|
className
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user