@@ -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))
+}
@@ -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": {