feat: bill OpenAI cache_write_tokens at cache-creation price with zero clamp
Parse OpenAI's native cache_write_tokens (chat prompt_tokens_details / responses input_tokens_details), bill it at the cache-creation ratio, and clamp the uncached prompt remainder at zero since cached + cache-write can exceed prompt_tokens. Propagate the field through chat/responses/claude format conversions and tiered expression billing (cc variable).
This commit is contained in:
@@ -88,6 +88,7 @@ func HasOpenAIUsageTokens(usage *Usage) bool {
|
|||||||
}
|
}
|
||||||
if usage.PromptTokensDetails.CachedTokens != 0 ||
|
if usage.PromptTokensDetails.CachedTokens != 0 ||
|
||||||
usage.PromptTokensDetails.CachedCreationTokens != 0 ||
|
usage.PromptTokensDetails.CachedCreationTokens != 0 ||
|
||||||
|
usage.PromptTokensDetails.CacheWriteTokens != 0 ||
|
||||||
usage.PromptTokensDetails.TextTokens != 0 ||
|
usage.PromptTokensDetails.TextTokens != 0 ||
|
||||||
usage.PromptTokensDetails.ImageTokens != 0 ||
|
usage.PromptTokensDetails.ImageTokens != 0 ||
|
||||||
usage.PromptTokensDetails.AudioTokens != 0 {
|
usage.PromptTokensDetails.AudioTokens != 0 {
|
||||||
|
|||||||
@@ -849,6 +849,7 @@ type OpenAIResponsesRequest struct {
|
|||||||
MaxOutputTokens *uint `json:"max_output_tokens,omitempty"`
|
MaxOutputTokens *uint `json:"max_output_tokens,omitempty"`
|
||||||
TopLogProbs *int `json:"top_logprobs,omitempty"`
|
TopLogProbs *int `json:"top_logprobs,omitempty"`
|
||||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||||
|
Moderation json.RawMessage `json:"moderation,omitempty"`
|
||||||
ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
|
ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
|
||||||
PreviousResponseID string `json:"previous_response_id,omitempty"`
|
PreviousResponseID string `json:"previous_response_id,omitempty"`
|
||||||
Reasoning *Reasoning `json:"reasoning,omitempty"`
|
Reasoning *Reasoning `json:"reasoning,omitempty"`
|
||||||
@@ -859,6 +860,7 @@ type OpenAIResponsesRequest struct {
|
|||||||
// This field is allowed by default and can be disabled via channel setting disable_store.
|
// This field is allowed by default and can be disabled via channel setting disable_store.
|
||||||
Store json.RawMessage `json:"store,omitempty"`
|
Store json.RawMessage `json:"store,omitempty"`
|
||||||
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
|
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
|
||||||
|
PromptCacheOptions json.RawMessage `json:"prompt_cache_options,omitempty"`
|
||||||
PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"`
|
PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"`
|
||||||
// SafetyIdentifier carries client identity for policy abuse detection.
|
// SafetyIdentifier carries client identity for policy abuse detection.
|
||||||
// This field is filtered by default and can be enabled via channel setting allow_safety_identifier.
|
// This field is filtered by default and can be enabled via channel setting allow_safety_identifier.
|
||||||
|
|||||||
+20
-3
@@ -256,9 +256,26 @@ type OpenAIVideoResponse struct {
|
|||||||
type InputTokenDetails struct {
|
type InputTokenDetails struct {
|
||||||
CachedTokens int `json:"cached_tokens"`
|
CachedTokens int `json:"cached_tokens"`
|
||||||
CachedCreationTokens int `json:"cached_creation_tokens,omitempty"`
|
CachedCreationTokens int `json:"cached_creation_tokens,omitempty"`
|
||||||
TextTokens int `json:"text_tokens"`
|
// CacheWriteTokens is OpenAI's native cache-write count, reported as
|
||||||
AudioTokens int `json:"audio_tokens"`
|
// prompt_tokens_details.cache_write_tokens (Chat Completions) or
|
||||||
ImageTokens int `json:"image_tokens"`
|
// input_tokens_details.cache_write_tokens (Responses). It is billed at the
|
||||||
|
// cache-creation price.
|
||||||
|
CacheWriteTokens int `json:"cache_write_tokens,omitempty"`
|
||||||
|
TextTokens int `json:"text_tokens"`
|
||||||
|
AudioTokens int `json:"audio_tokens"`
|
||||||
|
ImageTokens int `json:"image_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheCreationTokensTotal returns the cache-write token count regardless of
|
||||||
|
// 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.
|
||||||
|
func (d InputTokenDetails) CacheCreationTokensTotal() int {
|
||||||
|
if d.CacheWriteTokens > d.CachedCreationTokens {
|
||||||
|
return d.CacheWriteTokens
|
||||||
|
}
|
||||||
|
return d.CachedCreationTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
type OutputTokenDetails struct {
|
type OutputTokenDetails struct {
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ type OpenAIResponsesCompactionRequest struct {
|
|||||||
// Codex compact request parity:
|
// Codex compact request parity:
|
||||||
// https://github.com/openai/codex/commit/53d59722268dde82fb93c1f37964ce196c2a86d7
|
// https://github.com/openai/codex/commit/53d59722268dde82fb93c1f37964ce196c2a86d7
|
||||||
// https://github.com/openai/codex/commit/5d6f23a27bf9c90709af527a7108c1c2eadf5123
|
// https://github.com/openai/codex/commit/5d6f23a27bf9c90709af527a7108c1c2eadf5123
|
||||||
Tools json.RawMessage `json:"tools,omitempty"`
|
Tools json.RawMessage `json:"tools,omitempty"`
|
||||||
ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
|
ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
|
||||||
Reasoning *Reasoning `json:"reasoning,omitempty"`
|
Reasoning *Reasoning `json:"reasoning,omitempty"`
|
||||||
ServiceTier string `json:"service_tier,omitempty"`
|
ServiceTier string `json:"service_tier,omitempty"`
|
||||||
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
|
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
|
||||||
Text json.RawMessage `json:"text,omitempty"`
|
PromptCacheOptions json.RawMessage `json:"prompt_cache_options,omitempty"`
|
||||||
|
PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"`
|
||||||
|
Text json.RawMessage `json:"text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *OpenAIResponsesCompactionRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
func (r *OpenAIResponsesCompactionRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
|||||||
usage.TotalTokens = responsesResponse.Usage.TotalTokens
|
usage.TotalTokens = responsesResponse.Usage.TotalTokens
|
||||||
if responsesResponse.Usage.InputTokensDetails != nil {
|
if responsesResponse.Usage.InputTokensDetails != nil {
|
||||||
usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens
|
usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens
|
||||||
|
usage.PromptTokensDetails.CacheWriteTokens = responsesResponse.Usage.InputTokensDetails.CacheWriteTokens
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if info == nil || info.ResponsesUsageInfo == nil || info.ResponsesUsageInfo.BuiltInTools == nil {
|
if info == nil || info.ResponsesUsageInfo == nil || info.ResponsesUsageInfo.BuiltInTools == nil {
|
||||||
@@ -104,6 +105,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
|||||||
}
|
}
|
||||||
if streamResponse.Response.Usage.InputTokensDetails != nil {
|
if streamResponse.Response.Usage.InputTokensDetails != nil {
|
||||||
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
|
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
|
||||||
|
usage.PromptTokensDetails.CacheWriteTokens = streamResponse.Response.Usage.InputTokensDetails.CacheWriteTokens
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if streamResponse.Response.HasImageGenerationCall() {
|
if streamResponse.Response.HasImageGenerationCall() {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ func OaiResponsesCompactionHandler(c *gin.Context, resp *http.Response) (*dto.Us
|
|||||||
usage.TotalTokens = compactResp.Usage.TotalTokens
|
usage.TotalTokens = compactResp.Usage.TotalTokens
|
||||||
if compactResp.Usage.InputTokensDetails != nil {
|
if compactResp.Usage.InputTokensDetails != nil {
|
||||||
usage.PromptTokensDetails.CachedTokens = compactResp.Usage.InputTokensDetails.CachedTokens
|
usage.PromptTokensDetails.CachedTokens = compactResp.Usage.InputTokensDetails.CachedTokens
|
||||||
|
usage.PromptTokensDetails.CacheWriteTokens = compactResp.Usage.InputTokensDetails.CacheWriteTokens
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,12 +41,14 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
|||||||
responsesReq = req
|
responsesReq = req
|
||||||
case *dto.OpenAIResponsesCompactionRequest:
|
case *dto.OpenAIResponsesCompactionRequest:
|
||||||
responsesReq = &dto.OpenAIResponsesRequest{
|
responsesReq = &dto.OpenAIResponsesRequest{
|
||||||
Model: req.Model,
|
Model: req.Model,
|
||||||
Input: req.Input,
|
Input: req.Input,
|
||||||
Instructions: req.Instructions,
|
Instructions: req.Instructions,
|
||||||
PreviousResponseID: req.PreviousResponseID,
|
PreviousResponseID: req.PreviousResponseID,
|
||||||
ParallelToolCalls: req.ParallelToolCalls,
|
ParallelToolCalls: req.ParallelToolCalls,
|
||||||
ServiceTier: req.ServiceTier,
|
ServiceTier: req.ServiceTier,
|
||||||
|
PromptCacheOptions: req.PromptCacheOptions,
|
||||||
|
PromptCacheRetention: req.PromptCacheRetention,
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return types.NewErrorWithStatusCode(
|
return types.NewErrorWithStatusCode(
|
||||||
|
|||||||
@@ -216,6 +216,9 @@ func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
|
|||||||
usage.ClaudeCacheCreation1hTokens,
|
usage.ClaudeCacheCreation1hTokens,
|
||||||
)
|
)
|
||||||
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
|
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
|
||||||
|
// Expose the standard OpenAI cache-write field alongside the legacy
|
||||||
|
// cached_creation_tokens so OpenAI-format clients can bill cache writes.
|
||||||
|
clone.PromptTokensDetails.CacheWriteTokens = cacheCreationTokens
|
||||||
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
|
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
|
||||||
clone.PromptTokens = totalInputTokens
|
clone.PromptTokens = totalInputTokens
|
||||||
clone.InputTokens = totalInputTokens
|
clone.InputTokens = totalInputTokens
|
||||||
|
|||||||
@@ -39,10 +39,21 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
|
|||||||
oaiUsage.ClaudeCacheCreation5mTokens,
|
oaiUsage.ClaudeCacheCreation5mTokens,
|
||||||
oaiUsage.ClaudeCacheCreation1hTokens,
|
oaiUsage.ClaudeCacheCreation1hTokens,
|
||||||
)
|
)
|
||||||
|
cacheCreationTokens := oaiUsage.PromptTokensDetails.CacheCreationTokensTotal()
|
||||||
|
inputTokens := oaiUsage.PromptTokens
|
||||||
|
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.
|
||||||
|
inputTokens = oaiUsage.PromptTokens - oaiUsage.PromptTokensDetails.CachedTokens - cacheCreationTokens
|
||||||
|
if inputTokens < 0 {
|
||||||
|
inputTokens = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
usage := &dto.ClaudeUsage{
|
usage := &dto.ClaudeUsage{
|
||||||
InputTokens: oaiUsage.PromptTokens,
|
InputTokens: inputTokens,
|
||||||
OutputTokens: oaiUsage.CompletionTokens,
|
OutputTokens: oaiUsage.CompletionTokens,
|
||||||
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
|
CacheCreationInputTokens: cacheCreationTokens,
|
||||||
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
|
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
|
||||||
BillingUsage: billingUsage,
|
BillingUsage: billingUsage,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,30 @@ func TestResponseOpenAI2ClaudeUsageCarriesOpenAIBillingUsage(t *testing.T) {
|
|||||||
assert.Nil(t, resp.Usage.BillingUsage.OpenAIUsage.BillingUsage)
|
assert.Nil(t, resp.Usage.BillingUsage.OpenAIUsage.BillingUsage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildClaudeUsageFromOpenAICacheWriteUsage(t *testing.T) {
|
||||||
|
usage := buildClaudeUsageFromOpenAIUsage(&dto.Usage{
|
||||||
|
PromptTokens: 3619,
|
||||||
|
CompletionTokens: 36,
|
||||||
|
TotalTokens: 3655,
|
||||||
|
PromptTokensDetails: dto.InputTokenDetails{
|
||||||
|
CachedTokens: 2921,
|
||||||
|
CacheWriteTokens: 3616,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NotNil(t, usage)
|
||||||
|
// Claude semantics reports input_tokens excluding cache read/write; the
|
||||||
|
// remainder 3619-2921-3616 clamps to 0.
|
||||||
|
assert.Equal(t, 0, usage.InputTokens)
|
||||||
|
assert.Equal(t, 2921, usage.CacheReadInputTokens)
|
||||||
|
assert.Equal(t, 3616, usage.CacheCreationInputTokens)
|
||||||
|
assert.Equal(t, 36, usage.OutputTokens)
|
||||||
|
require.NotNil(t, usage.BillingUsage)
|
||||||
|
require.NotNil(t, usage.BillingUsage.OpenAIUsage)
|
||||||
|
assert.Equal(t, dto.BillingUsageSemanticOpenAI, usage.BillingUsage.Semantic)
|
||||||
|
assert.Equal(t, 3616, usage.BillingUsage.OpenAIUsage.PromptTokensDetails.CacheWriteTokens)
|
||||||
|
}
|
||||||
|
|
||||||
func TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks(t *testing.T) {
|
func TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks(t *testing.T) {
|
||||||
info := &relaycommon.RelayInfo{
|
info := &relaycommon.RelayInfo{
|
||||||
ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
|
ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ func UsageFromChatUsage(src *dto.Usage) *dto.Usage {
|
|||||||
src.PromptTokensDetails.ImageTokens != 0 ||
|
src.PromptTokensDetails.ImageTokens != 0 ||
|
||||||
src.PromptTokensDetails.AudioTokens != 0 ||
|
src.PromptTokensDetails.AudioTokens != 0 ||
|
||||||
src.PromptTokensDetails.CachedCreationTokens != 0 ||
|
src.PromptTokensDetails.CachedCreationTokens != 0 ||
|
||||||
|
src.PromptTokensDetails.CacheWriteTokens != 0 ||
|
||||||
src.PromptTokensDetails.TextTokens != 0 {
|
src.PromptTokensDetails.TextTokens != 0 {
|
||||||
details := src.PromptTokensDetails
|
details := src.PromptTokensDetails
|
||||||
usage.InputTokensDetails = &details
|
usage.InputTokensDetails = &details
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
|
|||||||
if src.InputTokensDetails != nil {
|
if src.InputTokensDetails != nil {
|
||||||
usage.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens
|
usage.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens
|
||||||
usage.PromptTokensDetails.CachedCreationTokens = src.InputTokensDetails.CachedCreationTokens
|
usage.PromptTokensDetails.CachedCreationTokens = src.InputTokensDetails.CachedCreationTokens
|
||||||
|
usage.PromptTokensDetails.CacheWriteTokens = src.InputTokensDetails.CacheWriteTokens
|
||||||
usage.PromptTokensDetails.TextTokens = src.InputTokensDetails.TextTokens
|
usage.PromptTokensDetails.TextTokens = src.InputTokensDetails.TextTokens
|
||||||
usage.PromptTokensDetails.ImageTokens = src.InputTokensDetails.ImageTokens
|
usage.PromptTokensDetails.ImageTokens = src.InputTokensDetails.ImageTokens
|
||||||
usage.PromptTokensDetails.AudioTokens = src.InputTokensDetails.AudioTokens
|
usage.PromptTokensDetails.AudioTokens = src.InputTokensDetails.AudioTokens
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ func TestConvertResponseProviderToOAIChatUsage(t *testing.T) {
|
|||||||
assert.Equal(t, 22, toChat.Usage.TotalTokens)
|
assert.Equal(t, 22, toChat.Usage.TotalTokens)
|
||||||
assert.Equal(t, 3, toChat.Usage.PromptTokensDetails.CachedTokens)
|
assert.Equal(t, 3, toChat.Usage.PromptTokensDetails.CachedTokens)
|
||||||
assert.Equal(t, 4, toChat.Usage.PromptTokensDetails.CachedCreationTokens)
|
assert.Equal(t, 4, toChat.Usage.PromptTokensDetails.CachedCreationTokens)
|
||||||
|
assert.Equal(t, 4, toChat.Usage.PromptTokensDetails.CacheWriteTokens)
|
||||||
require.NotNil(t, toChat.Usage.BillingUsage)
|
require.NotNil(t, toChat.Usage.BillingUsage)
|
||||||
require.NotNil(t, toChat.Usage.BillingUsage.ClaudeUsage)
|
require.NotNil(t, toChat.Usage.BillingUsage.ClaudeUsage)
|
||||||
assert.Equal(t, dto.BillingUsageSourceClaudeMessages, toChat.Usage.BillingUsage.Source)
|
assert.Equal(t, dto.BillingUsageSourceClaudeMessages, toChat.Usage.BillingUsage.Source)
|
||||||
@@ -539,6 +540,7 @@ func TestResponseUsageMatrixChatAndResponsesDetails(t *testing.T) {
|
|||||||
PromptTokensDetails: dto.InputTokenDetails{
|
PromptTokensDetails: dto.InputTokenDetails{
|
||||||
CachedTokens: 3,
|
CachedTokens: 3,
|
||||||
CachedCreationTokens: 2,
|
CachedCreationTokens: 2,
|
||||||
|
CacheWriteTokens: 6,
|
||||||
TextTokens: 4,
|
TextTokens: 4,
|
||||||
AudioTokens: 1,
|
AudioTokens: 1,
|
||||||
ImageTokens: 5,
|
ImageTokens: 5,
|
||||||
@@ -558,6 +560,7 @@ func TestResponseUsageMatrixChatAndResponsesDetails(t *testing.T) {
|
|||||||
require.NotNil(t, result.Usage.InputTokensDetails)
|
require.NotNil(t, result.Usage.InputTokensDetails)
|
||||||
assert.Equal(t, 3, result.Usage.InputTokensDetails.CachedTokens)
|
assert.Equal(t, 3, result.Usage.InputTokensDetails.CachedTokens)
|
||||||
assert.Equal(t, 2, result.Usage.InputTokensDetails.CachedCreationTokens)
|
assert.Equal(t, 2, result.Usage.InputTokensDetails.CachedCreationTokens)
|
||||||
|
assert.Equal(t, 6, result.Usage.InputTokensDetails.CacheWriteTokens)
|
||||||
assert.Equal(t, 4, result.Usage.InputTokensDetails.TextTokens)
|
assert.Equal(t, 4, result.Usage.InputTokensDetails.TextTokens)
|
||||||
assert.Equal(t, 1, result.Usage.InputTokensDetails.AudioTokens)
|
assert.Equal(t, 1, result.Usage.InputTokensDetails.AudioTokens)
|
||||||
assert.Equal(t, 5, result.Usage.InputTokensDetails.ImageTokens)
|
assert.Equal(t, 5, result.Usage.InputTokensDetails.ImageTokens)
|
||||||
@@ -579,6 +582,7 @@ func TestResponseUsageMatrixChatAndResponsesDetails(t *testing.T) {
|
|||||||
InputTokensDetails: &dto.InputTokenDetails{
|
InputTokensDetails: &dto.InputTokenDetails{
|
||||||
CachedTokens: 4,
|
CachedTokens: 4,
|
||||||
CachedCreationTokens: 1,
|
CachedCreationTokens: 1,
|
||||||
|
CacheWriteTokens: 7,
|
||||||
TextTokens: 5,
|
TextTokens: 5,
|
||||||
AudioTokens: 2,
|
AudioTokens: 2,
|
||||||
ImageTokens: 1,
|
ImageTokens: 1,
|
||||||
@@ -598,6 +602,7 @@ func TestResponseUsageMatrixChatAndResponsesDetails(t *testing.T) {
|
|||||||
assert.Equal(t, 21, result.Usage.TotalTokens)
|
assert.Equal(t, 21, result.Usage.TotalTokens)
|
||||||
assert.Equal(t, 4, result.Usage.PromptTokensDetails.CachedTokens)
|
assert.Equal(t, 4, result.Usage.PromptTokensDetails.CachedTokens)
|
||||||
assert.Equal(t, 1, result.Usage.PromptTokensDetails.CachedCreationTokens)
|
assert.Equal(t, 1, result.Usage.PromptTokensDetails.CachedCreationTokens)
|
||||||
|
assert.Equal(t, 7, result.Usage.PromptTokensDetails.CacheWriteTokens)
|
||||||
assert.Equal(t, 5, result.Usage.PromptTokensDetails.TextTokens)
|
assert.Equal(t, 5, result.Usage.PromptTokensDetails.TextTokens)
|
||||||
assert.Equal(t, 2, result.Usage.PromptTokensDetails.AudioTokens)
|
assert.Equal(t, 2, result.Usage.PromptTokensDetails.AudioTokens)
|
||||||
assert.Equal(t, 1, result.Usage.PromptTokensDetails.ImageTokens)
|
assert.Equal(t, 1, result.Usage.PromptTokensDetails.ImageTokens)
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf
|
|||||||
summary.CompletionTokens = usage.CompletionTokens
|
summary.CompletionTokens = usage.CompletionTokens
|
||||||
summary.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
summary.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||||
summary.CacheTokens = usage.PromptTokensDetails.CachedTokens
|
summary.CacheTokens = usage.PromptTokensDetails.CachedTokens
|
||||||
summary.CacheCreationTokens = usage.PromptTokensDetails.CachedCreationTokens
|
summary.CacheCreationTokens = usage.PromptTokensDetails.CacheCreationTokensTotal()
|
||||||
summary.CacheCreationTokens5m = usage.ClaudeCacheCreation5mTokens
|
summary.CacheCreationTokens5m = usage.ClaudeCacheCreation5mTokens
|
||||||
summary.CacheCreationTokens1h = usage.ClaudeCacheCreation1hTokens
|
summary.CacheCreationTokens1h = usage.ClaudeCacheCreation1hTokens
|
||||||
summary.ImageTokens = usage.PromptTokensDetails.ImageTokens
|
summary.ImageTokens = usage.PromptTokensDetails.ImageTokens
|
||||||
@@ -294,6 +294,13 @@ 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.
|
||||||
|
if baseTokens.IsNegative() {
|
||||||
|
baseTokens = decimal.Zero
|
||||||
|
}
|
||||||
|
|
||||||
promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio).Add(cachedCreationTokensWithRatio)
|
promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio).Add(cachedCreationTokensWithRatio)
|
||||||
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
|
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
|
||||||
quotaCalculateDecimal := promptQuota.Add(completionQuota).Mul(ratio)
|
quotaCalculateDecimal := promptQuota.Add(completionQuota).Mul(ratio)
|
||||||
|
|||||||
@@ -375,6 +375,62 @@ func TestCalculateTextQuotaSummaryHandlesLegacyClaudeDerivedOpenAIUsage(t *testi
|
|||||||
require.Equal(t, 1624, summary.Quota)
|
require.Equal(t, 1624, summary.Quota)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCalculateTextQuotaSummaryBillsOpenAICacheWriteTokens(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(w)
|
||||||
|
|
||||||
|
relayInfo := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatOpenAI,
|
||||||
|
OriginModelName: "gpt-5.1",
|
||||||
|
PriceData: types.PriceData{
|
||||||
|
ModelRatio: 1,
|
||||||
|
CompletionRatio: 2,
|
||||||
|
CacheRatio: 0.1,
|
||||||
|
CacheCreationRatio: 1.25,
|
||||||
|
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
|
||||||
|
},
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("uncached remainder stays positive", func(t *testing.T) {
|
||||||
|
usage := &dto.Usage{
|
||||||
|
PromptTokens: 1473,
|
||||||
|
CompletionTokens: 19,
|
||||||
|
PromptTokensDetails: dto.InputTokenDetails{
|
||||||
|
CacheWriteTokens: 1470,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
|
||||||
|
|
||||||
|
require.Equal(t, 1470, summary.CacheCreationTokens)
|
||||||
|
// (1473-0-1470) + 1470*1.25 + 19*2 = 3 + 1837.5 + 38 = 1878.5 => 1879
|
||||||
|
require.Equal(t, 1879, summary.Quota)
|
||||||
|
})
|
||||||
|
|
||||||
|
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.
|
||||||
|
usage := &dto.Usage{
|
||||||
|
PromptTokens: 3619,
|
||||||
|
CompletionTokens: 36,
|
||||||
|
PromptTokensDetails: dto.InputTokenDetails{
|
||||||
|
CachedTokens: 2921,
|
||||||
|
CacheWriteTokens: 3616,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
|
||||||
|
|
||||||
|
require.Equal(t, 3619, summary.PromptTokens)
|
||||||
|
require.Equal(t, 3616, summary.CacheCreationTokens)
|
||||||
|
// max(3619-2921-3616, 0) + 2921*0.1 + 3616*1.25 + 36*2 = 4884.1 => 4884
|
||||||
|
require.Equal(t, 4884, summary.Quota)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestCalculateTextQuotaSummarySeparatesOpenRouterCacheReadFromPromptBilling(t *testing.T) {
|
func TestCalculateTextQuotaSummarySeparatesOpenRouterCacheReadFromPromptBilling(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVa
|
|||||||
p := float64(usage.PromptTokens)
|
p := float64(usage.PromptTokens)
|
||||||
c := float64(usage.CompletionTokens)
|
c := float64(usage.CompletionTokens)
|
||||||
cr := float64(usage.PromptTokensDetails.CachedTokens)
|
cr := float64(usage.PromptTokensDetails.CachedTokens)
|
||||||
cc5m := float64(usage.PromptTokensDetails.CachedCreationTokens)
|
cc5m := float64(usage.PromptTokensDetails.CacheCreationTokensTotal())
|
||||||
cc1h := float64(0)
|
cc1h := float64(0)
|
||||||
|
|
||||||
if usage.UsageSemantic == "anthropic" {
|
if usage.UsageSemantic == "anthropic" {
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ var defaultCacheRatio = map[string]float64{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var defaultCreateCacheRatio = map[string]float64{
|
var defaultCreateCacheRatio = map[string]float64{
|
||||||
|
"gpt-5.6-sol": 1.25,
|
||||||
|
"gpt-5.6-terra": 1.25,
|
||||||
|
"gpt-5.6-luna": 1.25,
|
||||||
"claude-3-sonnet-20240229": 1.25,
|
"claude-3-sonnet-20240229": 1.25,
|
||||||
"claude-3-opus-20240229": 1.25,
|
"claude-3-opus-20240229": 1.25,
|
||||||
"claude-3-haiku-20240307": 1.25,
|
"claude-3-haiku-20240307": 1.25,
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ function CommonLogsCard<TData>({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid grid-cols-[minmax(0,1.35fr)_minmax(0,0.75fr)] gap-1.5'>
|
<div className='grid grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)] gap-1.5'>
|
||||||
<div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
|
<div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
|
||||||
<MobileLogTimeStatus
|
<MobileLogTimeStatus
|
||||||
createdAt={rowData?.created_at}
|
createdAt={rowData?.created_at}
|
||||||
|
|||||||
Reference in New Issue
Block a user