From 7f32446466288889235db13242ac5bb8409f8f71 Mon Sep 17 00:00:00 2001 From: wangbo Date: Tue, 23 Jun 2026 17:17:57 +0800 Subject: [PATCH] feat: support cached input token billing --- apps/api/internal/clients/clients_test.go | 110 ++++++++++++- apps/api/internal/clients/gemini.go | 13 +- apps/api/internal/clients/helpers.go | 45 +++++- apps/api/internal/clients/types.go | 7 +- .../httpapi/chat_completions_mode_test.go | 81 ++++++++++ apps/api/internal/httpapi/model_catalog.go | 19 ++- apps/api/internal/httpapi/openapi_models.go | 16 +- apps/api/internal/runner/limits_test.go | 39 +++++ apps/api/internal/runner/pricing.go | 46 +++++- apps/api/internal/runner/recording.go | 4 + .../internal/store/model_billing_filter.go | 2 +- .../store/model_billing_filter_test.go | 16 +- apps/api/internal/store/pricing_rules.go | 5 + apps/web/src/pages/WorkspacePage.tsx | 47 +++++- .../pages/admin/PricingRuleVisualEditor.tsx | 148 ++++++++++++++++-- .../web/src/pages/admin/PricingRulesPanel.tsx | 17 +- 16 files changed, 570 insertions(+), 45 deletions(-) diff --git a/apps/api/internal/clients/clients_test.go b/apps/api/internal/clients/clients_test.go index 8571374..5429115 100644 --- a/apps/api/internal/clients/clients_test.go +++ b/apps/api/internal/clients/clients_test.go @@ -631,7 +631,7 @@ func TestOpenAIClientChatStreamContract(t *testing.T) { w.Header().Set("Content-Type", "text/event-stream") _, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}],\"usage\":null}\n\n")) _, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\" world\"},\"finish_reason\":\"stop\"}],\"usage\":null}\n\n")) - _, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n")) + _, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3,\"prompt_tokens_details\":{\"cached_tokens\":1}}}\n\n")) _, _ = w.Write([]byte("data: [DONE]\n\n")) })) defer server.Close() @@ -662,6 +662,9 @@ func TestOpenAIClientChatStreamContract(t *testing.T) { if response.Usage.TotalTokens != 3 { t.Fatalf("unexpected usage: %+v", response.Usage) } + if response.Usage.CachedInputTokens != 1 { + t.Fatalf("expected cached input tokens from stream usage, got %+v", response.Usage) + } choices, _ := response.Result["choices"].([]any) choice, _ := choices[0].(map[string]any) message, _ := choice["message"].(map[string]any) @@ -670,6 +673,111 @@ func TestOpenAIClientChatStreamContract(t *testing.T) { } } +func TestOpenAIClientChatStreamExtractsCachedInputTokensFromProviderUsageShapes(t *testing.T) { + cases := []struct { + name string + usage map[string]any + cached int + }{ + { + name: "aliyun dashscope openai-compatible", + usage: map[string]any{ + "prompt_tokens": 10, + "completion_tokens": 2, + "total_tokens": 12, + "prompt_tokens_details": map[string]any{ + "cached_tokens": 7, + }, + }, + cached: 7, + }, + { + name: "volces responses-style input details", + usage: map[string]any{ + "prompt_tokens": 9, + "completion_tokens": 3, + "total_tokens": 12, + "input_tokens_details": map[string]any{ + "cached_tokens": 6, + }, + }, + cached: 6, + }, + { + name: "deepseek prompt cache hit tokens", + usage: map[string]any{ + "prompt_tokens": 8, + "completion_tokens": 4, + "total_tokens": 12, + "prompt_cache_hit_tokens": 5, + "prompt_cache_miss_tokens": 3, + }, + cached: 5, + }, + { + name: "glm openai-compatible", + usage: map[string]any{ + "prompt_tokens": 11, + "completion_tokens": 1, + "total_tokens": 12, + "prompt_tokens_details": map[string]any{ + "cached_tokens": 4, + }, + }, + cached: 4, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var gotIncludeUsage bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + streamOptions, _ := body["stream_options"].(map[string]any) + gotIncludeUsage, _ = streamOptions["include_usage"].(bool) + usageBytes, _ := json.Marshal(tc.usage) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-cache\",\"object\":\"chat.completion.chunk\",\"model\":\"cache-test\",\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}],\"usage\":null}\n\n")) + _, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-cache\",\"object\":\"chat.completion.chunk\",\"model\":\"cache-test\",\"choices\":[],\"usage\":" + string(usageBytes) + "}\n\n")) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + defer server.Close() + + response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{ + Kind: "chat.completions", + Model: "Cache-Test", + Body: map[string]any{ + "model": "Cache-Test", + "messages": []any{map[string]any{"role": "user", "content": "ping"}}, + "stream": true, + }, + Candidate: store.RuntimeModelCandidate{ + BaseURL: server.URL, + ModelName: "cache-test", + Credentials: map[string]any{"apiKey": "test-key"}, + }, + }) + if err != nil { + t.Fatalf("run openai stream client: %v", err) + } + if !gotIncludeUsage { + t.Fatal("expected upstream stream_options.include_usage=true") + } + if response.Usage.CachedInputTokens != tc.cached { + t.Fatalf("expected cached input tokens %d, got %+v", tc.cached, response.Usage) + } + resultUsage, _ := response.Result["usage"].(map[string]any) + promptDetails, _ := resultUsage["prompt_tokens_details"].(map[string]any) + if intFromAny(promptDetails["cached_tokens"]) != tc.cached { + t.Fatalf("result usage should expose normalized cached_tokens, got %+v", resultUsage) + } + }) + } +} + func TestOpenAIClientChatStreamPreservesStructuredDeltas(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") diff --git a/apps/api/internal/clients/gemini.go b/apps/api/internal/clients/gemini.go index 2649bde..292d29b 100644 --- a/apps/api/internal/clients/gemini.go +++ b/apps/api/internal/clients/gemini.go @@ -519,7 +519,11 @@ func geminiUsage(raw map[string]any) Usage { input := intFromAny(usageMap["prompt_tokens"]) output := intFromAny(usageMap["completion_tokens"]) total := intFromAny(usageMap["total_tokens"]) - return Usage{InputTokens: input, OutputTokens: output, TotalTokens: total} + cachedInput := cachedInputTokensFromOpenAIUsage(usageMap) + if cachedInput > input && input > 0 { + cachedInput = input + } + return Usage{InputTokens: input, OutputTokens: output, CachedInputTokens: cachedInput, TotalTokens: total} } func geminiUsageMap(raw map[string]any) map[string]any { @@ -527,8 +531,13 @@ func geminiUsageMap(raw map[string]any) map[string]any { input := intFromAny(meta["promptTokenCount"]) output := intFromAny(meta["candidatesTokenCount"]) total := intFromAny(meta["totalTokenCount"]) + cachedInput := intFromAny(firstPresent(meta["cachedContentTokenCount"], meta["cached_content_token_count"])) if total == 0 { total = input + output } - return map[string]any{"prompt_tokens": input, "completion_tokens": output, "total_tokens": total} + usage := map[string]any{"prompt_tokens": input, "completion_tokens": output, "total_tokens": total} + if cachedInput > 0 { + usage["prompt_tokens_details"] = map[string]any{"cached_tokens": cachedInput} + } + return usage } diff --git a/apps/api/internal/clients/helpers.go b/apps/api/internal/clients/helpers.go index cc853de..20f32a8 100644 --- a/apps/api/internal/clients/helpers.go +++ b/apps/api/internal/clients/helpers.go @@ -185,11 +185,17 @@ func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts }}, } if usage.TotalTokens > 0 { - out["usage"] = map[string]any{ + usageMap := map[string]any{ "prompt_tokens": usage.InputTokens, "completion_tokens": usage.OutputTokens, "total_tokens": usage.TotalTokens, } + if usage.CachedInputTokens > 0 { + usageMap["prompt_tokens_details"] = map[string]any{ + "cached_tokens": usage.CachedInputTokens, + } + } + out["usage"] = usageMap } return out } @@ -877,7 +883,42 @@ func usageFromOpenAI(result map[string]any) Usage { if total == 0 { total = input + output } - return Usage{InputTokens: input, OutputTokens: output, TotalTokens: total} + cachedInput := cachedInputTokensFromOpenAIUsage(usage) + if cachedInput > input && input > 0 { + cachedInput = input + } + return Usage{InputTokens: input, OutputTokens: output, CachedInputTokens: cachedInput, TotalTokens: total} +} + +func cachedInputTokensFromOpenAIUsage(usage map[string]any) int { + if len(usage) == 0 { + return 0 + } + promptDetails, _ := firstPresent(usage["prompt_tokens_details"], usage["promptTokensDetails"]).(map[string]any) + inputDetails, _ := firstPresent(usage["input_tokens_details"], usage["inputTokensDetails"]).(map[string]any) + usageMetadata, _ := usage["usageMetadata"].(map[string]any) + return intFromAny(firstPresent( + promptDetails["cached_tokens"], + promptDetails["cachedTokens"], + promptDetails["cache_read_input_tokens"], + promptDetails["cacheReadInputTokens"], + inputDetails["cached_tokens"], + inputDetails["cachedTokens"], + inputDetails["cache_read_input_tokens"], + inputDetails["cacheReadInputTokens"], + usage["prompt_cache_hit_tokens"], + usage["promptCacheHitTokens"], + usage["cached_tokens"], + usage["cachedTokens"], + usage["cached_input_tokens"], + usage["cachedInputTokens"], + usage["cache_read_input_tokens"], + usage["cacheReadInputTokens"], + usage["cached_content_token_count"], + usage["cachedContentTokenCount"], + usageMetadata["cached_content_token_count"], + usageMetadata["cachedContentTokenCount"], + )) } func requestIDFromHTTPResponse(resp *http.Response) string { diff --git a/apps/api/internal/clients/types.go b/apps/api/internal/clients/types.go index 18b2540..87a4dab 100644 --- a/apps/api/internal/clients/types.go +++ b/apps/api/internal/clients/types.go @@ -36,9 +36,10 @@ type Response struct { } type Usage struct { - InputTokens int - OutputTokens int - TotalTokens int + InputTokens int + OutputTokens int + CachedInputTokens int + TotalTokens int } type Progress struct { diff --git a/apps/api/internal/httpapi/chat_completions_mode_test.go b/apps/api/internal/httpapi/chat_completions_mode_test.go index c126780..54681e8 100644 --- a/apps/api/internal/httpapi/chat_completions_mode_test.go +++ b/apps/api/internal/httpapi/chat_completions_mode_test.go @@ -165,6 +165,87 @@ func TestWriteCompatibleTaskResponseReturnsSSEWhenStreamIsTrue(t *testing.T) { } } +func TestWriteCompatibleTaskResponseStreamsCachedUsageProviderShapes(t *testing.T) { + cases := []struct { + name string + usage map[string]any + fragment string + }{ + { + name: "aliyun dashscope", + usage: map[string]any{ + "prompt_tokens": 10, + "completion_tokens": 2, + "total_tokens": 12, + "prompt_tokens_details": map[string]any{ + "cached_tokens": 7, + }, + }, + fragment: `"prompt_tokens_details":{"cached_tokens":7}`, + }, + { + name: "volces responses", + usage: map[string]any{ + "prompt_tokens": 9, + "completion_tokens": 3, + "total_tokens": 12, + "input_tokens_details": map[string]any{ + "cached_tokens": 6, + }, + }, + fragment: `"input_tokens_details":{"cached_tokens":6}`, + }, + { + name: "deepseek", + usage: map[string]any{ + "prompt_tokens": 8, + "completion_tokens": 4, + "total_tokens": 12, + "prompt_cache_hit_tokens": 5, + "prompt_cache_miss_tokens": 3, + }, + fragment: `"prompt_cache_hit_tokens":5`, + }, + { + name: "glm", + usage: map[string]any{ + "prompt_tokens": 11, + "completion_tokens": 1, + "total_tokens": 12, + "prompt_tokens_details": map[string]any{ + "cached_tokens": 4, + }, + }, + fragment: `"prompt_tokens_details":{"cached_tokens":4}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + executor := &fakeTaskExecutor{ + deltas: []clients.StreamDeltaEvent{{Text: "ok"}}, + output: map[string]any{ + "id": "chatcmpl-cache", + "object": "chat.completion", + "usage": tc.usage, + }, + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/chat/completions", nil) + recorder := httptest.NewRecorder() + + writeCompatibleTaskResponse(context.Background(), recorder, req, executor, "chat.completions", "cache-test", store.GatewayTask{ID: "task-test"}, &auth.User{}, true, true) + + body := recorder.Body.String() + if !strings.Contains(body, tc.fragment) { + t.Fatalf("SSE body missing cached usage fragment %s: %s", tc.fragment, body) + } + if !strings.Contains(body, `"choices":[]`) || !strings.Contains(body, "data: [DONE]") { + t.Fatalf("SSE body should include final usage chunk and done marker: %s", body) + } + }) + } +} + func TestWriteCompatibleTaskResponseStreamsStructuredToolAndReasoningDeltas(t *testing.T) { executor := &fakeTaskExecutor{ deltas: []clients.StreamDeltaEvent{ diff --git a/apps/api/internal/httpapi/model_catalog.go b/apps/api/internal/httpapi/model_catalog.go index 986e482..73d274e 100644 --- a/apps/api/internal/httpapi/model_catalog.go +++ b/apps/api/internal/httpapi/model_catalog.go @@ -759,7 +759,7 @@ func billingConfigLines(config map[string]any) []string { func textPricingResource(config map[string]any) bool { switch strings.ToLower(stringValue(config["resourceType"])) { - case "text", "text_total", "text_input", "text_output": + case "text", "text_total", "text_input", "text_cached_input", "text_output": return true default: return false @@ -771,6 +771,7 @@ func textPricingLines(config map[string]any) []string { return nil } inputPrice, hasInput := textPriceFromConfig(config, []string{"textInputPer1k", "textInput", "text_input", "inputTokenPrice", "inputPrice"}) + cachedInputPrice, hasCachedInput := textPriceFromConfig(config, []string{"textCachedInputPer1k", "textCachedInput", "text_cached_input", "cachedInputTokenPrice", "inputCacheHitTokenPrice", "cachedInputPrice"}) outputPrice, hasOutput := textPriceFromConfig(config, []string{"textOutputPer1k", "textOutput", "text_output", "outputTokenPrice", "outputPrice"}) for _, key := range []string{"formulaConfig", "formula_config"} { @@ -778,6 +779,9 @@ func textPricingLines(config map[string]any) []string { if !hasInput { inputPrice, hasInput = textPriceFromConfig(formulaConfig, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"}) } + if !hasCachedInput { + cachedInputPrice, hasCachedInput = textPriceFromConfig(formulaConfig, []string{"cachedInputTokenPrice", "textCachedInputPer1k", "textCachedInput", "text_cached_input", "inputCacheHitTokenPrice", "cachedInputPrice"}) + } if !hasOutput { outputPrice, hasOutput = textPriceFromConfig(formulaConfig, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"}) } @@ -791,6 +795,9 @@ func textPricingLines(config map[string]any) []string { if !hasInput { inputPrice, hasInput = textPriceFromConfig(nested, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"}) } + if !hasCachedInput { + cachedInputPrice, hasCachedInput = textPriceFromConfig(nested, []string{"cachedInputTokenPrice", "textCachedInputPer1k", "textCachedInput", "text_cached_input", "inputCacheHitTokenPrice", "cachedInputPrice"}) + } if !hasOutput { outputPrice, hasOutput = textPriceFromConfig(nested, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"}) } @@ -799,6 +806,9 @@ func textPricingLines(config map[string]any) []string { if !hasInput { inputPrice, hasInput = textPriceFromConfig(formulaConfig, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"}) } + if !hasCachedInput { + cachedInputPrice, hasCachedInput = textPriceFromConfig(formulaConfig, []string{"cachedInputTokenPrice", "textCachedInputPer1k", "textCachedInput", "text_cached_input", "inputCacheHitTokenPrice", "cachedInputPrice"}) + } if !hasOutput { outputPrice, hasOutput = textPriceFromConfig(formulaConfig, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"}) } @@ -824,6 +834,10 @@ func textPricingLines(config map[string]any) []string { if !hasInput { inputPrice, hasInput = basePrice, true } + case "text_cached_input": + if !hasCachedInput { + cachedInputPrice, hasCachedInput = basePrice, true + } case "text_output": if !hasOutput { outputPrice, hasOutput = basePrice, true @@ -835,6 +849,9 @@ func textPricingLines(config map[string]any) []string { if hasInput { lines = append(lines, "输入 "+formatNumber(inputPrice)+"/k tokens") } + if hasCachedInput { + lines = append(lines, "缓存输入 "+formatNumber(cachedInputPrice)+"/k tokens") + } if hasOutput { lines = append(lines, "输出 "+formatNumber(outputPrice)+"/k tokens") } diff --git a/apps/api/internal/httpapi/openapi_models.go b/apps/api/internal/httpapi/openapi_models.go index d07ad79..27ec9da 100644 --- a/apps/api/internal/httpapi/openapi_models.go +++ b/apps/api/internal/httpapi/openapi_models.go @@ -294,9 +294,19 @@ type ChatCompletionChoiceMessage struct { } type ChatCompletionUsage struct { - PromptTokens int `json:"prompt_tokens,omitempty" example:"12"` - CompletionTokens int `json:"completion_tokens,omitempty" example:"8"` - TotalTokens int `json:"total_tokens,omitempty" example:"20"` + PromptTokens int `json:"prompt_tokens,omitempty" example:"12"` + CompletionTokens int `json:"completion_tokens,omitempty" example:"8"` + TotalTokens int `json:"total_tokens,omitempty" example:"20"` + PromptTokensDetails *ChatPromptTokensDetails `json:"prompt_tokens_details,omitempty"` + InputTokensDetails *ChatInputTokensDetails `json:"input_tokens_details,omitempty"` +} + +type ChatPromptTokensDetails struct { + CachedTokens int `json:"cached_tokens,omitempty" example:"4"` +} + +type ChatInputTokensDetails struct { + CachedTokens int `json:"cached_tokens,omitempty" example:"4"` } type NetworkProxyConfigResponse struct { diff --git a/apps/api/internal/runner/limits_test.go b/apps/api/internal/runner/limits_test.go index abe47b3..6155439 100644 --- a/apps/api/internal/runner/limits_test.go +++ b/apps/api/internal/runner/limits_test.go @@ -1,6 +1,7 @@ package runner import ( + "context" "testing" "github.com/easyai/easyai-ai-gateway/apps/api/internal/clients" @@ -29,6 +30,44 @@ func TestTokenUsageAmountsFallsBackToInputOutputTotal(t *testing.T) { } } +func TestBillingsSplitsCachedInputTokens(t *testing.T) { + service := &Service{} + candidate := store.RuntimeModelCandidate{ + ModelName: "test-model", + BillingConfig: map[string]any{ + "textInputPer1k": 1.0, + "textCachedInputPer1k": 0.25, + "textOutputPer1k": 2.0, + }, + } + response := clients.Response{ + Usage: clients.Usage{ + InputTokens: 1000, + CachedInputTokens: 400, + OutputTokens: 500, + TotalTokens: 1500, + }, + } + + lines := service.billings(context.Background(), nil, "chat.completions", nil, candidate, response, false) + + if len(lines) != 3 { + t.Fatalf("expected uncached input, cached input, and output lines, got %+v", lines) + } + uncached, _ := lines[0].(map[string]any) + cached, _ := lines[1].(map[string]any) + output, _ := lines[2].(map[string]any) + if uncached["resourceType"] != "text_input" || uncached["quantity"] != 600 || uncached["amount"] != 0.6 { + t.Fatalf("unexpected uncached input billing line: %+v", uncached) + } + if cached["resourceType"] != "text_cached_input" || cached["quantity"] != 400 || cached["amount"] != 0.1 { + t.Fatalf("unexpected cached input billing line: %+v", cached) + } + if output["resourceType"] != "text_output" || output["quantity"] != 500 || output["amount"] != 1.0 { + t.Fatalf("unexpected output billing line: %+v", output) + } +} + func TestEffectiveRateLimitPolicyTreatsEmptyRuntimePolicyAsUnlimited(t *testing.T) { policy := effectiveRateLimitPolicy(store.RuntimeModelCandidate{ PlatformRateLimitPolicy: map[string]any{"rules": []any{ diff --git a/apps/api/internal/runner/pricing.go b/apps/api/internal/runner/pricing.go index 470f6c6..3ae7092 100644 --- a/apps/api/internal/runner/pricing.go +++ b/apps/api/internal/runner/pricing.go @@ -62,6 +62,7 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo if isTextBillingKind(kind) { inputTokens := response.Usage.InputTokens outputTokens := response.Usage.OutputTokens + cachedInputTokens := response.Usage.CachedInputTokens if isTextInputOnlyKind(kind) && inputTokens == 0 && response.Usage.TotalTokens > 0 { inputTokens = response.Usage.TotalTokens } @@ -71,8 +72,37 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo outputTokens = 1 } } - inputAmount := roundPrice(float64(inputTokens) / 1000 * resourcePrice(config, "text", "textInputPer1k", "inputTokenPrice", "basePrice") * discount) - lines := []any{billingLine(candidate, "text_input", "1k_tokens", inputTokens, inputAmount, discount, simulated)} + if cachedInputTokens > inputTokens && inputTokens > 0 { + cachedInputTokens = inputTokens + } + uncachedInputTokens := inputTokens - cachedInputTokens + if uncachedInputTokens < 0 { + uncachedInputTokens = 0 + } + inputPrice := resourcePrice(config, "text", "textInputPer1k", "inputTokenPrice", "basePrice") + cachedInputPrice := resourcePrice(config, "text", "textCachedInputPer1k", "cachedInputTokenPrice", "cacheInputTokenPrice", "textInputCacheHitPer1k", "inputCacheHitTokenPrice") + if cachedInputPrice <= 0 { + cachedInputPrice = inputPrice + } + inputAmount := roundPrice(float64(uncachedInputTokens) / 1000 * inputPrice * discount) + lines := []any{} + if uncachedInputTokens > 0 || cachedInputTokens == 0 { + lines = append(lines, billingLineWithDetails(candidate, "text_input", "1k_tokens", uncachedInputTokens, inputAmount, discount, simulated, map[string]any{ + "inputTokens": inputTokens, + "uncachedInputTokens": uncachedInputTokens, + "cachedInputTokens": cachedInputTokens, + "pricePer1k": inputPrice, + })) + } + if cachedInputTokens > 0 { + cachedInputAmount := roundPrice(float64(cachedInputTokens) / 1000 * cachedInputPrice * discount) + lines = append(lines, billingLineWithDetails(candidate, "text_cached_input", "1k_tokens", cachedInputTokens, cachedInputAmount, discount, simulated, map[string]any{ + "inputTokens": inputTokens, + "uncachedInputTokens": uncachedInputTokens, + "cachedInputTokens": cachedInputTokens, + "pricePer1k": cachedInputPrice, + })) + } if isTextGenerationKind(kind) { outputAmount := roundPrice(float64(outputTokens) / 1000 * resourcePrice(config, "text", "textOutputPer1k", "outputTokenPrice", "basePrice") * discount) lines = append(lines, billingLine(candidate, "text_output", "1k_tokens", outputTokens, outputAmount, discount, simulated)) @@ -234,10 +264,22 @@ func resourcePrice(config map[string]any, resource string, keys ...string) float return value } } + if formulaConfig, ok := resourceConfig["formulaConfig"].(map[string]any); ok { + for _, key := range keys { + if value := floatFromAny(formulaConfig[key]); value > 0 { + return value + } + } + } if value := floatFromAny(resourceConfig["basePrice"]); value > 0 { return value } } + if resource == "text" { + if value := resourcePrice(config, "text_total", keys...); value > 0 { + return value + } + } if resource == "image_edit" { return resourcePrice(config, "image", keys...) } diff --git a/apps/api/internal/runner/recording.go b/apps/api/internal/runner/recording.go index d23dc28..6c975ed 100644 --- a/apps/api/internal/runner/recording.go +++ b/apps/api/internal/runner/recording.go @@ -151,6 +151,10 @@ func usageToMap(usage clients.Usage) map[string]any { out["inputTokens"] = usage.InputTokens out["promptTokens"] = usage.InputTokens } + if usage.CachedInputTokens > 0 { + out["cachedInputTokens"] = usage.CachedInputTokens + out["cachedPromptTokens"] = usage.CachedInputTokens + } if usage.OutputTokens > 0 { out["outputTokens"] = usage.OutputTokens out["completionTokens"] = usage.OutputTokens diff --git a/apps/api/internal/store/model_billing_filter.go b/apps/api/internal/store/model_billing_filter.go index 48ae21a..284fb1d 100644 --- a/apps/api/internal/store/model_billing_filter.go +++ b/apps/api/internal/store/model_billing_filter.go @@ -94,7 +94,7 @@ func inferBillingResources(modelType string, resources map[string]bool) { func billingConfigKeyAllowed(key string, resources map[string]bool) bool { switch normalizeBillingConfigKey(key) { - case "text", "texttotal", "text_total", "textinputper1k", "textoutputper1k", "textinput", "textoutput", "text_input", "text_output", "inputtokenprice", "outputtokenprice": + case "text", "texttotal", "text_total", "textinputper1k", "textcachedinputper1k", "textoutputper1k", "textinput", "textcachedinput", "textoutput", "text_input", "text_cached_input", "text_output", "inputtokenprice", "cachedinputtokenprice", "inputcachehittokenprice", "outputtokenprice": return resources["text"] case "image", "imagebase": return resources["image"] diff --git a/apps/api/internal/store/model_billing_filter_test.go b/apps/api/internal/store/model_billing_filter_test.go index 0e08c8c..5865dc0 100644 --- a/apps/api/internal/store/model_billing_filter_test.go +++ b/apps/api/internal/store/model_billing_filter_test.go @@ -60,23 +60,25 @@ func TestFilterPlatformModelBillingConfigKeepsTextFlatPricing(t *testing.T) { model := PlatformModel{ ModelType: StringList{"text_generate"}, BillingConfig: map[string]any{ - "textInputPer1k": 0.01, - "textOutputPer1k": 0.02, + "textInputPer1k": 0.01, + "textCachedInputPer1k": 0.003, + "textOutputPer1k": 0.02, "text_total": map[string]any{ "basePrice": 0.01, - "formulaConfig": map[string]any{"inputTokenPrice": 0.01, "outputTokenPrice": 0.02}, + "formulaConfig": map[string]any{"inputTokenPrice": 0.01, "cachedInputTokenPrice": 0.003, "outputTokenPrice": 0.02}, "dynamicWeight": map[string]any{"cached": 0.5}, "dimensionSchema": map[string]any{"unit": "1k_tokens"}, }, - "inputTokenPrice": 0.01, - "outputTokenPrice": 0.02, - "image": map[string]any{"basePrice": 10}, + "inputTokenPrice": 0.01, + "cachedInputTokenPrice": 0.003, + "outputTokenPrice": 0.02, + "image": map[string]any{"basePrice": 10}, }, } filtered := FilterPlatformModelBillingConfig(model) - assertHasKeys(t, filtered.BillingConfig, "textInputPer1k", "textOutputPer1k", "text_total", "inputTokenPrice", "outputTokenPrice") + assertHasKeys(t, filtered.BillingConfig, "textInputPer1k", "textCachedInputPer1k", "textOutputPer1k", "text_total", "inputTokenPrice", "cachedInputTokenPrice", "outputTokenPrice") assertMissingKeys(t, filtered.BillingConfig, "image") } diff --git a/apps/api/internal/store/pricing_rules.go b/apps/api/internal/store/pricing_rules.go index 89991dc..8e4efbc 100644 --- a/apps/api/internal/store/pricing_rules.go +++ b/apps/api/internal/store/pricing_rules.go @@ -207,6 +207,8 @@ ORDER BY priority ASC, resource_type ASC`, id) switch resourceType { case "text_input": config["textInputPer1k"] = basePrice + case "text_cached_input": + config["textCachedInputPer1k"] = basePrice case "text_output": config["textOutputPer1k"] = basePrice case "text_total": @@ -215,6 +217,9 @@ ORDER BY priority ASC, resource_type ASC`, id) inputPrice = value } config["textInputPer1k"] = inputPrice + if cachedInputPrice, ok := pricingRuleNumberFromKeys(formulaConfig, "cachedInputTokenPrice", "cached_input_token_price", "textCachedInputPer1k", "text_cached_input", "inputCacheHitTokenPrice", "input_cache_hit_token_price"); ok { + config["textCachedInputPer1k"] = cachedInputPrice + } if outputPrice, ok := pricingRuleNumberFromKeys(formulaConfig, "outputTokenPrice", "output_token_price", "textOutputPer1k", "text_output"); ok { config["textOutputPer1k"] = outputPrice } diff --git a/apps/web/src/pages/WorkspacePage.tsx b/apps/web/src/pages/WorkspacePage.tsx index aa0e354..d20bc92 100644 --- a/apps/web/src/pages/WorkspacePage.tsx +++ b/apps/web/src/pages/WorkspacePage.tsx @@ -1561,18 +1561,57 @@ function transactionChargeCurrency(transaction: GatewayWalletTransaction) { } function formatTokenUsage(usage: Record) { - const input = tokenValue(usage.inputTokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens); - const output = tokenValue(usage.outputTokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.completion_tokens); - const total = tokenValue(usage.totalTokens ?? usage.total_tokens ?? (input !== null && output !== null ? input + output : null)); - if (input === null && output === null && total === null) return '-'; + const input = tokenValue( + usage.inputTokens ?? + usage.promptTokens ?? + usage.input_tokens ?? + usage.prompt_tokens, + ); + const promptDetails = usageObject( + usage.prompt_tokens_details ?? usage.promptTokensDetails, + ); + const inputDetails = usageObject( + usage.input_tokens_details ?? usage.inputTokensDetails, + ); + const cachedInput = tokenValue( + usage.cachedInputTokens ?? + usage.cachedPromptTokens ?? + usage.cached_input_tokens ?? + usage.cached_tokens ?? + promptDetails.cached_tokens ?? + promptDetails.cachedTokens ?? + inputDetails.cached_tokens ?? + inputDetails.cachedTokens, + ); + const output = tokenValue( + usage.outputTokens ?? + usage.completionTokens ?? + usage.output_tokens ?? + usage.completion_tokens, + ); + const total = tokenValue( + usage.totalTokens ?? + usage.total_tokens ?? + (input !== null && output !== null ? input + output : null), + ); + if (input === null && cachedInput === null && output === null && total === null) { + return '-'; + } return ( 输入:{formatCellValue(input)}/输出:{formatCellValue(output)} + {cachedInput !== null ? 缓存输入:{formatCellValue(cachedInput)} : null} 总计:{formatCellValue(total)} ); } +function usageObject(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; +} + function tokenValue(value: unknown) { if (value === undefined || value === null || value === '') return null; const numericValue = typeof value === 'number' ? value : Number(value); diff --git a/apps/web/src/pages/admin/PricingRuleVisualEditor.tsx b/apps/web/src/pages/admin/PricingRuleVisualEditor.tsx index 75c21c2..336d47b 100644 --- a/apps/web/src/pages/admin/PricingRuleVisualEditor.tsx +++ b/apps/web/src/pages/admin/PricingRuleVisualEditor.tsx @@ -30,9 +30,10 @@ const modeDefinitions: ModeDefinition[] = [ { key: 'text', label: '文本', - formula: '一次对话扣费 = 输入 Token / 1000 × 输入扣费单价 + 输出 Token / 1000 × 输出扣费单价。', - match: (rule) => rule.resourceType.startsWith('text_'), - templates: (currency) => [createTextRule(currency, 0.01, 0.03)], + formula: + '一次对话扣费 = 未命中缓存输入 Token / 1000 × 输入扣费单价 + 命中缓存输入 Token / 1000 × 缓存输入扣费单价 + 输出 Token / 1000 × 输出扣费单价。', + match: isTextPricingRule, + templates: (currency) => [createTextRule(currency, 0.01, 0.01, 0.03)], parameterGroups: [], }, { @@ -160,7 +161,7 @@ function summarizePricingMode(mode: ModeDefinition, rules: PricingRuleInput[]) { const rule = rules[0]; if (mode.key === 'text') { const prices = textPrices(rule); - return `文本按输入/输出 Token 分别计费,输入 ${prices.inputTokenPrice}/${unitLabel(rule.unit)},输出 ${prices.outputTokenPrice}/${unitLabel(rule.unit)}。`; + return `文本按输入/缓存输入/输出 Token 分别计费,输入 ${prices.inputTokenPrice}/${unitLabel(rule.unit)},缓存输入 ${prices.cachedInputTokenPrice}/${unitLabel(rule.unit)},输出 ${prices.outputTokenPrice}/${unitLabel(rule.unit)}。`; } const groupSummaries = mode.parameterGroups .map((group) => summarizeWeightGroup(group, readGroup(rule.dynamicWeight, group))) @@ -260,7 +261,11 @@ function TextRuleEditor(props: { }) { const prices = textPrices(props.rule); - function updatePrices(inputTokenPrice: number, outputTokenPrice: number) { + function updatePrices( + inputTokenPrice: number, + cachedInputTokenPrice: number, + outputTokenPrice: number, + ) { props.onChange({ ...props.rule, basePrice: inputTokenPrice, @@ -272,11 +277,17 @@ function TextRuleEditor(props: { ...(props.rule.formulaConfig ?? {}), formula: textFormula, inputTokenPrice, + cachedInputTokenPrice, outputTokenPrice, }, dimensionSchema: { ...(props.rule.dimensionSchema ?? {}), - metrics: ['input_tokens', 'output_tokens'], + metrics: [ + 'input_tokens', + 'uncached_input_tokens', + 'cached_input_tokens', + 'output_tokens', + ], unitScale: 1000, }, }); @@ -301,10 +312,52 @@ function TextRuleEditor(props: { - updatePrices(Number(event.target.value), prices.outputTokenPrice)} /> + + updatePrices( + Number(event.target.value), + prices.cachedInputTokenPrice, + prices.outputTokenPrice, + ) + } + /> + + + + updatePrices( + prices.inputTokenPrice, + Number(event.target.value), + prices.outputTokenPrice, + ) + } + /> - updatePrices(prices.inputTokenPrice, Number(event.target.value))} /> + + updatePrices( + prices.inputTokenPrice, + prices.cachedInputTokenPrice, + Number(event.target.value), + ) + } + /> @@ -462,14 +515,38 @@ function mergeTemplateRules(templates: PricingRuleInput[], rules: PricingRuleInp } function mergeTextRules(rules: PricingRuleInput[], currency: string): PricingRuleInput { - const totalRule = rules.find((rule) => rule.resourceType === 'text_total'); + const totalRule = rules.find((rule) => rule.resourceType === 'text_total' || rule.resourceType === 'text' || rule.ruleKey === 'text'); const inputRule = rules.find((rule) => rule.resourceType === 'text_input'); + const cachedInputRule = rules.find( + (rule) => rule.resourceType === 'text_cached_input', + ); const outputRule = rules.find((rule) => rule.resourceType === 'text_output'); - const source = totalRule ?? inputRule ?? outputRule ?? createTextRule(currency, 0.01, 0.03); + const source = + totalRule ?? + inputRule ?? + cachedInputRule ?? + outputRule ?? + createTextRule(currency, 0.01, 0.01, 0.03); return createTextRule( source.currency ?? currency, - Number(source.formulaConfig?.inputTokenPrice ?? inputRule?.basePrice ?? source.basePrice ?? 0.01), - Number(source.formulaConfig?.outputTokenPrice ?? outputRule?.basePrice ?? 0.03), + Number( + source.formulaConfig?.inputTokenPrice ?? + inputRule?.basePrice ?? + source.basePrice ?? + 0.01, + ), + Number( + source.formulaConfig?.cachedInputTokenPrice ?? + source.formulaConfig?.inputCacheHitTokenPrice ?? + cachedInputRule?.basePrice ?? + source.formulaConfig?.inputTokenPrice ?? + inputRule?.basePrice ?? + source.basePrice ?? + 0.01, + ), + Number( + source.formulaConfig?.outputTokenPrice ?? outputRule?.basePrice ?? 0.03, + ), source, ); } @@ -478,9 +555,16 @@ function readGroup(value: RecordValue | undefined, group: ModeDefinition['parame return isPlainObject(value?.[group.key]) ? value?.[group.key] as RecordValue : group.defaults; } -const textFormula = 'input_tokens / 1000 * input_token_price + output_tokens / 1000 * output_token_price'; +const textFormula = + 'uncached_input_tokens / 1000 * input_token_price + cached_input_tokens / 1000 * cached_input_token_price + output_tokens / 1000 * output_token_price'; -function createTextRule(currency: string, inputTokenPrice: number, outputTokenPrice: number, source?: PricingRuleInput): PricingRuleInput { +function createTextRule( + currency: string, + inputTokenPrice: number, + cachedInputTokenPrice: number, + outputTokenPrice: number, + source?: PricingRuleInput, +): PricingRuleInput { return { ...(source ?? {}), ruleKey: 'text', @@ -493,7 +577,12 @@ function createTextRule(currency: string, inputTokenPrice: number, outputTokenPr baseWeight: {}, dynamicWeight: {}, dimensionSchema: { - metrics: ['input_tokens', 'output_tokens'], + metrics: [ + 'input_tokens', + 'uncached_input_tokens', + 'cached_input_tokens', + 'output_tokens', + ], unitScale: 1000, ...(source?.dimensionSchema ?? {}), }, @@ -501,6 +590,7 @@ function createTextRule(currency: string, inputTokenPrice: number, outputTokenPr ...(source?.formulaConfig ?? {}), formula: textFormula, inputTokenPrice, + cachedInputTokenPrice, outputTokenPrice, }, priority: source?.priority ?? 100, @@ -510,12 +600,36 @@ function createTextRule(currency: string, inputTokenPrice: number, outputTokenPr } function textPrices(rule: PricingRuleInput) { + const compatRule = rule as PricingRuleInput & { + cachedInputTokenPrice?: number; + inputCacheHitTokenPrice?: number; + inputTokenPrice?: number; + outputTokenPrice?: number; + }; + const inputTokenPrice = Number( + rule.formulaConfig?.inputTokenPrice ?? compatRule.inputTokenPrice ?? rule.basePrice ?? 0.01, + ); return { - inputTokenPrice: Number(rule.formulaConfig?.inputTokenPrice ?? rule.basePrice ?? 0.01), - outputTokenPrice: Number(rule.formulaConfig?.outputTokenPrice ?? 0.03), + inputTokenPrice, + cachedInputTokenPrice: Number( + rule.formulaConfig?.cachedInputTokenPrice ?? + rule.formulaConfig?.inputCacheHitTokenPrice ?? + compatRule.cachedInputTokenPrice ?? + compatRule.inputCacheHitTokenPrice ?? + inputTokenPrice, + ), + outputTokenPrice: Number(rule.formulaConfig?.outputTokenPrice ?? compatRule.outputTokenPrice ?? 0.03), }; } +function isTextPricingRule(rule: PricingRuleInput) { + const resourceType = String(rule.resourceType ?? ''); + const ruleKey = String(rule.ruleKey ?? ''); + return textPricingKeys.has(resourceType) || textPricingKeys.has(ruleKey); +} + +const textPricingKeys = new Set(['text', 'text_total', 'text_input', 'text_cached_input', 'text_output']); + function createRule(ruleKey: string, displayName: string, resourceType: string, unit: string, basePrice: number, currency: string, calculatorType: string, formula: string, dynamicWeight: RecordValue, dimensionSchema: RecordValue): PricingRuleInput { return { ruleKey, diff --git a/apps/web/src/pages/admin/PricingRulesPanel.tsx b/apps/web/src/pages/admin/PricingRulesPanel.tsx index db11e72..7bf44f1 100644 --- a/apps/web/src/pages/admin/PricingRulesPanel.tsx +++ b/apps/web/src/pages/admin/PricingRulesPanel.tsx @@ -266,14 +266,27 @@ function pricingRuleSummaries(ruleSet: PricingRuleSet): PricingRuleSummary[] { const rules = ruleSet.rules ?? []; const textTotal = rules.find((rule) => rule.resourceType === 'text_total' || rule.ruleKey === 'text'); const textInput = rules.find((rule) => rule.resourceType === 'text_input'); + const textCachedInput = rules.find( + (rule) => rule.resourceType === 'text_cached_input', + ); const textOutput = rules.find((rule) => rule.resourceType === 'text_output'); const image = rules.find((rule) => rule.resourceType === 'image'); const video = rules.find((rule) => rule.resourceType === 'video'); const items: PricingRuleSummary[] = []; - if (textTotal || textInput || textOutput) { + if (textTotal || textInput || textCachedInput || textOutput) { const inputPrice = Number(textTotal?.formulaConfig?.inputTokenPrice ?? textInput?.basePrice ?? textTotal?.basePrice ?? 0); + const cachedInputPrice = Number( + textTotal?.formulaConfig?.cachedInputTokenPrice ?? + textTotal?.formulaConfig?.inputCacheHitTokenPrice ?? + textCachedInput?.basePrice ?? + inputPrice, + ); const outputPrice = Number(textTotal?.formulaConfig?.outputTokenPrice ?? textOutput?.basePrice ?? 0); - items.push({ kind: 'text', label: '文本', value: `输入 ${formatPrice(inputPrice)} / 输出 ${formatPrice(outputPrice)} / 1K Token` }); + items.push({ + kind: 'text', + label: '文本', + value: `输入 ${formatPrice(inputPrice)} / 缓存输入 ${formatPrice(cachedInputPrice)} / 输出 ${formatPrice(outputPrice)} / 1K Token`, + }); } if (image) items.push({ kind: 'image', label: '图像', value: `${formatPrice(image.basePrice)} / ${unitLabel(image.unit)}` }); if (video) items.push({ kind: 'video', label: '视频', value: `${formatPrice(video.basePrice)} / ${unitLabel(video.unit)}` });