on main help sign in
fix(schema): fix `crush.json` schema generation (#2574)
commitfa1565813cc109d70c6d5ef2d312b25cf3c657b8 authorBruno Krugel <brunokrugel@gmail.com> dateWed 8 Apr 2026, 10:05 (6 months ago) parent6e854b4
3 files changed+256−4
internal/cmd/schema_test.go+59 internal/csync/maps.go+4−1 schema.json+193−3
internal/cmd/schema_test.gonew file+59
@@ -0,0 +1,59 @@
1+package cmd
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+
8+ "github.com/charmbracelet/crush/internal/config"
9+ "github.com/invopop/jsonschema"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestSchemaNoBrokenRefs(t *testing.T) {
14+ t.Parallel()
15+
16+ reflector := new(jsonschema.Reflector)
17+ bts, err := json.Marshal(reflector.Reflect(&config.Config{}))
18+ require.NoError(t, err)
19+
20+ var schema struct {
21+ Defs map[string]json.RawMessage `json:"$defs"`
22+ }
23+ require.NoError(t, json.Unmarshal(bts, &schema))
24+ require.NotEmpty(t, schema.Defs, "schema should have definitions")
25+
26+ for name := range schema.Defs {
27+ require.NotContains(t, name, "/", "schema $def key %q contains '/' which breaks JSON Pointer $ref resolution", name)
28+ }
29+}
30+
31+func TestSchemaProvidersHasAdditionalProperties(t *testing.T) {
32+ t.Parallel()
33+
34+ reflector := new(jsonschema.Reflector)
35+ bts, err := json.Marshal(reflector.Reflect(&config.Config{}))
36+ require.NoError(t, err)
37+
38+ var schema struct {
39+ Defs map[string]json.RawMessage `json:"$defs"`
40+ }
41+ require.NoError(t, json.Unmarshal(bts, &schema))
42+
43+ var cfg struct {
44+ Properties map[string]json.RawMessage `json:"properties"`
45+ }
46+ require.NoError(t, json.Unmarshal(schema.Defs["Config"], &cfg))
47+
48+ providersRaw, ok := cfg.Properties["providers"]
49+ require.True(t, ok, "Config should have a providers property")
50+
51+ var providers struct {
52+ Type string `json:"type"`
53+ AdditionalProperties json.RawMessage `json:"additionalProperties"`
54+ }
55+ require.NoError(t, json.Unmarshal(providersRaw, &providers))
56+ require.Equal(t, "object", providers.Type)
57+ require.True(t, strings.Contains(string(providers.AdditionalProperties), "ProviderConfig"),
58+ "providers should use additionalProperties with a ProviderConfig ref, got: %s", string(providers.AdditionalProperties))
59+}
internal/csync/maps.go+4−1
@@ -131,7 +131,10 @@ var (
131131 _ json.Marshaler = &Map[string, any]{}
132132)
133133
134−func (*Map[K, V]) JSONSchemaAlias() any {
134+// JSONSchemaAlias returns the underlying map type for JSON schema generation.
135+// Value receiver is required because github.com/invopop/jsonschema checks
136+// interface satisfaction on the non-pointer type after stripping pointers.
137+func (Map[K, V]) JSONSchemaAlias() any { //nolint
135138 m := map[K]V{}
136139 return m
137140}
schema.json+193−3
@@ -64,7 +64,10 @@
6464 "description": "Model configurations for different model types"
6565 },
6666 "providers": {
67− "$ref": "#/$defs/Map[string,github.com/charmbracelet/crush/internal/config.ProviderConfig]",
67+ "additionalProperties": {
68+ "$ref": "#/$defs/ProviderConfig"
69+ },
70+ "type": "object",
6871 "description": "AI provider configurations"
6972 },
7073 "mcp": {
@@ -262,8 +265,88 @@
262265 },
263266 "type": "object"
264267 },
265− "Map[string,github.com/charmbracelet/crush/internal/config.ProviderConfig]": {
266− "properties": {},
268+ "Model": {
269+ "properties": {
270+ "id": {
271+ "type": "string"
272+ },
273+ "name": {
274+ "type": "string"
275+ },
276+ "cost_per_1m_in": {
277+ "type": "number"
278+ },
279+ "cost_per_1m_out": {
280+ "type": "number"
281+ },
282+ "cost_per_1m_in_cached": {
283+ "type": "number"
284+ },
285+ "cost_per_1m_out_cached": {
286+ "type": "number"
287+ },
288+ "context_window": {
289+ "type": "integer"
290+ },
291+ "default_max_tokens": {
292+ "type": "integer"
293+ },
294+ "can_reason": {
295+ "type": "boolean"
296+ },
297+ "reasoning_levels": {
298+ "items": {
299+ "type": "string"
300+ },
301+ "type": "array"
302+ },
303+ "default_reasoning_effort": {
304+ "type": "string"
305+ },
306+ "supports_attachments": {
307+ "type": "boolean"
308+ },
309+ "options": {
310+ "$ref": "#/$defs/ModelOptions"
311+ }
312+ },
313+ "additionalProperties": false,
314+ "type": "object",
315+ "required": [
316+ "id",
317+ "name",
318+ "cost_per_1m_in",
319+ "cost_per_1m_out",
320+ "cost_per_1m_in_cached",
321+ "cost_per_1m_out_cached",
322+ "context_window",
323+ "default_max_tokens",
324+ "can_reason",
325+ "supports_attachments",
326+ "options"
327+ ]
328+ },
329+ "ModelOptions": {
330+ "properties": {
331+ "temperature": {
332+ "type": "number"
333+ },
334+ "top_p": {
335+ "type": "number"
336+ },
337+ "top_k": {
338+ "type": "integer"
339+ },
340+ "frequency_penalty": {
341+ "type": "number"
342+ },
343+ "presence_penalty": {
344+ "type": "number"
345+ },
346+ "provider_options": {
347+ "type": "object"
348+ }
349+ },
267350 "additionalProperties": false,
268351 "type": "object"
269352 },
@@ -405,6 +488,89 @@
405488 "additionalProperties": false,
406489 "type": "object"
407490 },
491+ "ProviderConfig": {
492+ "properties": {
493+ "id": {
494+ "type": "string",
495+ "description": "Unique identifier for the provider",
496+ "examples": [
497+ "openai"
498+ ]
499+ },
500+ "name": {
501+ "type": "string",
502+ "description": "Human-readable name for the provider",
503+ "examples": [
504+ "OpenAI"
505+ ]
506+ },
507+ "base_url": {
508+ "type": "string",
509+ "format": "uri",
510+ "description": "Base URL for the provider's API",
511+ "examples": [
512+ "https://api.openai.com/v1"
513+ ]
514+ },
515+ "type": {
516+ "type": "string",
517+ "enum": [
518+ "openai",
519+ "openai-compat",
520+ "anthropic",
521+ "gemini",
522+ "azure",
523+ "vertexai"
524+ ],
525+ "description": "Provider type that determines the API format",
526+ "default": "openai"
527+ },
528+ "api_key": {
529+ "type": "string",
530+ "description": "API key for authentication with the provider",
531+ "examples": [
532+ "$OPENAI_API_KEY"
533+ ]
534+ },
535+ "oauth": {
536+ "$ref": "#/$defs/Token",
537+ "description": "OAuth2 token for authentication with the provider"
538+ },
539+ "disable": {
540+ "type": "boolean",
541+ "description": "Whether this provider is disabled",
542+ "default": false
543+ },
544+ "system_prompt_prefix": {
545+ "type": "string",
546+ "description": "Custom prefix to add to system prompts for this provider"
547+ },
548+ "extra_headers": {
549+ "additionalProperties": {
550+ "type": "string"
551+ },
552+ "type": "object",
553+ "description": "Additional HTTP headers to send with requests"
554+ },
555+ "extra_body": {
556+ "type": "object",
557+ "description": "Additional fields to include in request bodies"
558+ },
559+ "provider_options": {
560+ "type": "object",
561+ "description": "Additional provider-specific options for this provider"
562+ },
563+ "models": {
564+ "items": {
565+ "$ref": "#/$defs/Model"
566+ },
567+ "type": "array",
568+ "description": "List of models available from this provider"
569+ }
570+ },
571+ "additionalProperties": false,
572+ "type": "object"
573+ },
408574 "SelectedModel": {
409575 "properties": {
410576 "model": {
@@ -515,6 +681,30 @@
515681 "completions"
516682 ]
517683 },
684+ "Token": {
685+ "properties": {
686+ "access_token": {
687+ "type": "string"
688+ },
689+ "refresh_token": {
690+ "type": "string"
691+ },
692+ "expires_in": {
693+ "type": "integer"
694+ },
695+ "expires_at": {
696+ "type": "integer"
697+ }
698+ },
699+ "additionalProperties": false,
700+ "type": "object",
701+ "required": [
702+ "access_token",
703+ "refresh_token",
704+ "expires_in",
705+ "expires_at"
706+ ]
707+ },
518708 "ToolGrep": {
519709 "properties": {
520710 "timeout": {