feat: support cached input token billing

This commit is contained in:
2026-06-23 17:17:57 +08:00
parent 6089aa6085
commit 7f32446466
16 changed files with 570 additions and 45 deletions
+109 -1
View File
@@ -631,7 +631,7 @@ func TestOpenAIClientChatStreamContract(t *testing.T) {
w.Header().Set("Content-Type", "text/event-stream") 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\":\"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\":[{\"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")) _, _ = w.Write([]byte("data: [DONE]\n\n"))
})) }))
defer server.Close() defer server.Close()
@@ -662,6 +662,9 @@ func TestOpenAIClientChatStreamContract(t *testing.T) {
if response.Usage.TotalTokens != 3 { if response.Usage.TotalTokens != 3 {
t.Fatalf("unexpected usage: %+v", response.Usage) 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) choices, _ := response.Result["choices"].([]any)
choice, _ := choices[0].(map[string]any) choice, _ := choices[0].(map[string]any)
message, _ := choice["message"].(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) { func TestOpenAIClientChatStreamPreservesStructuredDeltas(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
+11 -2
View File
@@ -519,7 +519,11 @@ func geminiUsage(raw map[string]any) Usage {
input := intFromAny(usageMap["prompt_tokens"]) input := intFromAny(usageMap["prompt_tokens"])
output := intFromAny(usageMap["completion_tokens"]) output := intFromAny(usageMap["completion_tokens"])
total := intFromAny(usageMap["total_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 { 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"]) input := intFromAny(meta["promptTokenCount"])
output := intFromAny(meta["candidatesTokenCount"]) output := intFromAny(meta["candidatesTokenCount"])
total := intFromAny(meta["totalTokenCount"]) total := intFromAny(meta["totalTokenCount"])
cachedInput := intFromAny(firstPresent(meta["cachedContentTokenCount"], meta["cached_content_token_count"]))
if total == 0 { if total == 0 {
total = input + output 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
} }
+43 -2
View File
@@ -185,11 +185,17 @@ func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts
}}, }},
} }
if usage.TotalTokens > 0 { if usage.TotalTokens > 0 {
out["usage"] = map[string]any{ usageMap := map[string]any{
"prompt_tokens": usage.InputTokens, "prompt_tokens": usage.InputTokens,
"completion_tokens": usage.OutputTokens, "completion_tokens": usage.OutputTokens,
"total_tokens": usage.TotalTokens, "total_tokens": usage.TotalTokens,
} }
if usage.CachedInputTokens > 0 {
usageMap["prompt_tokens_details"] = map[string]any{
"cached_tokens": usage.CachedInputTokens,
}
}
out["usage"] = usageMap
} }
return out return out
} }
@@ -877,7 +883,42 @@ func usageFromOpenAI(result map[string]any) Usage {
if total == 0 { if total == 0 {
total = input + output 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 { func requestIDFromHTTPResponse(resp *http.Response) string {
+4 -3
View File
@@ -36,9 +36,10 @@ type Response struct {
} }
type Usage struct { type Usage struct {
InputTokens int InputTokens int
OutputTokens int OutputTokens int
TotalTokens int CachedInputTokens int
TotalTokens int
} }
type Progress struct { type Progress struct {
@@ -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) { func TestWriteCompatibleTaskResponseStreamsStructuredToolAndReasoningDeltas(t *testing.T) {
executor := &fakeTaskExecutor{ executor := &fakeTaskExecutor{
deltas: []clients.StreamDeltaEvent{ deltas: []clients.StreamDeltaEvent{
+18 -1
View File
@@ -759,7 +759,7 @@ func billingConfigLines(config map[string]any) []string {
func textPricingResource(config map[string]any) bool { func textPricingResource(config map[string]any) bool {
switch strings.ToLower(stringValue(config["resourceType"])) { 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 return true
default: default:
return false return false
@@ -771,6 +771,7 @@ func textPricingLines(config map[string]any) []string {
return nil return nil
} }
inputPrice, hasInput := textPriceFromConfig(config, []string{"textInputPer1k", "textInput", "text_input", "inputTokenPrice", "inputPrice"}) 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"}) outputPrice, hasOutput := textPriceFromConfig(config, []string{"textOutputPer1k", "textOutput", "text_output", "outputTokenPrice", "outputPrice"})
for _, key := range []string{"formulaConfig", "formula_config"} { for _, key := range []string{"formulaConfig", "formula_config"} {
@@ -778,6 +779,9 @@ func textPricingLines(config map[string]any) []string {
if !hasInput { if !hasInput {
inputPrice, hasInput = textPriceFromConfig(formulaConfig, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"}) 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 { if !hasOutput {
outputPrice, hasOutput = textPriceFromConfig(formulaConfig, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"}) outputPrice, hasOutput = textPriceFromConfig(formulaConfig, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"})
} }
@@ -791,6 +795,9 @@ func textPricingLines(config map[string]any) []string {
if !hasInput { if !hasInput {
inputPrice, hasInput = textPriceFromConfig(nested, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"}) 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 { if !hasOutput {
outputPrice, hasOutput = textPriceFromConfig(nested, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"}) outputPrice, hasOutput = textPriceFromConfig(nested, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"})
} }
@@ -799,6 +806,9 @@ func textPricingLines(config map[string]any) []string {
if !hasInput { if !hasInput {
inputPrice, hasInput = textPriceFromConfig(formulaConfig, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"}) 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 { if !hasOutput {
outputPrice, hasOutput = textPriceFromConfig(formulaConfig, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"}) outputPrice, hasOutput = textPriceFromConfig(formulaConfig, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"})
} }
@@ -824,6 +834,10 @@ func textPricingLines(config map[string]any) []string {
if !hasInput { if !hasInput {
inputPrice, hasInput = basePrice, true inputPrice, hasInput = basePrice, true
} }
case "text_cached_input":
if !hasCachedInput {
cachedInputPrice, hasCachedInput = basePrice, true
}
case "text_output": case "text_output":
if !hasOutput { if !hasOutput {
outputPrice, hasOutput = basePrice, true outputPrice, hasOutput = basePrice, true
@@ -835,6 +849,9 @@ func textPricingLines(config map[string]any) []string {
if hasInput { if hasInput {
lines = append(lines, "输入 "+formatNumber(inputPrice)+"/k tokens") lines = append(lines, "输入 "+formatNumber(inputPrice)+"/k tokens")
} }
if hasCachedInput {
lines = append(lines, "缓存输入 "+formatNumber(cachedInputPrice)+"/k tokens")
}
if hasOutput { if hasOutput {
lines = append(lines, "输出 "+formatNumber(outputPrice)+"/k tokens") lines = append(lines, "输出 "+formatNumber(outputPrice)+"/k tokens")
} }
+13 -3
View File
@@ -294,9 +294,19 @@ type ChatCompletionChoiceMessage struct {
} }
type ChatCompletionUsage struct { type ChatCompletionUsage struct {
PromptTokens int `json:"prompt_tokens,omitempty" example:"12"` PromptTokens int `json:"prompt_tokens,omitempty" example:"12"`
CompletionTokens int `json:"completion_tokens,omitempty" example:"8"` CompletionTokens int `json:"completion_tokens,omitempty" example:"8"`
TotalTokens int `json:"total_tokens,omitempty" example:"20"` 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 { type NetworkProxyConfigResponse struct {
+39
View File
@@ -1,6 +1,7 @@
package runner package runner
import ( import (
"context"
"testing" "testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients" "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) { func TestEffectiveRateLimitPolicyTreatsEmptyRuntimePolicyAsUnlimited(t *testing.T) {
policy := effectiveRateLimitPolicy(store.RuntimeModelCandidate{ policy := effectiveRateLimitPolicy(store.RuntimeModelCandidate{
PlatformRateLimitPolicy: map[string]any{"rules": []any{ PlatformRateLimitPolicy: map[string]any{"rules": []any{
+44 -2
View File
@@ -62,6 +62,7 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
if isTextBillingKind(kind) { if isTextBillingKind(kind) {
inputTokens := response.Usage.InputTokens inputTokens := response.Usage.InputTokens
outputTokens := response.Usage.OutputTokens outputTokens := response.Usage.OutputTokens
cachedInputTokens := response.Usage.CachedInputTokens
if isTextInputOnlyKind(kind) && inputTokens == 0 && response.Usage.TotalTokens > 0 { if isTextInputOnlyKind(kind) && inputTokens == 0 && response.Usage.TotalTokens > 0 {
inputTokens = response.Usage.TotalTokens inputTokens = response.Usage.TotalTokens
} }
@@ -71,8 +72,37 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
outputTokens = 1 outputTokens = 1
} }
} }
inputAmount := roundPrice(float64(inputTokens) / 1000 * resourcePrice(config, "text", "textInputPer1k", "inputTokenPrice", "basePrice") * discount) if cachedInputTokens > inputTokens && inputTokens > 0 {
lines := []any{billingLine(candidate, "text_input", "1k_tokens", inputTokens, inputAmount, discount, simulated)} 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) { if isTextGenerationKind(kind) {
outputAmount := roundPrice(float64(outputTokens) / 1000 * resourcePrice(config, "text", "textOutputPer1k", "outputTokenPrice", "basePrice") * discount) 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)) 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 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 { if value := floatFromAny(resourceConfig["basePrice"]); value > 0 {
return value return value
} }
} }
if resource == "text" {
if value := resourcePrice(config, "text_total", keys...); value > 0 {
return value
}
}
if resource == "image_edit" { if resource == "image_edit" {
return resourcePrice(config, "image", keys...) return resourcePrice(config, "image", keys...)
} }
+4
View File
@@ -151,6 +151,10 @@ func usageToMap(usage clients.Usage) map[string]any {
out["inputTokens"] = usage.InputTokens out["inputTokens"] = usage.InputTokens
out["promptTokens"] = usage.InputTokens out["promptTokens"] = usage.InputTokens
} }
if usage.CachedInputTokens > 0 {
out["cachedInputTokens"] = usage.CachedInputTokens
out["cachedPromptTokens"] = usage.CachedInputTokens
}
if usage.OutputTokens > 0 { if usage.OutputTokens > 0 {
out["outputTokens"] = usage.OutputTokens out["outputTokens"] = usage.OutputTokens
out["completionTokens"] = usage.OutputTokens out["completionTokens"] = usage.OutputTokens
@@ -94,7 +94,7 @@ func inferBillingResources(modelType string, resources map[string]bool) {
func billingConfigKeyAllowed(key string, resources map[string]bool) bool { func billingConfigKeyAllowed(key string, resources map[string]bool) bool {
switch normalizeBillingConfigKey(key) { 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"] return resources["text"]
case "image", "imagebase": case "image", "imagebase":
return resources["image"] return resources["image"]
@@ -60,23 +60,25 @@ func TestFilterPlatformModelBillingConfigKeepsTextFlatPricing(t *testing.T) {
model := PlatformModel{ model := PlatformModel{
ModelType: StringList{"text_generate"}, ModelType: StringList{"text_generate"},
BillingConfig: map[string]any{ BillingConfig: map[string]any{
"textInputPer1k": 0.01, "textInputPer1k": 0.01,
"textOutputPer1k": 0.02, "textCachedInputPer1k": 0.003,
"textOutputPer1k": 0.02,
"text_total": map[string]any{ "text_total": map[string]any{
"basePrice": 0.01, "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}, "dynamicWeight": map[string]any{"cached": 0.5},
"dimensionSchema": map[string]any{"unit": "1k_tokens"}, "dimensionSchema": map[string]any{"unit": "1k_tokens"},
}, },
"inputTokenPrice": 0.01, "inputTokenPrice": 0.01,
"outputTokenPrice": 0.02, "cachedInputTokenPrice": 0.003,
"image": map[string]any{"basePrice": 10}, "outputTokenPrice": 0.02,
"image": map[string]any{"basePrice": 10},
}, },
} }
filtered := FilterPlatformModelBillingConfig(model) 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") assertMissingKeys(t, filtered.BillingConfig, "image")
} }
+5
View File
@@ -207,6 +207,8 @@ ORDER BY priority ASC, resource_type ASC`, id)
switch resourceType { switch resourceType {
case "text_input": case "text_input":
config["textInputPer1k"] = basePrice config["textInputPer1k"] = basePrice
case "text_cached_input":
config["textCachedInputPer1k"] = basePrice
case "text_output": case "text_output":
config["textOutputPer1k"] = basePrice config["textOutputPer1k"] = basePrice
case "text_total": case "text_total":
@@ -215,6 +217,9 @@ ORDER BY priority ASC, resource_type ASC`, id)
inputPrice = value inputPrice = value
} }
config["textInputPer1k"] = inputPrice 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 { if outputPrice, ok := pricingRuleNumberFromKeys(formulaConfig, "outputTokenPrice", "output_token_price", "textOutputPer1k", "text_output"); ok {
config["textOutputPer1k"] = outputPrice config["textOutputPer1k"] = outputPrice
} }
+43 -4
View File
@@ -1561,18 +1561,57 @@ function transactionChargeCurrency(transaction: GatewayWalletTransaction) {
} }
function formatTokenUsage(usage: Record<string, unknown>) { function formatTokenUsage(usage: Record<string, unknown>) {
const input = tokenValue(usage.inputTokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens); const input = tokenValue(
const output = tokenValue(usage.outputTokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.completion_tokens); usage.inputTokens ??
const total = tokenValue(usage.totalTokens ?? usage.total_tokens ?? (input !== null && output !== null ? input + output : null)); usage.promptTokens ??
if (input === null && output === null && total === null) return '-'; 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 ( return (
<span className="taskRecordTokenUsage"> <span className="taskRecordTokenUsage">
<span>{formatCellValue(input)}/{formatCellValue(output)}</span> <span>{formatCellValue(input)}/{formatCellValue(output)}</span>
{cachedInput !== null ? <span>{formatCellValue(cachedInput)}</span> : null}
<span>{formatCellValue(total)}</span> <span>{formatCellValue(total)}</span>
</span> </span>
); );
} }
function usageObject(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function tokenValue(value: unknown) { function tokenValue(value: unknown) {
if (value === undefined || value === null || value === '') return null; if (value === undefined || value === null || value === '') return null;
const numericValue = typeof value === 'number' ? value : Number(value); const numericValue = typeof value === 'number' ? value : Number(value);
@@ -30,9 +30,10 @@ const modeDefinitions: ModeDefinition[] = [
{ {
key: 'text', key: 'text',
label: '文本', label: '文本',
formula: '一次对话扣费 = 输入 Token / 1000 × 输入扣费单价 + 输出 Token / 1000 × 输出扣费单价。', formula:
match: (rule) => rule.resourceType.startsWith('text_'), '一次对话扣费 = 未命中缓存输入 Token / 1000 × 输入扣费单价 + 命中缓存输入 Token / 1000 × 缓存输入扣费单价 + 输出 Token / 1000 × 输出扣费单价。',
templates: (currency) => [createTextRule(currency, 0.01, 0.03)], match: isTextPricingRule,
templates: (currency) => [createTextRule(currency, 0.01, 0.01, 0.03)],
parameterGroups: [], parameterGroups: [],
}, },
{ {
@@ -160,7 +161,7 @@ function summarizePricingMode(mode: ModeDefinition, rules: PricingRuleInput[]) {
const rule = rules[0]; const rule = rules[0];
if (mode.key === 'text') { if (mode.key === 'text') {
const prices = textPrices(rule); 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 const groupSummaries = mode.parameterGroups
.map((group) => summarizeWeightGroup(group, readGroup(rule.dynamicWeight, group))) .map((group) => summarizeWeightGroup(group, readGroup(rule.dynamicWeight, group)))
@@ -260,7 +261,11 @@ function TextRuleEditor(props: {
}) { }) {
const prices = textPrices(props.rule); const prices = textPrices(props.rule);
function updatePrices(inputTokenPrice: number, outputTokenPrice: number) { function updatePrices(
inputTokenPrice: number,
cachedInputTokenPrice: number,
outputTokenPrice: number,
) {
props.onChange({ props.onChange({
...props.rule, ...props.rule,
basePrice: inputTokenPrice, basePrice: inputTokenPrice,
@@ -272,11 +277,17 @@ function TextRuleEditor(props: {
...(props.rule.formulaConfig ?? {}), ...(props.rule.formulaConfig ?? {}),
formula: textFormula, formula: textFormula,
inputTokenPrice, inputTokenPrice,
cachedInputTokenPrice,
outputTokenPrice, outputTokenPrice,
}, },
dimensionSchema: { dimensionSchema: {
...(props.rule.dimensionSchema ?? {}), ...(props.rule.dimensionSchema ?? {}),
metrics: ['input_tokens', 'output_tokens'], metrics: [
'input_tokens',
'uncached_input_tokens',
'cached_input_tokens',
'output_tokens',
],
unitScale: 1000, unitScale: 1000,
}, },
}); });
@@ -301,10 +312,52 @@ function TextRuleEditor(props: {
<Select size="sm" value={props.rule.status ?? 'active'} onChange={(event) => props.onChange({ ...props.rule, status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select> <Select size="sm" value={props.rule.status ?? 'active'} onChange={(event) => props.onChange({ ...props.rule, status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
</PricingFormRow> </PricingFormRow>
<PricingFormRow label="输入单价/1K tokens"> <PricingFormRow label="输入单价/1K tokens">
<Input size="sm" min="0" step="0.0001" type="number" value={prices.inputTokenPrice} onChange={(event) => updatePrices(Number(event.target.value), prices.outputTokenPrice)} /> <Input
size="sm"
min="0"
step="0.0001"
type="number"
value={prices.inputTokenPrice}
onChange={(event) =>
updatePrices(
Number(event.target.value),
prices.cachedInputTokenPrice,
prices.outputTokenPrice,
)
}
/>
</PricingFormRow>
<PricingFormRow label="缓存输入单价/1K tokens">
<Input
size="sm"
min="0"
step="0.0001"
type="number"
value={prices.cachedInputTokenPrice}
onChange={(event) =>
updatePrices(
prices.inputTokenPrice,
Number(event.target.value),
prices.outputTokenPrice,
)
}
/>
</PricingFormRow> </PricingFormRow>
<PricingFormRow label="输出单价/1K tokens"> <PricingFormRow label="输出单价/1K tokens">
<Input size="sm" min="0" step="0.0001" type="number" value={prices.outputTokenPrice} onChange={(event) => updatePrices(prices.inputTokenPrice, Number(event.target.value))} /> <Input
size="sm"
min="0"
step="0.0001"
type="number"
value={prices.outputTokenPrice}
onChange={(event) =>
updatePrices(
prices.inputTokenPrice,
prices.cachedInputTokenPrice,
Number(event.target.value),
)
}
/>
</PricingFormRow> </PricingFormRow>
<PricingReadonlyRow label="计价单位" value={unitLabel(props.rule.unit)} /> <PricingReadonlyRow label="计价单位" value={unitLabel(props.rule.unit)} />
<PricingReadonlyRow label="计算方式" value={calculatorLabel(props.rule.calculatorType)} /> <PricingReadonlyRow label="计算方式" value={calculatorLabel(props.rule.calculatorType)} />
@@ -462,14 +515,38 @@ function mergeTemplateRules(templates: PricingRuleInput[], rules: PricingRuleInp
} }
function mergeTextRules(rules: PricingRuleInput[], currency: string): PricingRuleInput { 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 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 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( return createTextRule(
source.currency ?? currency, source.currency ?? currency,
Number(source.formulaConfig?.inputTokenPrice ?? inputRule?.basePrice ?? source.basePrice ?? 0.01), Number(
Number(source.formulaConfig?.outputTokenPrice ?? outputRule?.basePrice ?? 0.03), 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, 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; 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 { return {
...(source ?? {}), ...(source ?? {}),
ruleKey: 'text', ruleKey: 'text',
@@ -493,7 +577,12 @@ function createTextRule(currency: string, inputTokenPrice: number, outputTokenPr
baseWeight: {}, baseWeight: {},
dynamicWeight: {}, dynamicWeight: {},
dimensionSchema: { dimensionSchema: {
metrics: ['input_tokens', 'output_tokens'], metrics: [
'input_tokens',
'uncached_input_tokens',
'cached_input_tokens',
'output_tokens',
],
unitScale: 1000, unitScale: 1000,
...(source?.dimensionSchema ?? {}), ...(source?.dimensionSchema ?? {}),
}, },
@@ -501,6 +590,7 @@ function createTextRule(currency: string, inputTokenPrice: number, outputTokenPr
...(source?.formulaConfig ?? {}), ...(source?.formulaConfig ?? {}),
formula: textFormula, formula: textFormula,
inputTokenPrice, inputTokenPrice,
cachedInputTokenPrice,
outputTokenPrice, outputTokenPrice,
}, },
priority: source?.priority ?? 100, priority: source?.priority ?? 100,
@@ -510,12 +600,36 @@ function createTextRule(currency: string, inputTokenPrice: number, outputTokenPr
} }
function textPrices(rule: PricingRuleInput) { 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 { return {
inputTokenPrice: Number(rule.formulaConfig?.inputTokenPrice ?? rule.basePrice ?? 0.01), inputTokenPrice,
outputTokenPrice: Number(rule.formulaConfig?.outputTokenPrice ?? 0.03), 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 { function createRule(ruleKey: string, displayName: string, resourceType: string, unit: string, basePrice: number, currency: string, calculatorType: string, formula: string, dynamicWeight: RecordValue, dimensionSchema: RecordValue): PricingRuleInput {
return { return {
ruleKey, ruleKey,
+15 -2
View File
@@ -266,14 +266,27 @@ function pricingRuleSummaries(ruleSet: PricingRuleSet): PricingRuleSummary[] {
const rules = ruleSet.rules ?? []; const rules = ruleSet.rules ?? [];
const textTotal = rules.find((rule) => rule.resourceType === 'text_total' || rule.ruleKey === 'text'); const textTotal = rules.find((rule) => rule.resourceType === 'text_total' || rule.ruleKey === 'text');
const textInput = rules.find((rule) => rule.resourceType === 'text_input'); 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 textOutput = rules.find((rule) => rule.resourceType === 'text_output');
const image = rules.find((rule) => rule.resourceType === 'image'); const image = rules.find((rule) => rule.resourceType === 'image');
const video = rules.find((rule) => rule.resourceType === 'video'); const video = rules.find((rule) => rule.resourceType === 'video');
const items: PricingRuleSummary[] = []; const items: PricingRuleSummary[] = [];
if (textTotal || textInput || textOutput) { if (textTotal || textInput || textCachedInput || textOutput) {
const inputPrice = Number(textTotal?.formulaConfig?.inputTokenPrice ?? textInput?.basePrice ?? textTotal?.basePrice ?? 0); 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); 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 (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)}` }); if (video) items.push({ kind: 'video', label: '视频', value: `${formatPrice(video.basePrice)} / ${unitLabel(video.unit)}` });