feat: support cached input token billing
This commit is contained in:
parent
6089aa6085
commit
7f32446466
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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...)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)}` });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user