mirror of
https://github.com/coder/coder.git
synced 2026-06-03 13:08:25 +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
254 lines
7.1 KiB
Go
254 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"github.com/shopspring/decimal"
|
|
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// SchemaField describes a single form field in the generated schema.
|
|
type SchemaField struct {
|
|
JSONName string `json:"json_name"`
|
|
GoName string `json:"go_name"`
|
|
Type string `json:"type"`
|
|
Description string `json:"description,omitempty"`
|
|
Required bool `json:"required"`
|
|
Enum []string `json:"enum,omitempty"`
|
|
InputType string `json:"input_type"`
|
|
Hidden bool `json:"hidden,omitempty"`
|
|
}
|
|
|
|
// FieldGroup holds the fields for a struct or provider.
|
|
type FieldGroup struct {
|
|
Fields []SchemaField `json:"fields"`
|
|
}
|
|
|
|
// Schema is the top-level output structure.
|
|
type Schema struct {
|
|
General FieldGroup `json:"general"`
|
|
Providers map[string]FieldGroup `json:"providers"`
|
|
ProviderAliases map[string]string `json:"provider_aliases"`
|
|
}
|
|
|
|
func main() {
|
|
schema := Schema{
|
|
Providers: make(map[string]FieldGroup),
|
|
ProviderAliases: map[string]string{
|
|
"azure": "openai",
|
|
"bedrock": "anthropic",
|
|
},
|
|
}
|
|
|
|
// General options from ChatModelCallConfig, excluding
|
|
// the provider_options field which is handled separately.
|
|
schema.General = extractFields(
|
|
reflect.TypeOf(codersdk.ChatModelCallConfig{}),
|
|
"",
|
|
map[string]bool{"ProviderOptions": true},
|
|
)
|
|
|
|
// Provider-specific options. Each entry maps a provider key
|
|
// to the concrete options struct used for that provider.
|
|
providerTypes := []struct {
|
|
key string
|
|
typ reflect.Type
|
|
}{
|
|
{"openai", reflect.TypeOf(codersdk.ChatModelOpenAIProviderOptions{})},
|
|
{"anthropic", reflect.TypeOf(codersdk.ChatModelAnthropicProviderOptions{})},
|
|
{"google", reflect.TypeOf(codersdk.ChatModelGoogleProviderOptions{})},
|
|
{"openaicompat", reflect.TypeOf(codersdk.ChatModelOpenAICompatProviderOptions{})},
|
|
{"openrouter", reflect.TypeOf(codersdk.ChatModelOpenRouterProviderOptions{})},
|
|
{"vercel", reflect.TypeOf(codersdk.ChatModelVercelProviderOptions{})},
|
|
}
|
|
|
|
for _, p := range providerTypes {
|
|
schema.Providers[p.key] = extractFields(p.typ, "", nil)
|
|
}
|
|
|
|
out, err := json.MarshalIndent(schema, "", "\t")
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(os.Stderr, "marshal schema: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Print the generated header and JSON body.
|
|
_, _ = fmt.Println("// Code generated by scripts/modeloptionsgen. DO NOT EDIT.")
|
|
_, _ = fmt.Println(string(out))
|
|
}
|
|
|
|
// extractFields walks the struct fields of t and returns a FieldGroup.
|
|
// prefix is used to build dot-separated json_name values for nested
|
|
// structs. skip lists Go field names to exclude from output.
|
|
func extractFields(t reflect.Type, prefix string, skip map[string]bool) FieldGroup {
|
|
var fields []SchemaField
|
|
|
|
for i := range t.NumField() {
|
|
f := t.Field(i)
|
|
|
|
if skip != nil && skip[f.Name] {
|
|
continue
|
|
}
|
|
|
|
jsonTag := f.Tag.Get("json")
|
|
if jsonTag == "" || jsonTag == "-" {
|
|
continue
|
|
}
|
|
jsonName := strings.Split(jsonTag, ",")[0]
|
|
if jsonName == "" {
|
|
continue
|
|
}
|
|
|
|
fullJSONName := jsonName
|
|
if prefix != "" {
|
|
fullJSONName = prefix + "." + jsonName
|
|
}
|
|
|
|
// Determine the underlying type, dereferencing pointers.
|
|
ft := f.Type
|
|
if ft.Kind() == reflect.Ptr {
|
|
ft = ft.Elem()
|
|
}
|
|
|
|
// Check the hidden tag before recursing into nested structs
|
|
// so that entire sub-objects can be marked hidden.
|
|
hidden := f.Tag.Get("hidden") == "true"
|
|
|
|
// decimal.Decimal is an opaque numeric type used for pricing
|
|
// precision; do not recurse into its internal struct fields.
|
|
isDecimal := ft == reflect.TypeOf(decimal.Decimal{})
|
|
|
|
// If the field is a struct (not a map), recurse to flatten
|
|
// its children using dot-separated names — unless the
|
|
// entire struct is marked hidden, in which case emit it
|
|
// as a single opaque field.
|
|
if ft.Kind() == reflect.Struct && !hidden && !isDecimal {
|
|
nested := extractFields(ft, fullJSONName, nil)
|
|
fields = append(fields, nested.Fields...)
|
|
continue
|
|
}
|
|
|
|
typeName := goTypeToSchemaType(f.Type)
|
|
description := f.Tag.Get("description")
|
|
enumTag := f.Tag.Get("enum")
|
|
|
|
var enumValues []string
|
|
if enumTag != "" {
|
|
enumValues = strings.Split(enumTag, ",")
|
|
}
|
|
|
|
required := !strings.Contains(jsonTag, "omitempty")
|
|
inputType := inferInputType(typeName, enumValues)
|
|
|
|
fields = append(fields, SchemaField{
|
|
JSONName: fullJSONName,
|
|
GoName: goFieldPath(prefix, f.Name, t, fullJSONName),
|
|
Type: typeName,
|
|
Description: description,
|
|
Required: required,
|
|
Enum: enumValues,
|
|
InputType: inputType,
|
|
Hidden: hidden,
|
|
})
|
|
}
|
|
|
|
return FieldGroup{Fields: fields}
|
|
}
|
|
|
|
// goFieldPath builds a dot-separated Go field name for nested fields.
|
|
// For top-level fields it returns just the field name. For nested
|
|
// fields it reconstructs the parent struct field name from the prefix
|
|
// by looking at the enclosing type's fields.
|
|
func goFieldPath(prefix, name string, _ reflect.Type, fullJSONName string) string {
|
|
if prefix == "" {
|
|
return name
|
|
}
|
|
// Build the Go path by walking the JSON name segments. Each
|
|
// segment maps to a struct field that we already traversed
|
|
// during recursion, so we reconstruct the path from the JSON
|
|
// parts. The parent extractFields call sets the prefix to the
|
|
// parent json name, so we can derive the Go path from the
|
|
// json segments by title-casing each part.
|
|
parts := strings.Split(fullJSONName, ".")
|
|
goNames := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
goNames = append(goNames, jsonSegmentToGoName(p))
|
|
}
|
|
return strings.Join(goNames, ".")
|
|
}
|
|
|
|
// jsonSegmentToGoName converts a snake_case JSON segment to a
|
|
// PascalCase Go field name using common conventions.
|
|
func jsonSegmentToGoName(seg string) string {
|
|
words := strings.Split(seg, "_")
|
|
var b strings.Builder
|
|
for _, w := range words {
|
|
if w == "" {
|
|
continue
|
|
}
|
|
// Handle common acronyms.
|
|
upper := strings.ToUpper(w)
|
|
switch upper {
|
|
case "ID", "URL", "IP", "HTTP", "JSON", "API", "UI":
|
|
_, _ = b.WriteString(upper)
|
|
default:
|
|
_, _ = b.WriteString(strings.ToUpper(w[:1]))
|
|
_, _ = b.WriteString(w[1:])
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// goTypeToSchemaType maps a Go reflect.Type to a JSON schema type
|
|
// string.
|
|
func goTypeToSchemaType(t reflect.Type) string {
|
|
// Dereference pointers.
|
|
for t.Kind() == reflect.Ptr {
|
|
t = t.Elem()
|
|
}
|
|
|
|
// decimal.Decimal represents a precise numeric value and should
|
|
// map to the "number" schema type.
|
|
if t == reflect.TypeOf(decimal.Decimal{}) {
|
|
return "number"
|
|
}
|
|
|
|
switch t.Kind() {
|
|
case reflect.String:
|
|
return "string"
|
|
case reflect.Bool:
|
|
return "boolean"
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
return "integer"
|
|
case reflect.Float32, reflect.Float64:
|
|
return "number"
|
|
case reflect.Slice:
|
|
return "array"
|
|
case reflect.Map:
|
|
return "object"
|
|
default:
|
|
return "string"
|
|
}
|
|
}
|
|
|
|
// inferInputType decides the appropriate frontend input widget for
|
|
// a field based on its schema type and enum values.
|
|
func inferInputType(typeName string, enum []string) string {
|
|
if len(enum) > 0 {
|
|
return "select"
|
|
}
|
|
switch typeName {
|
|
case "boolean":
|
|
return "select"
|
|
case "array", "object":
|
|
return "json"
|
|
default:
|
|
return "input"
|
|
}
|
|
}
|