feat: support cached input token billing
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user