mirror of
https://github.com/coder/coder.git
synced 2026-09-23 22:20:22 +08:00
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).
333 lines
13 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|