feat(codersdk): generate chat model provider options schema from Go structs (#22568)

This commit is contained in:
Kyle Carberry
2026-03-03 16:29:58 -05:00
committed by GitHub
parent 5b1cf4a6a3
commit f758443f44
6 changed files with 1005 additions and 77 deletions
+241
View File
@@ -0,0 +1,241 @@
package main
import (
"encoding/json"
"fmt"
"os"
"reflect"
"strings"
"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"
// 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 {
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()
}
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"
}
}