diff --git a/dto/billing_usage.go b/dto/billing_usage.go index 4c8f3f51..075bce41 100644 --- a/dto/billing_usage.go +++ b/dto/billing_usage.go @@ -88,6 +88,7 @@ func HasOpenAIUsageTokens(usage *Usage) bool { } if usage.PromptTokensDetails.CachedTokens != 0 || usage.PromptTokensDetails.CachedCreationTokens != 0 || + usage.PromptTokensDetails.CacheWriteTokens != 0 || usage.PromptTokensDetails.TextTokens != 0 || usage.PromptTokensDetails.ImageTokens != 0 || usage.PromptTokensDetails.AudioTokens != 0 { diff --git a/dto/openai_request.go b/dto/openai_request.go index 114f0481..3bb2b34c 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -849,6 +849,7 @@ type OpenAIResponsesRequest struct { MaxOutputTokens *uint `json:"max_output_tokens,omitempty"` TopLogProbs *int `json:"top_logprobs,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"` + Moderation json.RawMessage `json:"moderation,omitempty"` ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"` PreviousResponseID string `json:"previous_response_id,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. Store json.RawMessage `json:"store,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"` // SafetyIdentifier carries client identity for policy abuse detection. // This field is filtered by default and can be enabled via channel setting allow_safety_identifier. diff --git a/dto/openai_response.go b/dto/openai_response.go index 90416e35..ecda485c 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -256,9 +256,26 @@ type OpenAIVideoResponse struct { type InputTokenDetails struct { CachedTokens int `json:"cached_tokens"` CachedCreationTokens int `json:"cached_creation_tokens,omitempty"` - TextTokens int `json:"text_tokens"` - AudioTokens int `json:"audio_tokens"` - ImageTokens int `json:"image_tokens"` + // CacheWriteTokens is OpenAI's native cache-write count, reported as + // prompt_tokens_details.cache_write_tokens (Chat Completions) or + // 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 { diff --git a/dto/openai_responses_compaction_request.go b/dto/openai_responses_compaction_request.go index 0988908f..f3d1cb66 100644 --- a/dto/openai_responses_compaction_request.go +++ b/dto/openai_responses_compaction_request.go @@ -17,12 +17,14 @@ type OpenAIResponsesCompactionRequest struct { // Codex compact request parity: // https://github.com/openai/codex/commit/53d59722268dde82fb93c1f37964ce196c2a86d7 // https://github.com/openai/codex/commit/5d6f23a27bf9c90709af527a7108c1c2eadf5123 - Tools json.RawMessage `json:"tools,omitempty"` - ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"` - Reasoning *Reasoning `json:"reasoning,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` - PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` - Text json.RawMessage `json:"text,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` + ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"` + Reasoning *Reasoning `json:"reasoning,omitempty"` + ServiceTier string `json:"service_tier,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"` + Text json.RawMessage `json:"text,omitempty"` } func (r *OpenAIResponsesCompactionRequest) GetTokenCountMeta() *types.TokenCountMeta { diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index 2665b8d0..92931831 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -51,6 +51,7 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http usage.TotalTokens = responsesResponse.Usage.TotalTokens if responsesResponse.Usage.InputTokensDetails != nil { usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens + usage.PromptTokensDetails.CacheWriteTokens = responsesResponse.Usage.InputTokensDetails.CacheWriteTokens } } 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 { usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens + usage.PromptTokensDetails.CacheWriteTokens = streamResponse.Response.Usage.InputTokensDetails.CacheWriteTokens } } if streamResponse.Response.HasImageGenerationCall() { diff --git a/relay/channel/openai/relay_responses_compact.go b/relay/channel/openai/relay_responses_compact.go index 390de8ed..1180538c 100644 --- a/relay/channel/openai/relay_responses_compact.go +++ b/relay/channel/openai/relay_responses_compact.go @@ -37,6 +37,7 @@ func OaiResponsesCompactionHandler(c *gin.Context, resp *http.Response) (*dto.Us usage.TotalTokens = compactResp.Usage.TotalTokens if compactResp.Usage.InputTokensDetails != nil { usage.PromptTokensDetails.CachedTokens = compactResp.Usage.InputTokensDetails.CachedTokens + usage.PromptTokensDetails.CacheWriteTokens = compactResp.Usage.InputTokensDetails.CacheWriteTokens } } diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 7679a70d..45037687 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -41,12 +41,14 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * responsesReq = req case *dto.OpenAIResponsesCompactionRequest: responsesReq = &dto.OpenAIResponsesRequest{ - Model: req.Model, - Input: req.Input, - Instructions: req.Instructions, - PreviousResponseID: req.PreviousResponseID, - ParallelToolCalls: req.ParallelToolCalls, - ServiceTier: req.ServiceTier, + Model: req.Model, + Input: req.Input, + Instructions: req.Instructions, + PreviousResponseID: req.PreviousResponseID, + ParallelToolCalls: req.ParallelToolCalls, + ServiceTier: req.ServiceTier, + PromptCacheOptions: req.PromptCacheOptions, + PromptCacheRetention: req.PromptCacheRetention, } default: return types.NewErrorWithStatusCode( diff --git a/service/relayconvert/internal/claude_messages/to_oai_chat_resp.go b/service/relayconvert/internal/claude_messages/to_oai_chat_resp.go index a1d8488e..4f96eb79 100644 --- a/service/relayconvert/internal/claude_messages/to_oai_chat_resp.go +++ b/service/relayconvert/internal/claude_messages/to_oai_chat_resp.go @@ -216,6 +216,9 @@ func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage { usage.ClaudeCacheCreation1hTokens, ) 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 clone.PromptTokens = totalInputTokens clone.InputTokens = totalInputTokens 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 67e174f7..2add756f 100644 --- a/service/relayconvert/internal/oai_chat/to_claude_messages_resp.go +++ b/service/relayconvert/internal/oai_chat/to_claude_messages_resp.go @@ -39,10 +39,21 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage { oaiUsage.ClaudeCacheCreation5mTokens, 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{ - InputTokens: oaiUsage.PromptTokens, + InputTokens: inputTokens, OutputTokens: oaiUsage.CompletionTokens, - CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens, + CacheCreationInputTokens: cacheCreationTokens, CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens, BillingUsage: billingUsage, } 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 0b121d05..d3841e50 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 @@ -79,6 +79,30 @@ func TestResponseOpenAI2ClaudeUsageCarriesOpenAIBillingUsage(t *testing.T) { 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) { info := &relaycommon.RelayInfo{ ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{ diff --git a/service/relayconvert/internal/oai_chat/to_oai_responses_resp.go b/service/relayconvert/internal/oai_chat/to_oai_responses_resp.go index 2d6a5b52..b3a15ad9 100644 --- a/service/relayconvert/internal/oai_chat/to_oai_responses_resp.go +++ b/service/relayconvert/internal/oai_chat/to_oai_responses_resp.go @@ -137,6 +137,7 @@ func UsageFromChatUsage(src *dto.Usage) *dto.Usage { src.PromptTokensDetails.ImageTokens != 0 || src.PromptTokensDetails.AudioTokens != 0 || src.PromptTokensDetails.CachedCreationTokens != 0 || + src.PromptTokensDetails.CacheWriteTokens != 0 || src.PromptTokensDetails.TextTokens != 0 { details := src.PromptTokensDetails usage.InputTokensDetails = &details diff --git a/service/relayconvert/internal/oai_responses/to_oai_chat_resp.go b/service/relayconvert/internal/oai_responses/to_oai_chat_resp.go index d8a3f3f7..9fcb3a1d 100644 --- a/service/relayconvert/internal/oai_responses/to_oai_chat_resp.go +++ b/service/relayconvert/internal/oai_responses/to_oai_chat_resp.go @@ -156,6 +156,7 @@ func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage { if src.InputTokensDetails != nil { usage.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens usage.PromptTokensDetails.CachedCreationTokens = src.InputTokensDetails.CachedCreationTokens + usage.PromptTokensDetails.CacheWriteTokens = src.InputTokensDetails.CacheWriteTokens usage.PromptTokensDetails.TextTokens = src.InputTokensDetails.TextTokens usage.PromptTokensDetails.ImageTokens = src.InputTokensDetails.ImageTokens usage.PromptTokensDetails.AudioTokens = src.InputTokensDetails.AudioTokens diff --git a/service/relayconvert/response_registry_test.go b/service/relayconvert/response_registry_test.go index 0f058f42..e5553cc0 100644 --- a/service/relayconvert/response_registry_test.go +++ b/service/relayconvert/response_registry_test.go @@ -277,6 +277,7 @@ func TestConvertResponseProviderToOAIChatUsage(t *testing.T) { assert.Equal(t, 22, toChat.Usage.TotalTokens) assert.Equal(t, 3, toChat.Usage.PromptTokensDetails.CachedTokens) 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.ClaudeUsage) assert.Equal(t, dto.BillingUsageSourceClaudeMessages, toChat.Usage.BillingUsage.Source) @@ -539,6 +540,7 @@ func TestResponseUsageMatrixChatAndResponsesDetails(t *testing.T) { PromptTokensDetails: dto.InputTokenDetails{ CachedTokens: 3, CachedCreationTokens: 2, + CacheWriteTokens: 6, TextTokens: 4, AudioTokens: 1, ImageTokens: 5, @@ -558,6 +560,7 @@ func TestResponseUsageMatrixChatAndResponsesDetails(t *testing.T) { require.NotNil(t, result.Usage.InputTokensDetails) assert.Equal(t, 3, result.Usage.InputTokensDetails.CachedTokens) 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, 1, result.Usage.InputTokensDetails.AudioTokens) assert.Equal(t, 5, result.Usage.InputTokensDetails.ImageTokens) @@ -579,6 +582,7 @@ func TestResponseUsageMatrixChatAndResponsesDetails(t *testing.T) { InputTokensDetails: &dto.InputTokenDetails{ CachedTokens: 4, CachedCreationTokens: 1, + CacheWriteTokens: 7, TextTokens: 5, AudioTokens: 2, ImageTokens: 1, @@ -598,6 +602,7 @@ func TestResponseUsageMatrixChatAndResponsesDetails(t *testing.T) { assert.Equal(t, 21, result.Usage.TotalTokens) assert.Equal(t, 4, result.Usage.PromptTokensDetails.CachedTokens) 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, 2, result.Usage.PromptTokensDetails.AudioTokens) assert.Equal(t, 1, result.Usage.PromptTokensDetails.ImageTokens) diff --git a/service/text_quota.go b/service/text_quota.go index c99c75e7..66244ec1 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -208,7 +208,7 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf summary.CompletionTokens = usage.CompletionTokens summary.TotalTokens = usage.PromptTokens + usage.CompletionTokens summary.CacheTokens = usage.PromptTokensDetails.CachedTokens - summary.CacheCreationTokens = usage.PromptTokensDetails.CachedCreationTokens + summary.CacheCreationTokens = usage.PromptTokensDetails.CacheCreationTokensTotal() summary.CacheCreationTokens5m = usage.ClaudeCacheCreation5mTokens summary.CacheCreationTokens1h = usage.ClaudeCacheCreation1hTokens 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) completionQuota := dCompletionTokens.Mul(dCompletionRatio) quotaCalculateDecimal := promptQuota.Add(completionQuota).Mul(ratio) diff --git a/service/text_quota_test.go b/service/text_quota_test.go index 717d35d5..d7f04f33 100644 --- a/service/text_quota_test.go +++ b/service/text_quota_test.go @@ -375,6 +375,62 @@ func TestCalculateTextQuotaSummaryHandlesLegacyClaudeDerivedOpenAIUsage(t *testi 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) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() diff --git a/service/tiered_settle.go b/service/tiered_settle.go index eede3450..00eeeb8e 100644 --- a/service/tiered_settle.go +++ b/service/tiered_settle.go @@ -22,7 +22,7 @@ func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVa p := float64(usage.PromptTokens) c := float64(usage.CompletionTokens) cr := float64(usage.PromptTokensDetails.CachedTokens) - cc5m := float64(usage.PromptTokensDetails.CachedCreationTokens) + cc5m := float64(usage.PromptTokensDetails.CacheCreationTokensTotal()) cc1h := float64(0) if usage.UsageSemantic == "anthropic" { diff --git a/setting/ratio_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go index 89d0bfc2..6e874b5b 100644 --- a/setting/ratio_setting/cache_ratio.go +++ b/setting/ratio_setting/cache_ratio.go @@ -81,6 +81,9 @@ var defaultCacheRatio = 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-opus-20240229": 1.25, "claude-3-haiku-20240307": 1.25, diff --git a/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx b/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx index cb28966b..9e4261a3 100644 --- a/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx +++ b/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx @@ -329,7 +329,7 @@ function CommonLogsCard({ /> -
+