mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: remove native chat cost tracking in favor of AI Gateway cost data (#27330)
## Stack Context This stack makes AI Gateway data and budgets the source of truth for AI spend controls. 1. Re-back the per-chat cost endpoint with AI Gateway data (#27328, merged). 2. Remove native chat usage limits (#27329, merged). 3. **This PR, now based on `main`:** remove native chat cost tracking and its dedicated admin UI. ## Summary Removes native per-message price calculation, model pricing fields, cost persistence, aggregate cost queries, and admin cost API types. It also deletes the Analytics and Spend pages plus their legacy redirects. The AI Gateway-backed per-chat cost row and compact budget indicators remain. The spend documentation is renamed to `spend-management.md` and updated for the remaining surfaces, group budget APIs, CSV export, upgrade handling for native pricing and cost history, and the absence of a deployment-wide spend dashboard. The per-chat cost API documents that data follows AI Gateway retention and reports zero after all matching requests are purged. No schema is dropped in this release. `chat_messages.total_cost_micros` remains nullable and unwritten so replicas from the previous release can continue inserting messages during rolling upgrades. #27600 tracks removal after the compatibility window. > Mux prepared this PR on Mike's behalf.
This commit is contained in:
+15
-209
@@ -17,7 +17,6 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/invopop/jsonschema"
|
||||
"github.com/shopspring/decimal"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
@@ -1459,14 +1458,6 @@ type ChatModelVercelProviderOptions struct {
|
||||
ExtraBody map[string]any `json:"extra_body,omitempty" description:"Additional fields to include in the request body" hidden:"true"`
|
||||
}
|
||||
|
||||
// ModelCostConfig stores pricing metadata for a chat model.
|
||||
type ModelCostConfig struct {
|
||||
InputPricePerMillionTokens *decimal.Decimal `json:"input_price_per_million_tokens,omitempty" description:"Input token price in USD per 1M tokens"`
|
||||
OutputPricePerMillionTokens *decimal.Decimal `json:"output_price_per_million_tokens,omitempty" description:"Output token price in USD per 1M tokens"`
|
||||
CacheReadPricePerMillionTokens *decimal.Decimal `json:"cache_read_price_per_million_tokens,omitempty" description:"Cache read token price in USD per 1M tokens"`
|
||||
CacheWritePricePerMillionTokens *decimal.Decimal `json:"cache_write_price_per_million_tokens,omitempty" description:"Cache write or cache creation token price in USD per 1M tokens"`
|
||||
}
|
||||
|
||||
// Reasoning effort levels, ordered low to high for clamping and comparison.
|
||||
const (
|
||||
ChatModelReasoningEffortNone = "none"
|
||||
@@ -1509,7 +1500,6 @@ type ChatModelCallConfig struct {
|
||||
TopK *int64 `json:"top_k,omitempty" description:"Number of highest-probability tokens to keep for sampling"`
|
||||
PresencePenalty *float64 `json:"presence_penalty,omitempty" description:"Penalty for tokens that have already appeared in the output"`
|
||||
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty" description:"Penalty for tokens based on their frequency in the output"`
|
||||
Cost *ModelCostConfig `json:"cost,omitempty" description:"Optional pricing metadata for this model"`
|
||||
ReasoningEffort *ChatModelReasoningEffortConfig `json:"reasoning_effort,omitempty" description:"Default and max reasoning effort for the model"`
|
||||
OpenAIConfig *ChatModelOpenAIConfig `json:"openai_config,omitempty" description:"OpenAI client construction settings" providers:"openai"`
|
||||
ProviderOptions *ChatModelProviderOptions `json:"provider_options,omitempty" description:"Provider-specific option overrides"`
|
||||
@@ -1521,67 +1511,30 @@ type ChatModelOpenAIConfig struct {
|
||||
UseResponsesAPI *bool `json:"use_responses_api,omitempty" label:"Use Responses API" description:"Override which OpenAI API this model uses. Leave unset to decide from the provider SDK's known-model list, true to force the Responses API, false to force Chat Completions. Azure OpenAI providers ignore this and always follow the known-model list."`
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts both the current nested cost object and the previous
|
||||
// top-level pricing keys so legacy stored model_config JSON continues to load.
|
||||
func (c *ChatModelCallConfig) UnmarshalJSON(data []byte) error {
|
||||
return c.unmarshal(data, json.Unmarshal)
|
||||
}
|
||||
|
||||
// UnmarshalStrict is UnmarshalJSON except unknown fields are an error instead
|
||||
// of being silently dropped. Clients that accept free-form model config JSON
|
||||
// (e.g. the Terraform provider) use it to reject settings this SDK version
|
||||
// does not recognize before they are lost.
|
||||
// UnmarshalStrict rejects unknown fields except for removed pricing fields,
|
||||
// which may still be present in stored model configuration JSON: the nested
|
||||
// cost object and the four top-level per-million-token price keys that
|
||||
// predate it (see migration 000435, which read both forms).
|
||||
func (c *ChatModelCallConfig) UnmarshalStrict(data []byte) error {
|
||||
return c.unmarshal(data, func(data []byte, v any) error {
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
// Match json.Unmarshal: reject any trailing data after the value.
|
||||
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
|
||||
return xerrors.New("unexpected trailing data after JSON value")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *ChatModelCallConfig) unmarshal(data []byte, decode func(data []byte, v any) error) error {
|
||||
type chatModelCallConfigAlias ChatModelCallConfig
|
||||
aux := struct {
|
||||
*chatModelCallConfigAlias
|
||||
InputPricePerMillionTokens *decimal.Decimal `json:"input_price_per_million_tokens,omitempty"`
|
||||
OutputPricePerMillionTokens *decimal.Decimal `json:"output_price_per_million_tokens,omitempty"`
|
||||
CacheReadPricePerMillionTokens *decimal.Decimal `json:"cache_read_price_per_million_tokens,omitempty"`
|
||||
CacheWritePricePerMillionTokens *decimal.Decimal `json:"cache_write_price_per_million_tokens,omitempty"`
|
||||
Cost json.RawMessage `json:"cost"`
|
||||
InputPrice json.RawMessage `json:"input_price_per_million_tokens"`
|
||||
OutputPrice json.RawMessage `json:"output_price_per_million_tokens"`
|
||||
CacheReadPrice json.RawMessage `json:"cache_read_price_per_million_tokens"`
|
||||
CacheWritePrice json.RawMessage `json:"cache_write_price_per_million_tokens"`
|
||||
}{
|
||||
chatModelCallConfigAlias: (*chatModelCallConfigAlias)(c),
|
||||
}
|
||||
if err := decode(data, &aux); err != nil {
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if aux.InputPricePerMillionTokens == nil &&
|
||||
aux.OutputPricePerMillionTokens == nil &&
|
||||
aux.CacheReadPricePerMillionTokens == nil &&
|
||||
aux.CacheWritePricePerMillionTokens == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if c.Cost == nil {
|
||||
c.Cost = &ModelCostConfig{}
|
||||
}
|
||||
if c.Cost.InputPricePerMillionTokens == nil {
|
||||
c.Cost.InputPricePerMillionTokens = aux.InputPricePerMillionTokens
|
||||
}
|
||||
if c.Cost.OutputPricePerMillionTokens == nil {
|
||||
c.Cost.OutputPricePerMillionTokens = aux.OutputPricePerMillionTokens
|
||||
}
|
||||
if c.Cost.CacheReadPricePerMillionTokens == nil {
|
||||
c.Cost.CacheReadPricePerMillionTokens = aux.CacheReadPricePerMillionTokens
|
||||
}
|
||||
if c.Cost.CacheWritePricePerMillionTokens == nil {
|
||||
c.Cost.CacheWritePricePerMillionTokens = aux.CacheWritePricePerMillionTokens
|
||||
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
|
||||
return xerrors.New("unexpected trailing data after JSON value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1940,64 +1893,6 @@ type ChatStreamEvent struct {
|
||||
ActionRequired *ChatStreamActionRequired `json:"action_required,omitempty"`
|
||||
}
|
||||
|
||||
// ChatCostSummaryOptions are optional query parameters for GetChatCostSummary.
|
||||
type ChatCostSummaryOptions struct {
|
||||
StartDate time.Time
|
||||
EndDate time.Time
|
||||
}
|
||||
|
||||
// ChatCostUsersOptions are optional query parameters for GetChatCostUsers.
|
||||
type ChatCostUsersOptions struct {
|
||||
StartDate time.Time
|
||||
EndDate time.Time
|
||||
Username string
|
||||
Pagination
|
||||
}
|
||||
|
||||
// ChatCostSummary is the response from the chat cost summary endpoint.
|
||||
type ChatCostSummary struct {
|
||||
StartDate time.Time `json:"start_date" format:"date-time"`
|
||||
EndDate time.Time `json:"end_date" format:"date-time"`
|
||||
TotalCostMicros int64 `json:"total_cost_micros"`
|
||||
PricedMessageCount int64 `json:"priced_message_count"`
|
||||
UnpricedMessagesHavingUsageCount int64 `json:"unpriced_messages_having_usage_count"`
|
||||
TotalInputTokens int64 `json:"total_input_tokens"`
|
||||
TotalOutputTokens int64 `json:"total_output_tokens"`
|
||||
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
|
||||
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
|
||||
TotalRuntimeMs int64 `json:"total_runtime_ms"`
|
||||
ByModel []ChatCostModelBreakdown `json:"by_model"`
|
||||
ByChat []ChatCostChatBreakdown `json:"by_chat"`
|
||||
}
|
||||
|
||||
// ChatCostModelBreakdown contains per-model cost aggregation.
|
||||
type ChatCostModelBreakdown struct {
|
||||
ModelConfigID uuid.UUID `json:"model_config_id" format:"uuid"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
TotalCostMicros int64 `json:"total_cost_micros"`
|
||||
MessageCount int64 `json:"message_count"`
|
||||
TotalInputTokens int64 `json:"total_input_tokens"`
|
||||
TotalOutputTokens int64 `json:"total_output_tokens"`
|
||||
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
|
||||
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
|
||||
TotalRuntimeMs int64 `json:"total_runtime_ms"`
|
||||
}
|
||||
|
||||
// ChatCostChatBreakdown contains per-root-chat cost aggregation.
|
||||
type ChatCostChatBreakdown struct {
|
||||
RootChatID uuid.UUID `json:"root_chat_id" format:"uuid"`
|
||||
ChatTitle string `json:"chat_title"`
|
||||
TotalCostMicros int64 `json:"total_cost_micros"`
|
||||
MessageCount int64 `json:"message_count"`
|
||||
TotalInputTokens int64 `json:"total_input_tokens"`
|
||||
TotalOutputTokens int64 `json:"total_output_tokens"`
|
||||
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
|
||||
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
|
||||
TotalRuntimeMs int64 `json:"total_runtime_ms"`
|
||||
}
|
||||
|
||||
// ChatCost is the AI Gateway cost for the requested chat's whole tree.
|
||||
// Root and subagent chats report the same total.
|
||||
// RequestCount counts every finished request in the tree, including ones that
|
||||
@@ -2012,30 +1907,6 @@ type ChatCost struct {
|
||||
UnpricedRequestCount int64 `json:"unpriced_request_count"`
|
||||
}
|
||||
|
||||
// ChatCostUserRollup contains per-user cost aggregation for admin views.
|
||||
type ChatCostUserRollup struct {
|
||||
UserID uuid.UUID `json:"user_id" format:"uuid"`
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
TotalCostMicros int64 `json:"total_cost_micros"`
|
||||
MessageCount int64 `json:"message_count"`
|
||||
ChatCount int64 `json:"chat_count"`
|
||||
TotalInputTokens int64 `json:"total_input_tokens"`
|
||||
TotalOutputTokens int64 `json:"total_output_tokens"`
|
||||
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
|
||||
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
|
||||
TotalRuntimeMs int64 `json:"total_runtime_ms"`
|
||||
}
|
||||
|
||||
// ChatCostUsersResponse is the response from the admin chat cost users endpoint.
|
||||
type ChatCostUsersResponse struct {
|
||||
StartDate time.Time `json:"start_date" format:"date-time"`
|
||||
EndDate time.Time `json:"end_date" format:"date-time"`
|
||||
Count int64 `json:"count"`
|
||||
Users []ChatCostUserRollup `json:"users"`
|
||||
}
|
||||
|
||||
// ChatHookDispatchFailedResponse is the error body returned when a
|
||||
// lifecycle hook dispatch fails during a synchronous chat operation.
|
||||
// Kind lets clients classify the failure without parsing message text.
|
||||
@@ -2357,34 +2228,6 @@ func (c *ExperimentalClient) DeleteChatModelConfig(ctx context.Context, modelCon
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetChatCostSummary returns an aggregate cost summary for the specified
|
||||
// user. Zero-valued StartDate or EndDate fields are omitted from the
|
||||
// request, letting the server apply its own defaults (typically the last
|
||||
// 30 days).
|
||||
func (c *ExperimentalClient) GetChatCostSummary(ctx context.Context, user string, opts ChatCostSummaryOptions) (ChatCostSummary, error) {
|
||||
qp := url.Values{}
|
||||
if !opts.StartDate.IsZero() {
|
||||
qp.Set("start_date", opts.StartDate.Format(time.RFC3339))
|
||||
}
|
||||
if !opts.EndDate.IsZero() {
|
||||
qp.Set("end_date", opts.EndDate.Format(time.RFC3339))
|
||||
}
|
||||
reqURL := fmt.Sprintf("/api/experimental/chats/cost/%s/summary", user)
|
||||
if len(qp) > 0 {
|
||||
reqURL += "?" + qp.Encode()
|
||||
}
|
||||
res, err := c.Request(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return ChatCostSummary{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return ChatCostSummary{}, ReadBodyAsError(res)
|
||||
}
|
||||
var summary ChatCostSummary
|
||||
return summary, json.NewDecoder(res.Body).Decode(&summary)
|
||||
}
|
||||
|
||||
// GetChatCost returns the AI Gateway cost for the whole chat tree that
|
||||
// contains chatID.
|
||||
func (c *ExperimentalClient) GetChatCost(ctx context.Context, chatID uuid.UUID) (ChatCost, error) {
|
||||
@@ -2400,43 +2243,6 @@ func (c *ExperimentalClient) GetChatCost(ctx context.Context, chatID uuid.UUID)
|
||||
return cost, json.NewDecoder(res.Body).Decode(&cost)
|
||||
}
|
||||
|
||||
// GetChatCostUsers returns a per-user cost rollup for the deployment
|
||||
// (admin only). Zero-valued StartDate or EndDate fields are omitted from
|
||||
// the request, letting the server apply its own defaults (typically the
|
||||
// last 30 days).
|
||||
func (c *ExperimentalClient) GetChatCostUsers(ctx context.Context, opts ChatCostUsersOptions) (ChatCostUsersResponse, error) {
|
||||
qp := url.Values{}
|
||||
if !opts.StartDate.IsZero() {
|
||||
qp.Set("start_date", opts.StartDate.Format(time.RFC3339))
|
||||
}
|
||||
if !opts.EndDate.IsZero() {
|
||||
qp.Set("end_date", opts.EndDate.Format(time.RFC3339))
|
||||
}
|
||||
if opts.Username != "" {
|
||||
qp.Set("username", opts.Username)
|
||||
}
|
||||
if opts.Limit > 0 {
|
||||
qp.Set("limit", strconv.Itoa(opts.Limit))
|
||||
}
|
||||
if opts.Offset > 0 {
|
||||
qp.Set("offset", strconv.Itoa(opts.Offset))
|
||||
}
|
||||
reqURL := "/api/experimental/chats/cost/users"
|
||||
if len(qp) > 0 {
|
||||
reqURL += "?" + qp.Encode()
|
||||
}
|
||||
res, err := c.Request(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return ChatCostUsersResponse{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return ChatCostUsersResponse{}, ReadBodyAsError(res)
|
||||
}
|
||||
var resp ChatCostUsersResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// GetChatSystemPrompt returns the deployment-wide chat system prompt.
|
||||
func (c *ExperimentalClient) GetChatSystemPrompt(ctx context.Context) (ChatSystemPromptResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/system-prompt", nil)
|
||||
|
||||
+23
-74
@@ -9,7 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -434,75 +433,40 @@ func TestChatMessagePart_ReasoningTimestamps_JSON(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestModelCostConfig_LegacyNumericJSON(t *testing.T) {
|
||||
func TestChatModelCallConfig_UnmarshalStoredCost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var decoded codersdk.ModelCostConfig
|
||||
err := json.Unmarshal([]byte("{\"input_price_per_million_tokens\": 1.5}"), &decoded)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decoded.InputPricePerMillionTokens)
|
||||
require.True(t, decoded.InputPricePerMillionTokens.Equal(decimal.RequireFromString("1.5")))
|
||||
}
|
||||
|
||||
func TestModelCostConfig_QuotedDecimalJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var decoded codersdk.ModelCostConfig
|
||||
err := json.Unmarshal([]byte("{\"input_price_per_million_tokens\": \"1.5\"}"), &decoded)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decoded.InputPricePerMillionTokens)
|
||||
require.True(t, decoded.InputPricePerMillionTokens.Equal(decimal.RequireFromString("1.5")))
|
||||
}
|
||||
|
||||
func TestModelCostConfig_NilVsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
zero := decimal.Zero
|
||||
raw, err := json.Marshal(struct {
|
||||
Nil codersdk.ModelCostConfig `json:"nil"`
|
||||
Zero codersdk.ModelCostConfig `json:"zero"`
|
||||
}{
|
||||
Nil: codersdk.ModelCostConfig{},
|
||||
Zero: codersdk.ModelCostConfig{InputPricePerMillionTokens: &zero},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(raw), "\"zero\":{\"input_price_per_million_tokens\":\"0\"}")
|
||||
require.Contains(t, string(raw), "\"nil\":{}")
|
||||
}
|
||||
|
||||
func TestChatModelCallConfig_UnmarshalLegacyPricing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var decoded codersdk.ChatModelCallConfig
|
||||
err := json.Unmarshal([]byte("{\"input_price_per_million_tokens\": 1.5}"), &decoded)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decoded.Cost)
|
||||
require.NotNil(t, decoded.Cost.InputPricePerMillionTokens)
|
||||
require.True(t, decoded.Cost.InputPricePerMillionTokens.Equal(decimal.RequireFromString("1.5")))
|
||||
}
|
||||
|
||||
func TestChatModelCallConfig_UnmarshalStrict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var decoded codersdk.ChatModelCallConfig
|
||||
err := decoded.UnmarshalStrict([]byte(`{
|
||||
raw := []byte(`{
|
||||
"temperature": 0.5,
|
||||
"cost": {"input_price_per_million_tokens": "5"},
|
||||
"input_price_per_million_tokens": 1.5,
|
||||
"provider_options": {"anthropic": {"thinking": {"budget_tokens": 1024}}}
|
||||
}`))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decoded.Temperature)
|
||||
require.True(t, decoded.Cost.InputPricePerMillionTokens.Equal(decimal.RequireFromString("5")))
|
||||
}`)
|
||||
|
||||
err = decoded.UnmarshalStrict([]byte(`{"provider_options": {"anthropic": {"bogus_setting": true}}}`))
|
||||
var decoded codersdk.ChatModelCallConfig
|
||||
require.NoError(t, json.Unmarshal(raw, &decoded))
|
||||
require.NotNil(t, decoded.Temperature)
|
||||
|
||||
require.NoError(t, decoded.UnmarshalStrict(raw))
|
||||
require.NotNil(t, decoded.Temperature)
|
||||
|
||||
// Configs predating the nested cost object stored the pricing keys at
|
||||
// the top level (see migration 000435).
|
||||
legacyTopLevel := []byte(`{
|
||||
"temperature": 0.5,
|
||||
"input_price_per_million_tokens": "5",
|
||||
"output_price_per_million_tokens": "10",
|
||||
"cache_read_price_per_million_tokens": "1",
|
||||
"cache_write_price_per_million_tokens": "2"
|
||||
}`)
|
||||
require.NoError(t, decoded.UnmarshalStrict(legacyTopLevel))
|
||||
require.NotNil(t, decoded.Temperature)
|
||||
|
||||
err := decoded.UnmarshalStrict([]byte(`{"provider_options": {"anthropic": {"bogus_setting": true}}}`))
|
||||
require.ErrorContains(t, err, `unknown field "bogus_setting"`)
|
||||
|
||||
// Trailing data after the first value is rejected, matching json.Unmarshal.
|
||||
err = decoded.UnmarshalStrict([]byte(`{"temperature": 0.5} {"bogus_setting": true}`))
|
||||
require.ErrorContains(t, err, "trailing data")
|
||||
|
||||
// UnmarshalJSON stays lenient.
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"bogus_setting": true}`), &decoded))
|
||||
}
|
||||
|
||||
@@ -528,21 +492,6 @@ func TestChatModelCallConfig_UseResponsesAPIRoundTrip(t *testing.T) {
|
||||
require.NotContains(t, string(raw), "use_responses_api")
|
||||
}
|
||||
|
||||
func TestChatCostSummary_JSONRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
original := codersdk.ChatCostSummary{
|
||||
TotalCostMicros: 123,
|
||||
}
|
||||
raw, err := json.Marshal(original)
|
||||
require.NoError(t, err)
|
||||
|
||||
var decoded codersdk.ChatCostSummary
|
||||
err = json.Unmarshal(raw, &decoded)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, original.TotalCostMicros, decoded.TotalCostMicros)
|
||||
}
|
||||
|
||||
// TestChat_JSONRoundTrip verifies that every field of codersdk.Chat
|
||||
// survives a JSON marshal/unmarshal cycle. This catches omitempty
|
||||
// silently eating zero-ish values, struct tag typos, and similar
|
||||
|
||||
Reference in New Issue
Block a user