diff --git a/controller/model_management_test.go b/controller/model_management_test.go index 2e0d708c0e..a66668162d 100644 --- a/controller/model_management_test.go +++ b/controller/model_management_test.go @@ -276,7 +276,8 @@ func TestModelPricingConversionDatabaseMatrix(t *testing.T) { }{ {"unrecognized-model", nil, model.CacheWriteNone}, {"gpt-5.6-terra", nil, model.CacheWriteNone}, - {"gpt-5.6-terra", common.GetPointer(1.25), model.CacheWriteStandard}, + {"gpt-5.6-terra", common.GetPointer(1.25), model.CacheWriteOpenAITTL}, + {"custom-GPT-alias", common.GetPointer(0.0), model.CacheWriteOpenAITTL}, {"vendor/claude-sonnet-4-6-high", nil, model.CacheWriteClaudeTTL}, {"custom-CLAUDE-alias", common.GetPointer(0.0), model.CacheWriteClaudeTTL}, {"deepseek-reasoner", common.GetPointer(0.0), model.CacheWriteStandard}, @@ -306,13 +307,19 @@ func TestModelPricingConversionDatabaseMatrix(t *testing.T) { vars := billingexpr.UsedVars(response.Data.Expression) assert.Equal(t, tc.mode != model.CacheWriteNone, vars["cc"]) assert.Equal(t, tc.mode == model.CacheWriteClaudeTTL, vars["cc1h"]) + assert.Equal(t, tc.mode == model.CacheWriteOpenAITTL, vars["cc12h"]) if tc.configured != nil && *tc.configured == 0 { - cost, _, err := billingexpr.RunExpr(response.Data.Expression, billingexpr.TokenParams{CC: 100, CC1h: 100}) + cost, _, err := billingexpr.RunExpr(response.Data.Expression, billingexpr.TokenParams{CC: 100, CC1h: 100, CC12h: 100}) require.NoError(t, err) assert.Zero(t, cost, "explicit zero cache prices must stay free") } }) } + // GPT names price the 12h write at twice the configured write price. + openai, err := model.PreviewModelPricingConversion("gpt-5.6-terra", model.PricingValues{"ModelRatio": 5.0, "CompletionRatio": 5.0, "CacheRatio": 0.1, "CreateCacheRatio": 1.25}) + require.NoError(t, err) + assert.Equal(t, model.CacheWriteOpenAITTL, openai.CacheWriteMode) + assert.Equal(t, `tier("base", p * 10 + c * 50 + cr * 1 + cc * 12.5 + cc12h * 25)`, openai.Expression) // The billing name remains authoritative for cache terms. require.NoError(t, (&model.Channel{Name: "Cache alias", Type: 1, Models: "opaque-cache-alias", ModelMapping: common.GetPointer(`{"opaque-cache-alias":"claude-sonnet-4-6"}`)}).Insert()) alias, err := model.PreviewModelPricingConversion("opaque-cache-alias", model.PricingValues{"ModelRatio": 2.0}) diff --git a/model/model_pricing_conversion.go b/model/model_pricing_conversion.go index bde0e473d9..79dc48b7b1 100644 --- a/model/model_pricing_conversion.go +++ b/model/model_pricing_conversion.go @@ -14,6 +14,7 @@ import ( "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" hostreasoning "github.com/QuantumNous/new-api/setting/reasoning" + "github.com/QuantumNous/new-api/types" "github.com/shopspring/decimal" ) @@ -126,16 +127,21 @@ const ( CacheWriteNone CacheWriteMode = "none" CacheWriteStandard CacheWriteMode = "standard" CacheWriteClaudeTTL CacheWriteMode = "claude_ttl" + CacheWriteOpenAITTL CacheWriteMode = "openai_ttl" ) // ResolveCacheWriteMode describes display and migration only. A generic engine // fallback is not configured cache-write pricing. Only Claude names inherit the -// legacy dual-TTL rule; other models retain their configured write price. +// legacy dual-TTL rule; a configured write price on a GPT name also prices the +// OpenAI 12h write, and other models retain their configured write price. func ResolveCacheWriteMode(name string, configured PricingValues) CacheWriteMode { if strings.Contains(strings.ToLower(name), "claude") { return CacheWriteClaudeTTL } if _, exists := configured["CreateCacheRatio"]; exists { + if strings.Contains(strings.ToLower(name), "gpt") { + return CacheWriteOpenAITTL + } return CacheWriteStandard } return CacheWriteNone @@ -342,8 +348,9 @@ func PreviewModelPricingConversion(name string, draft PricingValues) (*ModelPric var expressionBody strings.Builder expressionBody.WriteString(`tier("base", p * `) expressionBody.WriteString(base.String()) - // Retain explicit zero coefficients for included categories. Only - // Claude names inherit the relay's 1h / 5m factor of 6 / 3.75. + // Retain explicit zero coefficients for included categories. Claude + // names inherit the relay's 1h / 5m factor and GPT names its 12h / 30m + // factor. for _, lane := range []struct { variable, key string multiplier float64 @@ -351,14 +358,16 @@ func PreviewModelPricingConversion(name string, draft PricingValues) (*ModelPric {"c", "CompletionRatio", 1}, {"cr", "CacheRatio", 1}, {"cc", "CreateCacheRatio", 1}, - {"cc1h", "CreateCacheRatio", 6 / 3.75}, + {"cc1h", "CreateCacheRatio", types.ClaudeCacheCreation1hMultiplier}, + {"cc12h", "CreateCacheRatio", types.OpenAICacheCreation12hMultiplier}, {"img", "ImageRatio", 1}, } { if ordinaryAudio && !preview.BillingDetails.AudioTextBranches && lane.variable != "c" { continue } if lane.variable == "cc" && preview.CacheWriteMode == CacheWriteNone || - lane.variable == "cc1h" && preview.CacheWriteMode != CacheWriteClaudeTTL { + lane.variable == "cc1h" && preview.CacheWriteMode != CacheWriteClaudeTTL || + lane.variable == "cc12h" && preview.CacheWriteMode != CacheWriteOpenAITTL { continue } multiplier := effective[lane.key].(float64) diff --git a/pkg/billingexpr/compile.go b/pkg/billingexpr/compile.go index 05118e9028..8ef81d9f0b 100644 --- a/pkg/billingexpr/compile.go +++ b/pkg/billingexpr/compile.go @@ -132,6 +132,7 @@ var compileEnvPrototypeV1 = map[string]any{ "cr": float64(0), "cc": float64(0), "cc1h": float64(0), + "cc12h": float64(0), "img": float64(0), "img_cr": float64(0), "img_o": float64(0), diff --git a/pkg/billingexpr/expr.md b/pkg/billingexpr/expr.md index ee54bd7307..e1b523f573 100644 --- a/pkg/billingexpr/expr.md +++ b/pkg/billingexpr/expr.md @@ -10,7 +10,7 @@ The expression is the billing contract between the administrator and the system. 1. **Expression is self-contained** — The expression string alone determines billing. No external ratio tables, no implicit completion multipliers, no hidden conversion factors. Given the same token counts and request context, the same expression always produces the same cost. -2. **Variables are opt-in** — `p` (prompt) and `c` (completion) are the base. Cache (`cr`, `cc`, `cc1h`), image (`img`), and audio (`ai`, `ao`) variables are optional. If omitted, those tokens are included in `p`/`c` and priced at their rate. The system automatically detects which variables the expression uses (via AST introspection) and adjusts token normalization accordingly. +2. **Variables are opt-in** — `p` (prompt) and `c` (completion) are the base. Cache (`cr`, `cc`, `cc1h`, `cc12h`), image (`img`), and audio (`ai`, `ao`) variables are optional. If omitted, those tokens are included in `p`/`c` and priced at their rate; an omitted long-lived cache write keeps its fixed multiple of the short write price. The system automatically detects which variables the expression uses (via AST introspection) and adjusts token normalization accordingly. 3. **Prices are real prices** — Token coefficients are actual $/1M tokens prices as published by providers. `p * 2.5` means $2.50 per 1M prompt tokens; `fixed(0.01)` means $0.01 per request. No ratio conversion or `/2` convention is required. @@ -33,8 +33,9 @@ Powered by [expr-lang/expr](https://github.com/expr-lang/expr). Expressions are | `p` | 输入 token 数(**计价用**)。**自动排除**表达式中单独计价的子类别(见下方说明) | | `len` | 输入上下文总长度(**条件判断用**)。不受自动排除影响,始终反映完整输入长度。非 Claude:等于原始 `prompt_tokens`;Claude:等于文本输入 + 缓存读取 + 缓存创建 | | `cr` | 缓存命中(读取)token 数 | -| `cc` | 缓存创建 token 数(Claude 5分钟 TTL / 通用) | +| `cc` | 缓存创建 token 数(Claude 5分钟 TTL / OpenAI 30分钟 TTL / 通用) | | `cc1h` | 缓存创建 token 数 — 1小时 TTL(Claude 专用) | +| `cc12h` | 缓存创建 token 数 — 12小时 TTL(OpenAI 专用) | | `img` | 图片输入 token 数 | | `img_cr` | 图片缓存读取 token 数;只有表达式显式引用且上游提供有效缓存模态明细时才单独拆分 | | `ai` | 音频输入 token 数 | @@ -70,6 +71,9 @@ Powered by [expr-lang/expr](https://github.com/expr-lang/expr). Expressions are | `p * 3 + c * 15` | 500 | 没用 `ao`,音频输出包含在 `c` 里按 $15 计费 | | `p * 3 + c * 15 + ao * 50` | 400 | 用了 `ao`,音频 100 从 `c` 中扣除按 $50 计费 | +长效缓存写入(Claude `cc1h`、OpenAI `cc12h`)没有单独计价时,按短效写入价格的固定倍数计费: +OpenAI 12 小时为 2 倍,Claude 1 小时为 6÷3.75 倍。短效写入价格是 `cc`,`cc` 也未计价时是 `p`。 + #### 图片缓存与兼容性 OpenAI 兼容图片接口可报告 @@ -116,9 +120,10 @@ Anthropic 的独立缓存计数语义不采用这一 OpenAI 图片拆分。 省略 `cr` 时,Anthropic 用量中单独报告的缓存读取数会加回 `p`,而 `len` 保持完整上下文长度。 缓存创建按对外计费名和原始配置处理:名称包含 `claude`(不区分大小写)时保留 `cc` 和 `cc1h`, 其中 1h 价格沿用旧引擎的 `CreateCacheRatio * 6 / 3.75`;其他名称仅在草稿已配置 `CreateCacheRatio` -时生成通用 `cc`(包括显式 0),不生成 `cc1h`。没有配置的通用 1.25 倍兜底不产生新计费项。 +时生成通用 `cc`(包括显式 0),不生成 `cc1h`。这时名称包含 `gpt`(不区分大小写)的模型同时生成 `cc12h`, +价格沿用旧引擎的 `CreateCacheRatio * 2`。没有配置的通用 1.25 倍兜底不产生新计费项。 不按渠道映射或模型白名单扩展缓存类型,也不要求管理员另选类型。 -定价快照条目、草稿预览响应和转换结果附带只读 `cache_write_mode`(`none` / `standard` / `claude_ttl`), +定价快照条目、草稿预览响应和转换结果附带只读 `cache_write_mode`(`none` / `standard` / `claude_ttl` / `openai_ttl`), 用于统一展示。`POST /api/option/model_pricing/preview` 的响应统一为 `{success, message, data: {effective, cache_write_mode, billing_details}}`,调用者从 `data.effective` 读取生效价格;转换接口和快照条目的 JSON 结构不变。元信息不保存、不参与表达式执行。 @@ -156,7 +161,7 @@ OpenAI 已于 2026-05-12 下线 DALL·E 2/3;其校验、默认值和倍率保 (Qwen-Image-3.0 为 `usage.output_image_count`)或按各 `choices[].message.content[]` / `results[]` 中的图片载荷数结算,任务消费日志同样记录 `image_count`。 -> **注意:** 自动扣除针对 GPT/OpenAI 格式的 API(prompt_tokens 包含子类别)。Claude 格式的 API 不重复扣除缓存;未独立计价的缓存读取加回输入。系统根据上游返回格式自动处理。 +> **注意:** 自动扣除针对 GPT/OpenAI 格式的 API(prompt_tokens 包含子类别)。Claude 格式的 API 不重复扣除缓存;未独立计价的缓存读取和缓存写入加回输入。系统根据上游返回格式自动处理。 ### Built-in Functions @@ -473,7 +478,7 @@ After the upstream response returns with actual token usage: 1. `BuildTieredTokenParams(usage, isClaudeUsageSemantic, usedVars)`: - Reads actual token counts from `dto.Usage` - For GPT-format APIs (prompt_tokens includes everything): subtracts sub-categories from P/C **only when** the expression uses their variables (detected via AST introspection of the compiled expression) - - For Claude-format APIs: cache reads omitted from the expression are added to input; separately priced cache remains separate + - For Claude-format APIs: cache reads and writes omitted from the expression are added to input; separately priced cache remains separate 2. `TryTieredSettle(relayInfo, params)`: - Uses the captured `BillingSnapshot`, whose group-dependent fields have been refreshed from the final selected group diff --git a/pkg/billingexpr/run.go b/pkg/billingexpr/run.go index ab773d996a..da56b7b84d 100644 --- a/pkg/billingexpr/run.go +++ b/pkg/billingexpr/run.go @@ -17,7 +17,7 @@ import ( // The environment exposes: // - p, c — prompt / completion tokens (auto-excluding separately-priced sub-categories) // - len — total input context length for tier conditions (never reduced by sub-category exclusion) -// - cr, cc, cc1h — cache read / creation / creation-1h tokens +// - cr, cc, cc1h, cc12h — cache read / creation / creation-1h / creation-12h tokens // - tier(name, value) — trace callback that records which tier matched // - max, min, abs, ceil, floor — standard math helpers // @@ -75,6 +75,7 @@ func runProgram(prog *vm.Program, requestRules []RequestRuleTrace, usedVars map[ "cr": params.CR, "cc": params.CC, "cc1h": params.CC1h, + "cc12h": params.CC12h, "img": params.Img, "img_cr": params.ImgCR, "img_o": params.ImgO, diff --git a/pkg/billingexpr/types.go b/pkg/billingexpr/types.go index 2ecead0464..ab0d6e4291 100644 --- a/pkg/billingexpr/types.go +++ b/pkg/billingexpr/types.go @@ -24,8 +24,9 @@ type TokenParams struct { C float64 // completion tokens (text) — auto-excludes sub-categories priced separately Len float64 // total input context length for tier conditions (non-Claude: raw prompt_tokens; Claude: text + cache read + cache creation) CR float64 // cache read (hit) tokens - CC float64 // cache creation tokens (5-min TTL for Claude, generic for others) + CC float64 // cache creation tokens (5-min TTL for Claude, 30-min TTL for OpenAI, generic for others) CC1h float64 // cache creation tokens — 1-hour TTL (Claude only) + CC12h float64 // cache creation tokens — 12-hour TTL (OpenAI only) Img float64 // image input tokens ImgCR float64 // image cache read tokens, separated only when explicitly priced ImgO float64 // image output tokens diff --git a/relay/channel/openai/relay_responses_billing_test.go b/relay/channel/openai/relay_responses_billing_test.go index 80cd97979e..cf0f933645 100644 --- a/relay/channel/openai/relay_responses_billing_test.go +++ b/relay/channel/openai/relay_responses_billing_test.go @@ -469,6 +469,60 @@ func TestOaiResponsesStreamHandlerDoesNotCountPartialImageEvent(t *testing.T) { assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount) } +// The 12h cache-write split reaches billing from JSON, stream and compaction +// responses. The usage is the OpenAI prompt caching guide example. +func TestOaiResponsesHandlersKeepCacheWriteTTLSplit(t *testing.T) { + gin.SetMode(gin.TestMode) + oldTimeout := constant.StreamingTimeout + constant.StreamingTimeout = 30 + t.Cleanup(func() { + constant.StreamingTimeout = oldTimeout + }) + + const usage = `"usage":{"input_tokens":12000,"output_tokens":0,"total_tokens":12000,"input_tokens_details":{"cached_tokens":4000,"cache_write_tokens":7000,"cache_write_tokens_details":{"ttl_30m_tokens":3000,"ttl_12h_tokens":4000}}}` + const response = `{"id":"resp_1","object":"response","model":"gpt-6-astra","status":"completed","output":[],` + usage + `}` + for _, tt := range []struct { + name, contentType, body string + }{ + {"json", "application/json", response}, + {"stream", "text/event-stream", `data: {"type":"response.completed","response":` + response + "}\n\ndata: [DONE]\n\n"}, + {"compaction", "application/json", `{"id":"cmp_1","object":"response.compaction","output":[],` + usage + `}`}, + } { + t.Run(tt.name, func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + info := &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatOpenAIResponses, + OriginModelName: "gpt-6-astra", + IsStream: tt.name == "stream", + DisablePing: true, + ChannelMeta: &relaycommon.ChannelMeta{ChannelType: constant.ChannelTypeOpenAI, UpstreamModelName: "gpt-6-astra"}, + } + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(tt.body)), + Header: http.Header{"Content-Type": []string{tt.contentType}}, + } + var usage *dto.Usage + var apiErr *types.NewAPIError + switch tt.name { + case "json": + usage, apiErr = OaiResponsesHandler(c, info, resp) + case "stream": + usage, apiErr = OaiResponsesStreamHandler(c, info, resp) + default: + usage, apiErr = OaiResponsesCompactionHandler(c, resp) + } + require.Nil(t, apiErr) + require.NotNil(t, usage) + assert.Equal(t, 12000, usage.PromptTokens) + assert.Equal(t, 4000, usage.PromptTokensDetails.CachedTokens) + assert.Equal(t, 7000, usage.PromptTokensDetails.CacheCreationTokensTotal()) + assert.Equal(t, 4000, usage.PromptTokensDetails.CacheWrite12hTokens()) + }) + } +} + func TestOaiResponsesHandlerRewritesSGLangCreatedAtToInt(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/relay/channel/openai/relay_responses_compact.go b/relay/channel/openai/relay_responses_compact.go index ff30d36e41..c9cd238d28 100644 --- a/relay/channel/openai/relay_responses_compact.go +++ b/relay/channel/openai/relay_responses_compact.go @@ -38,6 +38,7 @@ func OaiResponsesCompactionHandler(c *gin.Context, resp *http.Response) (*dto.Us if compactResp.Usage.InputTokensDetails != nil { usage.PromptTokensDetails.CachedTokens = compactResp.Usage.InputTokensDetails.CachedTokens usage.PromptTokensDetails.CacheWriteTokens = compactResp.Usage.InputTokensDetails.CacheWriteTokens + usage.PromptTokensDetails.CacheWriteTokensDetails = compactResp.Usage.InputTokensDetails.CacheWriteTokensDetails } } diff --git a/relay/helper/price.go b/relay/helper/price.go index adebd8783c..27ba3ad14b 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -33,9 +33,6 @@ func modelPriceNotConfiguredError(c *gin.Context, modelName string, userId int) return errors.New(i18n.T(c, key, map[string]any{"Model": modelName})) } -// https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration -const claudeCacheCreation1hMultiplier = 6 / 3.75 - // HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.UsingGroup if present func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) hosttypes.GroupRatioInfo { groupRatioInfo := hosttypes.GroupRatioInfo{ @@ -89,6 +86,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens var cacheCreationRatio float64 var cacheCreationRatio5m float64 var cacheCreationRatio1h float64 + var cacheCreationRatio12h float64 var audioRatio float64 var audioCompletionRatio float64 var freeModel bool @@ -115,7 +113,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(billingModelName) cacheCreationRatio5m = cacheCreationRatio // 固定1h和5min缓存写入价格的比例 - cacheCreationRatio1h = cacheCreationRatio * claudeCacheCreation1hMultiplier + cacheCreationRatio1h = cacheCreationRatio * hosttypes.ClaudeCacheCreation1hMultiplier + cacheCreationRatio12h = cacheCreationRatio * hosttypes.OpenAICacheCreation12hMultiplier imageRatio, _ = ratio_setting.GetImageRatio(billingModelName) audioRatio = ratio_setting.GetAudioRatio(billingModelName) audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(billingModelName) @@ -157,20 +156,21 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens } priceData := hosttypes.PriceData{ - FreeModel: freeModel, - ModelPrice: modelPrice, - ModelRatio: modelRatio, - CompletionRatio: completionRatio, - GroupRatioInfo: groupRatioInfo, - UsePrice: usePrice, - CacheRatio: cacheRatio, - ImageRatio: imageRatio, - AudioRatio: audioRatio, - AudioCompletionRatio: audioCompletionRatio, - CacheCreationRatio: cacheCreationRatio, - CacheCreation5mRatio: cacheCreationRatio5m, - CacheCreation1hRatio: cacheCreationRatio1h, - QuotaToPreConsume: preConsumedQuota, + FreeModel: freeModel, + ModelPrice: modelPrice, + ModelRatio: modelRatio, + CompletionRatio: completionRatio, + GroupRatioInfo: groupRatioInfo, + UsePrice: usePrice, + CacheRatio: cacheRatio, + ImageRatio: imageRatio, + AudioRatio: audioRatio, + AudioCompletionRatio: audioCompletionRatio, + CacheCreationRatio: cacheCreationRatio, + CacheCreation5mRatio: cacheCreationRatio5m, + CacheCreation1hRatio: cacheCreationRatio1h, + CacheCreation12hRatio: cacheCreationRatio12h, + QuotaToPreConsume: preConsumedQuota, } if usePrice { for name, ratio := range meta.BillingRatios { diff --git a/relaykit/dto/billing_usage.go b/relaykit/dto/billing_usage.go index faa77d2b88..c4eacfb0b6 100644 --- a/relaykit/dto/billing_usage.go +++ b/relaykit/dto/billing_usage.go @@ -89,6 +89,7 @@ func HasOpenAIUsageTokens(usage *Usage) bool { return true } if usage.PromptTokensDetails.CachedTokensDetails.HasTokens() || + usage.PromptTokensDetails.CacheWriteTokensDetails.HasTokens() || usage.PromptTokensDetails.CachedTokens != 0 || usage.PromptTokensDetails.CachedCreationTokens != 0 || usage.PromptTokensDetails.CacheWriteTokens != 0 || @@ -107,6 +108,7 @@ func HasOpenAIUsageTokens(usage *Usage) bool { return false } return usage.InputTokensDetails.CachedTokensDetails.HasTokens() || + usage.InputTokensDetails.CacheWriteTokensDetails.HasTokens() || usage.InputTokensDetails.CachedTokens != 0 || usage.InputTokensDetails.CachedCreationTokens != 0 || usage.InputTokensDetails.CacheWriteTokens != 0 || diff --git a/relaykit/dto/billing_usage_test.go b/relaykit/dto/billing_usage_test.go index da81c447ff..0bf3a11450 100644 --- a/relaykit/dto/billing_usage_test.go +++ b/relaykit/dto/billing_usage_test.go @@ -76,6 +76,37 @@ func TestImageCacheDetailsSurviveUsageSnapshots(t *testing.T) { assert.NotContains(t, string(encoded), `"audio_tokens":null`) } +func TestCacheWriteTTLDetailsSurviveUsageSnapshots(t *testing.T) { + var original Usage + require.NoError(t, kitutil.Unmarshal([]byte(`{"input_tokens":12000,"input_tokens_details":{"cached_tokens":4000,"cache_write_tokens":7000,"cache_write_tokens_details":{"ttl_30m_tokens":3000,"ttl_12h_tokens":4000}}}`), &original)) + billing := NewOpenAIResponsesBillingUsage(&original) + require.NotNil(t, billing) + original.InputTokensDetails.CacheWriteTokensDetails.TTL12hTokens = 99 + canonical, ok := billing.CanonicalUsage() + require.True(t, ok) + assert.Equal(t, 7000, canonical.PromptTokensDetails.CacheCreationTokensTotal()) + assert.Equal(t, 4000, canonical.PromptTokensDetails.CacheWrite12hTokens()) + canonical.PromptTokensDetails.CacheWriteTokensDetails.TTL12hTokens = 42 + assert.Equal(t, 4000, billing.OpenAIUsage.InputTokensDetails.CacheWriteTokensDetails.TTL12hTokens) + + // Stream snapshots merge like the scalar counts: a later zero keeps the + // earlier value, and the merged split is detached from both inputs. + current := &Usage{InputTokensDetails: &InputTokenDetails{CacheWriteTokensDetails: &CacheWriteTokenDetails{TTL30mTokens: 3000}}} + var incoming Usage + require.NoError(t, kitutil.Unmarshal([]byte(`{"input_tokens_details":{"cache_write_tokens":7000,"cache_write_tokens_details":{"ttl_30m_tokens":0,"ttl_12h_tokens":4000}}}`), &incoming)) + merged := MergeUsageNonZero(current, &incoming) + assert.Equal(t, &CacheWriteTokenDetails{TTL30mTokens: 3000, TTL12hTokens: 4000}, merged.InputTokensDetails.CacheWriteTokensDetails) + incoming.InputTokensDetails.CacheWriteTokensDetails.TTL12hTokens = 9 + assert.Equal(t, 4000, merged.InputTokensDetails.CacheWriteTokensDetails.TTL12hTokens) + encoded, err := kitutil.Marshal(merged) + require.NoError(t, err) + assert.Contains(t, string(encoded), `"cache_write_tokens_details":{"ttl_30m_tokens":3000,"ttl_12h_tokens":4000}`) + + // Billing never takes more 12h writes than the reported write total. + assert.Equal(t, 7000, InputTokenDetails{CacheWriteTokens: 7000, CacheWriteTokensDetails: &CacheWriteTokenDetails{TTL12hTokens: 9000}}.CacheWrite12hTokens()) + assert.Zero(t, InputTokenDetails{CacheWriteTokens: 7000, CacheWriteTokensDetails: &CacheWriteTokenDetails{TTL12hTokens: -1}}.CacheWrite12hTokens()) +} + func TestNewEstimatedGeminiChatBillingUsage(t *testing.T) { billingUsage := NewEstimatedGeminiChatBillingUsage(&Usage{ PromptTokens: 11, diff --git a/relaykit/dto/openai_request.go b/relaykit/dto/openai_request.go index 4daadac5c6..b7889542b5 100644 --- a/relaykit/dto/openai_request.go +++ b/relaykit/dto/openai_request.go @@ -74,6 +74,7 @@ type GeneralOpenAIRequest struct { Store json.RawMessage `json:"store,omitempty"` // Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field PromptCacheKey string `json:"prompt_cache_key,omitempty"` + PromptCacheOptions json.RawMessage `json:"prompt_cache_options,omitempty"` PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"` LogitBias json.RawMessage `json:"logit_bias,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"` diff --git a/relaykit/dto/openai_response.go b/relaykit/dto/openai_response.go index 9936166fa6..52c5db25e8 100644 --- a/relaykit/dto/openai_response.go +++ b/relaykit/dto/openai_response.go @@ -268,9 +268,22 @@ type InputTokenDetails struct { // 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"` + // CacheWriteTokensDetails splits CacheWriteTokens by cache lifetime. + CacheWriteTokensDetails *CacheWriteTokenDetails `json:"cache_write_tokens_details,omitempty"` + TextTokens int `json:"text_tokens"` + AudioTokens int `json:"audio_tokens"` + ImageTokens int `json:"image_tokens"` +} + +// CacheWriteTokenDetails is OpenAI's split of cache writes by lifetime. The +// two counts do not overlap and sum to cache_write_tokens. +type CacheWriteTokenDetails struct { + TTL30mTokens int `json:"ttl_30m_tokens"` + TTL12hTokens int `json:"ttl_12h_tokens"` +} + +func (d *CacheWriteTokenDetails) HasTokens() bool { + return d != nil && (d.TTL30mTokens != 0 || d.TTL12hTokens != 0) } // CachedTokenDetails describes subsets of cached_tokens. Pointers distinguish @@ -304,6 +317,10 @@ func (d InputTokenDetails) Clone() InputTokenDetails { } d.CachedTokensDetails = &cached } + if d.CacheWriteTokensDetails != nil { + cacheWrite := *d.CacheWriteTokensDetails + d.CacheWriteTokensDetails = &cacheWrite + } return d } @@ -321,6 +338,15 @@ func (d InputTokenDetails) CacheCreationTokensTotal() int { return total } +// CacheWrite12hTokens returns the 12-hour subset of the cache writes, bounded +// by CacheCreationTokensTotal so it never bills more writes than reported. +func (d InputTokenDetails) CacheWrite12hTokens() int { + if d.CacheWriteTokensDetails == nil { + return 0 + } + return min(max(d.CacheWriteTokensDetails.TTL12hTokens, 0), d.CacheCreationTokensTotal()) +} + type OutputTokenDetails struct { TextTokens int `json:"text_tokens"` AudioTokens int `json:"audio_tokens"` diff --git a/relaykit/dto/usage_merge.go b/relaykit/dto/usage_merge.go index 499b00159e..081b9129a4 100644 --- a/relaykit/dto/usage_merge.go +++ b/relaykit/dto/usage_merge.go @@ -50,7 +50,8 @@ func MergeUsageNonZero(current *Usage, incoming *Usage) *Usage { details.CacheWriteTokens > 0 || details.TextTokens > 0 || details.AudioTokens > 0 || - details.ImageTokens > 0 || details.CachedTokensDetails != nil { + details.ImageTokens > 0 || details.CachedTokensDetails != nil || + details.CacheWriteTokensDetails != nil { if current.InputTokensDetails == nil { current.InputTokensDetails = &InputTokenDetails{} } @@ -319,6 +320,21 @@ func mergeInputTokenDetails(current *InputTokenDetails, incoming InputTokenDetai if incoming.CacheWriteTokens > 0 { current.CacheWriteTokens = incoming.CacheWriteTokens } + if incoming.CacheWriteTokensDetails != nil { + // A detached copy; like the scalar counts, a later zero keeps the + // earlier value. + var merged CacheWriteTokenDetails + if current.CacheWriteTokensDetails != nil { + merged = *current.CacheWriteTokensDetails + } + if incoming.CacheWriteTokensDetails.TTL30mTokens > 0 { + merged.TTL30mTokens = incoming.CacheWriteTokensDetails.TTL30mTokens + } + if incoming.CacheWriteTokensDetails.TTL12hTokens > 0 { + merged.TTL12hTokens = incoming.CacheWriteTokensDetails.TTL12hTokens + } + current.CacheWriteTokensDetails = &merged + } if incoming.TextTokens > 0 { current.TextTokens = incoming.TextTokens } diff --git a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp.go b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp.go index 4704399cf7..b9fc8fe522 100644 --- a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp.go +++ b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp.go @@ -187,7 +187,7 @@ func UsageFromChatUsage(src *dto.Usage) *dto.Usage { src.PromptTokensDetails.CachedCreationTokens != 0 || src.PromptTokensDetails.CacheWriteTokens != 0 || src.PromptTokensDetails.TextTokens != 0 { - details := src.PromptTokensDetails + details := src.PromptTokensDetails.Clone() usage.InputTokensDetails = &details } if src.CompletionTokenDetails.ReasoningTokens != 0 || diff --git a/service/log_info_generate.go b/service/log_info_generate.go index bda3e61200..01416c77e4 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -346,7 +346,7 @@ func InjectTieredBillingInfo(other *model.LogOther, relayInfo *relaycommon.Relay other.SetPublic("image_cache_tokens", tokens.ImgCR) other.SetPublic("billing_tokens", map[string]float64{ "p": tokens.P, "c": tokens.C, "len": tokens.Len, - "cr": tokens.CR, "cc": tokens.CC, "cc1h": tokens.CC1h, + "cr": tokens.CR, "cc": tokens.CC, "cc1h": tokens.CC1h, "cc12h": tokens.CC12h, "img": tokens.Img, "img_cr": tokens.ImgCR, "img_o": tokens.ImgO, "ai": tokens.AI, "ao": tokens.AO, }) diff --git a/service/responses_usage.go b/service/responses_usage.go index 67b2f36b3d..21f1944bc0 100644 --- a/service/responses_usage.go +++ b/service/responses_usage.go @@ -157,7 +157,7 @@ func ApplyResponsesUsage(dst *dto.Usage, src *dto.Usage) { } incoming := relayconvert.NormalizeResponsesUsage(src) if src.InputTokensDetails != nil { - inputDetails := *src.InputTokensDetails + inputDetails := src.InputTokensDetails.Clone() incoming.InputTokensDetails = &inputDetails } if src.OutputTokensDetails != nil { diff --git a/service/text_quota.go b/service/text_quota.go index 47aba08003..03a0a31575 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -43,6 +43,7 @@ type textQuotaSummary struct { CacheCreationTokens int CacheCreationTokens5m int CacheCreationTokens1h int + CacheCreationTokens12h int ImageTokens int AudioTokens int ModelName string @@ -57,6 +58,7 @@ type textQuotaSummary struct { CacheCreationRatio float64 CacheCreationRatio5m float64 CacheCreationRatio1h float64 + CacheCreationRatio12h float64 Quota int IsClaudeUsageSemantic bool UsageSemantic string @@ -226,19 +228,20 @@ func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaS // the result with tiered billing, affinity observation and logging. func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) textQuotaSummary { summary := textQuotaSummary{ - ModelName: relayInfo.GetBillingModelName(), - TokenName: ctx.GetString("token_name"), - UseTimeSeconds: time.Now().Unix() - relayInfo.StartTime.Unix(), - CompletionRatio: relayInfo.PriceData.CompletionRatio, - CacheRatio: relayInfo.PriceData.CacheRatio, - ImageRatio: relayInfo.PriceData.ImageRatio, - ModelRatio: relayInfo.PriceData.ModelRatio, - GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio, - ModelPrice: relayInfo.PriceData.ModelPrice, - CacheCreationRatio: relayInfo.PriceData.CacheCreationRatio, - CacheCreationRatio5m: relayInfo.PriceData.CacheCreation5mRatio, - CacheCreationRatio1h: relayInfo.PriceData.CacheCreation1hRatio, - UsageSemantic: usageSemanticFromUsage(relayInfo, usage), + ModelName: relayInfo.GetBillingModelName(), + TokenName: ctx.GetString("token_name"), + UseTimeSeconds: time.Now().Unix() - relayInfo.StartTime.Unix(), + CompletionRatio: relayInfo.PriceData.CompletionRatio, + CacheRatio: relayInfo.PriceData.CacheRatio, + ImageRatio: relayInfo.PriceData.ImageRatio, + ModelRatio: relayInfo.PriceData.ModelRatio, + GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio, + ModelPrice: relayInfo.PriceData.ModelPrice, + CacheCreationRatio: relayInfo.PriceData.CacheCreationRatio, + CacheCreationRatio5m: relayInfo.PriceData.CacheCreation5mRatio, + CacheCreationRatio1h: relayInfo.PriceData.CacheCreation1hRatio, + CacheCreationRatio12h: relayInfo.PriceData.CacheCreation12hRatio, + UsageSemantic: usageSemanticFromUsage(relayInfo, usage), } summary.IsClaudeUsageSemantic = summary.UsageSemantic == "anthropic" @@ -260,6 +263,9 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf summary.ImageTokens = usage.PromptTokensDetails.ImageTokens summary.AudioTokens = usage.PromptTokensDetails.AudioTokens legacyClaudeDerived := isLegacyClaudeDerivedOpenAIUsage(relayInfo, usage) + if !summary.IsClaudeUsageSemantic && !legacyClaudeDerived { + summary.CacheCreationTokens12h = usage.PromptTokensDetails.CacheWrite12hTokens() + } isOpenRouterClaudeBilling := relayInfo.ChannelMeta != nil && relayInfo.ChannelType == constant.ChannelTypeOpenRouter && summary.IsClaudeUsageSemantic @@ -291,6 +297,7 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf dCacheCreationRatio := decimal.NewFromFloat(summary.CacheCreationRatio) dCacheCreationRatio5m := decimal.NewFromFloat(summary.CacheCreationRatio5m) dCacheCreationRatio1h := decimal.NewFromFloat(summary.CacheCreationRatio1h) + dCacheCreationRatio12h := decimal.NewFromFloat(summary.CacheCreationRatio12h) dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) ratio := dModelRatio.Mul(dGroupRatio) @@ -313,7 +320,10 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf if !dCachedCreationTokens.IsZero() || hasSplitCacheCreationTokens { if !summary.IsClaudeUsageSemantic && !legacyClaudeDerived { baseTokens = baseTokens.Sub(dCachedCreationTokens) - cachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCacheCreationRatio) + // The total includes the 12h writes, which have their own ratio. + dCachedCreationTokens12h := decimal.NewFromInt(int64(summary.CacheCreationTokens12h)) + cachedCreationTokensWithRatio = dCachedCreationTokens.Sub(dCachedCreationTokens12h).Mul(dCacheCreationRatio) + cachedCreationTokensWithRatio = cachedCreationTokensWithRatio.Add(dCachedCreationTokens12h.Mul(dCacheCreationRatio12h)) } else { remaining := max(summary.CacheCreationTokens-summary.CacheCreationTokens5m-summary.CacheCreationTokens1h, 0) cachedCreationTokensWithRatio = decimal.NewFromInt(int64(remaining)).Mul(dCacheCreationRatio) @@ -507,6 +517,10 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us other.SetPublic("cache_creation_tokens_1h", summary.CacheCreationTokens1h) other.SetPublic("cache_creation_ratio_1h", summary.CacheCreationRatio1h) } + if summary.CacheCreationTokens12h > 0 { + other.SetPublic("cache_creation_tokens_12h", summary.CacheCreationTokens12h) + other.SetPublic("cache_creation_ratio_12h", summary.CacheCreationRatio12h) + } cacheWriteTokens := cacheWriteTokensTotal(summary) if cacheWriteTokens > 0 { // cache_write_tokens: normalized cache creation total for UI display. diff --git a/service/text_quota_test.go b/service/text_quota_test.go index d8cfa569d2..3dc65bf4c2 100644 --- a/service/text_quota_test.go +++ b/service/text_quota_test.go @@ -864,6 +864,62 @@ func TestCalculateTextQuotaSummaryBillsOpenAICacheWriteTokens(t *testing.T) { }) } +// OpenAI 12h cache writes bill at twice the write price and keep their own +// count in the consume log, with ratio and expression pricing alike. +func TestPostTextConsumeQuotaBillsOpenAI12hCacheWrites(t *testing.T) { + truncate(t) + const userID, channelID = 63, 63 + seedUser(t, userID, 1_000_000) + seedChannel(t, channelID) + gin.SetMode(gin.TestMode) + // The OpenAI prompt caching guide example at gpt-6-astra prices: $10 input, + // $1 cached, $12.50 30m write and $25 12h write per 1M tokens. + usage := &dto.Usage{PromptTokens: 12000, TotalTokens: 12000, PromptTokensDetails: dto.InputTokenDetails{ + CachedTokens: 4000, CacheWriteTokens: 7000, + CacheWriteTokensDetails: &dto.CacheWriteTokenDetails{TTL30mTokens: 3000, TTL12hTokens: 4000}, + }} + for _, tc := range []struct { + name string + price hosttypes.PriceData + expr string + ratio12h float64 + }{ + {"ratio", hosttypes.PriceData{ModelRatio: 5, CompletionRatio: 5, CacheRatio: 0.1, CacheCreationRatio: 1.25, CacheCreation12hRatio: 2.5}, "", 2.5}, + {"expression without cc12h", hosttypes.PriceData{}, `tier("standard", p * 10 + c * 50 + cr * 1 + cc * 12.5)`, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("POST", "/v1/responses", nil) + tc.price.GroupRatioInfo = hosttypes.GroupRatioInfo{GroupRatio: 1} + info := &relaycommon.RelayInfo{ + UserId: userID, + OriginModelName: "gpt-6-astra", + UsingGroup: "default", + StartTime: time.Now(), + RelayFormat: types.RelayFormatOpenAIResponses, + ChannelMeta: &relaycommon.ChannelMeta{ChannelId: channelID}, + PriceData: tc.price, + } + if tc.expr != "" { + info.TieredBillingSnapshot = makeSnapshot(tc.expr, 1, 0, 0) + } + + PostTextConsumeQuota(ctx, info, usage, nil) + + log := getLastLog(t) + require.NotNil(t, log) + // 1000 input + 4000 cached + 3000 30m + 4000 12h writes = $0.1515. + assert.Equal(t, 75750, log.Quota) + var other map[string]any + require.NoError(t, common.UnmarshalJsonStr(log.Other, &other)) + assert.Equal(t, float64(7000), other["cache_creation_tokens"]) + assert.Equal(t, float64(7000), other["cache_write_tokens"]) + assert.Equal(t, float64(4000), other["cache_creation_tokens_12h"]) + assert.Equal(t, tc.ratio12h, other["cache_creation_ratio_12h"]) + }) + } +} + 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 bd01e5fa0b..b3d486fa86 100644 --- a/service/tiered_settle.go +++ b/service/tiered_settle.go @@ -9,6 +9,7 @@ import ( "github.com/QuantumNous/new-api/relaykit/dto" "github.com/QuantumNous/new-api/relaykit/relayconvert" "github.com/QuantumNous/new-api/relaykit/types" + hosttypes "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" ) @@ -24,12 +25,17 @@ type TieredResultWrapper = billingexpr.TieredResult // include all sub-categories (cache, image, audio). Claude-format APIs // report them as text-only. This function normalizes to text-only when // sub-categories are separately priced. +// +// A long-lived cache write (Claude 1h, OpenAI 12h) without its own price +// costs a fixed multiple of the short write price: it is billed in cc, or in +// p when cc has no price either. func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVars map[string]bool) billingexpr.TokenParams { p := float64(usage.PromptTokens) c := float64(usage.CompletionTokens) cr := float64(usage.PromptTokensDetails.CachedTokens) - cc5m := float64(usage.PromptTokensDetails.CacheCreationTokensTotal()) + cc := float64(usage.PromptTokensDetails.CacheCreationTokensTotal()) cc1h := float64(0) + cc12h := float64(0) if usage.UsageSemantic == "anthropic" { // Upstreams may report the cache-write total without a full 5m/1h @@ -39,7 +45,7 @@ func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVa usage.ClaudeCacheCreation5mTokens, usage.ClaudeCacheCreation1hTokens, ) - cc5m = float64(split5m) + cc = float64(split5m) cc1h = float64(split1h) } @@ -84,21 +90,45 @@ func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVa // Claude: input_tokens is text-only, so add cache read + cache creation. inputLen := p if isClaudeUsageSemantic { - inputLen = p + cr + cc5m + cc1h + inputLen = p + cr + cc + cc1h } if isClaudeUsageSemantic { - // Anthropic input excludes cache reads. When cr has no separate - // price, merge those tokens into the input category instead. + // Anthropic input excludes cache reads and writes. When they have no + // separate price, merge those tokens into the input category instead. if !usedVars["cr"] { p += cr } + if !usedVars["cc1h"] { + cc += cc1h * hosttypes.ClaudeCacheCreation1hMultiplier + cc1h = 0 + } + if !usedVars["cc"] { + p += cc + cc = 0 + } } else { if usedVars["cr"] { p -= cr } + // prompt_tokens counts every cache write once. A 12h write without + // its own price joins cc at twice its count; ccTokens keeps the real + // count that cc takes out of p, and an unpriced cc leaves p the extra. + cc12h = float64(usage.PromptTokensDetails.CacheWrite12hTokens()) + cc -= cc12h + ccTokens := cc + if usedVars["cc12h"] { + p -= cc12h + } else { + ccTokens += cc12h + cc += cc12h * hosttypes.OpenAICacheCreation12hMultiplier + cc12h = 0 + } if usedVars["cc"] { - p -= cc5m + p -= ccTokens + } else { + p += cc - ccTokens + cc = 0 } if usedVars["cc1h"] { p -= cc1h @@ -134,8 +164,9 @@ func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVa C: c, Len: inputLen, CR: cr, - CC: cc5m, + CC: cc, CC1h: cc1h, + CC12h: cc12h, Img: img, ImgCR: imgCR, ImgO: imgO, diff --git a/service/tiered_settle_test.go b/service/tiered_settle_test.go index ef908aafc7..069cb2ffdd 100644 --- a/service/tiered_settle_test.go +++ b/service/tiered_settle_test.go @@ -975,6 +975,59 @@ func TestBuildTieredTokenParams_ClaudeUnsplitCacheWrites(t *testing.T) { } } +// A long-lived cache write without its own price costs a fixed multiple of the +// short write price: 2x for OpenAI 12h, 6/3.75x for Claude 1h. The short price +// is cc, or p when cc has no price either. +func TestBuildTieredTokenParams_UnpricedLongTTLCacheWrites(t *testing.T) { + // The OpenAI prompt caching guide example: 12000 input tokens with 4000 + // cache reads and 7000 writes, 3000 of them 30m and 4000 12h. + openai := dto.Usage{PromptTokens: 12000, PromptTokensDetails: dto.InputTokenDetails{ + CachedTokens: 4000, CacheWriteTokens: 7000, + CacheWriteTokensDetails: &dto.CacheWriteTokenDetails{TTL30mTokens: 3000, TTL12hTokens: 4000}, + }} + unsplit := openai + unsplit.PromptTokensDetails.CacheWriteTokensDetails = nil + claude := func(split5m, split1h int) dto.Usage { + return dto.Usage{PromptTokens: 100, UsageSemantic: "anthropic", + ClaudeCacheCreation5mTokens: split5m, ClaudeCacheCreation1hTokens: split1h, + PromptTokensDetails: dto.InputTokenDetails{CachedCreationTokens: split5m + split1h}} + } + for _, tc := range []struct { + name string + usage dto.Usage + expr string + p, cc, cc1h, cc12h float64 + cost float64 + }{ + {"openai 30m and 12h priced", openai, `tier("base", p * 10 + cr * 1 + cc * 12.5 + cc12h * 25)`, 1000, 3000, 0, 4000, 151500}, + {"openai 12h joins cc at twice its count", openai, `tier("base", p * 10 + cr * 1 + cc * 12.5)`, 1000, 11000, 0, 0, 151500}, + {"openai 30m stays in p", openai, `tier("base", p * 10 + cr * 1 + cc12h * 25)`, 4000, 0, 0, 4000, 144000}, + {"openai 12h bills twice in p", openai, `tier("base", p * 10 + cr * 1)`, 12000, 0, 0, 0, 124000}, + {"openai writes without a ttl split", unsplit, `tier("base", p * 10 + cr * 1 + cc * 12.5 + cc12h * 25)`, 1000, 7000, 0, 0, 101500}, + {"claude 1h joins cc at 1.6x", claude(0, 10000), `tier("base", p * 3 + c * 15 + cr * 0.3 + cc * 3.75)`, 100, 16000, 0, 0, 60300}, + {"claude 5m bills as input", claude(10000, 0), `tier("base", p * 3 + c * 15)`, 10100, 0, 0, 0, 30300}, + {"claude 5m bills as input beside priced 1h", claude(2000, 10000), `tier("base", p * 3 + cc1h * 6)`, 2100, 0, 10000, 0, 66300}, + {"claude 1h bills as input at 1.6x", claude(2000, 10000), `tier("base", p * 3)`, 18100, 0, 0, 0, 54300}, + } { + t.Run(tc.name, func(t *testing.T) { + isClaude := tc.usage.UsageSemantic == "anthropic" + params := BuildTieredTokenParams(&tc.usage, isClaude, billingexpr.UsedVars(tc.expr)) + assert.Equal(t, tc.p, params.P) + assert.Equal(t, tc.cc, params.CC) + assert.Equal(t, tc.cc1h, params.CC1h) + assert.Equal(t, tc.cc12h, params.CC12h) + wantLen := float64(tc.usage.PromptTokens) + if isClaude { + wantLen += float64(tc.usage.PromptTokensDetails.CacheCreationTokensTotal()) + } + assert.Equal(t, wantLen, params.Len, "len keeps the unweighted counts") + cost, _, err := billingexpr.RunExpr(tc.expr, params) + require.NoError(t, err) + assert.Equal(t, tc.cost, cost) + }) + } +} + func TestBuildTieredTokenParams_Len_TierCondition(t *testing.T) { // Test that len-based tier conditions work correctly when p is reduced by cache usage := &dto.Usage{ diff --git a/setting/billing_setting/builtin_billing.go b/setting/billing_setting/builtin_billing.go index b03370db8a..3f6f56c198 100644 --- a/setting/billing_setting/builtin_billing.go +++ b/setting/billing_setting/builtin_billing.go @@ -18,10 +18,12 @@ var builtinBillingExpr = map[string]string{ "gpt-image-2.5-sunburst": `tier("standard", p * 5 + cr * 1.25 + img * 8 + img_cr * 2 + c * 30)`, "gpt-image-2.5-flare": `tier("standard", p * 5 + cr * 1.25 + img * 8 + img_cr * 2 + c * 30)`, // https://developers.openai.com/api/docs/models/gpt-6-astra - // Standard pricing; the long-context rates apply to the whole request. + // https://developers.openai.com/api/docs/pricing (Standard, 2026-10-22): + // cc is the 30m cache write and cc12h the 12h write. The long-context + // rates apply to the whole request. // Do not infer service-tier discounts from incoming request parameters: // channels filter service_tier by default, so it may not reach the upstream. - "gpt-6-astra": `len <= 272000 ? tier("standard", p * 10 + c * 50 + cr * 1 + cc * 12.5) : tier("long_context", p * 20 + c * 75 + cr * 2 + cc * 25)`, + "gpt-6-astra": `len <= 272000 ? tier("standard", p * 10 + c * 50 + cr * 1 + cc * 12.5 + cc12h * 25) : tier("long_context", p * 20 + c * 75 + cr * 2 + cc * 25 + cc12h * 50)`, // https://api-docs.deepseek.com/quick_start/pricing (2026-10-17). Off-peak // is half the peak price. deepseek-v4-flash and deepseek-v4-flash-vision-exp // run on V4.1 Flash and bill at the Flash price. Images bill as input diff --git a/setting/billing_setting/builtin_billing_test.go b/setting/billing_setting/builtin_billing_test.go index e940d6bd9f..6ab637c276 100644 --- a/setting/billing_setting/builtin_billing_test.go +++ b/setting/billing_setting/builtin_billing_test.go @@ -37,20 +37,24 @@ func TestGPT6AstraBuiltinBilling(t *testing.T) { require.True(t, ok) for _, tc := range []struct { - name string - input, output, cached, written int - request string - quota int + name string + input, output, cached, written, written12h int + request string + quota int }{ - {"standard", 1000, 100, 0, 0, `{}`, 7500}, - {"client flex cannot discount standard pricing", 1000, 100, 0, 0, `{"service_tier":"flex"}`, 7500}, - {"cache at context boundary", 272000, 1000, 200000, 20000, `{}`, 510000}, - {"whole request above boundary", 272001, 1000, 200000, 20000, `{}`, 1007510}, + {"standard", 1000, 100, 0, 0, 0, `{}`, 7500}, + {"client flex cannot discount standard pricing", 1000, 100, 0, 0, 0, `{"service_tier":"flex"}`, 7500}, + {"cache at context boundary", 272000, 1000, 200000, 20000, 0, `{}`, 510000}, + {"whole request above boundary", 272001, 1000, 200000, 20000, 0, `{}`, 1007510}, + // The prompt caching guide example: 4000 of the 7000 writes are 12h. + {"12h cache writes", 12000, 0, 4000, 7000, 4000, `{}`, 75750}, + {"12h cache writes above boundary", 300000, 0, 200000, 20000, 10000, `{}`, 1375000}, } { t.Run(tc.name, func(t *testing.T) { usage := &dto.Usage{ PromptTokens: tc.input, CompletionTokens: tc.output, - PromptTokensDetails: dto.InputTokenDetails{CachedTokens: tc.cached, CacheWriteTokens: tc.written}, + PromptTokensDetails: dto.InputTokenDetails{CachedTokens: tc.cached, CacheWriteTokens: tc.written, + CacheWriteTokensDetails: &dto.CacheWriteTokenDetails{TTL30mTokens: tc.written - tc.written12h, TTL12hTokens: tc.written12h}}, } params := service.BuildTieredTokenParams(usage, false, billingexpr.UsedVars(expression)) result, err := billingexpr.ComputeTieredQuotaWithRequest(&billingexpr.BillingSnapshot{ diff --git a/types/price_data.go b/types/price_data.go index b9215f7b73..df0ced0a29 100644 --- a/types/price_data.go +++ b/types/price_data.go @@ -7,6 +7,14 @@ import ( "github.com/shopspring/decimal" ) +// Long-lived cache writes cost a fixed multiple of the short write price. +const ( + // https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration + ClaudeCacheCreation1hMultiplier = 6 / 3.75 + // https://developers.openai.com/api/docs/guides/prompt-caching + OpenAICacheCreation12hMultiplier = 2 +) + type GroupRatioInfo struct { GroupRatio float64 GroupSpecialRatio float64 @@ -14,22 +22,23 @@ type GroupRatioInfo struct { } type PriceData struct { - FreeModel bool - ModelPrice float64 - ModelRatio float64 - CompletionRatio float64 - CacheRatio float64 - CacheCreationRatio float64 - CacheCreation5mRatio float64 - CacheCreation1hRatio float64 - ImageRatio float64 - AudioRatio float64 - AudioCompletionRatio float64 - otherRatios map[string]float64 - UsePrice bool - Quota int // 按次计费的最终额度(MJ / Task) - QuotaToPreConsume int // 按量计费的预消耗额度 - GroupRatioInfo GroupRatioInfo + FreeModel bool + ModelPrice float64 + ModelRatio float64 + CompletionRatio float64 + CacheRatio float64 + CacheCreationRatio float64 + CacheCreation5mRatio float64 + CacheCreation1hRatio float64 + CacheCreation12hRatio float64 + ImageRatio float64 + AudioRatio float64 + AudioCompletionRatio float64 + otherRatios map[string]float64 + UsePrice bool + Quota int // 按次计费的最终额度(MJ / Task) + QuotaToPreConsume int // 按量计费的预消耗额度 + GroupRatioInfo GroupRatioInfo } func (p *PriceData) AddOtherRatio(key string, ratio float64) { @@ -108,5 +117,5 @@ func isValidOtherRatio(ratio float64) bool { } func (p *PriceData) ToSetting() string { - return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio) + return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, CacheCreation12hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.CacheCreation12hRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio) } diff --git a/web/src/features/model-pricing/__tests__/editor-currency.test.tsx b/web/src/features/model-pricing/__tests__/editor-currency.test.tsx index 4d536c76f9..1d893263f0 100644 --- a/web/src/features/model-pricing/__tests__/editor-currency.test.tsx +++ b/web/src/features/model-pricing/__tests__/editor-currency.test.tsx @@ -260,6 +260,12 @@ it.each([ mode: 'claude_ttl', expression: 'tier("base", p * 2 + c * 4 + cr * 1 + cc * 3 + cc1h * 4.8)', }, + { + name: 'custom-gpt', + ratio: 1.5, + mode: 'openai_ttl', + expression: 'tier("base", p * 2 + c * 4 + cr * 1 + cc * 3 + cc12h * 6)', + }, ])( 'keeps configured cache prices visible on both sides for $name', async (fixture) => { @@ -299,6 +305,7 @@ it.each([ ? 'Cache write price' : 'Cache create price' if (fixture.mode === 'claude_ttl') label = 'Cache Creation (5m)' + if (fixture.mode === 'openai_ttl') label = 'Cache Creation (30m)' expect( within(panel).getByText(label).nextElementSibling ).toHaveTextContent(`$${fixture.ratio * 2} / 1M token`) @@ -311,6 +318,15 @@ it.each([ within(panel).queryByText('Cache create (1h) price') ).not.toBeInTheDocument() } + if (fixture.mode === 'openai_ttl') { + expect( + within(panel).getByText('Cache create (12h) price').nextElementSibling + ).toHaveTextContent('$6 / 1M token') + } else { + expect( + within(panel).queryByText('Cache create (12h) price') + ).not.toBeInTheDocument() + } } await userEvent .setup() diff --git a/web/src/features/model-pricing/__tests__/editor-layout.test.tsx b/web/src/features/model-pricing/__tests__/editor-layout.test.tsx index d1398ea8b9..3aea256f15 100644 --- a/web/src/features/model-pricing/__tests__/editor-layout.test.tsx +++ b/web/src/features/model-pricing/__tests__/editor-layout.test.tsx @@ -59,7 +59,7 @@ afterEach(() => { } }) -it.each(['none', 'standard', 'claude_ttl'] as const)( +it.each(['none', 'standard', 'claude_ttl', 'openai_ttl'] as const)( 'uses the %s cache profile consistently in current billing and draft previews', async (mode) => { const cacheWriteMode = mode @@ -134,11 +134,15 @@ it.each(['none', 'standard', 'claude_ttl'] as const)( expect( within(panel).queryByText('Cache create (1h) price') ).not.toBeInTheDocument() - } else { expect( - within(panel).getByText( - mode === 'claude_ttl' ? 'Cache Creation (5m)' : 'Cache write price' - ).nextElementSibling + within(panel).queryByText('Cache create (12h) price') + ).not.toBeInTheDocument() + } else { + let writeLabel = 'Cache write price' + if (mode === 'claude_ttl') writeLabel = 'Cache Creation (5m)' + if (mode === 'openai_ttl') writeLabel = 'Cache Creation (30m)' + expect( + within(panel).getByText(writeLabel).nextElementSibling ).toHaveTextContent('$5') if (mode === 'claude_ttl') { expect( @@ -150,6 +154,16 @@ it.each(['none', 'standard', 'claude_ttl'] as const)( within(panel).queryByText('Cache create (1h) price') ).not.toBeInTheDocument() } + if (mode === 'openai_ttl') { + expect( + within(panel).getByText('Cache create (12h) price') + .nextElementSibling + ).toHaveTextContent('$10') + } else { + expect( + within(panel).queryByText('Cache create (12h) price') + ).not.toBeInTheDocument() + } } } expect( diff --git a/web/src/features/model-pricing/pricing.ts b/web/src/features/model-pricing/pricing.ts index 8be6a9bdb7..401de84b5e 100644 --- a/web/src/features/model-pricing/pricing.ts +++ b/web/src/features/model-pricing/pricing.ts @@ -56,7 +56,7 @@ export type PricingValues = Partial< } export type PricingOptions = Record -export type CacheWriteMode = 'none' | 'standard' | 'claude_ttl' +export type CacheWriteMode = 'none' | 'standard' | 'claude_ttl' | 'openai_ttl' export type LegacyBillingDetails = { audio_input_price?: number @@ -98,6 +98,7 @@ export function modelPricingDisplay( !( entry.cache_write_mode === 'standard' || entry.cache_write_mode === 'claude_ttl' || + entry.cache_write_mode === 'openai_ttl' || entry.configured?.CreateCacheRatio !== undefined ) ? undefined diff --git a/web/src/features/pricing/lib/__tests__/billing-expression.test.ts b/web/src/features/pricing/lib/__tests__/billing-expression.test.ts index 36e35fa120..daaa1052e8 100644 --- a/web/src/features/pricing/lib/__tests__/billing-expression.test.ts +++ b/web/src/features/pricing/lib/__tests__/billing-expression.test.ts @@ -44,6 +44,8 @@ import { import { buildEstimatorTokens, evalExprLocally, + generateExprFromVisualConfig, + tryParseVisualConfig, type ExtraTokenValues, } from '../tier-expr' @@ -51,6 +53,7 @@ const extras: ExtraTokenValues = { cacheReadTokens: 100, cacheCreateTokens: 0, cacheCreate1hTokens: 0, + cacheCreate12hTokens: 0, imageTokens: 0, imageOutputTokens: 0, imageCacheTokens: 0, @@ -58,17 +61,89 @@ const extras: ExtraTokenValues = { audioOutputTokens: 0, } +test('parses a 12-hour cache write price and regenerates the expression unchanged', () => { + const source = + 'tier("base", p * 10 + c * 40 + cr * 1 + cc * 12.5 + cc12h * 25)' + const config = tryParseVisualConfig(source) + assert(config) + expect(config.tiers[0]).toMatchObject({ + cache_create_unit_cost: 12.5, + cache_create_12h_unit_cost: 25, + }) + expect(generateExprFromVisualConfig(config)).toBe(source) +}) + +test('estimates long-lived cache writes at their own price, at the cc price fallback, or free when cc is unpriced', () => { + const source = + 'len <= 2000 ? tier("short", p * 10 + c * 40 + cr * 1 + cc * 12.5 + cc12h * 25) : tier("long", p * 20 + c * 80 + cr * 2 + cc * 25 + cc12h * 50)' + const tokens = { + ...extras, + cacheCreateTokens: 200, + cacheCreate12hTokens: 300, + } + expect(buildEstimatorTokens(1000, 100, tokens, source)).toMatchObject({ + len: 1600, + cc: 200, + cc12h: 300, + }) + expect(evalExprLocally(source, 1000, 100, tokens)).toEqual({ + cost: 24100, + matchedTier: 'short', + error: null, + }) + expect( + evalExprLocally(source, 1000, 100, { ...tokens, cacheCreate12hTokens: 950 }) + ).toMatchObject({ cost: 80700, matchedTier: 'long' }) + + const ccOnly = 'tier("base", p * 10 + c * 50 + cr * 1 + cc * 12.5)' + const twelveHours = { ...tokens, cacheCreate12hTokens: 1000 } + const oneHour = { + ...extras, + cacheCreateTokens: 200, + cacheCreate1hTokens: 1000, + } + expect(evalExprLocally(ccOnly, 1000, 100, twelveHours).cost).toBe(42600) + expect( + evalExprLocally( + 'tier("base", p * 10 + c * 50 + cr * 1 + cc * 12.5 + cc12h * 25)', + 1000, + 100, + twelveHours + ).cost + ).toBe(42600) + expect(evalExprLocally(ccOnly, 1000, 100, oneHour).cost).toBe(37600) + expect( + evalExprLocally( + 'tier("base", p * 10 + c * 50 + cr * 1 + cc * 12.5 + cc1h * 20)', + 1000, + 100, + oneHour + ).cost + ).toBe(37600) + expect( + evalExprLocally('tier("base", p * 10 + c * 50)', 1000, 100, { + ...oneHour, + cacheCreate12hTokens: 1000, + }).cost + ).toBe(15000) +}) + test('evaluates and round-trips separate image cache prices including an explicit zero', () => { const source = 'tier("standard", p * 5 + cr * 1.25 + img * 8 + img_cr * 2 + c * 30)' const document = parseVisualBillingDocument(source) expect( - buildEstimatorTokens(300, 100, { - ...extras, - cacheReadTokens: 100, - imageTokens: 400, - imageCacheTokens: 200, - }).len + buildEstimatorTokens( + 300, + 100, + { + ...extras, + cacheReadTokens: 100, + imageTokens: 400, + imageCacheTokens: 200, + }, + source + ).len ).toBe(1000) assert(document) const regenerated = serializeVisualBillingDocument(document) diff --git a/web/src/features/pricing/lib/billing-expr.ts b/web/src/features/pricing/lib/billing-expr.ts index c67011cf1a..99d6ec4489 100644 --- a/web/src/features/pricing/lib/billing-expr.ts +++ b/web/src/features/pricing/lib/billing-expr.ts @@ -120,6 +120,15 @@ export const BILLING_VARS: BillingVar[] = [ side: 'input', group: 'cache', }, + { + key: 'cc12h', + field: 'cacheCreate12hPrice', + tierField: 'cache_create_12h_unit_cost', + label: 'Cache create (12h) price', + shortLabel: 'Cache Write (12h)', + side: 'input', + group: 'cache', + }, { key: 'img', field: 'imagePrice', @@ -181,6 +190,10 @@ export const BILLING_CACHE_VAR_MAP = BILLING_EXTRA_VARS.map((v) => ({ exprVar: v.key, })) +/** Long-lived cache writes cost a fixed multiple of the `cc` write price. */ +export const CLAUDE_CACHE_CREATION_1H_MULTIPLIER = 6 / 3.75 +export const OPENAI_CACHE_CREATION_12H_MULTIPLIER = 2 + // --------------------------------------------------------------------------- // Request rule constants // --------------------------------------------------------------------------- diff --git a/web/src/features/pricing/lib/billing-expression/README.md b/web/src/features/pricing/lib/billing-expression/README.md index 5243acdc3a..1c75906fdf 100644 --- a/web/src/features/pricing/lib/billing-expression/README.md +++ b/web/src/features/pricing/lib/billing-expression/README.md @@ -37,7 +37,8 @@ ``` - `tokens` 是已归一化的计费变量;模拟器不再次减缓存。`p`、`c` 与现有预估输入含义相同。 -- 基础预估的 `len` 默认沿用 `p + cr + cc + cc1h`,可显式覆盖;不从请求 JSON 推断 token。 +- 基础预估的 `len` 默认沿用 `p + cr + cc + cc1h + cc12h`,可显式覆盖;不从请求 JSON 推断 token。 +- 表达式给 `cc` 定价、未给 `cc1h` 或 `cc12h` 定价时,基础预估与后端一样把 1 小时写入的 6/3.75 倍、12 小时写入的 2 倍计入 `cc`,`cc` 本身未定价时这些写入不计费。 - 省略 `request` 表示没有模拟请求上下文;显式 `{}` 表示一个没有请求头、请求体字段的请求。只有开启请求模拟后,界面才提供空请求。 - `usage` 对应任务 `u()` 字段;不从请求体猜测任务用量。 - `now` 是一次求值共享的绝对时刻,省略时在开始求值时取得当前时间。 diff --git a/web/src/features/pricing/lib/billing-expression/types.ts b/web/src/features/pricing/lib/billing-expression/types.ts index d335389e08..2006b12988 100644 --- a/web/src/features/pricing/lib/billing-expression/types.ts +++ b/web/src/features/pricing/lib/billing-expression/types.ts @@ -23,6 +23,7 @@ export const TOKEN_VARIABLES = [ 'cr', 'cc', 'cc1h', + 'cc12h', 'img', 'img_cr', 'img_o', diff --git a/web/src/features/pricing/lib/dynamic-price.ts b/web/src/features/pricing/lib/dynamic-price.ts index 4e36d628c8..663524ca91 100644 --- a/web/src/features/pricing/lib/dynamic-price.ts +++ b/web/src/features/pricing/lib/dynamic-price.ts @@ -354,20 +354,23 @@ export function getDynamicPriceEntries( return [] } + let label = variable.label + let shortLabel = variable.shortLabel + if (variable.key === 'cc') { + if (typeof (tier as ParsedTier).cacheCreate1hPrice === 'number') { + label = 'Cache Creation (5m)' + shortLabel = 'Cache Write (5m)' + } else if (typeof (tier as ParsedTier).cacheCreate12hPrice === 'number') { + label = 'Cache Creation (30m)' + shortLabel = 'Cache Write (30m)' + } + } return [ { key: variable.key, field: variable.field, - label: - variable.key === 'cc' && - typeof (tier as ParsedTier).cacheCreate1hPrice === 'number' - ? 'Cache Creation (5m)' - : variable.label, - shortLabel: - variable.key === 'cc' && - typeof (tier as ParsedTier).cacheCreate1hPrice === 'number' - ? 'Cache Write (5m)' - : variable.shortLabel, + label, + shortLabel, labelKind: 'i18n' as const, value, formatted: formatDynamicUnitPrice(value, options), diff --git a/web/src/features/pricing/lib/tier-expr.ts b/web/src/features/pricing/lib/tier-expr.ts index 272e10927a..98453071a7 100644 --- a/web/src/features/pricing/lib/tier-expr.ts +++ b/web/src/features/pricing/lib/tier-expr.ts @@ -16,7 +16,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { BILLING_CACHE_VAR_MAP } from './billing-expr' +import { + BILLING_CACHE_VAR_MAP, + CLAUDE_CACHE_CREATION_1H_MULTIPLIER, + OPENAI_CACHE_CREATION_12H_MULTIPLIER, +} from './billing-expr' import { compileBillingExpression } from './billing-expression/parser' import { evaluateBillingExpression } from './billing-expression/runtime' import type { @@ -45,6 +49,7 @@ export type VisualTier = { cache_read_unit_cost?: number cache_create_unit_cost?: number cache_create_1h_unit_cost?: number + cache_create_12h_unit_cost?: number image_unit_cost?: number image_output_unit_cost?: number audio_input_unit_cost?: number @@ -77,6 +82,7 @@ function normalizeVisualTier(tier: Partial = {}): VisualTier { cache_read_unit_cost: Number(tier.cache_read_unit_cost) || 0, cache_create_unit_cost: Number(tier.cache_create_unit_cost) || 0, cache_create_1h_unit_cost: Number(tier.cache_create_1h_unit_cost) || 0, + cache_create_12h_unit_cost: Number(tier.cache_create_12h_unit_cost) || 0, image_unit_cost: Number(tier.image_unit_cost) || 0, image_output_unit_cost: Number(tier.image_output_unit_cost) || 0, audio_input_unit_cost: Number(tier.audio_input_unit_cost) || 0, @@ -258,6 +264,7 @@ const ESTIMATOR_VARS = [ { var: 'cr', stateKey: 'cacheReadTokens' }, { var: 'cc', stateKey: 'cacheCreateTokens' }, { var: 'cc1h', stateKey: 'cacheCreate1hTokens' }, + { var: 'cc12h', stateKey: 'cacheCreate12hTokens' }, { var: 'img', stateKey: 'imageTokens' }, { var: 'img_cr', stateKey: 'imageCacheTokens' }, { var: 'img_o', stateKey: 'imageOutputTokens' }, @@ -288,7 +295,12 @@ export function evalExprLocally( const result = evaluateBillingExpression(exprStr, { ...context, tokens: { - ...buildEstimatorTokens(promptTokens, completionTokens, extraTokenValues), + ...buildEstimatorTokens( + promptTokens, + completionTokens, + extraTokenValues, + exprStr + ), ...context?.tokens, }, }) @@ -308,8 +320,24 @@ export function evalExprLocally( export function buildEstimatorTokens( promptTokens: number, completionTokens: number, - extraTokenValues: ExtraTokenValues + extraTokenValues: ExtraTokenValues, + exprStr: string ): Partial> { + // Like backend token normalization, a long-lived cache write without its + // own price joins cc at a fixed multiple. With cc unpriced it stays free. + const compiled = compileBillingExpression(exprStr) + const usedVars: ReadonlySet = + compiled.status === 'ready' ? compiled.variables : new Set() + let cacheCreateTokens = extraTokenValues.cacheCreateTokens + if (usedVars.has('cc') && !usedVars.has('cc1h')) { + cacheCreateTokens += + extraTokenValues.cacheCreate1hTokens * CLAUDE_CACHE_CREATION_1H_MULTIPLIER + } + if (usedVars.has('cc') && !usedVars.has('cc12h')) { + cacheCreateTokens += + extraTokenValues.cacheCreate12hTokens * + OPENAI_CACHE_CREATION_12H_MULTIPLIER + } return { p: promptTokens, c: completionTokens, @@ -320,13 +348,15 @@ export function buildEstimatorTokens( extraTokenValues.imageTokens + extraTokenValues.audioInputTokens + extraTokenValues.cacheCreateTokens + - extraTokenValues.cacheCreate1hTokens, + extraTokenValues.cacheCreate1hTokens + + extraTokenValues.cacheCreate12hTokens, ...Object.fromEntries( ESTIMATOR_VARS.map((field) => [ field.var, extraTokenValues[field.stateKey], ]) ), + cc: cacheCreateTokens, } } diff --git a/web/src/features/system-settings/models/__tests__/visual-billing-editor.test.tsx b/web/src/features/system-settings/models/__tests__/visual-billing-editor.test.tsx index 6b35deebd7..ed3c580202 100644 --- a/web/src/features/system-settings/models/__tests__/visual-billing-editor.test.tsx +++ b/web/src/features/system-settings/models/__tests__/visual-billing-editor.test.tsx @@ -450,6 +450,49 @@ test('edits image cache pricing and preserves an explicitly free cache lane', () ).toBeChecked() }) +test('keeps an OpenAI 12-hour cache write price through edits and labels the default write as 30 minutes', async () => { + const source = + 'tier("base", p * 10 + c * 40 + cr * 1 + cc * 12.5 + cc12h * 25)' + const onBillingExprChange = vi.fn() + render( + + ) + const user = userEvent.setup() + const tier = within(screen.getByRole('group', { name: 'Pricing tier base' })) + expect( + tier.getByRole('textbox', { name: 'Cache Creation (30m)' }) + ).toHaveValue('12.5') + expect( + tier.getByRole('textbox', { name: 'Cache create (12h) price' }) + ).toHaveValue('25') + expect( + tier.getByRole('checkbox', { name: 'Include Cache create (1h) price' }) + ).not.toBeChecked() + expect(onBillingExprChange).not.toHaveBeenCalled() + + fireEvent.change(tier.getByRole('textbox', { name: 'Input price' }), { + target: { value: '11' }, + }) + expect(onBillingExprChange).toHaveBeenLastCalledWith( + source.replace('p * 10', 'p * 11') + ) + + await user.click( + tier.getByRole('checkbox', { name: 'Include Cache create (12h) price' }) + ) + expect(onBillingExprChange).toHaveBeenLastCalledWith( + 'tier("base", p * 11 + c * 40 + cr * 1 + cc * 12.5)' + ) + expect(tier.getByRole('textbox', { name: 'Cache create price' })).toHaveValue( + '12.5' + ) +}) + describe('visual time billing editor', () => { test.each([ ['simple tiers', 'tier("base", p * 2 + c * 8)', '2'], diff --git a/web/src/features/system-settings/models/model-pricing-core.ts b/web/src/features/system-settings/models/model-pricing-core.ts index c9f94778b7..423c9e934e 100644 --- a/web/src/features/system-settings/models/model-pricing-core.ts +++ b/web/src/features/system-settings/models/model-pricing-core.ts @@ -28,7 +28,11 @@ import type { CacheWriteMode, LegacyBillingDetails, } from '@/features/model-pricing/pricing' -import { combineBillingExpr } from '@/features/pricing/lib/billing-expr' +import { + CLAUDE_CACHE_CREATION_1H_MULTIPLIER, + OPENAI_CACHE_CREATION_12H_MULTIPLIER, + combineBillingExpr, +} from '@/features/pricing/lib/billing-expr' import { formatBillingCondition } from '@/features/pricing/lib/billing-expression/condition-display' import { compileBillingExpression } from '@/features/pricing/lib/billing-expression/parser' import type { BillingUsageSchema } from '@/features/pricing/types' @@ -310,6 +314,12 @@ export function buildPreviewRows( currency ) } + let createCacheLabel = t('Cache write price') + if (cacheWriteMode === 'claude_ttl') { + createCacheLabel = t('Cache Creation (5m)') + } else if (cacheWriteMode === 'openai_ttl') { + createCacheLabel = t('Cache Creation (30m)') + } const rows: PreviewRow[] = [ { key: 'inputPrice', @@ -336,10 +346,7 @@ export function buildPreviewRows( }, { key: 'createCache', - label: - cacheWriteMode === 'claude_ttl' - ? t('Cache Creation (5m)') - : t('Cache write price'), + label: createCacheLabel, value: laneEnabled.createCache && lanePrices.createCache ? formatPricingAmount(lanePrices.createCache, currency) @@ -373,7 +380,21 @@ export function buildPreviewRows( key: 'createCache1h', label: t('Cache create (1h) price'), value: formatPricingAmount( - Number(lanePrices.createCache) * (6 / 3.75), + Number(lanePrices.createCache) * CLAUDE_CACHE_CREATION_1H_MULTIPLIER, + currency + ), + }) + } + if ( + cacheWriteMode === 'openai_ttl' && + laneEnabled.createCache && + lanePrices.createCache + ) { + rows.splice(4, 0, { + key: 'createCache12h', + label: t('Cache create (12h) price'), + value: formatPricingAmount( + Number(lanePrices.createCache) * OPENAI_CACHE_CREATION_12H_MULTIPLIER, currency ), }) diff --git a/web/src/features/system-settings/models/tier-price-fields.tsx b/web/src/features/system-settings/models/tier-price-fields.tsx index 94a63a07d0..f6cc05d1f7 100644 --- a/web/src/features/system-settings/models/tier-price-fields.tsx +++ b/web/src/features/system-settings/models/tier-price-fields.tsx @@ -137,28 +137,32 @@ export function TierPriceFields(props: TierPriceFieldsProps) { const renderPriceVariable = (variable: { key: VisualPrice['variable'] label: string - }) => ( - props.onChange(variable.key, value)} - included={ - props.onInclude ? props.prices[variable.key] !== undefined : undefined - } - onInclude={ - props.onInclude - ? (included) => props.onInclude?.(variable.key, included) - : undefined - } - invalid={props.invalidVariables?.includes(variable.key)} - /> - ) + }) => { + let label = t(variable.label) + if (variable.key === 'cc' && props.prices.cc1h !== undefined) { + label = t('Cache Creation (5m)') + } else if (variable.key === 'cc' && props.prices.cc12h !== undefined) { + label = t('Cache Creation (30m)') + } + return ( + props.onChange(variable.key, value)} + included={ + props.onInclude ? props.prices[variable.key] !== undefined : undefined + } + onInclude={ + props.onInclude + ? (included) => props.onInclude?.(variable.key, included) + : undefined + } + invalid={props.invalidVariables?.includes(variable.key)} + /> + ) + } const billingControl = props.onBillingUnitChange && (