diff --git a/dto/openai_response.go b/dto/openai_response.go index ecda485c..2de6014f 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -270,12 +270,17 @@ type InputTokenDetails struct { // which field the upstream reported it in: Claude-derived conversions populate // CachedCreationTokens while OpenAI reports cache_write_tokens natively. Both // 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 { - if d.CacheWriteTokens > d.CachedCreationTokens { - return d.CacheWriteTokens + total := d.CachedCreationTokens + if d.CacheWriteTokens > total { + total = d.CacheWriteTokens } - return d.CachedCreationTokens + if total < 0 { + return 0 + } + return total } type OutputTokenDetails struct { diff --git a/relay/channel/openai/relay_image.go b/relay/channel/openai/relay_image.go index 90fb8794..e0f09aae 100644 --- a/relay/channel/openai/relay_image.go +++ b/relay/channel/openai/relay_image.go @@ -80,6 +80,7 @@ func normalizeOpenAIUsage(usage *dto.Usage) { if usage.InputTokensDetails != nil { usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens usage.PromptTokensDetails.CachedCreationTokens = usage.InputTokensDetails.CachedCreationTokens + usage.PromptTokensDetails.CacheWriteTokens = usage.InputTokensDetails.CacheWriteTokens usage.PromptTokensDetails.ImageTokens = usage.InputTokensDetails.ImageTokens usage.PromptTokensDetails.TextTokens = usage.InputTokensDetails.TextTokens usage.PromptTokensDetails.AudioTokens = usage.InputTokensDetails.AudioTokens diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 45037687..5fa23d09 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -40,6 +40,11 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * case *dto.OpenAIResponsesRequest: responsesReq = req 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{ Model: req.Model, Input: req.Input, @@ -47,6 +52,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * PreviousResponseID: req.PreviousResponseID, ParallelToolCalls: req.ParallelToolCalls, ServiceTier: req.ServiceTier, + PromptCacheKey: req.PromptCacheKey, PromptCacheOptions: req.PromptCacheOptions, PromptCacheRetention: req.PromptCacheRetention, } diff --git a/service/relayconvert/internal/oai_chat/to_claude_messages_resp.go b/service/relayconvert/internal/oai_chat/to_claude_messages_resp.go index 2add756f..7f0c6b8b 100644 --- a/service/relayconvert/internal/oai_chat/to_claude_messages_resp.go +++ b/service/relayconvert/internal/oai_chat/to_claude_messages_resp.go @@ -44,7 +44,8 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage { if oaiUsage.PromptTokensDetails.CacheWriteTokens > 0 { // OpenAI native cache-write usage counts cached and cache-write 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 if inputTokens < 0 { inputTokens = 0 diff --git a/service/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go b/service/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go index d3841e50..facd873e 100644 --- a/service/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go +++ b/service/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go @@ -92,7 +92,7 @@ func TestBuildClaudeUsageFromOpenAICacheWriteUsage(t *testing.T) { require.NotNil(t, usage) // 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, 2921, usage.CacheReadInputTokens) assert.Equal(t, 3616, usage.CacheCreationInputTokens) diff --git a/service/text_quota.go b/service/text_quota.go index 66244ec1..7da33912 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -294,9 +294,10 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf } } - // OpenAI cache-write usage can report cached_tokens + cache_write_tokens - // exceeding prompt_tokens; the uncached remainder must clamp at zero so - // billing never subtracts more than the reported input. + // OpenAI cache-write usage reports unadjusted prefix counts, so + // cached_tokens + cache_write_tokens can exceed prompt_tokens and the + // remainder can go negative. Clamp at zero so overlap never turns into + // a negative base charge. if baseTokens.IsNegative() { baseTokens = decimal.Zero } diff --git a/service/text_quota_test.go b/service/text_quota_test.go index d7f04f33..93092125 100644 --- a/service/text_quota_test.go +++ b/service/text_quota_test.go @@ -411,8 +411,8 @@ func TestCalculateTextQuotaSummaryBillsOpenAICacheWriteTokens(t *testing.T) { t.Run("uncached remainder clamps to zero", func(t *testing.T) { // Real OpenAI payload shape: cached_tokens + cache_write_tokens exceeds - // prompt_tokens, so the uncached remainder must clamp to 0 instead of - // producing a negative charge component. + // prompt_tokens because both are unadjusted prefix counts. The negative + // remainder must clamp to zero, never turn into a negative base charge. usage := &dto.Usage{ PromptTokens: 3619, CompletionTokens: 36, diff --git a/service/tiered_settle.go b/service/tiered_settle.go index 00eeeb8e..05337bd7 100644 --- a/service/tiered_settle.go +++ b/service/tiered_settle.go @@ -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 { p = 0 } diff --git a/web/default/src/components/ui/sidebar.tsx b/web/default/src/components/ui/sidebar.tsx index c1ab863b..36a113f6 100644 --- a/web/default/src/components/ui/sidebar.tsx +++ b/web/default/src/components/ui/sidebar.tsx @@ -444,7 +444,7 @@ function SidebarGroupAction({ props: mergeProps<'button'>( { 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 ), }, @@ -495,13 +495,13 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) { } 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: { variant: { - default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', + default: 'hover:bg-muted hover:text-sidebar-foreground', 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: { default: 'h-8 text-sm', @@ -586,7 +586,7 @@ function SidebarMenuAction({ props: mergeProps<'button'>( { 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 && '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 @@ -611,7 +611,7 @@ function SidebarMenuBadge({ data-slot='sidebar-menu-badge' data-sidebar='menu-badge' 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 )} {...props} @@ -701,7 +701,7 @@ function SidebarMenuSubButton({ props: mergeProps<'a'>( { 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 ), },