feat: support cached input token billing

This commit is contained in:
wangbo 2026-06-23 17:17:57 +08:00
parent 6089aa6085
commit 7f32446466
16 changed files with 570 additions and 45 deletions

View File

@ -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")

View File

@ -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
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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{

View File

@ -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")
}

View File

@ -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 {

View File

@ -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{

View File

@ -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...)
}

View File

@ -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

View File

@ -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"]

View File

@ -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")
}

View File

@ -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
}

View File

@ -1561,18 +1561,57 @@ function transactionChargeCurrency(transaction: GatewayWalletTransaction) {
}
function formatTokenUsage(usage: Record<string, unknown>) {
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 (
<span className="taskRecordTokenUsage">
<span>{formatCellValue(input)}/{formatCellValue(output)}</span>
{cachedInput !== null ? <span>{formatCellValue(cachedInput)}</span> : null}
<span>{formatCellValue(total)}</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) {
if (value === undefined || value === null || value === '') return null;
const numericValue = typeof value === 'number' ? value : Number(value);

View File

@ -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: {
<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 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 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>
<PricingReadonlyRow label="计价单位" value={unitLabel(props.rule.unit)} />
<PricingReadonlyRow label="计算方式" value={calculatorLabel(props.rule.calculatorType)} />
@ -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,

View File

@ -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)}` });