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.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")
+11 -2
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
}
+43 -2
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 {
+4 -3
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 {
@@ -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{
+18 -1
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")
}
+13 -3
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 {
+39
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{
+44 -2
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...)
}
+4
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
@@ -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")
}
+5
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
}