diff --git a/cmd/root.go b/cmd/root.go index b2a68367..694d77e7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -154,6 +154,9 @@ func InitConfig() { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } + // Rebuild the model registry now that viper has the config loaded, + // so customModels defined in the config file are picked up. + models.ReloadGlobalRegistry() } // LoadConfigWithEnvSubstitution loads a config file with environment variable diff --git a/internal/config/config.go b/internal/config/config.go index 194e4c6d..e75572e1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -162,6 +162,8 @@ type Theme struct { // and merged into the custom provider in the model registry. type CustomModelConfig struct { Name string `json:"name" yaml:"name"` + BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"` + APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"` Family string `json:"family,omitempty" yaml:"family,omitempty"` Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"` Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"` diff --git a/internal/models/custom.go b/internal/models/custom.go index 5f51aa31..28a06935 100644 --- a/internal/models/custom.go +++ b/internal/models/custom.go @@ -37,6 +37,8 @@ func modelConfigToModelInfo(modelID string, cfg CustomModelConfig) ModelInfo { Attachment: cfg.Attachment, Reasoning: cfg.Reasoning, Temperature: cfg.Temperature, + BaseURL: cfg.BaseURL, + APIKey: cfg.APIKey, Cost: Cost{ Input: cfg.Cost.Input, Output: cfg.Cost.Output, @@ -52,6 +54,8 @@ func modelConfigToModelInfo(modelID string, cfg CustomModelConfig) ModelInfo { // This is a duplicate here to avoid circular dependencies with internal/config. type CustomModelConfig struct { Name string `json:"name" yaml:"name"` + BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"` + APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"` Family string `json:"family,omitempty" yaml:"family,omitempty"` Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"` Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"` diff --git a/internal/models/providers.go b/internal/models/providers.go index 4d2f91f2..4c723d2d 100644 --- a/internal/models/providers.go +++ b/internal/models/providers.go @@ -1128,11 +1128,23 @@ func customToPromptFunc(prompt fantasy.Prompt, systemPrompt, user string) ([]ope } func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) { - if config.ProviderURL == "" { - return nil, fmt.Errorf("custom provider requires --provider-url") + // Resolve base URL: per-model override > global provider-url flag/config + registry := GetGlobalRegistry() + modelInfo := registry.LookupModel("custom", modelName) + + baseURL := config.ProviderURL + if modelInfo != nil && modelInfo.BaseURL != "" { + baseURL = modelInfo.BaseURL + } + + if baseURL == "" { + return nil, fmt.Errorf("custom provider requires --provider-url or a baseUrl in the model config") } apiKey := config.ProviderAPIKey + if modelInfo != nil && modelInfo.APIKey != "" { + apiKey = modelInfo.APIKey + } if apiKey == "" { apiKey = os.Getenv("CUSTOM_API_KEY") } @@ -1144,7 +1156,7 @@ func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName // Use the openai provider directly with custom hooks to handle tags // from models like Qwen and DeepSeek that wrap reasoning in XML tags. var opts []openai.Option - opts = append(opts, openai.WithBaseURL(config.ProviderURL)) + opts = append(opts, openai.WithBaseURL(baseURL)) opts = append(opts, openai.WithAPIKey(apiKey)) opts = append(opts, openai.WithName("custom")) opts = append(opts, openai.WithLanguageModelOptions( diff --git a/internal/models/registry.go b/internal/models/registry.go index 06a153f4..d7076073 100644 --- a/internal/models/registry.go +++ b/internal/models/registry.go @@ -24,6 +24,8 @@ type ModelInfo struct { Cost Cost Limit Limit ProviderNPM string // Model-specific provider npm override (e.g. "@ai-sdk/anthropic") + BaseURL string // Per-model base URL override (custom models only) + APIKey string // Per-model API key override (custom models only) } // SupportsCaching returns true if this model family supports prompt caching. @@ -367,8 +369,8 @@ func (r *ModelsRegistry) GetFantasyProviders() []string { // isProviderLLMSupported checks if a provider can be used with the LLM layer. func isProviderLLMSupported(providerID string, info *ProviderInfo) bool { - // Ollama is always supported (via openaicompat pointed at localhost) - if providerID == "ollama" { + // Ollama and custom are always supported (model names are user-defined). + if providerID == "ollama" || providerID == "custom" { return true } diff --git a/internal/ui/model_selector.go b/internal/ui/model_selector.go index 103a7bad..41b6f957 100644 --- a/internal/ui/model_selector.go +++ b/internal/ui/model_selector.go @@ -57,7 +57,22 @@ func NewModelSelector(currentModel string, width, height int) *ModelSelectorComp continue } + // For the custom provider, skip the built-in "custom" stub when + // user-defined models are present — the stub is a fallback for + // --provider-url usage and would just clutter the list. + userDefinedCustomModels := 0 + if providerID == "custom" { + for modelID := range modelsMap { + if modelID != "custom" { + userDefinedCustomModels++ + } + } + } + for modelID, info := range modelsMap { + if providerID == "custom" && modelID == "custom" && userDefinedCustomModels > 0 { + continue + } allModels = append(allModels, ModelEntry{ Provider: providerID, ModelID: modelID, diff --git a/www/pages/configuration.md b/www/pages/configuration.md index 521cf4aa..ce41609c 100644 --- a/www/pages/configuration.md +++ b/www/pages/configuration.md @@ -104,6 +104,8 @@ Define custom models in your `.kit.yml` for use with the `custom` provider. This customModels: my-model: name: "My Custom Model" + baseUrl: "http://localhost:8080/v1" + apiKey: "my-secret-key" reasoning: true temperature: true cost: @@ -119,6 +121,8 @@ customModels: | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Display name for the model | +| `baseUrl` | string | No | Per-model base URL override; when set, `--provider-url` is not required | +| `apiKey` | string | No | Per-model API key override | | `reasoning` | bool | No | Whether the model supports reasoning/thinking | | `temperature` | bool | No | Whether the model supports temperature adjustment | | `cost.input` | float | No | Cost per 1K input tokens | @@ -126,7 +130,13 @@ customModels: | `limit.context` | int | Yes | Maximum context window in tokens | | `limit.output` | int | No | Maximum output tokens | -Use with a custom provider URL: +Use with a per-model `baseUrl` (no `--provider-url` needed): + +```bash +kit --model custom/my-model "Hello" +``` + +Or override the base URL at runtime: ```bash kit --provider-url "http://localhost:8080/v1" --model custom/my-model "Hello"