mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
feat(billing): bill OpenAI 12-hour prompt cache writes at their own price
OpenAI added a 12-hour prompt cache to the Responses API on 2026-10-20 (prompt_cache_options.ttl "12h", GPT-5.6 and later). A 12-hour write costs 2.5x the input price, twice the 30-minute write. We already forwarded the option but billed every cache write at the 30-minute price. - Read cache_write_tokens_details from Responses usage and keep the split through streaming, compaction and format conversion. - Ratio pricing bills 12-hour writes at twice the cache write ratio, the same way Claude 1-hour writes use 6/3.75. - Expressions get a cc12h price. When an expression leaves the long-lived price out, 12-hour writes cost twice the cc price and Claude 1-hour writes 6/3.75 times it; without a cc price they bill as input. Claude cache writes are no longer free when an expression omits cc or cc1h. - gpt-6-astra prices 12-hour writes at $25 ($50 above 272K tokens). - GPT models with a cache write ratio convert to expressions with cc12h. - Chat Completions forwards prompt_cache_options. - The pricing editor, cost estimator, pricing pages and usage log details show the 12-hour price and tokens.
This commit is contained in:
50 files changed
+860
-156
No files matched your search
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
+11
-6
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-18
@@ -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 {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+28
-14
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
+26
-17
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -56,7 +56,7 @@ export type PricingValues = Partial<
|
||||
}
|
||||
export type PricingOptions = Record<PricingKey, string>
|
||||
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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` 是一次求值共享的绝对时刻,省略时在开始求值时取得当前时间。
|
||||
|
||||
@@ -23,6 +23,7 @@ export const TOKEN_VARIABLES = [
|
||||
'cr',
|
||||
'cc',
|
||||
'cc1h',
|
||||
'cc12h',
|
||||
'img',
|
||||
'img_cr',
|
||||
'img_o',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -16,7 +16,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
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> = {}): 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<Record<TokenVariable, number>> {
|
||||
// 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<string> =
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
<TieredPricingEditor
|
||||
billingExpr={source}
|
||||
requestRuleExpr=''
|
||||
onBillingExprChange={onBillingExprChange}
|
||||
onRequestRuleExprChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
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'],
|
||||
|
||||
@@ -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
|
||||
),
|
||||
})
|
||||
|
||||
@@ -137,28 +137,32 @@ export function TierPriceFields(props: TierPriceFieldsProps) {
|
||||
const renderPriceVariable = (variable: {
|
||||
key: VisualPrice['variable']
|
||||
label: string
|
||||
}) => (
|
||||
<PriceField
|
||||
key={variable.key}
|
||||
currency={props.currency}
|
||||
label={
|
||||
variable.key === 'cc' && props.prices.cc1h !== undefined
|
||||
? t('Cache Creation (5m)')
|
||||
: t(variable.label)
|
||||
}
|
||||
value={props.prices[variable.key] ?? 0}
|
||||
onChange={(value) => 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 (
|
||||
<PriceField
|
||||
key={variable.key}
|
||||
currency={props.currency}
|
||||
label={label}
|
||||
value={props.prices[variable.key] ?? 0}
|
||||
onChange={(value) => 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 && (
|
||||
<Select
|
||||
items={[
|
||||
|
||||
@@ -316,8 +316,8 @@ function RawExprEditor({ exprString, onChange }: RawExprEditorProps) {
|
||||
<div>
|
||||
{t('Variables')}: <code>len</code>, <code>p</code>, <code>c</code>,{' '}
|
||||
<code>cr</code>, <code>cc</code>, <code>cc1h</code>,{' '}
|
||||
<code>img</code>, <code>img_cr</code>, <code>img_o</code>,{' '}
|
||||
<code>ai</code>, <code>ao</code>
|
||||
<code>cc12h</code>, <code>img</code>, <code>img_cr</code>,{' '}
|
||||
<code>img_o</code>, <code>ai</code>, <code>ao</code>
|
||||
</div>
|
||||
<div>
|
||||
{t('Functions')}: <code>tier(name, value)</code>,{' '}
|
||||
@@ -733,6 +733,7 @@ function CostEstimator({ effectiveExpr, fullExpr, currency }: EstimatorProps) {
|
||||
cacheReadTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheCreate1hTokens: 0,
|
||||
cacheCreate12hTokens: 0,
|
||||
imageTokens: 0,
|
||||
imageCacheTokens: 0,
|
||||
imageOutputTokens: 0,
|
||||
@@ -746,10 +747,15 @@ function CostEstimator({ effectiveExpr, fullExpr, currency }: EstimatorProps) {
|
||||
)
|
||||
|
||||
const tokens = useMemo(() => {
|
||||
const values = buildEstimatorTokens(promptTokens, completionTokens, extras)
|
||||
const values = buildEstimatorTokens(
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
extras,
|
||||
effectiveExpr
|
||||
)
|
||||
if (lengthOverride.trim()) values.len = Number(lengthOverride)
|
||||
return values
|
||||
}, [promptTokens, completionTokens, extras, lengthOverride])
|
||||
}, [promptTokens, completionTokens, extras, lengthOverride, effectiveExpr])
|
||||
|
||||
const result = useMemo(
|
||||
() =>
|
||||
@@ -901,8 +907,9 @@ Input side:
|
||||
- p — input token count (for pricing). Automatically excludes sub-categories priced separately (e.g., if cr is used, cache tokens are deducted from p)
|
||||
- len — total input context length (for condition checks). Not affected by auto-exclusion; always reflects the full input length. Use in tier conditions
|
||||
- cr — cache-hit (read) token count
|
||||
- cc — cache-create token count (5-min TTL)
|
||||
- cc — cache-create token count (Claude 5-min TTL, OpenAI 30-min TTL)
|
||||
- cc1h — cache-create token count (1-hour TTL, Claude-specific)
|
||||
- cc12h — cache-create token count (12-hour TTL, OpenAI-specific)
|
||||
- img — image input token count
|
||||
- ai — audio input token count
|
||||
|
||||
|
||||
@@ -109,6 +109,49 @@ test('shows the recorded request and response models in log details', () => {
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
test('splits OpenAI cache writes into 30-minute and 12-hour rows with their prices', () => {
|
||||
const queryClient = renderDetails(
|
||||
{
|
||||
model_ratio: 5,
|
||||
completion_ratio: 4,
|
||||
group_ratio: 1,
|
||||
cache_tokens: 2000,
|
||||
cache_ratio: 0.1,
|
||||
cache_creation_tokens: 1500,
|
||||
cache_creation_ratio: 1.25,
|
||||
cache_creation_tokens_12h: 1000,
|
||||
cache_creation_ratio_12h: 2.5,
|
||||
},
|
||||
4000
|
||||
)
|
||||
expect(rowValue('Cache Write (30m)')).toBe('500')
|
||||
expect(rowValue('Cache Write (12h)')).toBe('1,000')
|
||||
expect(screen.queryByText('Cache Write')).toBeNull()
|
||||
expect(rowValue('Cache Creation (30m)')).toBe('$12.5/M')
|
||||
expect(rowValue('Cache Creation (12h)')).toBe('$25/M')
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
test('keeps one cache write row and no cache prices for OpenAI logs without 12-hour writes', () => {
|
||||
const queryClient = renderDetails(
|
||||
{
|
||||
model_ratio: 5,
|
||||
completion_ratio: 4,
|
||||
group_ratio: 1,
|
||||
cache_tokens: 2000,
|
||||
cache_ratio: 0.1,
|
||||
cache_creation_tokens: 1500,
|
||||
cache_creation_ratio: 1.25,
|
||||
},
|
||||
4000
|
||||
)
|
||||
expect(rowValue('Cache Write')).toBe('1,500')
|
||||
expect(screen.queryByText('Cache Write (30m)')).toBeNull()
|
||||
expect(screen.queryByText('Cache Creation (30m)')).toBeNull()
|
||||
expect(screen.queryByText('Cache Creation')).toBeNull()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
describe('usage facts billing details', () => {
|
||||
test('shows the settled image count and a per-image price', () => {
|
||||
const queryClient = renderDetails({
|
||||
|
||||
@@ -228,9 +228,12 @@ function buildTypeDetailSegments(
|
||||
|
||||
const cacheEntries = tieredSummary.priceEntries
|
||||
.filter((entry) =>
|
||||
['cacheReadPrice', 'cacheCreatePrice', 'cacheCreate1hPrice'].includes(
|
||||
entry.field
|
||||
)
|
||||
[
|
||||
'cacheReadPrice',
|
||||
'cacheCreatePrice',
|
||||
'cacheCreate1hPrice',
|
||||
'cacheCreate12hPrice',
|
||||
].includes(entry.field)
|
||||
)
|
||||
.map((entry) => {
|
||||
return formatPriceCompact(entry.price)
|
||||
@@ -251,6 +254,7 @@ function buildTypeDetailSegments(
|
||||
'cacheReadPrice',
|
||||
'cacheCreatePrice',
|
||||
'cacheCreate1hPrice',
|
||||
'cacheCreate12hPrice',
|
||||
].includes(entry.field)
|
||||
)
|
||||
.map((entry) =>
|
||||
@@ -301,6 +305,10 @@ function buildTypeDetailSegments(
|
||||
other.cache_creation_ratio_1h !== 0
|
||||
? formatPriceCompact(inputPriceUSD * other.cache_creation_ratio_1h)
|
||||
: null,
|
||||
other.cache_creation_ratio_12h != null &&
|
||||
other.cache_creation_ratio_12h !== 0
|
||||
? formatPriceCompact(inputPriceUSD * other.cache_creation_ratio_12h)
|
||||
: null,
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
if (cacheEntries.length > 0) {
|
||||
|
||||
@@ -174,6 +174,8 @@ function BillingBreakdown(props: {
|
||||
const { log, other, isAdmin } = props
|
||||
const isPerCall = isPerCallBilling(other.model_price)
|
||||
const isClaude = other.claude === true
|
||||
// OpenAI 12-hour cache writes have their own price, listed like Claude's.
|
||||
const hasCacheWrite12h = (other.cache_creation_tokens_12h || 0) > 0
|
||||
const isTieredExpr = other.billing_mode === 'tiered_expr'
|
||||
const tieredSummary = getTieredBillingSummary(other)
|
||||
|
||||
@@ -240,7 +242,11 @@ function BillingBreakdown(props: {
|
||||
})
|
||||
}
|
||||
|
||||
if (!isTieredExpr && isClaude && hasAnyCacheTokens(other)) {
|
||||
if (
|
||||
!isTieredExpr &&
|
||||
(isClaude || hasCacheWrite12h) &&
|
||||
hasAnyCacheTokens(other)
|
||||
) {
|
||||
if (other.cache_ratio != null && other.cache_ratio !== 1) {
|
||||
rows.push({
|
||||
label: t('Cache Read'),
|
||||
@@ -252,7 +258,9 @@ function BillingBreakdown(props: {
|
||||
other.cache_creation_ratio !== 1
|
||||
) {
|
||||
rows.push({
|
||||
label: t('Cache Creation'),
|
||||
label: hasCacheWrite12h
|
||||
? t('Cache Creation (30m)')
|
||||
: t('Cache Creation'),
|
||||
value: `${fmtPrice(baseInputUSD * other.cache_creation_ratio)}/M`,
|
||||
})
|
||||
}
|
||||
@@ -274,6 +282,15 @@ function BillingBreakdown(props: {
|
||||
value: `${fmtPrice(baseInputUSD * other.cache_creation_ratio_1h)}/M`,
|
||||
})
|
||||
}
|
||||
if (
|
||||
other.cache_creation_ratio_12h != null &&
|
||||
other.cache_creation_ratio_12h !== 0
|
||||
) {
|
||||
rows.push({
|
||||
label: t('Cache Creation (12h)'),
|
||||
value: `${fmtPrice(baseInputUSD * other.cache_creation_ratio_12h)}/M`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!isTieredExpr) {
|
||||
@@ -383,6 +400,10 @@ function TokenBreakdown(props: { log: UsageLog; other: LogOtherData }) {
|
||||
const cacheWrite = other.cache_creation_tokens || 0
|
||||
const cacheWrite5m = other.cache_creation_tokens_5m || 0
|
||||
const cacheWrite1h = other.cache_creation_tokens_1h || 0
|
||||
const cacheWrite12h = other.cache_creation_tokens_12h || 0
|
||||
// cache_creation_tokens counts OpenAI 30-minute and 12-hour writes together.
|
||||
const cacheWrite30m =
|
||||
cacheWrite12h > 0 ? Math.max(cacheWrite - cacheWrite12h, 0) : 0
|
||||
const hasTokens = promptTokens > 0 || completionTokens > 0
|
||||
|
||||
if (!hasTokens) return null
|
||||
@@ -409,7 +430,12 @@ function TokenBreakdown(props: { log: UsageLog; other: LogOtherData }) {
|
||||
})
|
||||
}
|
||||
|
||||
if (cacheWrite > 0 && cacheWrite5m === 0 && cacheWrite1h === 0) {
|
||||
if (
|
||||
cacheWrite > 0 &&
|
||||
cacheWrite5m === 0 &&
|
||||
cacheWrite1h === 0 &&
|
||||
cacheWrite12h === 0
|
||||
) {
|
||||
rows.push({
|
||||
label: t('Cache Write'),
|
||||
value: cacheWrite.toLocaleString(),
|
||||
@@ -430,6 +456,20 @@ function TokenBreakdown(props: { log: UsageLog; other: LogOtherData }) {
|
||||
})
|
||||
}
|
||||
|
||||
if (cacheWrite30m > 0) {
|
||||
rows.push({
|
||||
label: t('Cache Write (30m)'),
|
||||
value: cacheWrite30m.toLocaleString(),
|
||||
})
|
||||
}
|
||||
|
||||
if (cacheWrite12h > 0) {
|
||||
rows.push({
|
||||
label: t('Cache Write (12h)'),
|
||||
value: cacheWrite12h.toLocaleString(),
|
||||
})
|
||||
}
|
||||
|
||||
if (other.image && other.image_output) {
|
||||
rows.push({
|
||||
label: t('Image Tokens'),
|
||||
|
||||
@@ -115,6 +115,28 @@ describe('usage log CSV export', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('cache write cell counts OpenAI 12-hour writes once as part of the total', async () => {
|
||||
const t = await chineseTranslator()
|
||||
const header = getLogCsvHeader(false, t)
|
||||
|
||||
const row = getLogCsvRow(
|
||||
{
|
||||
...consumeLog,
|
||||
other: JSON.stringify({
|
||||
cache_tokens: 1906,
|
||||
cache_creation_tokens: 1500,
|
||||
cache_creation_ratio: 1.25,
|
||||
cache_creation_tokens_12h: 1000,
|
||||
cache_creation_ratio_12h: 2.5,
|
||||
}),
|
||||
},
|
||||
false,
|
||||
t
|
||||
)
|
||||
|
||||
expect(row[header.indexOf('Cache Write')]).toBe('1500')
|
||||
})
|
||||
|
||||
test('top-up log row leaves usage cells empty and shows the translated content', async () => {
|
||||
const t = await chineseTranslator()
|
||||
const topup: UsageLog = {
|
||||
|
||||
@@ -337,7 +337,8 @@ export function hasAnyCacheTokens(
|
||||
(other.image_cache_tokens || 0) > 0 ||
|
||||
(other.cache_creation_tokens || 0) > 0 ||
|
||||
(other.cache_creation_tokens_5m || 0) > 0 ||
|
||||
(other.cache_creation_tokens_1h || 0) > 0
|
||||
(other.cache_creation_tokens_1h || 0) > 0 ||
|
||||
(other.cache_creation_tokens_12h || 0) > 0
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ export interface LogOtherData {
|
||||
cache_creation_tokens?: number
|
||||
cache_creation_tokens_5m?: number
|
||||
cache_creation_tokens_1h?: number
|
||||
cache_creation_tokens_12h?: number
|
||||
claude?: boolean
|
||||
model_ratio?: number
|
||||
completion_ratio?: number
|
||||
@@ -201,6 +202,7 @@ export interface LogOtherData {
|
||||
cache_creation_ratio?: number
|
||||
cache_creation_ratio_5m?: number
|
||||
cache_creation_ratio_1h?: number
|
||||
cache_creation_ratio_12h?: number
|
||||
is_model_mapped?: boolean
|
||||
upstream_model_name?: string
|
||||
// Diagnostic only. Whether the names disagree is derived in the UI via
|
||||
|
||||
@@ -972,10 +972,13 @@
|
||||
"By tokens": "By tokens",
|
||||
"Cache": "Cache",
|
||||
"Cache create (1h) price": "Cache create (1h) price",
|
||||
"Cache create (12h) price": "Cache create (12h) price",
|
||||
"Cache create price": "Cache create price",
|
||||
"Cache Creation": "Cache Creation",
|
||||
"Cache Creation (1h)": "Cache Creation (1h)",
|
||||
"Cache Creation (12h)": "Cache Creation (12h)",
|
||||
"Cache Creation (5m)": "Cache Creation (5m)",
|
||||
"Cache Creation (30m)": "Cache Creation (30m)",
|
||||
"Cache Directory": "Cache Directory",
|
||||
"Cache Directory Disk Space": "Cache Directory Disk Space",
|
||||
"Cache Directory Info": "Cache Directory Info",
|
||||
@@ -990,7 +993,9 @@
|
||||
"Cache write": "Cache write",
|
||||
"Cache Write": "Cache Write",
|
||||
"Cache Write (1h)": "Cache Write (1h)",
|
||||
"Cache Write (12h)": "Cache Write (12h)",
|
||||
"Cache Write (5m)": "Cache Write (5m)",
|
||||
"Cache Write (30m)": "Cache Write (30m)",
|
||||
"Cache write price": "Cache write price",
|
||||
"Cached": "Cached",
|
||||
"Cached input": "Cached input",
|
||||
|
||||
@@ -972,10 +972,13 @@
|
||||
"By tokens": "Par jetons",
|
||||
"Cache": "Cache",
|
||||
"Cache create (1h) price": "Prix de création du cache (1 h)",
|
||||
"Cache create (12h) price": "Prix de création du cache (12 h)",
|
||||
"Cache create price": "Prix de création du cache",
|
||||
"Cache Creation": "Création cache",
|
||||
"Cache Creation (1h)": "Création cache (1h)",
|
||||
"Cache Creation (12h)": "Création cache (12h)",
|
||||
"Cache Creation (5m)": "Création cache (5m)",
|
||||
"Cache Creation (30m)": "Création cache (30m)",
|
||||
"Cache Directory": "Répertoire de cache",
|
||||
"Cache Directory Disk Space": "Espace disque du répertoire de cache",
|
||||
"Cache Directory Info": "Infos du répertoire de cache",
|
||||
@@ -990,7 +993,9 @@
|
||||
"Cache write": "Écriture du cache",
|
||||
"Cache Write": "Écriture du cache",
|
||||
"Cache Write (1h)": "Écriture cache (1h)",
|
||||
"Cache Write (12h)": "Écriture cache (12h)",
|
||||
"Cache Write (5m)": "Écriture cache (5m)",
|
||||
"Cache Write (30m)": "Écriture cache (30m)",
|
||||
"Cache write price": "Prix d’écriture du cache",
|
||||
"Cached": "Cache",
|
||||
"Cached input": "Entrée mise en cache",
|
||||
|
||||
@@ -972,10 +972,13 @@
|
||||
"By tokens": "トークン数別",
|
||||
"Cache": "キャッシュ",
|
||||
"Cache create (1h) price": "キャッシュ作成価格 (1時間)",
|
||||
"Cache create (12h) price": "キャッシュ作成価格 (12時間)",
|
||||
"Cache create price": "キャッシュ作成価格",
|
||||
"Cache Creation": "キャッシュ作成",
|
||||
"Cache Creation (1h)": "キャッシュ作成 (1h)",
|
||||
"Cache Creation (12h)": "キャッシュ作成 (12h)",
|
||||
"Cache Creation (5m)": "キャッシュ作成 (5m)",
|
||||
"Cache Creation (30m)": "キャッシュ作成 (30m)",
|
||||
"Cache Directory": "キャッシュディレクトリ",
|
||||
"Cache Directory Disk Space": "キャッシュディレクトリのディスク容量",
|
||||
"Cache Directory Info": "キャッシュディレクトリ情報",
|
||||
@@ -990,7 +993,9 @@
|
||||
"Cache write": "キャッシュ書き込み",
|
||||
"Cache Write": "キャッシュ書き込み",
|
||||
"Cache Write (1h)": "キャッシュ書込 (1h)",
|
||||
"Cache Write (12h)": "キャッシュ書込 (12h)",
|
||||
"Cache Write (5m)": "キャッシュ書込 (5m)",
|
||||
"Cache Write (30m)": "キャッシュ書込 (30m)",
|
||||
"Cache write price": "キャッシュ書き込み価格",
|
||||
"Cached": "キャッシュ",
|
||||
"Cached input": "キャッシュ入力",
|
||||
|
||||
@@ -972,10 +972,13 @@
|
||||
"By tokens": "По токенам",
|
||||
"Cache": "Кэш",
|
||||
"Cache create (1h) price": "Цена создания кэша (1 ч)",
|
||||
"Cache create (12h) price": "Цена создания кэша (12 ч)",
|
||||
"Cache create price": "Цена создания кэша",
|
||||
"Cache Creation": "Создание кэша",
|
||||
"Cache Creation (1h)": "Создание кэша (1h)",
|
||||
"Cache Creation (12h)": "Создание кэша (12h)",
|
||||
"Cache Creation (5m)": "Создание кэша (5m)",
|
||||
"Cache Creation (30m)": "Создание кэша (30m)",
|
||||
"Cache Directory": "Каталог кэша",
|
||||
"Cache Directory Disk Space": "Дисковое пространство каталога кэша",
|
||||
"Cache Directory Info": "Информация о каталоге кэша",
|
||||
@@ -990,7 +993,9 @@
|
||||
"Cache write": "Запись кэша",
|
||||
"Cache Write": "Запись в кэш",
|
||||
"Cache Write (1h)": "Запись кэша (1h)",
|
||||
"Cache Write (12h)": "Запись кэша (12h)",
|
||||
"Cache Write (5m)": "Запись кэша (5m)",
|
||||
"Cache Write (30m)": "Запись кэша (30m)",
|
||||
"Cache write price": "Цена записи кэша",
|
||||
"Cached": "Кэш",
|
||||
"Cached input": "Кэшированный ввод",
|
||||
|
||||
@@ -972,10 +972,13 @@
|
||||
"By tokens": "Theo số token",
|
||||
"Cache": "Bộ nhớ đệm",
|
||||
"Cache create (1h) price": "Giá tạo cache (1 giờ)",
|
||||
"Cache create (12h) price": "Giá tạo cache (12 giờ)",
|
||||
"Cache create price": "Giá tạo cache",
|
||||
"Cache Creation": "Tạo cache",
|
||||
"Cache Creation (1h)": "Tạo cache (1h)",
|
||||
"Cache Creation (12h)": "Tạo cache (12h)",
|
||||
"Cache Creation (5m)": "Tạo cache (5m)",
|
||||
"Cache Creation (30m)": "Tạo cache (30m)",
|
||||
"Cache Directory": "Thư mục bộ nhớ đệm",
|
||||
"Cache Directory Disk Space": "Dung lượng đĩa thư mục bộ nhớ đệm",
|
||||
"Cache Directory Info": "Thông tin thư mục bộ nhớ đệm",
|
||||
@@ -990,7 +993,9 @@
|
||||
"Cache write": "Ghi cache",
|
||||
"Cache Write": "Ghi bộ nhớ đệm",
|
||||
"Cache Write (1h)": "Ghi cache (1h)",
|
||||
"Cache Write (12h)": "Ghi cache (12h)",
|
||||
"Cache Write (5m)": "Ghi cache (5m)",
|
||||
"Cache Write (30m)": "Ghi cache (30m)",
|
||||
"Cache write price": "Giá ghi cache",
|
||||
"Cached": "Đã cache",
|
||||
"Cached input": "Đầu vào đã cache",
|
||||
|
||||
@@ -972,10 +972,13 @@
|
||||
"By tokens": "按 Token",
|
||||
"Cache": "緩存",
|
||||
"Cache create (1h) price": "1 小時緩存寫入價格",
|
||||
"Cache create (12h) price": "12 小時緩存寫入價格",
|
||||
"Cache create price": "緩存寫入價格",
|
||||
"Cache Creation": "緩存建立",
|
||||
"Cache Creation (1h)": "緩存建立 (1h)",
|
||||
"Cache Creation (12h)": "緩存建立 (12h)",
|
||||
"Cache Creation (5m)": "緩存建立 (5m)",
|
||||
"Cache Creation (30m)": "緩存建立 (30m)",
|
||||
"Cache Directory": "緩存目錄",
|
||||
"Cache Directory Disk Space": "緩存目錄磁碟空間",
|
||||
"Cache Directory Info": "緩存目錄資訊",
|
||||
@@ -990,7 +993,9 @@
|
||||
"Cache write": "緩存寫入",
|
||||
"Cache Write": "緩存寫入",
|
||||
"Cache Write (1h)": "緩存寫入 (1h)",
|
||||
"Cache Write (12h)": "緩存寫入 (12h)",
|
||||
"Cache Write (5m)": "緩存寫入 (5m)",
|
||||
"Cache Write (30m)": "緩存寫入 (30m)",
|
||||
"Cache write price": "緩存寫入價格",
|
||||
"Cached": "緩存",
|
||||
"Cached input": "緩存輸入",
|
||||
|
||||
@@ -972,10 +972,13 @@
|
||||
"By tokens": "按 Token",
|
||||
"Cache": "缓存",
|
||||
"Cache create (1h) price": "1 小时缓存写入价格",
|
||||
"Cache create (12h) price": "12 小时缓存写入价格",
|
||||
"Cache create price": "缓存写入价格",
|
||||
"Cache Creation": "缓存创建",
|
||||
"Cache Creation (1h)": "缓存创建 (1h)",
|
||||
"Cache Creation (12h)": "缓存创建 (12h)",
|
||||
"Cache Creation (5m)": "缓存创建 (5m)",
|
||||
"Cache Creation (30m)": "缓存创建 (30m)",
|
||||
"Cache Directory": "缓存目录",
|
||||
"Cache Directory Disk Space": "缓存目录磁盘空间",
|
||||
"Cache Directory Info": "缓存目录信息",
|
||||
@@ -990,7 +993,9 @@
|
||||
"Cache write": "缓存写入",
|
||||
"Cache Write": "缓存写入",
|
||||
"Cache Write (1h)": "缓存写入 (1h)",
|
||||
"Cache Write (12h)": "缓存写入 (12h)",
|
||||
"Cache Write (5m)": "缓存写入 (5m)",
|
||||
"Cache Write (30m)": "缓存写入 (30m)",
|
||||
"Cache write price": "缓存写入价格",
|
||||
"Cached": "缓存",
|
||||
"Cached input": "缓存输入",
|
||||
|
||||
Reference in new issue
Block a user