mirror of
https://github.com/coder/coder.git
synced 2026-06-03 21:18:24 +00:00
c3b6284955
Add cost tracking for LLM chat interactions with microdollar precision. ## Changes - Add `chatcost` package for per-message cost calculation using `shopspring/decimal` for intermediate arithmetic - **Ceil rounding policy**: fractional micros round UP to next whole micro (applied once after summing all components) - Database migration: `total_cost_micros` BIGINT column with historical backfill and `created_at` index - API endpoints: per-user cost summary and admin rollup under `/api/experimental/chats/cost/` - SDK types: `ChatCostSummary`, `ChatCostModelBreakdown`, `ChatCostUserRollup` - Fix `modeloptionsgen` to handle `decimal.Decimal` as opaque numeric type - Update frontend pricing test fixtures for string decimal types ## Design decisions - `NULL` = unpriced (no matching model config), `0` = free - Reasoning tokens included in output tokens (no double-counting) - Integer microdollars (BIGINT) for storage and API responses - Price config uses `decimal.Decimal` for exact parsing; totals use `int64` Frontend: #23037
151 lines
4.3 KiB
Go
151 lines
4.3 KiB
Go
package chatprovider_test
|
|
|
|
import (
|
|
"testing"
|
|
|
|
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
|
fantasyopenai "charm.land/fantasy/providers/openai"
|
|
fantasyopenrouter "charm.land/fantasy/providers/openrouter"
|
|
fantasyvercel "charm.land/fantasy/providers/vercel"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/chatd/chatprovider"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
func TestReasoningEffortFromChat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
provider string
|
|
input *string
|
|
want *string
|
|
}{
|
|
{
|
|
name: "OpenAICaseInsensitive",
|
|
provider: "openai",
|
|
input: stringPtr(" HIGH "),
|
|
want: stringPtr(string(fantasyopenai.ReasoningEffortHigh)),
|
|
},
|
|
{
|
|
name: "AnthropicEffort",
|
|
provider: "anthropic",
|
|
input: stringPtr("max"),
|
|
want: stringPtr(string(fantasyanthropic.EffortMax)),
|
|
},
|
|
{
|
|
name: "OpenRouterEffort",
|
|
provider: "openrouter",
|
|
input: stringPtr("medium"),
|
|
want: stringPtr(string(fantasyopenrouter.ReasoningEffortMedium)),
|
|
},
|
|
{
|
|
name: "VercelEffort",
|
|
provider: "vercel",
|
|
input: stringPtr("xhigh"),
|
|
want: stringPtr(string(fantasyvercel.ReasoningEffortXHigh)),
|
|
},
|
|
{
|
|
name: "InvalidEffortReturnsNil",
|
|
provider: "openai",
|
|
input: stringPtr("unknown"),
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "UnsupportedProviderReturnsNil",
|
|
provider: "bedrock",
|
|
input: stringPtr("high"),
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "NilInputReturnsNil",
|
|
provider: "openai",
|
|
input: nil,
|
|
want: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := chatprovider.ReasoningEffortFromChat(tt.provider, tt.input)
|
|
require.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMergeMissingProviderOptions_OpenRouterNested(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
options := &codersdk.ChatModelProviderOptions{
|
|
OpenRouter: &codersdk.ChatModelOpenRouterProviderOptions{
|
|
Reasoning: &codersdk.ChatModelOpenRouterReasoningOptions{
|
|
Enabled: boolPtr(true),
|
|
},
|
|
Provider: &codersdk.ChatModelOpenRouterProvider{
|
|
Order: []string{"openai"},
|
|
},
|
|
},
|
|
}
|
|
defaults := &codersdk.ChatModelProviderOptions{
|
|
OpenRouter: &codersdk.ChatModelOpenRouterProviderOptions{
|
|
Reasoning: &codersdk.ChatModelOpenRouterReasoningOptions{
|
|
Enabled: boolPtr(false),
|
|
Exclude: boolPtr(true),
|
|
MaxTokens: int64Ptr(123),
|
|
Effort: stringPtr("high"),
|
|
},
|
|
IncludeUsage: boolPtr(true),
|
|
Provider: &codersdk.ChatModelOpenRouterProvider{
|
|
Order: []string{"anthropic"},
|
|
AllowFallbacks: boolPtr(true),
|
|
RequireParameters: boolPtr(false),
|
|
DataCollection: stringPtr("allow"),
|
|
Only: []string{"openai"},
|
|
Ignore: []string{"foo"},
|
|
Quantizations: []string{"int8"},
|
|
Sort: stringPtr("latency"),
|
|
},
|
|
},
|
|
}
|
|
|
|
chatprovider.MergeMissingProviderOptions(&options, defaults)
|
|
|
|
require.NotNil(t, options)
|
|
require.NotNil(t, options.OpenRouter)
|
|
require.NotNil(t, options.OpenRouter.Reasoning)
|
|
require.True(t, *options.OpenRouter.Reasoning.Enabled)
|
|
require.Equal(t, true, *options.OpenRouter.Reasoning.Exclude)
|
|
require.EqualValues(t, 123, *options.OpenRouter.Reasoning.MaxTokens)
|
|
require.Equal(t, "high", *options.OpenRouter.Reasoning.Effort)
|
|
require.NotNil(t, options.OpenRouter.IncludeUsage)
|
|
require.True(t, *options.OpenRouter.IncludeUsage)
|
|
|
|
require.NotNil(t, options.OpenRouter.Provider)
|
|
require.Equal(t, []string{"openai"}, options.OpenRouter.Provider.Order)
|
|
require.NotNil(t, options.OpenRouter.Provider.AllowFallbacks)
|
|
require.True(t, *options.OpenRouter.Provider.AllowFallbacks)
|
|
require.NotNil(t, options.OpenRouter.Provider.RequireParameters)
|
|
require.False(t, *options.OpenRouter.Provider.RequireParameters)
|
|
require.Equal(t, "allow", *options.OpenRouter.Provider.DataCollection)
|
|
require.Equal(t, []string{"openai"}, options.OpenRouter.Provider.Only)
|
|
require.Equal(t, []string{"foo"}, options.OpenRouter.Provider.Ignore)
|
|
require.Equal(t, []string{"int8"}, options.OpenRouter.Provider.Quantizations)
|
|
require.Equal(t, "latency", *options.OpenRouter.Provider.Sort)
|
|
}
|
|
|
|
func stringPtr(value string) *string {
|
|
return &value
|
|
}
|
|
|
|
func boolPtr(value bool) *bool {
|
|
return &value
|
|
}
|
|
|
|
func int64Ptr(value int64) *int64 {
|
|
return &value
|
|
}
|