Files
coder/coderd/x/chatd/chatdebug/model_coverage_internal_test.go
T
Ethan 0fcd9c2005 chore: bump fantasy to sync from upstream (#26440)
Closes CODAGT-572

## Overview

Bumps `charm.land/fantasy` to the head of `coder_2_33`
(`v0.0.0-20260617050554-2e3ddbca75dd`) and adapts `chatd` to it.

The fantasy bump:
- Syncs upstream `charmbracelet/fantasy` main (v0.31.0) into
`coder_2_33` (coder/fantasy#42).
- Mirrors the request region when prefixing cross-region inference
profiles, so a legacy (un-qualified) Bedrock model ID is prefixed for
the same region the request is actually signed for.

Pulling in the new fantasy version propagates its required transitive
dependency upgrades (aws-sdk-go-v2, OpenTelemetry, google genai,
`golang.org/x/*`, etc.) through MVS, which accounts for the bulk of the
`go.mod`/`go.sum` churn.

## chatd changes

- Thread a per-provider `Region` through `ConfiguredProvider` and
`ProviderAPIKeys` (`RegionByProvider`), and merge/prune/resolve it
alongside API keys and base URLs.
- Source the Bedrock region from AI provider settings in `chatd` and
pass `fantasybedrock.WithRegion` when a region is configured.
- Migrate the runtime Bedrock title-generation model ID to a
fully-qualified `global.anthropic.*` ID.
- Emit a `finish_reason` in the test OpenAI streaming server so streams
close on a terminal event, matching fantasy's fail-closed stream
handling.

## Heads-up: most of this is short-lived

Almost all of the `chatd` code in this PR only executes on the **direct
(non-gateway) routing path** — the branch taken when
`AIGatewayRoutingEnabled` is `false`. That flag was a transition crutch
for AI Gateway routing, and it (plus the entire direct path /
`x/chatd/chatprovider` package that backs it) is slated for removal in
CODAGT-598. Under AI Gateway routing — which is the path every
deployment is expected to run — the Bedrock region is resolved by
aibridge directly from provider settings (`cli/aibridged.go` builds
`aibridge.AWSBedrockConfig{Region: settings.Bedrock.Region}`), so none
of the region plumbing added here is reached.

Concretely, expect the following to be deleted alongside the direct
path:
- The `RegionByProvider` map, the `Region()` accessor, and the region
preservation in merge/resolve plus the region pruning in
`PruneDisabledProviderKeys` (`chatprovider.go`).
- The `fantasybedrock.WithRegion(region)` branch in `ModelFromConfig` —
only reachable on the direct path; the gateway path builds a
`fantasyanthropic` client with no region key.
- Reading `settings.Bedrock.Region` in `aiProviderConfigFromKeys`
(`chatd.go`).
- The region-specific tests in `chatprovider_test.go`, and the
`chattest`/`model_coverage` adjustments that support direct-path
testing.

What survives the cleanup (independent of routing):
- The `charm.land/fantasy` bump and its `go.mod`/`go.sum` transitive
churn.
- The fully-qualified `global.anthropic.*` Bedrock title-generation
model ID in `quickgen.go` (a runtime-valid model identifier, not
direct-path-specific).

We're landing the full change anyway so the direct path stays correct
for the remaining transition window; just don't be surprised when
CODAGT-598 reclaims most of it.

## Notes

Depends on coder/fantasy `coder_2_33` already containing the upstream
sync and Bedrock region fix (merged via coder/fantasy#42 and
coder/fantasy#43).
2026-06-19 00:53:59 +10:00

333 lines
13 KiB
Go

package chatdebug
import (
"reflect"
"testing"
"charm.land/fantasy"
"github.com/stretchr/testify/require"
)
// fieldDisposition documents whether a fantasy struct field is captured
// by the corresponding normalized struct ("normalized") or
// intentionally omitted ("skipped: <reason>"). The test fails when a
// fantasy type gains a field that is not yet classified, forcing the
// developer to decide whether to normalize or skip it.
//
// This mirrors the audit-table exhaustiveness check in
// enterprise/audit/table.go; same idea, different domain.
type fieldDisposition = map[string]string
// TestNormalizationFieldCoverage ensures every exported field on the
// fantasy types that model.go normalizes is explicitly accounted for.
// When the fantasy library adds a field the test fails, surfacing the
// drift at `go test` time rather than silently dropping data.
func TestNormalizationFieldCoverage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
typ reflect.Type
fields fieldDisposition
}{
// ── struct-to-struct mappings ──────────────────────────
{
name: "fantasy.Usage → normalizedUsage",
typ: reflect.TypeFor[fantasy.Usage](),
fields: fieldDisposition{
"InputTokens": "normalized",
"OutputTokens": "normalized",
"TotalTokens": "normalized",
"ReasoningTokens": "normalized",
"CacheCreationTokens": "normalized",
"CacheReadTokens": "normalized",
},
},
{
name: "fantasy.Call → normalizedCallPayload",
typ: reflect.TypeFor[fantasy.Call](),
fields: fieldDisposition{
"Prompt": "normalized",
"MaxOutputTokens": "normalized",
"Temperature": "normalized",
"TopP": "normalized",
"TopK": "normalized",
"PresencePenalty": "normalized",
"FrequencyPenalty": "normalized",
"Tools": "normalized",
"ToolChoice": "normalized",
"UserAgent": "skipped: internal transport header, not useful for debug panel",
"ProviderOptions": "skipped: opaque provider data, only count preserved",
},
},
{
name: "fantasy.ObjectCall → normalizedObjectCallPayload",
typ: reflect.TypeFor[fantasy.ObjectCall](),
fields: fieldDisposition{
"Prompt": "normalized",
"Schema": "skipped: full schema too large; SchemaName+SchemaDescription captured instead",
"SchemaName": "normalized",
"SchemaDescription": "normalized",
"MaxOutputTokens": "normalized",
"Temperature": "normalized",
"TopP": "normalized",
"TopK": "normalized",
"PresencePenalty": "normalized",
"FrequencyPenalty": "normalized",
"UserAgent": "skipped: internal transport header, not useful for debug panel",
"ProviderOptions": "skipped: opaque provider data, only count preserved",
"RepairText": "skipped: function value, not serializable",
},
},
{
name: "fantasy.Response → normalizedResponsePayload",
typ: reflect.TypeFor[fantasy.Response](),
fields: fieldDisposition{
"Content": "normalized",
"FinishReason": "normalized",
"Usage": "normalized",
"Warnings": "normalized",
"ProviderMetadata": "skipped: opaque provider-specific metadata",
},
},
{
name: "fantasy.ObjectResponse → normalizedObjectResponsePayload",
typ: reflect.TypeFor[fantasy.ObjectResponse](),
fields: fieldDisposition{
"Object": "skipped: arbitrary user type, not serializable generically",
"RawText": "normalized: as RawTextLength (length only, content unbounded)",
"Usage": "normalized",
"FinishReason": "normalized",
"Warnings": "normalized",
"ProviderMetadata": "skipped: opaque provider-specific metadata",
},
},
{
name: "fantasy.CallWarning → normalizedWarning",
typ: reflect.TypeFor[fantasy.CallWarning](),
fields: fieldDisposition{
"Type": "normalized",
"Setting": "normalized",
"Tool": "skipped: interface value, warning message+type sufficient for debug panel",
"Details": "normalized",
"Message": "normalized",
},
},
{
name: "fantasy.StreamPart → appendNormalizedStreamContent",
typ: reflect.TypeFor[fantasy.StreamPart](),
fields: fieldDisposition{
"Type": "normalized",
"ID": "normalized: as ToolCallID in content parts",
"ToolCallName": "normalized: as ToolName in content parts",
"ToolCallInput": "normalized: as Arguments or Result (bounded)",
"Delta": "normalized: accumulated into text/reasoning content parts",
"ProviderExecuted": "skipped: provider vs client distinction not needed for debug panel",
"Usage": "normalized: captured in stream finalize",
"FinishReason": "normalized: captured in stream finalize",
"Error": "normalized: captured in stream error handling",
"Warnings": "normalized: captured in stream warning accumulation",
"SourceType": "normalized",
"URL": "normalized",
"Title": "normalized",
"ProviderMetadata": "skipped: opaque provider-specific metadata",
},
},
{
name: "fantasy.ObjectStreamPart → wrapObjectStreamSeq",
typ: reflect.TypeFor[fantasy.ObjectStreamPart](),
fields: fieldDisposition{
"Type": "normalized: drives switch in wrapObjectStreamSeq",
"Object": "skipped: arbitrary user type, only ObjectPartCount tracked",
"Delta": "normalized: accumulated into rawTextLength",
"Error": "normalized: captured in stream error handling",
"Usage": "normalized: captured in stream finalize",
"FinishReason": "normalized: captured in stream finalize",
"Warnings": "normalized: captured in stream warning accumulation",
"ProviderMetadata": "skipped: opaque provider-specific metadata",
},
},
// ── message part types (normalizeMessageParts) ────────
{
name: "fantasy.TextPart → normalizedMessagePart",
typ: reflect.TypeFor[fantasy.TextPart](),
fields: fieldDisposition{
"Text": "normalized: bounded to MaxMessagePartTextLength",
"ProviderOptions": "skipped: opaque provider-specific options",
},
},
{
name: "fantasy.ReasoningPart → normalizedMessagePart",
typ: reflect.TypeFor[fantasy.ReasoningPart](),
fields: fieldDisposition{
"Text": "normalized: bounded to MaxMessagePartTextLength",
"ProviderOptions": "skipped: opaque provider-specific options",
},
},
{
name: "fantasy.FilePart → normalizedMessagePart",
typ: reflect.TypeFor[fantasy.FilePart](),
fields: fieldDisposition{
"Filename": "normalized",
"Data": "skipped: binary data never stored in debug records",
"MediaType": "normalized",
"ProviderOptions": "skipped: opaque provider-specific options",
},
},
{
name: "fantasy.ToolCallPart → normalizedMessagePart",
typ: reflect.TypeFor[fantasy.ToolCallPart](),
fields: fieldDisposition{
"ToolCallID": "normalized",
"ToolName": "normalized",
"Input": "normalized: as Arguments (bounded)",
"ProviderExecuted": "skipped: provider vs client distinction not needed for debug panel",
"ProviderOptions": "skipped: opaque provider-specific options",
},
},
{
name: "fantasy.ToolResultPart → normalizedMessagePart",
typ: reflect.TypeFor[fantasy.ToolResultPart](),
fields: fieldDisposition{
"ToolCallID": "normalized",
"Output": "normalized: text extracted via normalizeToolResultOutput",
"ProviderExecuted": "skipped: provider vs client distinction not needed for debug panel",
"ProviderOptions": "skipped: opaque provider-specific options",
},
},
// ── response content types (normalizeContentParts) ────
{
name: "fantasy.TextContent → normalizedContentPart",
typ: reflect.TypeFor[fantasy.TextContent](),
fields: fieldDisposition{
"Text": "normalized: bounded to MaxMessagePartTextLength",
"ProviderMetadata": "skipped: opaque provider-specific metadata",
},
},
{
name: "fantasy.ReasoningContent → normalizedContentPart",
typ: reflect.TypeFor[fantasy.ReasoningContent](),
fields: fieldDisposition{
"Text": "normalized: bounded to MaxMessagePartTextLength",
"ProviderMetadata": "skipped: opaque provider-specific metadata",
},
},
{
name: "fantasy.FileContent → normalizedContentPart",
typ: reflect.TypeFor[fantasy.FileContent](),
fields: fieldDisposition{
"MediaType": "normalized",
"Data": "skipped: binary data never stored in debug records",
"ProviderMetadata": "skipped: opaque provider-specific metadata",
},
},
{
name: "fantasy.SourceContent → normalizedContentPart",
typ: reflect.TypeFor[fantasy.SourceContent](),
fields: fieldDisposition{
"SourceType": "normalized",
"ID": "skipped: provider-internal identifier, not actionable in debug panel",
"URL": "normalized",
"Title": "normalized",
"MediaType": "skipped: only relevant for document sources, rarely useful for debugging",
"Filename": "skipped: only relevant for document sources, rarely useful for debugging",
"ProviderMetadata": "skipped: opaque provider-specific metadata",
},
},
{
name: "fantasy.ToolCallContent → normalizedContentPart",
typ: reflect.TypeFor[fantasy.ToolCallContent](),
fields: fieldDisposition{
"ToolCallID": "normalized",
"ToolName": "normalized",
"Input": "normalized: as Arguments (bounded), InputLength tracks original",
"ProviderExecuted": "skipped: provider vs client distinction not needed for debug panel",
"ProviderMetadata": "skipped: opaque provider-specific metadata",
"Invalid": "skipped: validation state not surfaced in debug panel",
"ValidationError": "skipped: validation state not surfaced in debug panel",
},
},
{
name: "fantasy.ToolResultContent → normalizedContentPart",
typ: reflect.TypeFor[fantasy.ToolResultContent](),
fields: fieldDisposition{
"ToolCallID": "normalized",
"ToolName": "normalized",
"Result": "normalized: text extracted via normalizeToolResultOutput",
"ClientMetadata": "skipped: client execution metadata not needed for debug panel",
"ProviderExecuted": "skipped: provider vs client distinction not needed for debug panel",
"ProviderMetadata": "skipped: opaque provider-specific metadata",
"StopTurn": "skipped: control-flow flag is not rendered in the debug panel",
},
},
// ── tool types (normalizeTools) ───────────────────────
{
name: "fantasy.FunctionTool → normalizedTool",
typ: reflect.TypeFor[fantasy.FunctionTool](),
fields: fieldDisposition{
"Name": "normalized",
"Description": "normalized",
"InputSchema": "normalized: preserved as JSON for debug panel rendering",
"ProviderOptions": "skipped: opaque provider-specific options",
},
},
{
name: "fantasy.ProviderDefinedTool → normalizedTool",
typ: reflect.TypeFor[fantasy.ProviderDefinedTool](),
fields: fieldDisposition{
"ID": "normalized",
"Name": "normalized",
"Args": "skipped: provider-specific configuration not needed for debug panel",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Every exported field on the fantasy type must be
// registered as "normalized" or "skipped: <reason>".
for i := range tt.typ.NumField() {
field := tt.typ.Field(i)
if !field.IsExported() {
continue
}
disposition, ok := tt.fields[field.Name]
if !ok {
require.Failf(t, "unregistered field",
"%s.%s is not in the coverage map: "+
"add it as \"normalized\" or \"skipped: <reason>\"",
tt.typ.Name(), field.Name)
}
require.NotEmptyf(t, disposition,
"%s.%s has an empty disposition: "+
"use \"normalized\" or \"skipped: <reason>\"",
tt.typ.Name(), field.Name)
}
// Catch stale entries that reference removed fields.
for name := range tt.fields {
found := false
for i := range tt.typ.NumField() {
if tt.typ.Field(i).Name == name {
found = true
break
}
}
require.Truef(t, found,
"stale coverage entry %s.%s: "+
"field no longer exists in fantasy, remove it",
tt.typ.Name(), name)
}
})
}
}