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.Header().Set("Content-Type", "text/event-stream")
|
||||||
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}],\"usage\":null}\n\n"))
|
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}],\"usage\":null}\n\n"))
|
||||||
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\" world\"},\"finish_reason\":\"stop\"}],\"usage\":null}\n\n"))
|
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\" world\"},\"finish_reason\":\"stop\"}],\"usage\":null}\n\n"))
|
||||||
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n"))
|
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3,\"prompt_tokens_details\":{\"cached_tokens\":1}}}\n\n"))
|
||||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
@@ -662,6 +662,9 @@ func TestOpenAIClientChatStreamContract(t *testing.T) {
|
|||||||
if response.Usage.TotalTokens != 3 {
|
if response.Usage.TotalTokens != 3 {
|
||||||
t.Fatalf("unexpected usage: %+v", response.Usage)
|
t.Fatalf("unexpected usage: %+v", response.Usage)
|
||||||
}
|
}
|
||||||
|
if response.Usage.CachedInputTokens != 1 {
|
||||||
|
t.Fatalf("expected cached input tokens from stream usage, got %+v", response.Usage)
|
||||||
|
}
|
||||||
choices, _ := response.Result["choices"].([]any)
|
choices, _ := response.Result["choices"].([]any)
|
||||||
choice, _ := choices[0].(map[string]any)
|
choice, _ := choices[0].(map[string]any)
|
||||||
message, _ := choice["message"].(map[string]any)
|
message, _ := choice["message"].(map[string]any)
|
||||||
@@ -670,6 +673,111 @@ func TestOpenAIClientChatStreamContract(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenAIClientChatStreamExtractsCachedInputTokensFromProviderUsageShapes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
usage map[string]any
|
||||||
|
cached int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "aliyun dashscope openai-compatible",
|
||||||
|
usage: map[string]any{
|
||||||
|
"prompt_tokens": 10,
|
||||||
|
"completion_tokens": 2,
|
||||||
|
"total_tokens": 12,
|
||||||
|
"prompt_tokens_details": map[string]any{
|
||||||
|
"cached_tokens": 7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cached: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "volces responses-style input details",
|
||||||
|
usage: map[string]any{
|
||||||
|
"prompt_tokens": 9,
|
||||||
|
"completion_tokens": 3,
|
||||||
|
"total_tokens": 12,
|
||||||
|
"input_tokens_details": map[string]any{
|
||||||
|
"cached_tokens": 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cached: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deepseek prompt cache hit tokens",
|
||||||
|
usage: map[string]any{
|
||||||
|
"prompt_tokens": 8,
|
||||||
|
"completion_tokens": 4,
|
||||||
|
"total_tokens": 12,
|
||||||
|
"prompt_cache_hit_tokens": 5,
|
||||||
|
"prompt_cache_miss_tokens": 3,
|
||||||
|
},
|
||||||
|
cached: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "glm openai-compatible",
|
||||||
|
usage: map[string]any{
|
||||||
|
"prompt_tokens": 11,
|
||||||
|
"completion_tokens": 1,
|
||||||
|
"total_tokens": 12,
|
||||||
|
"prompt_tokens_details": map[string]any{
|
||||||
|
"cached_tokens": 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cached: 4,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var gotIncludeUsage bool
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("decode request: %v", err)
|
||||||
|
}
|
||||||
|
streamOptions, _ := body["stream_options"].(map[string]any)
|
||||||
|
gotIncludeUsage, _ = streamOptions["include_usage"].(bool)
|
||||||
|
usageBytes, _ := json.Marshal(tc.usage)
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-cache\",\"object\":\"chat.completion.chunk\",\"model\":\"cache-test\",\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}],\"usage\":null}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-cache\",\"object\":\"chat.completion.chunk\",\"model\":\"cache-test\",\"choices\":[],\"usage\":" + string(usageBytes) + "}\n\n"))
|
||||||
|
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||||
|
Kind: "chat.completions",
|
||||||
|
Model: "Cache-Test",
|
||||||
|
Body: map[string]any{
|
||||||
|
"model": "Cache-Test",
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
|
||||||
|
"stream": true,
|
||||||
|
},
|
||||||
|
Candidate: store.RuntimeModelCandidate{
|
||||||
|
BaseURL: server.URL,
|
||||||
|
ModelName: "cache-test",
|
||||||
|
Credentials: map[string]any{"apiKey": "test-key"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run openai stream client: %v", err)
|
||||||
|
}
|
||||||
|
if !gotIncludeUsage {
|
||||||
|
t.Fatal("expected upstream stream_options.include_usage=true")
|
||||||
|
}
|
||||||
|
if response.Usage.CachedInputTokens != tc.cached {
|
||||||
|
t.Fatalf("expected cached input tokens %d, got %+v", tc.cached, response.Usage)
|
||||||
|
}
|
||||||
|
resultUsage, _ := response.Result["usage"].(map[string]any)
|
||||||
|
promptDetails, _ := resultUsage["prompt_tokens_details"].(map[string]any)
|
||||||
|
if intFromAny(promptDetails["cached_tokens"]) != tc.cached {
|
||||||
|
t.Fatalf("result usage should expose normalized cached_tokens, got %+v", resultUsage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenAIClientChatStreamPreservesStructuredDeltas(t *testing.T) {
|
func TestOpenAIClientChatStreamPreservesStructuredDeltas(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
|||||||
@@ -519,7 +519,11 @@ func geminiUsage(raw map[string]any) Usage {
|
|||||||
input := intFromAny(usageMap["prompt_tokens"])
|
input := intFromAny(usageMap["prompt_tokens"])
|
||||||
output := intFromAny(usageMap["completion_tokens"])
|
output := intFromAny(usageMap["completion_tokens"])
|
||||||
total := intFromAny(usageMap["total_tokens"])
|
total := intFromAny(usageMap["total_tokens"])
|
||||||
return Usage{InputTokens: input, OutputTokens: output, TotalTokens: total}
|
cachedInput := cachedInputTokensFromOpenAIUsage(usageMap)
|
||||||
|
if cachedInput > input && input > 0 {
|
||||||
|
cachedInput = input
|
||||||
|
}
|
||||||
|
return Usage{InputTokens: input, OutputTokens: output, CachedInputTokens: cachedInput, TotalTokens: total}
|
||||||
}
|
}
|
||||||
|
|
||||||
func geminiUsageMap(raw map[string]any) map[string]any {
|
func geminiUsageMap(raw map[string]any) map[string]any {
|
||||||
@@ -527,8 +531,13 @@ func geminiUsageMap(raw map[string]any) map[string]any {
|
|||||||
input := intFromAny(meta["promptTokenCount"])
|
input := intFromAny(meta["promptTokenCount"])
|
||||||
output := intFromAny(meta["candidatesTokenCount"])
|
output := intFromAny(meta["candidatesTokenCount"])
|
||||||
total := intFromAny(meta["totalTokenCount"])
|
total := intFromAny(meta["totalTokenCount"])
|
||||||
|
cachedInput := intFromAny(firstPresent(meta["cachedContentTokenCount"], meta["cached_content_token_count"]))
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
total = input + output
|
total = input + output
|
||||||
}
|
}
|
||||||
return map[string]any{"prompt_tokens": input, "completion_tokens": output, "total_tokens": total}
|
usage := map[string]any{"prompt_tokens": input, "completion_tokens": output, "total_tokens": total}
|
||||||
|
if cachedInput > 0 {
|
||||||
|
usage["prompt_tokens_details"] = map[string]any{"cached_tokens": cachedInput}
|
||||||
|
}
|
||||||
|
return usage
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,11 +185,17 @@ func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts
|
|||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
if usage.TotalTokens > 0 {
|
if usage.TotalTokens > 0 {
|
||||||
out["usage"] = map[string]any{
|
usageMap := map[string]any{
|
||||||
"prompt_tokens": usage.InputTokens,
|
"prompt_tokens": usage.InputTokens,
|
||||||
"completion_tokens": usage.OutputTokens,
|
"completion_tokens": usage.OutputTokens,
|
||||||
"total_tokens": usage.TotalTokens,
|
"total_tokens": usage.TotalTokens,
|
||||||
}
|
}
|
||||||
|
if usage.CachedInputTokens > 0 {
|
||||||
|
usageMap["prompt_tokens_details"] = map[string]any{
|
||||||
|
"cached_tokens": usage.CachedInputTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out["usage"] = usageMap
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -877,7 +883,42 @@ func usageFromOpenAI(result map[string]any) Usage {
|
|||||||
if total == 0 {
|
if total == 0 {
|
||||||
total = input + output
|
total = input + output
|
||||||
}
|
}
|
||||||
return Usage{InputTokens: input, OutputTokens: output, TotalTokens: total}
|
cachedInput := cachedInputTokensFromOpenAIUsage(usage)
|
||||||
|
if cachedInput > input && input > 0 {
|
||||||
|
cachedInput = input
|
||||||
|
}
|
||||||
|
return Usage{InputTokens: input, OutputTokens: output, CachedInputTokens: cachedInput, TotalTokens: total}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cachedInputTokensFromOpenAIUsage(usage map[string]any) int {
|
||||||
|
if len(usage) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
promptDetails, _ := firstPresent(usage["prompt_tokens_details"], usage["promptTokensDetails"]).(map[string]any)
|
||||||
|
inputDetails, _ := firstPresent(usage["input_tokens_details"], usage["inputTokensDetails"]).(map[string]any)
|
||||||
|
usageMetadata, _ := usage["usageMetadata"].(map[string]any)
|
||||||
|
return intFromAny(firstPresent(
|
||||||
|
promptDetails["cached_tokens"],
|
||||||
|
promptDetails["cachedTokens"],
|
||||||
|
promptDetails["cache_read_input_tokens"],
|
||||||
|
promptDetails["cacheReadInputTokens"],
|
||||||
|
inputDetails["cached_tokens"],
|
||||||
|
inputDetails["cachedTokens"],
|
||||||
|
inputDetails["cache_read_input_tokens"],
|
||||||
|
inputDetails["cacheReadInputTokens"],
|
||||||
|
usage["prompt_cache_hit_tokens"],
|
||||||
|
usage["promptCacheHitTokens"],
|
||||||
|
usage["cached_tokens"],
|
||||||
|
usage["cachedTokens"],
|
||||||
|
usage["cached_input_tokens"],
|
||||||
|
usage["cachedInputTokens"],
|
||||||
|
usage["cache_read_input_tokens"],
|
||||||
|
usage["cacheReadInputTokens"],
|
||||||
|
usage["cached_content_token_count"],
|
||||||
|
usage["cachedContentTokenCount"],
|
||||||
|
usageMetadata["cached_content_token_count"],
|
||||||
|
usageMetadata["cachedContentTokenCount"],
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestIDFromHTTPResponse(resp *http.Response) string {
|
func requestIDFromHTTPResponse(resp *http.Response) string {
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ type Response struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Usage struct {
|
type Usage struct {
|
||||||
InputTokens int
|
InputTokens int
|
||||||
OutputTokens int
|
OutputTokens int
|
||||||
TotalTokens int
|
CachedInputTokens int
|
||||||
|
TotalTokens int
|
||||||
}
|
}
|
||||||
|
|
||||||
type Progress struct {
|
type Progress struct {
|
||||||
|
|||||||
@@ -165,6 +165,87 @@ func TestWriteCompatibleTaskResponseReturnsSSEWhenStreamIsTrue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteCompatibleTaskResponseStreamsCachedUsageProviderShapes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
usage map[string]any
|
||||||
|
fragment string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "aliyun dashscope",
|
||||||
|
usage: map[string]any{
|
||||||
|
"prompt_tokens": 10,
|
||||||
|
"completion_tokens": 2,
|
||||||
|
"total_tokens": 12,
|
||||||
|
"prompt_tokens_details": map[string]any{
|
||||||
|
"cached_tokens": 7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fragment: `"prompt_tokens_details":{"cached_tokens":7}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "volces responses",
|
||||||
|
usage: map[string]any{
|
||||||
|
"prompt_tokens": 9,
|
||||||
|
"completion_tokens": 3,
|
||||||
|
"total_tokens": 12,
|
||||||
|
"input_tokens_details": map[string]any{
|
||||||
|
"cached_tokens": 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fragment: `"input_tokens_details":{"cached_tokens":6}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deepseek",
|
||||||
|
usage: map[string]any{
|
||||||
|
"prompt_tokens": 8,
|
||||||
|
"completion_tokens": 4,
|
||||||
|
"total_tokens": 12,
|
||||||
|
"prompt_cache_hit_tokens": 5,
|
||||||
|
"prompt_cache_miss_tokens": 3,
|
||||||
|
},
|
||||||
|
fragment: `"prompt_cache_hit_tokens":5`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "glm",
|
||||||
|
usage: map[string]any{
|
||||||
|
"prompt_tokens": 11,
|
||||||
|
"completion_tokens": 1,
|
||||||
|
"total_tokens": 12,
|
||||||
|
"prompt_tokens_details": map[string]any{
|
||||||
|
"cached_tokens": 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fragment: `"prompt_tokens_details":{"cached_tokens":4}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
executor := &fakeTaskExecutor{
|
||||||
|
deltas: []clients.StreamDeltaEvent{{Text: "ok"}},
|
||||||
|
output: map[string]any{
|
||||||
|
"id": "chatcmpl-cache",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"usage": tc.usage,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat/completions", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
writeCompatibleTaskResponse(context.Background(), recorder, req, executor, "chat.completions", "cache-test", store.GatewayTask{ID: "task-test"}, &auth.User{}, true, true)
|
||||||
|
|
||||||
|
body := recorder.Body.String()
|
||||||
|
if !strings.Contains(body, tc.fragment) {
|
||||||
|
t.Fatalf("SSE body missing cached usage fragment %s: %s", tc.fragment, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `"choices":[]`) || !strings.Contains(body, "data: [DONE]") {
|
||||||
|
t.Fatalf("SSE body should include final usage chunk and done marker: %s", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteCompatibleTaskResponseStreamsStructuredToolAndReasoningDeltas(t *testing.T) {
|
func TestWriteCompatibleTaskResponseStreamsStructuredToolAndReasoningDeltas(t *testing.T) {
|
||||||
executor := &fakeTaskExecutor{
|
executor := &fakeTaskExecutor{
|
||||||
deltas: []clients.StreamDeltaEvent{
|
deltas: []clients.StreamDeltaEvent{
|
||||||
|
|||||||
@@ -759,7 +759,7 @@ func billingConfigLines(config map[string]any) []string {
|
|||||||
|
|
||||||
func textPricingResource(config map[string]any) bool {
|
func textPricingResource(config map[string]any) bool {
|
||||||
switch strings.ToLower(stringValue(config["resourceType"])) {
|
switch strings.ToLower(stringValue(config["resourceType"])) {
|
||||||
case "text", "text_total", "text_input", "text_output":
|
case "text", "text_total", "text_input", "text_cached_input", "text_output":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -771,6 +771,7 @@ func textPricingLines(config map[string]any) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
inputPrice, hasInput := textPriceFromConfig(config, []string{"textInputPer1k", "textInput", "text_input", "inputTokenPrice", "inputPrice"})
|
inputPrice, hasInput := textPriceFromConfig(config, []string{"textInputPer1k", "textInput", "text_input", "inputTokenPrice", "inputPrice"})
|
||||||
|
cachedInputPrice, hasCachedInput := textPriceFromConfig(config, []string{"textCachedInputPer1k", "textCachedInput", "text_cached_input", "cachedInputTokenPrice", "inputCacheHitTokenPrice", "cachedInputPrice"})
|
||||||
outputPrice, hasOutput := textPriceFromConfig(config, []string{"textOutputPer1k", "textOutput", "text_output", "outputTokenPrice", "outputPrice"})
|
outputPrice, hasOutput := textPriceFromConfig(config, []string{"textOutputPer1k", "textOutput", "text_output", "outputTokenPrice", "outputPrice"})
|
||||||
|
|
||||||
for _, key := range []string{"formulaConfig", "formula_config"} {
|
for _, key := range []string{"formulaConfig", "formula_config"} {
|
||||||
@@ -778,6 +779,9 @@ func textPricingLines(config map[string]any) []string {
|
|||||||
if !hasInput {
|
if !hasInput {
|
||||||
inputPrice, hasInput = textPriceFromConfig(formulaConfig, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"})
|
inputPrice, hasInput = textPriceFromConfig(formulaConfig, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"})
|
||||||
}
|
}
|
||||||
|
if !hasCachedInput {
|
||||||
|
cachedInputPrice, hasCachedInput = textPriceFromConfig(formulaConfig, []string{"cachedInputTokenPrice", "textCachedInputPer1k", "textCachedInput", "text_cached_input", "inputCacheHitTokenPrice", "cachedInputPrice"})
|
||||||
|
}
|
||||||
if !hasOutput {
|
if !hasOutput {
|
||||||
outputPrice, hasOutput = textPriceFromConfig(formulaConfig, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"})
|
outputPrice, hasOutput = textPriceFromConfig(formulaConfig, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"})
|
||||||
}
|
}
|
||||||
@@ -791,6 +795,9 @@ func textPricingLines(config map[string]any) []string {
|
|||||||
if !hasInput {
|
if !hasInput {
|
||||||
inputPrice, hasInput = textPriceFromConfig(nested, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"})
|
inputPrice, hasInput = textPriceFromConfig(nested, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"})
|
||||||
}
|
}
|
||||||
|
if !hasCachedInput {
|
||||||
|
cachedInputPrice, hasCachedInput = textPriceFromConfig(nested, []string{"cachedInputTokenPrice", "textCachedInputPer1k", "textCachedInput", "text_cached_input", "inputCacheHitTokenPrice", "cachedInputPrice"})
|
||||||
|
}
|
||||||
if !hasOutput {
|
if !hasOutput {
|
||||||
outputPrice, hasOutput = textPriceFromConfig(nested, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"})
|
outputPrice, hasOutput = textPriceFromConfig(nested, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"})
|
||||||
}
|
}
|
||||||
@@ -799,6 +806,9 @@ func textPricingLines(config map[string]any) []string {
|
|||||||
if !hasInput {
|
if !hasInput {
|
||||||
inputPrice, hasInput = textPriceFromConfig(formulaConfig, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"})
|
inputPrice, hasInput = textPriceFromConfig(formulaConfig, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"})
|
||||||
}
|
}
|
||||||
|
if !hasCachedInput {
|
||||||
|
cachedInputPrice, hasCachedInput = textPriceFromConfig(formulaConfig, []string{"cachedInputTokenPrice", "textCachedInputPer1k", "textCachedInput", "text_cached_input", "inputCacheHitTokenPrice", "cachedInputPrice"})
|
||||||
|
}
|
||||||
if !hasOutput {
|
if !hasOutput {
|
||||||
outputPrice, hasOutput = textPriceFromConfig(formulaConfig, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"})
|
outputPrice, hasOutput = textPriceFromConfig(formulaConfig, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"})
|
||||||
}
|
}
|
||||||
@@ -824,6 +834,10 @@ func textPricingLines(config map[string]any) []string {
|
|||||||
if !hasInput {
|
if !hasInput {
|
||||||
inputPrice, hasInput = basePrice, true
|
inputPrice, hasInput = basePrice, true
|
||||||
}
|
}
|
||||||
|
case "text_cached_input":
|
||||||
|
if !hasCachedInput {
|
||||||
|
cachedInputPrice, hasCachedInput = basePrice, true
|
||||||
|
}
|
||||||
case "text_output":
|
case "text_output":
|
||||||
if !hasOutput {
|
if !hasOutput {
|
||||||
outputPrice, hasOutput = basePrice, true
|
outputPrice, hasOutput = basePrice, true
|
||||||
@@ -835,6 +849,9 @@ func textPricingLines(config map[string]any) []string {
|
|||||||
if hasInput {
|
if hasInput {
|
||||||
lines = append(lines, "输入 "+formatNumber(inputPrice)+"/k tokens")
|
lines = append(lines, "输入 "+formatNumber(inputPrice)+"/k tokens")
|
||||||
}
|
}
|
||||||
|
if hasCachedInput {
|
||||||
|
lines = append(lines, "缓存输入 "+formatNumber(cachedInputPrice)+"/k tokens")
|
||||||
|
}
|
||||||
if hasOutput {
|
if hasOutput {
|
||||||
lines = append(lines, "输出 "+formatNumber(outputPrice)+"/k tokens")
|
lines = append(lines, "输出 "+formatNumber(outputPrice)+"/k tokens")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,9 +294,19 @@ type ChatCompletionChoiceMessage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ChatCompletionUsage struct {
|
type ChatCompletionUsage struct {
|
||||||
PromptTokens int `json:"prompt_tokens,omitempty" example:"12"`
|
PromptTokens int `json:"prompt_tokens,omitempty" example:"12"`
|
||||||
CompletionTokens int `json:"completion_tokens,omitempty" example:"8"`
|
CompletionTokens int `json:"completion_tokens,omitempty" example:"8"`
|
||||||
TotalTokens int `json:"total_tokens,omitempty" example:"20"`
|
TotalTokens int `json:"total_tokens,omitempty" example:"20"`
|
||||||
|
PromptTokensDetails *ChatPromptTokensDetails `json:"prompt_tokens_details,omitempty"`
|
||||||
|
InputTokensDetails *ChatInputTokensDetails `json:"input_tokens_details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatPromptTokensDetails struct {
|
||||||
|
CachedTokens int `json:"cached_tokens,omitempty" example:"4"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatInputTokensDetails struct {
|
||||||
|
CachedTokens int `json:"cached_tokens,omitempty" example:"4"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NetworkProxyConfigResponse struct {
|
type NetworkProxyConfigResponse struct {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||||
@@ -29,6 +30,44 @@ func TestTokenUsageAmountsFallsBackToInputOutputTotal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBillingsSplitsCachedInputTokens(t *testing.T) {
|
||||||
|
service := &Service{}
|
||||||
|
candidate := store.RuntimeModelCandidate{
|
||||||
|
ModelName: "test-model",
|
||||||
|
BillingConfig: map[string]any{
|
||||||
|
"textInputPer1k": 1.0,
|
||||||
|
"textCachedInputPer1k": 0.25,
|
||||||
|
"textOutputPer1k": 2.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response := clients.Response{
|
||||||
|
Usage: clients.Usage{
|
||||||
|
InputTokens: 1000,
|
||||||
|
CachedInputTokens: 400,
|
||||||
|
OutputTokens: 500,
|
||||||
|
TotalTokens: 1500,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := service.billings(context.Background(), nil, "chat.completions", nil, candidate, response, false)
|
||||||
|
|
||||||
|
if len(lines) != 3 {
|
||||||
|
t.Fatalf("expected uncached input, cached input, and output lines, got %+v", lines)
|
||||||
|
}
|
||||||
|
uncached, _ := lines[0].(map[string]any)
|
||||||
|
cached, _ := lines[1].(map[string]any)
|
||||||
|
output, _ := lines[2].(map[string]any)
|
||||||
|
if uncached["resourceType"] != "text_input" || uncached["quantity"] != 600 || uncached["amount"] != 0.6 {
|
||||||
|
t.Fatalf("unexpected uncached input billing line: %+v", uncached)
|
||||||
|
}
|
||||||
|
if cached["resourceType"] != "text_cached_input" || cached["quantity"] != 400 || cached["amount"] != 0.1 {
|
||||||
|
t.Fatalf("unexpected cached input billing line: %+v", cached)
|
||||||
|
}
|
||||||
|
if output["resourceType"] != "text_output" || output["quantity"] != 500 || output["amount"] != 1.0 {
|
||||||
|
t.Fatalf("unexpected output billing line: %+v", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEffectiveRateLimitPolicyTreatsEmptyRuntimePolicyAsUnlimited(t *testing.T) {
|
func TestEffectiveRateLimitPolicyTreatsEmptyRuntimePolicyAsUnlimited(t *testing.T) {
|
||||||
policy := effectiveRateLimitPolicy(store.RuntimeModelCandidate{
|
policy := effectiveRateLimitPolicy(store.RuntimeModelCandidate{
|
||||||
PlatformRateLimitPolicy: map[string]any{"rules": []any{
|
PlatformRateLimitPolicy: map[string]any{"rules": []any{
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
|
|||||||
if isTextBillingKind(kind) {
|
if isTextBillingKind(kind) {
|
||||||
inputTokens := response.Usage.InputTokens
|
inputTokens := response.Usage.InputTokens
|
||||||
outputTokens := response.Usage.OutputTokens
|
outputTokens := response.Usage.OutputTokens
|
||||||
|
cachedInputTokens := response.Usage.CachedInputTokens
|
||||||
if isTextInputOnlyKind(kind) && inputTokens == 0 && response.Usage.TotalTokens > 0 {
|
if isTextInputOnlyKind(kind) && inputTokens == 0 && response.Usage.TotalTokens > 0 {
|
||||||
inputTokens = response.Usage.TotalTokens
|
inputTokens = response.Usage.TotalTokens
|
||||||
}
|
}
|
||||||
@@ -71,8 +72,37 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
|
|||||||
outputTokens = 1
|
outputTokens = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inputAmount := roundPrice(float64(inputTokens) / 1000 * resourcePrice(config, "text", "textInputPer1k", "inputTokenPrice", "basePrice") * discount)
|
if cachedInputTokens > inputTokens && inputTokens > 0 {
|
||||||
lines := []any{billingLine(candidate, "text_input", "1k_tokens", inputTokens, inputAmount, discount, simulated)}
|
cachedInputTokens = inputTokens
|
||||||
|
}
|
||||||
|
uncachedInputTokens := inputTokens - cachedInputTokens
|
||||||
|
if uncachedInputTokens < 0 {
|
||||||
|
uncachedInputTokens = 0
|
||||||
|
}
|
||||||
|
inputPrice := resourcePrice(config, "text", "textInputPer1k", "inputTokenPrice", "basePrice")
|
||||||
|
cachedInputPrice := resourcePrice(config, "text", "textCachedInputPer1k", "cachedInputTokenPrice", "cacheInputTokenPrice", "textInputCacheHitPer1k", "inputCacheHitTokenPrice")
|
||||||
|
if cachedInputPrice <= 0 {
|
||||||
|
cachedInputPrice = inputPrice
|
||||||
|
}
|
||||||
|
inputAmount := roundPrice(float64(uncachedInputTokens) / 1000 * inputPrice * discount)
|
||||||
|
lines := []any{}
|
||||||
|
if uncachedInputTokens > 0 || cachedInputTokens == 0 {
|
||||||
|
lines = append(lines, billingLineWithDetails(candidate, "text_input", "1k_tokens", uncachedInputTokens, inputAmount, discount, simulated, map[string]any{
|
||||||
|
"inputTokens": inputTokens,
|
||||||
|
"uncachedInputTokens": uncachedInputTokens,
|
||||||
|
"cachedInputTokens": cachedInputTokens,
|
||||||
|
"pricePer1k": inputPrice,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if cachedInputTokens > 0 {
|
||||||
|
cachedInputAmount := roundPrice(float64(cachedInputTokens) / 1000 * cachedInputPrice * discount)
|
||||||
|
lines = append(lines, billingLineWithDetails(candidate, "text_cached_input", "1k_tokens", cachedInputTokens, cachedInputAmount, discount, simulated, map[string]any{
|
||||||
|
"inputTokens": inputTokens,
|
||||||
|
"uncachedInputTokens": uncachedInputTokens,
|
||||||
|
"cachedInputTokens": cachedInputTokens,
|
||||||
|
"pricePer1k": cachedInputPrice,
|
||||||
|
}))
|
||||||
|
}
|
||||||
if isTextGenerationKind(kind) {
|
if isTextGenerationKind(kind) {
|
||||||
outputAmount := roundPrice(float64(outputTokens) / 1000 * resourcePrice(config, "text", "textOutputPer1k", "outputTokenPrice", "basePrice") * discount)
|
outputAmount := roundPrice(float64(outputTokens) / 1000 * resourcePrice(config, "text", "textOutputPer1k", "outputTokenPrice", "basePrice") * discount)
|
||||||
lines = append(lines, billingLine(candidate, "text_output", "1k_tokens", outputTokens, outputAmount, discount, simulated))
|
lines = append(lines, billingLine(candidate, "text_output", "1k_tokens", outputTokens, outputAmount, discount, simulated))
|
||||||
@@ -234,10 +264,22 @@ func resourcePrice(config map[string]any, resource string, keys ...string) float
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if formulaConfig, ok := resourceConfig["formulaConfig"].(map[string]any); ok {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := floatFromAny(formulaConfig[key]); value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if value := floatFromAny(resourceConfig["basePrice"]); value > 0 {
|
if value := floatFromAny(resourceConfig["basePrice"]); value > 0 {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if resource == "text" {
|
||||||
|
if value := resourcePrice(config, "text_total", keys...); value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
if resource == "image_edit" {
|
if resource == "image_edit" {
|
||||||
return resourcePrice(config, "image", keys...)
|
return resourcePrice(config, "image", keys...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,10 @@ func usageToMap(usage clients.Usage) map[string]any {
|
|||||||
out["inputTokens"] = usage.InputTokens
|
out["inputTokens"] = usage.InputTokens
|
||||||
out["promptTokens"] = usage.InputTokens
|
out["promptTokens"] = usage.InputTokens
|
||||||
}
|
}
|
||||||
|
if usage.CachedInputTokens > 0 {
|
||||||
|
out["cachedInputTokens"] = usage.CachedInputTokens
|
||||||
|
out["cachedPromptTokens"] = usage.CachedInputTokens
|
||||||
|
}
|
||||||
if usage.OutputTokens > 0 {
|
if usage.OutputTokens > 0 {
|
||||||
out["outputTokens"] = usage.OutputTokens
|
out["outputTokens"] = usage.OutputTokens
|
||||||
out["completionTokens"] = usage.OutputTokens
|
out["completionTokens"] = usage.OutputTokens
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func inferBillingResources(modelType string, resources map[string]bool) {
|
|||||||
|
|
||||||
func billingConfigKeyAllowed(key string, resources map[string]bool) bool {
|
func billingConfigKeyAllowed(key string, resources map[string]bool) bool {
|
||||||
switch normalizeBillingConfigKey(key) {
|
switch normalizeBillingConfigKey(key) {
|
||||||
case "text", "texttotal", "text_total", "textinputper1k", "textoutputper1k", "textinput", "textoutput", "text_input", "text_output", "inputtokenprice", "outputtokenprice":
|
case "text", "texttotal", "text_total", "textinputper1k", "textcachedinputper1k", "textoutputper1k", "textinput", "textcachedinput", "textoutput", "text_input", "text_cached_input", "text_output", "inputtokenprice", "cachedinputtokenprice", "inputcachehittokenprice", "outputtokenprice":
|
||||||
return resources["text"]
|
return resources["text"]
|
||||||
case "image", "imagebase":
|
case "image", "imagebase":
|
||||||
return resources["image"]
|
return resources["image"]
|
||||||
|
|||||||
@@ -60,23 +60,25 @@ func TestFilterPlatformModelBillingConfigKeepsTextFlatPricing(t *testing.T) {
|
|||||||
model := PlatformModel{
|
model := PlatformModel{
|
||||||
ModelType: StringList{"text_generate"},
|
ModelType: StringList{"text_generate"},
|
||||||
BillingConfig: map[string]any{
|
BillingConfig: map[string]any{
|
||||||
"textInputPer1k": 0.01,
|
"textInputPer1k": 0.01,
|
||||||
"textOutputPer1k": 0.02,
|
"textCachedInputPer1k": 0.003,
|
||||||
|
"textOutputPer1k": 0.02,
|
||||||
"text_total": map[string]any{
|
"text_total": map[string]any{
|
||||||
"basePrice": 0.01,
|
"basePrice": 0.01,
|
||||||
"formulaConfig": map[string]any{"inputTokenPrice": 0.01, "outputTokenPrice": 0.02},
|
"formulaConfig": map[string]any{"inputTokenPrice": 0.01, "cachedInputTokenPrice": 0.003, "outputTokenPrice": 0.02},
|
||||||
"dynamicWeight": map[string]any{"cached": 0.5},
|
"dynamicWeight": map[string]any{"cached": 0.5},
|
||||||
"dimensionSchema": map[string]any{"unit": "1k_tokens"},
|
"dimensionSchema": map[string]any{"unit": "1k_tokens"},
|
||||||
},
|
},
|
||||||
"inputTokenPrice": 0.01,
|
"inputTokenPrice": 0.01,
|
||||||
"outputTokenPrice": 0.02,
|
"cachedInputTokenPrice": 0.003,
|
||||||
"image": map[string]any{"basePrice": 10},
|
"outputTokenPrice": 0.02,
|
||||||
|
"image": map[string]any{"basePrice": 10},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered := FilterPlatformModelBillingConfig(model)
|
filtered := FilterPlatformModelBillingConfig(model)
|
||||||
|
|
||||||
assertHasKeys(t, filtered.BillingConfig, "textInputPer1k", "textOutputPer1k", "text_total", "inputTokenPrice", "outputTokenPrice")
|
assertHasKeys(t, filtered.BillingConfig, "textInputPer1k", "textCachedInputPer1k", "textOutputPer1k", "text_total", "inputTokenPrice", "cachedInputTokenPrice", "outputTokenPrice")
|
||||||
assertMissingKeys(t, filtered.BillingConfig, "image")
|
assertMissingKeys(t, filtered.BillingConfig, "image")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -207,6 +207,8 @@ ORDER BY priority ASC, resource_type ASC`, id)
|
|||||||
switch resourceType {
|
switch resourceType {
|
||||||
case "text_input":
|
case "text_input":
|
||||||
config["textInputPer1k"] = basePrice
|
config["textInputPer1k"] = basePrice
|
||||||
|
case "text_cached_input":
|
||||||
|
config["textCachedInputPer1k"] = basePrice
|
||||||
case "text_output":
|
case "text_output":
|
||||||
config["textOutputPer1k"] = basePrice
|
config["textOutputPer1k"] = basePrice
|
||||||
case "text_total":
|
case "text_total":
|
||||||
@@ -215,6 +217,9 @@ ORDER BY priority ASC, resource_type ASC`, id)
|
|||||||
inputPrice = value
|
inputPrice = value
|
||||||
}
|
}
|
||||||
config["textInputPer1k"] = inputPrice
|
config["textInputPer1k"] = inputPrice
|
||||||
|
if cachedInputPrice, ok := pricingRuleNumberFromKeys(formulaConfig, "cachedInputTokenPrice", "cached_input_token_price", "textCachedInputPer1k", "text_cached_input", "inputCacheHitTokenPrice", "input_cache_hit_token_price"); ok {
|
||||||
|
config["textCachedInputPer1k"] = cachedInputPrice
|
||||||
|
}
|
||||||
if outputPrice, ok := pricingRuleNumberFromKeys(formulaConfig, "outputTokenPrice", "output_token_price", "textOutputPer1k", "text_output"); ok {
|
if outputPrice, ok := pricingRuleNumberFromKeys(formulaConfig, "outputTokenPrice", "output_token_price", "textOutputPer1k", "text_output"); ok {
|
||||||
config["textOutputPer1k"] = outputPrice
|
config["textOutputPer1k"] = outputPrice
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1561,18 +1561,57 @@ function transactionChargeCurrency(transaction: GatewayWalletTransaction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatTokenUsage(usage: Record<string, unknown>) {
|
function formatTokenUsage(usage: Record<string, unknown>) {
|
||||||
const input = tokenValue(usage.inputTokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens);
|
const input = tokenValue(
|
||||||
const output = tokenValue(usage.outputTokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.completion_tokens);
|
usage.inputTokens ??
|
||||||
const total = tokenValue(usage.totalTokens ?? usage.total_tokens ?? (input !== null && output !== null ? input + output : null));
|
usage.promptTokens ??
|
||||||
if (input === null && output === null && total === null) return '-';
|
usage.input_tokens ??
|
||||||
|
usage.prompt_tokens,
|
||||||
|
);
|
||||||
|
const promptDetails = usageObject(
|
||||||
|
usage.prompt_tokens_details ?? usage.promptTokensDetails,
|
||||||
|
);
|
||||||
|
const inputDetails = usageObject(
|
||||||
|
usage.input_tokens_details ?? usage.inputTokensDetails,
|
||||||
|
);
|
||||||
|
const cachedInput = tokenValue(
|
||||||
|
usage.cachedInputTokens ??
|
||||||
|
usage.cachedPromptTokens ??
|
||||||
|
usage.cached_input_tokens ??
|
||||||
|
usage.cached_tokens ??
|
||||||
|
promptDetails.cached_tokens ??
|
||||||
|
promptDetails.cachedTokens ??
|
||||||
|
inputDetails.cached_tokens ??
|
||||||
|
inputDetails.cachedTokens,
|
||||||
|
);
|
||||||
|
const output = tokenValue(
|
||||||
|
usage.outputTokens ??
|
||||||
|
usage.completionTokens ??
|
||||||
|
usage.output_tokens ??
|
||||||
|
usage.completion_tokens,
|
||||||
|
);
|
||||||
|
const total = tokenValue(
|
||||||
|
usage.totalTokens ??
|
||||||
|
usage.total_tokens ??
|
||||||
|
(input !== null && output !== null ? input + output : null),
|
||||||
|
);
|
||||||
|
if (input === null && cachedInput === null && output === null && total === null) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<span className="taskRecordTokenUsage">
|
<span className="taskRecordTokenUsage">
|
||||||
<span>输入:{formatCellValue(input)}/输出:{formatCellValue(output)}</span>
|
<span>输入:{formatCellValue(input)}/输出:{formatCellValue(output)}</span>
|
||||||
|
{cachedInput !== null ? <span>缓存输入:{formatCellValue(cachedInput)}</span> : null}
|
||||||
<span>总计:{formatCellValue(total)}</span>
|
<span>总计:{formatCellValue(total)}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function usageObject(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
function tokenValue(value: unknown) {
|
function tokenValue(value: unknown) {
|
||||||
if (value === undefined || value === null || value === '') return null;
|
if (value === undefined || value === null || value === '') return null;
|
||||||
const numericValue = typeof value === 'number' ? value : Number(value);
|
const numericValue = typeof value === 'number' ? value : Number(value);
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ const modeDefinitions: ModeDefinition[] = [
|
|||||||
{
|
{
|
||||||
key: 'text',
|
key: 'text',
|
||||||
label: '文本',
|
label: '文本',
|
||||||
formula: '一次对话扣费 = 输入 Token / 1000 × 输入扣费单价 + 输出 Token / 1000 × 输出扣费单价。',
|
formula:
|
||||||
match: (rule) => rule.resourceType.startsWith('text_'),
|
'一次对话扣费 = 未命中缓存输入 Token / 1000 × 输入扣费单价 + 命中缓存输入 Token / 1000 × 缓存输入扣费单价 + 输出 Token / 1000 × 输出扣费单价。',
|
||||||
templates: (currency) => [createTextRule(currency, 0.01, 0.03)],
|
match: isTextPricingRule,
|
||||||
|
templates: (currency) => [createTextRule(currency, 0.01, 0.01, 0.03)],
|
||||||
parameterGroups: [],
|
parameterGroups: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -160,7 +161,7 @@ function summarizePricingMode(mode: ModeDefinition, rules: PricingRuleInput[]) {
|
|||||||
const rule = rules[0];
|
const rule = rules[0];
|
||||||
if (mode.key === 'text') {
|
if (mode.key === 'text') {
|
||||||
const prices = textPrices(rule);
|
const prices = textPrices(rule);
|
||||||
return `文本按输入/输出 Token 分别计费,输入 ${prices.inputTokenPrice}/${unitLabel(rule.unit)},输出 ${prices.outputTokenPrice}/${unitLabel(rule.unit)}。`;
|
return `文本按输入/缓存输入/输出 Token 分别计费,输入 ${prices.inputTokenPrice}/${unitLabel(rule.unit)},缓存输入 ${prices.cachedInputTokenPrice}/${unitLabel(rule.unit)},输出 ${prices.outputTokenPrice}/${unitLabel(rule.unit)}。`;
|
||||||
}
|
}
|
||||||
const groupSummaries = mode.parameterGroups
|
const groupSummaries = mode.parameterGroups
|
||||||
.map((group) => summarizeWeightGroup(group, readGroup(rule.dynamicWeight, group)))
|
.map((group) => summarizeWeightGroup(group, readGroup(rule.dynamicWeight, group)))
|
||||||
@@ -260,7 +261,11 @@ function TextRuleEditor(props: {
|
|||||||
}) {
|
}) {
|
||||||
const prices = textPrices(props.rule);
|
const prices = textPrices(props.rule);
|
||||||
|
|
||||||
function updatePrices(inputTokenPrice: number, outputTokenPrice: number) {
|
function updatePrices(
|
||||||
|
inputTokenPrice: number,
|
||||||
|
cachedInputTokenPrice: number,
|
||||||
|
outputTokenPrice: number,
|
||||||
|
) {
|
||||||
props.onChange({
|
props.onChange({
|
||||||
...props.rule,
|
...props.rule,
|
||||||
basePrice: inputTokenPrice,
|
basePrice: inputTokenPrice,
|
||||||
@@ -272,11 +277,17 @@ function TextRuleEditor(props: {
|
|||||||
...(props.rule.formulaConfig ?? {}),
|
...(props.rule.formulaConfig ?? {}),
|
||||||
formula: textFormula,
|
formula: textFormula,
|
||||||
inputTokenPrice,
|
inputTokenPrice,
|
||||||
|
cachedInputTokenPrice,
|
||||||
outputTokenPrice,
|
outputTokenPrice,
|
||||||
},
|
},
|
||||||
dimensionSchema: {
|
dimensionSchema: {
|
||||||
...(props.rule.dimensionSchema ?? {}),
|
...(props.rule.dimensionSchema ?? {}),
|
||||||
metrics: ['input_tokens', 'output_tokens'],
|
metrics: [
|
||||||
|
'input_tokens',
|
||||||
|
'uncached_input_tokens',
|
||||||
|
'cached_input_tokens',
|
||||||
|
'output_tokens',
|
||||||
|
],
|
||||||
unitScale: 1000,
|
unitScale: 1000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -301,10 +312,52 @@ function TextRuleEditor(props: {
|
|||||||
<Select size="sm" value={props.rule.status ?? 'active'} onChange={(event) => props.onChange({ ...props.rule, status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
|
<Select size="sm" value={props.rule.status ?? 'active'} onChange={(event) => props.onChange({ ...props.rule, status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
|
||||||
</PricingFormRow>
|
</PricingFormRow>
|
||||||
<PricingFormRow label="输入单价/1K tokens">
|
<PricingFormRow label="输入单价/1K tokens">
|
||||||
<Input size="sm" min="0" step="0.0001" type="number" value={prices.inputTokenPrice} onChange={(event) => updatePrices(Number(event.target.value), prices.outputTokenPrice)} />
|
<Input
|
||||||
|
size="sm"
|
||||||
|
min="0"
|
||||||
|
step="0.0001"
|
||||||
|
type="number"
|
||||||
|
value={prices.inputTokenPrice}
|
||||||
|
onChange={(event) =>
|
||||||
|
updatePrices(
|
||||||
|
Number(event.target.value),
|
||||||
|
prices.cachedInputTokenPrice,
|
||||||
|
prices.outputTokenPrice,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PricingFormRow>
|
||||||
|
<PricingFormRow label="缓存输入单价/1K tokens">
|
||||||
|
<Input
|
||||||
|
size="sm"
|
||||||
|
min="0"
|
||||||
|
step="0.0001"
|
||||||
|
type="number"
|
||||||
|
value={prices.cachedInputTokenPrice}
|
||||||
|
onChange={(event) =>
|
||||||
|
updatePrices(
|
||||||
|
prices.inputTokenPrice,
|
||||||
|
Number(event.target.value),
|
||||||
|
prices.outputTokenPrice,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</PricingFormRow>
|
</PricingFormRow>
|
||||||
<PricingFormRow label="输出单价/1K tokens">
|
<PricingFormRow label="输出单价/1K tokens">
|
||||||
<Input size="sm" min="0" step="0.0001" type="number" value={prices.outputTokenPrice} onChange={(event) => updatePrices(prices.inputTokenPrice, Number(event.target.value))} />
|
<Input
|
||||||
|
size="sm"
|
||||||
|
min="0"
|
||||||
|
step="0.0001"
|
||||||
|
type="number"
|
||||||
|
value={prices.outputTokenPrice}
|
||||||
|
onChange={(event) =>
|
||||||
|
updatePrices(
|
||||||
|
prices.inputTokenPrice,
|
||||||
|
prices.cachedInputTokenPrice,
|
||||||
|
Number(event.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</PricingFormRow>
|
</PricingFormRow>
|
||||||
<PricingReadonlyRow label="计价单位" value={unitLabel(props.rule.unit)} />
|
<PricingReadonlyRow label="计价单位" value={unitLabel(props.rule.unit)} />
|
||||||
<PricingReadonlyRow label="计算方式" value={calculatorLabel(props.rule.calculatorType)} />
|
<PricingReadonlyRow label="计算方式" value={calculatorLabel(props.rule.calculatorType)} />
|
||||||
@@ -462,14 +515,38 @@ function mergeTemplateRules(templates: PricingRuleInput[], rules: PricingRuleInp
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mergeTextRules(rules: PricingRuleInput[], currency: string): PricingRuleInput {
|
function mergeTextRules(rules: PricingRuleInput[], currency: string): PricingRuleInput {
|
||||||
const totalRule = rules.find((rule) => rule.resourceType === 'text_total');
|
const totalRule = rules.find((rule) => rule.resourceType === 'text_total' || rule.resourceType === 'text' || rule.ruleKey === 'text');
|
||||||
const inputRule = rules.find((rule) => rule.resourceType === 'text_input');
|
const inputRule = rules.find((rule) => rule.resourceType === 'text_input');
|
||||||
|
const cachedInputRule = rules.find(
|
||||||
|
(rule) => rule.resourceType === 'text_cached_input',
|
||||||
|
);
|
||||||
const outputRule = rules.find((rule) => rule.resourceType === 'text_output');
|
const outputRule = rules.find((rule) => rule.resourceType === 'text_output');
|
||||||
const source = totalRule ?? inputRule ?? outputRule ?? createTextRule(currency, 0.01, 0.03);
|
const source =
|
||||||
|
totalRule ??
|
||||||
|
inputRule ??
|
||||||
|
cachedInputRule ??
|
||||||
|
outputRule ??
|
||||||
|
createTextRule(currency, 0.01, 0.01, 0.03);
|
||||||
return createTextRule(
|
return createTextRule(
|
||||||
source.currency ?? currency,
|
source.currency ?? currency,
|
||||||
Number(source.formulaConfig?.inputTokenPrice ?? inputRule?.basePrice ?? source.basePrice ?? 0.01),
|
Number(
|
||||||
Number(source.formulaConfig?.outputTokenPrice ?? outputRule?.basePrice ?? 0.03),
|
source.formulaConfig?.inputTokenPrice ??
|
||||||
|
inputRule?.basePrice ??
|
||||||
|
source.basePrice ??
|
||||||
|
0.01,
|
||||||
|
),
|
||||||
|
Number(
|
||||||
|
source.formulaConfig?.cachedInputTokenPrice ??
|
||||||
|
source.formulaConfig?.inputCacheHitTokenPrice ??
|
||||||
|
cachedInputRule?.basePrice ??
|
||||||
|
source.formulaConfig?.inputTokenPrice ??
|
||||||
|
inputRule?.basePrice ??
|
||||||
|
source.basePrice ??
|
||||||
|
0.01,
|
||||||
|
),
|
||||||
|
Number(
|
||||||
|
source.formulaConfig?.outputTokenPrice ?? outputRule?.basePrice ?? 0.03,
|
||||||
|
),
|
||||||
source,
|
source,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -478,9 +555,16 @@ function readGroup(value: RecordValue | undefined, group: ModeDefinition['parame
|
|||||||
return isPlainObject(value?.[group.key]) ? value?.[group.key] as RecordValue : group.defaults;
|
return isPlainObject(value?.[group.key]) ? value?.[group.key] as RecordValue : group.defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
const textFormula = 'input_tokens / 1000 * input_token_price + output_tokens / 1000 * output_token_price';
|
const textFormula =
|
||||||
|
'uncached_input_tokens / 1000 * input_token_price + cached_input_tokens / 1000 * cached_input_token_price + output_tokens / 1000 * output_token_price';
|
||||||
|
|
||||||
function createTextRule(currency: string, inputTokenPrice: number, outputTokenPrice: number, source?: PricingRuleInput): PricingRuleInput {
|
function createTextRule(
|
||||||
|
currency: string,
|
||||||
|
inputTokenPrice: number,
|
||||||
|
cachedInputTokenPrice: number,
|
||||||
|
outputTokenPrice: number,
|
||||||
|
source?: PricingRuleInput,
|
||||||
|
): PricingRuleInput {
|
||||||
return {
|
return {
|
||||||
...(source ?? {}),
|
...(source ?? {}),
|
||||||
ruleKey: 'text',
|
ruleKey: 'text',
|
||||||
@@ -493,7 +577,12 @@ function createTextRule(currency: string, inputTokenPrice: number, outputTokenPr
|
|||||||
baseWeight: {},
|
baseWeight: {},
|
||||||
dynamicWeight: {},
|
dynamicWeight: {},
|
||||||
dimensionSchema: {
|
dimensionSchema: {
|
||||||
metrics: ['input_tokens', 'output_tokens'],
|
metrics: [
|
||||||
|
'input_tokens',
|
||||||
|
'uncached_input_tokens',
|
||||||
|
'cached_input_tokens',
|
||||||
|
'output_tokens',
|
||||||
|
],
|
||||||
unitScale: 1000,
|
unitScale: 1000,
|
||||||
...(source?.dimensionSchema ?? {}),
|
...(source?.dimensionSchema ?? {}),
|
||||||
},
|
},
|
||||||
@@ -501,6 +590,7 @@ function createTextRule(currency: string, inputTokenPrice: number, outputTokenPr
|
|||||||
...(source?.formulaConfig ?? {}),
|
...(source?.formulaConfig ?? {}),
|
||||||
formula: textFormula,
|
formula: textFormula,
|
||||||
inputTokenPrice,
|
inputTokenPrice,
|
||||||
|
cachedInputTokenPrice,
|
||||||
outputTokenPrice,
|
outputTokenPrice,
|
||||||
},
|
},
|
||||||
priority: source?.priority ?? 100,
|
priority: source?.priority ?? 100,
|
||||||
@@ -510,12 +600,36 @@ function createTextRule(currency: string, inputTokenPrice: number, outputTokenPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
function textPrices(rule: PricingRuleInput) {
|
function textPrices(rule: PricingRuleInput) {
|
||||||
|
const compatRule = rule as PricingRuleInput & {
|
||||||
|
cachedInputTokenPrice?: number;
|
||||||
|
inputCacheHitTokenPrice?: number;
|
||||||
|
inputTokenPrice?: number;
|
||||||
|
outputTokenPrice?: number;
|
||||||
|
};
|
||||||
|
const inputTokenPrice = Number(
|
||||||
|
rule.formulaConfig?.inputTokenPrice ?? compatRule.inputTokenPrice ?? rule.basePrice ?? 0.01,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
inputTokenPrice: Number(rule.formulaConfig?.inputTokenPrice ?? rule.basePrice ?? 0.01),
|
inputTokenPrice,
|
||||||
outputTokenPrice: Number(rule.formulaConfig?.outputTokenPrice ?? 0.03),
|
cachedInputTokenPrice: Number(
|
||||||
|
rule.formulaConfig?.cachedInputTokenPrice ??
|
||||||
|
rule.formulaConfig?.inputCacheHitTokenPrice ??
|
||||||
|
compatRule.cachedInputTokenPrice ??
|
||||||
|
compatRule.inputCacheHitTokenPrice ??
|
||||||
|
inputTokenPrice,
|
||||||
|
),
|
||||||
|
outputTokenPrice: Number(rule.formulaConfig?.outputTokenPrice ?? compatRule.outputTokenPrice ?? 0.03),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTextPricingRule(rule: PricingRuleInput) {
|
||||||
|
const resourceType = String(rule.resourceType ?? '');
|
||||||
|
const ruleKey = String(rule.ruleKey ?? '');
|
||||||
|
return textPricingKeys.has(resourceType) || textPricingKeys.has(ruleKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textPricingKeys = new Set(['text', 'text_total', 'text_input', 'text_cached_input', 'text_output']);
|
||||||
|
|
||||||
function createRule(ruleKey: string, displayName: string, resourceType: string, unit: string, basePrice: number, currency: string, calculatorType: string, formula: string, dynamicWeight: RecordValue, dimensionSchema: RecordValue): PricingRuleInput {
|
function createRule(ruleKey: string, displayName: string, resourceType: string, unit: string, basePrice: number, currency: string, calculatorType: string, formula: string, dynamicWeight: RecordValue, dimensionSchema: RecordValue): PricingRuleInput {
|
||||||
return {
|
return {
|
||||||
ruleKey,
|
ruleKey,
|
||||||
|
|||||||
@@ -266,14 +266,27 @@ function pricingRuleSummaries(ruleSet: PricingRuleSet): PricingRuleSummary[] {
|
|||||||
const rules = ruleSet.rules ?? [];
|
const rules = ruleSet.rules ?? [];
|
||||||
const textTotal = rules.find((rule) => rule.resourceType === 'text_total' || rule.ruleKey === 'text');
|
const textTotal = rules.find((rule) => rule.resourceType === 'text_total' || rule.ruleKey === 'text');
|
||||||
const textInput = rules.find((rule) => rule.resourceType === 'text_input');
|
const textInput = rules.find((rule) => rule.resourceType === 'text_input');
|
||||||
|
const textCachedInput = rules.find(
|
||||||
|
(rule) => rule.resourceType === 'text_cached_input',
|
||||||
|
);
|
||||||
const textOutput = rules.find((rule) => rule.resourceType === 'text_output');
|
const textOutput = rules.find((rule) => rule.resourceType === 'text_output');
|
||||||
const image = rules.find((rule) => rule.resourceType === 'image');
|
const image = rules.find((rule) => rule.resourceType === 'image');
|
||||||
const video = rules.find((rule) => rule.resourceType === 'video');
|
const video = rules.find((rule) => rule.resourceType === 'video');
|
||||||
const items: PricingRuleSummary[] = [];
|
const items: PricingRuleSummary[] = [];
|
||||||
if (textTotal || textInput || textOutput) {
|
if (textTotal || textInput || textCachedInput || textOutput) {
|
||||||
const inputPrice = Number(textTotal?.formulaConfig?.inputTokenPrice ?? textInput?.basePrice ?? textTotal?.basePrice ?? 0);
|
const inputPrice = Number(textTotal?.formulaConfig?.inputTokenPrice ?? textInput?.basePrice ?? textTotal?.basePrice ?? 0);
|
||||||
|
const cachedInputPrice = Number(
|
||||||
|
textTotal?.formulaConfig?.cachedInputTokenPrice ??
|
||||||
|
textTotal?.formulaConfig?.inputCacheHitTokenPrice ??
|
||||||
|
textCachedInput?.basePrice ??
|
||||||
|
inputPrice,
|
||||||
|
);
|
||||||
const outputPrice = Number(textTotal?.formulaConfig?.outputTokenPrice ?? textOutput?.basePrice ?? 0);
|
const outputPrice = Number(textTotal?.formulaConfig?.outputTokenPrice ?? textOutput?.basePrice ?? 0);
|
||||||
items.push({ kind: 'text', label: '文本', value: `输入 ${formatPrice(inputPrice)} / 输出 ${formatPrice(outputPrice)} / 1K Token` });
|
items.push({
|
||||||
|
kind: 'text',
|
||||||
|
label: '文本',
|
||||||
|
value: `输入 ${formatPrice(inputPrice)} / 缓存输入 ${formatPrice(cachedInputPrice)} / 输出 ${formatPrice(outputPrice)} / 1K Token`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (image) items.push({ kind: 'image', label: '图像', value: `${formatPrice(image.basePrice)} / ${unitLabel(image.unit)}` });
|
if (image) items.push({ kind: 'image', label: '图像', value: `${formatPrice(image.basePrice)} / ${unitLabel(image.unit)}` });
|
||||||
if (video) items.push({ kind: 'video', label: '视频', value: `${formatPrice(video.basePrice)} / ${unitLabel(video.unit)}` });
|
if (video) items.push({ kind: 'video', label: '视频', value: `${formatPrice(video.basePrice)} / ${unitLabel(video.unit)}` });
|
||||||
|
|||||||
Reference in New Issue
Block a user