From fa1565813cc109d70c6d5ef2d312b25cf3c657b8 Mon Sep 17 00:00:00 2001 From: Bruno Krugel Date: Wed, 8 Apr 2026 10:05:47 -0300 Subject: [PATCH] fix(schema): fix `crush.json` schema generation (#2574) --- internal/cmd/schema_test.go | 59 +++++++++++ internal/csync/maps.go | 5 +- schema.json | 196 +++++++++++++++++++++++++++++++++++- 3 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 internal/cmd/schema_test.go diff --git a/internal/cmd/schema_test.go b/internal/cmd/schema_test.go new file mode 100644 index 0000000000000000000000000000000000000000..60bd29ae7fdb4a1bf0dcb95ca95097e2672c88aa --- /dev/null +++ b/internal/cmd/schema_test.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/charmbracelet/crush/internal/config" + "github.com/invopop/jsonschema" + "github.com/stretchr/testify/require" +) + +func TestSchemaNoBrokenRefs(t *testing.T) { + t.Parallel() + + reflector := new(jsonschema.Reflector) + bts, err := json.Marshal(reflector.Reflect(&config.Config{})) + require.NoError(t, err) + + var schema struct { + Defs map[string]json.RawMessage `json:"$defs"` + } + require.NoError(t, json.Unmarshal(bts, &schema)) + require.NotEmpty(t, schema.Defs, "schema should have definitions") + + for name := range schema.Defs { + require.NotContains(t, name, "/", "schema $def key %q contains '/' which breaks JSON Pointer $ref resolution", name) + } +} + +func TestSchemaProvidersHasAdditionalProperties(t *testing.T) { + t.Parallel() + + reflector := new(jsonschema.Reflector) + bts, err := json.Marshal(reflector.Reflect(&config.Config{})) + require.NoError(t, err) + + var schema struct { + Defs map[string]json.RawMessage `json:"$defs"` + } + require.NoError(t, json.Unmarshal(bts, &schema)) + + var cfg struct { + Properties map[string]json.RawMessage `json:"properties"` + } + require.NoError(t, json.Unmarshal(schema.Defs["Config"], &cfg)) + + providersRaw, ok := cfg.Properties["providers"] + require.True(t, ok, "Config should have a providers property") + + var providers struct { + Type string `json:"type"` + AdditionalProperties json.RawMessage `json:"additionalProperties"` + } + require.NoError(t, json.Unmarshal(providersRaw, &providers)) + require.Equal(t, "object", providers.Type) + require.True(t, strings.Contains(string(providers.AdditionalProperties), "ProviderConfig"), + "providers should use additionalProperties with a ProviderConfig ref, got: %s", string(providers.AdditionalProperties)) +} diff --git a/internal/csync/maps.go b/internal/csync/maps.go index df63dc8bd385982133d97c446d26c4333c1f0797..549cda3ea2efe6ae176b6f5e96a6c910e2041722 100644 --- a/internal/csync/maps.go +++ b/internal/csync/maps.go @@ -131,7 +131,10 @@ var ( _ json.Marshaler = &Map[string, any]{} ) -func (*Map[K, V]) JSONSchemaAlias() any { +// JSONSchemaAlias returns the underlying map type for JSON schema generation. +// Value receiver is required because github.com/invopop/jsonschema checks +// interface satisfaction on the non-pointer type after stripping pointers. +func (Map[K, V]) JSONSchemaAlias() any { //nolint m := map[K]V{} return m } diff --git a/schema.json b/schema.json index 3379bee09fde443082a029c2a67155f299562e12..750e44d7674b46c3afb3874100de2b4545273bf5 100644 --- a/schema.json +++ b/schema.json @@ -64,7 +64,10 @@ "description": "Model configurations for different model types" }, "providers": { - "$ref": "#/$defs/Map[string,github.com/charmbracelet/crush/internal/config.ProviderConfig]", + "additionalProperties": { + "$ref": "#/$defs/ProviderConfig" + }, + "type": "object", "description": "AI provider configurations" }, "mcp": { @@ -262,8 +265,88 @@ }, "type": "object" }, - "Map[string,github.com/charmbracelet/crush/internal/config.ProviderConfig]": { - "properties": {}, + "Model": { + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "cost_per_1m_in": { + "type": "number" + }, + "cost_per_1m_out": { + "type": "number" + }, + "cost_per_1m_in_cached": { + "type": "number" + }, + "cost_per_1m_out_cached": { + "type": "number" + }, + "context_window": { + "type": "integer" + }, + "default_max_tokens": { + "type": "integer" + }, + "can_reason": { + "type": "boolean" + }, + "reasoning_levels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "default_reasoning_effort": { + "type": "string" + }, + "supports_attachments": { + "type": "boolean" + }, + "options": { + "$ref": "#/$defs/ModelOptions" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "name", + "cost_per_1m_in", + "cost_per_1m_out", + "cost_per_1m_in_cached", + "cost_per_1m_out_cached", + "context_window", + "default_max_tokens", + "can_reason", + "supports_attachments", + "options" + ] + }, + "ModelOptions": { + "properties": { + "temperature": { + "type": "number" + }, + "top_p": { + "type": "number" + }, + "top_k": { + "type": "integer" + }, + "frequency_penalty": { + "type": "number" + }, + "presence_penalty": { + "type": "number" + }, + "provider_options": { + "type": "object" + } + }, "additionalProperties": false, "type": "object" }, @@ -405,6 +488,89 @@ "additionalProperties": false, "type": "object" }, + "ProviderConfig": { + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the provider", + "examples": [ + "openai" + ] + }, + "name": { + "type": "string", + "description": "Human-readable name for the provider", + "examples": [ + "OpenAI" + ] + }, + "base_url": { + "type": "string", + "format": "uri", + "description": "Base URL for the provider's API", + "examples": [ + "https://api.openai.com/v1" + ] + }, + "type": { + "type": "string", + "enum": [ + "openai", + "openai-compat", + "anthropic", + "gemini", + "azure", + "vertexai" + ], + "description": "Provider type that determines the API format", + "default": "openai" + }, + "api_key": { + "type": "string", + "description": "API key for authentication with the provider", + "examples": [ + "$OPENAI_API_KEY" + ] + }, + "oauth": { + "$ref": "#/$defs/Token", + "description": "OAuth2 token for authentication with the provider" + }, + "disable": { + "type": "boolean", + "description": "Whether this provider is disabled", + "default": false + }, + "system_prompt_prefix": { + "type": "string", + "description": "Custom prefix to add to system prompts for this provider" + }, + "extra_headers": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Additional HTTP headers to send with requests" + }, + "extra_body": { + "type": "object", + "description": "Additional fields to include in request bodies" + }, + "provider_options": { + "type": "object", + "description": "Additional provider-specific options for this provider" + }, + "models": { + "items": { + "$ref": "#/$defs/Model" + }, + "type": "array", + "description": "List of models available from this provider" + } + }, + "additionalProperties": false, + "type": "object" + }, "SelectedModel": { "properties": { "model": { @@ -515,6 +681,30 @@ "completions" ] }, + "Token": { + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "expires_at": { + "type": "integer" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "access_token", + "refresh_token", + "expires_in", + "expires_at" + ] + }, "ToolGrep": { "properties": { "timeout": {