feat(config): per-model baseUrl and apiKey for custom models

- Add `baseUrl` and `apiKey` fields to CustomModelConfig (config and models packages)
- Store them on ModelInfo so they travel through the registry
- createCustomProvider resolves URL/key from model definition first,
  falling back to global --provider-url / --provider-api-key
- Fix registry initialisation: call ReloadGlobalRegistry() in InitConfig()
  so customModels from config are visible on startup (not just at init time)
- Include custom provider in GetLLMProviders() so custom models appear
  in the /model selector
- Hide the built-in custom/custom stub from the selector when user-defined
  custom models are present
This commit is contained in:
Ed Zynda
2026-04-03 12:37:14 +03:00
parent 3496bc2684
commit 788e3b71fd
7 changed files with 54 additions and 6 deletions
+3
View File
@@ -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
+2
View File
@@ -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"`
+4
View File
@@ -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"`
+15 -3
View File
@@ -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 <think> 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(
+4 -2
View File
@@ -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
}
+15
View File
@@ -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,
+11 -1
View File
@@ -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"