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
Show 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 {
...
@@ -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
{
...
...
relay/channel/openai/relay_image.go
View file @
92d3c9d1
...
@@ -80,6 +80,7 @@ func normalizeOpenAIUsage(usage *dto.Usage) {
...
@@ -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
...
...
relay/responses_handler.go
View file @
92d3c9d1
...
@@ -40,6 +40,11 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
...
@@ -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 *
...
@@ -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
,
}
}
...
...
service/relayconvert/internal/oai_chat/to_claude_messages_resp.go
View file @
92d3c9d1
...
@@ -44,7 +44,8 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
...
@@ -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
...
...
service/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
View file @
92d3c9d1
...
@@ -92,7 +92,7 @@ func TestBuildClaudeUsageFromOpenAICacheWriteUsage(t *testing.T) {
...
@@ -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
)
...
...
service/text_quota.go
View file @
92d3c9d1
...
@@ -294,9 +294,10 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf
...
@@ -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
}
}
...
...
service/text_quota_test.go
View file @
92d3c9d1
...
@@ -411,8 +411,8 @@ func TestCalculateTextQuotaSummaryBillsOpenAICacheWriteTokens(t *testing.T) {
...
@@ -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
,
...
...
service/tiered_settle.go
View file @
92d3c9d1
...
@@ -67,6 +67,8 @@ func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVa
...
@@ -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
}
}
...
...
web/default/src/components/ui/sidebar.tsx
View file @
92d3c9d1
...
@@ -444,7 +444,7 @@ function SidebarGroupAction({
...
@@ -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'>) {
...
@@ -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({
...
@@ -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({
...
@@ -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({
...
@@ -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
),
),
},
},
...
...
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