Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
new-api
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Unverified
Commit
92d3c9d1
authored
Jul 11, 2026
by
CaIon
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: bound uncached remainder by prompt-max(cached,write) and forward compact prompt_cache_key
parent
48068ce9
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
34 additions
and
18 deletions
+34
-18
dto/openai_response.go
+9
-4
relay/channel/openai/relay_image.go
+1
-0
relay/responses_handler.go
+6
-0
service/relayconvert/internal/oai_chat/to_claude_messages_resp.go
+2
-1
service/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
+1
-1
service/text_quota.go
+4
-3
service/text_quota_test.go
+2
-2
service/tiered_settle.go
+2
-0
web/default/src/components/ui/sidebar.tsx
+7
-7
No files found.
dto/openai_response.go
View file @
92d3c9d1
...
...
@@ -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
{
...
...
relay/channel/openai/relay_image.go
View file @
92d3c9d1
...
...
@@ -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
...
...
relay/responses_handler.go
View file @
92d3c9d1
...
...
@@ -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
,
}
...
...
service/relayconvert/internal/oai_chat/to_claude_messages_resp.go
View file @
92d3c9d1
...
...
@@ -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
...
...
service/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
View file @
92d3c9d1
...
...
@@ -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
)
...
...
service/text_quota.go
View file @
92d3c9d1
...
...
@@ -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
}
...
...
service/text_quota_test.go
View file @
92d3c9d1
...
...
@@ -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
,
...
...
service/tiered_settle.go
View file @
92d3c9d1
...
...
@@ -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
}
...
...
web/default/src/components/ui/sidebar.tsx
View file @
92d3c9d1
...
...
@@ -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
),
},
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment