mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
7a04bdfeba
* feat(kit): isolate viper config per Kit instance + add NewAgent (#40) - Give each kit.New()/NewAgent() call an isolated *viper.Viper store so multiple Kit instances in one process no longer clobber each other's config; runtime mutators (SetModel, SetThinkingLevel) touch only the owning instance, making subagent spawning and multi-Kit embedding race-free - Thread the per-instance store through internal/config, internal/models (ProviderConfig.ConfigStore), internal/kitsetup, and the extension runner, with a nil -> process-global fallback so the CLI is unaffected - Share the global store when Options.CLI != nil to preserve cobra flag bindings (also opted in for internal/acpserver) - Remove viperInitMu; preserve the tri-state IsSet precedence contract and sdkDefaultMaxTokens floor - Add ergonomic NewAgent + functional options (WithModel, WithStreaming, Ephemeral, etc.); NewAgent defaults streaming on, opt out via WithStreaming(false). New(ctx, *Options) behavior is unchanged - Add config-isolation regression test and NewAgent/option coverage; document NewAgent and per-instance isolation in README Fixes #40 * docs(sdk): document NewAgent options and per-instance config isolation - Add "Functional options (NewAgent)" and "Per-instance config isolation" sections to the docs site SDK overview, with an options table and a "when to use which" constructor comparison - Cross-reference NewAgent from the SDK options page and correct the now per-instance ProviderAPIKey precedence wording - Document NewAgent + With* helpers and config isolation in pkg/kit/README and list NewAgent/Option in the API reference - Show the NewAgent constructor in the SDK examples getting-started snippet * fix(kit): correct config loading and isolate ACP sessions - Isolate each ACP session's config store instead of sharing the global viper, preventing per-session SetModel/SetThinkingLevel races; seed the root-command flag values (model, thinking-level, provider URL/key) so `kit acp -m <model>` is still honored - Run initConfig for isolated SDK stores by gating on opts.CLI instead of v.GetString("model"), which setSDKDefaults always populates and thus skipped .kit.yml / KIT_* loading for SDK callers - Configure KIT_* env overrides unconditionally in initConfig so passing an explicit config file no longer disables environment variable support - Wrap config unmarshal/validate errors with %w to preserve the error chain * fix(kit): make Options.Streaming a *bool to honor unset - Change Options.Streaming from bool to *bool so a zero-valued Options no longer forces stream=false; New only sets the key when non-nil, letting streaming resolve through the precedence chain (env -> config -> default true). This also fixes the CLI path, which never set the field - Mirror the existing sampling-parameter pointer pattern instead of adding a separate StreamingSet sentinel, keeping Options internally consistent - Update WithStreaming/NewAgent, subagent, and ACP callers to the pointer form; add regression tests for the nil-default and explicit opt-out paths - Update SDK docs (README, pkg/kit/README, options page) with the ptrBool helper and *bool semantics * fix(kit): inherit parent provider config in subagents - Copy the parent's effective provider/runtime config (API key, URL, TLS, thinking level, max-tokens, samplers) onto child Options in Kit.Subagent. After the per-instance viper isolation, the child's isolated store only re-loaded .kit.yml / KIT_*, silently dropping config the parent set via programmatic Options or runtime setters like SetThinkingLevel - Preserve the IsSet tri-state for max-tokens and samplers so per-model defaults still apply on the child when the parent left them unset - Add TestInheritProviderConfig covering propagation, unset keys, and nil-safety
335 lines
12 KiB
Go
335 lines
12 KiB
Go
package models
|
|
|
|
import (
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// loadCustomModelsFromConfig loads custom model definitions from the config file
|
|
// and returns them as a map of model ID -> ModelInfo. Returns nil if no custom
|
|
// models are configured. Reads from the process-global viper store (the model
|
|
// registry is a process-global singleton).
|
|
func loadCustomModelsFromConfig() map[string]ModelInfo {
|
|
return loadCustomModelsFrom(viper.GetViper())
|
|
}
|
|
|
|
// loadCustomModelsFrom loads custom model definitions from the supplied store.
|
|
// When v is nil the process-global store is used.
|
|
func loadCustomModelsFrom(v *viper.Viper) map[string]ModelInfo {
|
|
if v == nil {
|
|
v = viper.GetViper()
|
|
}
|
|
if !v.IsSet("customModels") {
|
|
return nil
|
|
}
|
|
|
|
var customModels map[string]CustomModelConfig
|
|
if err := v.UnmarshalKey("customModels", &customModels); err != nil {
|
|
log.Printf("Warning: Failed to parse customModels: %v", err)
|
|
return nil
|
|
}
|
|
|
|
result := make(map[string]ModelInfo, len(customModels))
|
|
for modelID, cfg := range customModels {
|
|
info := modelConfigToModelInfo(modelID, cfg)
|
|
result[modelID] = info
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// modelConfigToModelInfo converts a CustomModelConfig to a ModelInfo.
|
|
func modelConfigToModelInfo(modelID string, cfg CustomModelConfig) ModelInfo {
|
|
info := ModelInfo{
|
|
ID: modelID,
|
|
Name: cfg.Name,
|
|
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,
|
|
},
|
|
Limit: Limit{
|
|
Context: cfg.Limit.Context,
|
|
Output: cfg.Limit.Output,
|
|
},
|
|
}
|
|
|
|
// Convert custom model generation params if any are set.
|
|
if p := convertGenerationParams(cfg.Params); p != nil {
|
|
info.Params = p
|
|
}
|
|
|
|
return info
|
|
}
|
|
|
|
// LoadModelSettingsFromConfig loads per-model generation parameter overrides
|
|
// from the process-global viper store. Keys are "provider/model" strings.
|
|
// Returns nil if no model settings are configured.
|
|
func LoadModelSettingsFromConfig() map[string]*GenerationParams {
|
|
return LoadModelSettingsFrom(viper.GetViper())
|
|
}
|
|
|
|
// LoadModelSettingsFrom loads per-model generation parameter overrides from the
|
|
// supplied per-instance store. When v is nil the process-global store is used.
|
|
// Keys are "provider/model" strings. Returns nil if no model settings are
|
|
// configured.
|
|
func LoadModelSettingsFrom(v *viper.Viper) map[string]*GenerationParams {
|
|
if v == nil {
|
|
v = viper.GetViper()
|
|
}
|
|
if !v.IsSet("modelSettings") {
|
|
return nil
|
|
}
|
|
|
|
var settings map[string]GenerationParamsConfig
|
|
if err := v.UnmarshalKey("modelSettings", &settings); err != nil {
|
|
log.Printf("Warning: Failed to parse modelSettings: %v", err)
|
|
return nil
|
|
}
|
|
|
|
result := make(map[string]*GenerationParams, len(settings))
|
|
for modelKey, cfg := range settings {
|
|
if p := convertGenerationParams(cfg); p != nil {
|
|
result[modelKey] = p
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// convertGenerationParams converts a GenerationParamsConfig to a GenerationParams.
|
|
// Returns nil if no parameters are set.
|
|
func convertGenerationParams(cfg GenerationParamsConfig) *GenerationParams {
|
|
p := &GenerationParams{}
|
|
any := false
|
|
|
|
if cfg.MaxTokens != nil {
|
|
p.MaxTokens = cfg.MaxTokens
|
|
any = true
|
|
}
|
|
if cfg.Temperature != nil {
|
|
p.Temperature = cfg.Temperature
|
|
any = true
|
|
}
|
|
if cfg.TopP != nil {
|
|
p.TopP = cfg.TopP
|
|
any = true
|
|
}
|
|
if cfg.TopK != nil {
|
|
p.TopK = cfg.TopK
|
|
any = true
|
|
}
|
|
if cfg.FrequencyPenalty != nil {
|
|
p.FrequencyPenalty = cfg.FrequencyPenalty
|
|
any = true
|
|
}
|
|
if cfg.PresencePenalty != nil {
|
|
p.PresencePenalty = cfg.PresencePenalty
|
|
any = true
|
|
}
|
|
if len(cfg.StopSequences) > 0 {
|
|
p.StopSequences = cfg.StopSequences
|
|
any = true
|
|
}
|
|
if cfg.ThinkingLevel != "" {
|
|
p.ThinkingLevel = ParseThinkingLevel(cfg.ThinkingLevel)
|
|
any = true
|
|
}
|
|
if cfg.SystemPrompt != "" {
|
|
p.SystemPrompt = cfg.SystemPrompt
|
|
any = true
|
|
}
|
|
|
|
if !any {
|
|
return nil
|
|
}
|
|
return p
|
|
}
|
|
|
|
// ApplyModelSettings merges per-model generation parameter defaults from the
|
|
// registry into a ProviderConfig. Model-level params are only applied for
|
|
// fields where the user has not explicitly set a value (i.e., the
|
|
// corresponding viper key is not set via CLI flag or global config).
|
|
//
|
|
// The lookup order is:
|
|
// 1. modelSettings["provider/model"] from config (highest model-level priority)
|
|
// 2. ModelInfo.Params from custom model definitions
|
|
//
|
|
// Both are overridden by explicit CLI flags / global config values.
|
|
func ApplyModelSettings(config *ProviderConfig, modelInfo *ModelInfo) {
|
|
provider, modelName, err := ParseModelString(config.ModelString)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Resolve the config store: prefer the per-instance store carried on the
|
|
// ProviderConfig (set by BuildProviderConfig / Kit.New), falling back to
|
|
// the process-global store for callers that don't thread one through.
|
|
store := config.ConfigStore
|
|
|
|
// Collect model-level params: modelSettings override > custom model params.
|
|
// modelSettings takes priority because it's the more specific/intentional config.
|
|
var params *GenerationParams
|
|
|
|
// First check modelSettings from config.
|
|
if settings := LoadModelSettingsFrom(store); settings != nil {
|
|
modelKey := provider + "/" + modelName
|
|
if p, ok := settings[modelKey]; ok {
|
|
params = p
|
|
}
|
|
}
|
|
|
|
// Fall back to ModelInfo.Params (from custom model definitions).
|
|
if params == nil && modelInfo != nil && modelInfo.Params != nil {
|
|
params = modelInfo.Params
|
|
}
|
|
|
|
if params == nil {
|
|
return
|
|
}
|
|
|
|
// Apply each parameter only when the user hasn't explicitly set it.
|
|
// We check viper.IsSet() which returns true only when the key was
|
|
// set via CLI flag, environment variable, or config file global section.
|
|
|
|
if params.MaxTokens != nil && !isExplicitlySet(store, "max-tokens") {
|
|
config.MaxTokens = *params.MaxTokens
|
|
}
|
|
if params.Temperature != nil && !isExplicitlySet(store, "temperature") {
|
|
config.Temperature = params.Temperature
|
|
}
|
|
if params.TopP != nil && !isExplicitlySet(store, "top-p") {
|
|
config.TopP = params.TopP
|
|
}
|
|
if params.TopK != nil && !isExplicitlySet(store, "top-k") {
|
|
config.TopK = params.TopK
|
|
}
|
|
if params.FrequencyPenalty != nil && !isExplicitlySet(store, "frequency-penalty") {
|
|
config.FrequencyPenalty = params.FrequencyPenalty
|
|
}
|
|
if params.PresencePenalty != nil && !isExplicitlySet(store, "presence-penalty") {
|
|
config.PresencePenalty = params.PresencePenalty
|
|
}
|
|
if len(params.StopSequences) > 0 && !isExplicitlySet(store, "stop-sequences") {
|
|
config.StopSequences = params.StopSequences
|
|
}
|
|
if params.ThinkingLevel != "" && !isExplicitlySet(store, "thinking-level") {
|
|
config.ThinkingLevel = params.ThinkingLevel
|
|
}
|
|
if params.SystemPrompt != "" && config.SystemPrompt == "" {
|
|
// Resolve file paths: if the value points to an existing file, read it.
|
|
// We check config.SystemPrompt == "" rather than isExplicitlySet because
|
|
// viper.BindPFlag causes IsSet to return true even for unset flags.
|
|
config.SystemPrompt = LoadSystemPromptValue(params.SystemPrompt)
|
|
}
|
|
}
|
|
|
|
// LoadSystemPromptValue resolves a system prompt value that may be either
|
|
// inline text or a file path. If the value is a path to an existing file,
|
|
// its contents are read and returned. Otherwise the string is returned as-is.
|
|
// This mirrors config.LoadSystemPrompt but lives in the models package to
|
|
// avoid circular dependencies.
|
|
func LoadSystemPromptValue(input string) string {
|
|
if input == "" {
|
|
return ""
|
|
}
|
|
if info, err := os.Stat(input); err == nil && !info.IsDir() {
|
|
content, err := os.ReadFile(input)
|
|
if err != nil {
|
|
log.Printf("Warning: failed to read system prompt file %q: %v", input, err)
|
|
return input
|
|
}
|
|
return strings.TrimSpace(string(content))
|
|
}
|
|
return input
|
|
}
|
|
|
|
// isExplicitlySet returns true when the user has explicitly set a config key
|
|
// via CLI flag, environment variable, or the global section of the config file.
|
|
// Model-level defaults should not override explicitly set values.
|
|
//
|
|
// The check runs against the supplied per-instance store when non-nil,
|
|
// otherwise the process-global store. This keeps the "explicit vs unset"
|
|
// precedence contract per-Kit-instance once a store is threaded through.
|
|
func isExplicitlySet(v *viper.Viper, key string) bool {
|
|
if v == nil {
|
|
v = viper.GetViper()
|
|
}
|
|
// viper.IsSet returns true if the key has been set in any of the
|
|
// data stores (flag, env, config file, default). We need to check
|
|
// whether the value was set at the global config level (not just
|
|
// as a default). For generation params, the global config keys use
|
|
// hyphenated names (e.g. "max-tokens", "top-p").
|
|
//
|
|
// Since viper merges all sources, IsSet returns true even for config
|
|
// file values. This means global config file values (e.g.
|
|
// temperature: 0.7 at the top level) will correctly take precedence
|
|
// over model-level defaults, which is the desired behavior.
|
|
return v.IsSet(key)
|
|
}
|
|
|
|
// GenerationParams holds per-model generation parameter defaults.
|
|
// These are stored on ModelInfo and applied during provider creation.
|
|
// Nil pointer fields mean "no model-level default" — the global config
|
|
// or CLI flag value (if any) will be used instead.
|
|
type GenerationParams struct {
|
|
MaxTokens *int
|
|
Temperature *float32
|
|
TopP *float32
|
|
TopK *int32
|
|
FrequencyPenalty *float32
|
|
PresencePenalty *float32
|
|
StopSequences []string
|
|
ThinkingLevel ThinkingLevel
|
|
SystemPrompt string // Per-model system prompt (inline text or file path)
|
|
}
|
|
|
|
// CustomModelConfig defines a custom model configuration loaded from the config file.
|
|
// 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"`
|
|
Temperature bool `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
|
Knowledge string `json:"knowledge,omitempty" yaml:"knowledge,omitempty"`
|
|
Cost CostConfig `json:"cost" yaml:"cost"`
|
|
Limit LimitConfig `json:"limit" yaml:"limit"`
|
|
Params GenerationParamsConfig `json:"params,omitzero" yaml:"params,omitempty"`
|
|
}
|
|
|
|
// GenerationParamsConfig is the JSON/YAML-serializable form of generation
|
|
// parameter defaults. Used in both customModels[].params and modelSettings[].
|
|
type GenerationParamsConfig struct {
|
|
MaxTokens *int `json:"maxTokens,omitempty" yaml:"maxTokens,omitempty"`
|
|
Temperature *float32 `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
|
TopP *float32 `json:"topP,omitempty" yaml:"topP,omitempty"`
|
|
TopK *int32 `json:"topK,omitempty" yaml:"topK,omitempty"`
|
|
FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty" yaml:"frequencyPenalty,omitempty"`
|
|
PresencePenalty *float32 `json:"presencePenalty,omitempty" yaml:"presencePenalty,omitempty"`
|
|
StopSequences []string `json:"stopSequences,omitempty" yaml:"stopSequences,omitempty"`
|
|
ThinkingLevel string `json:"thinkingLevel,omitempty" yaml:"thinkingLevel,omitempty"`
|
|
SystemPrompt string `json:"systemPrompt,omitempty" yaml:"systemPrompt,omitempty"`
|
|
}
|
|
|
|
// CostConfig defines the pricing for a custom model.
|
|
type CostConfig struct {
|
|
Input float64 `json:"input" yaml:"input"`
|
|
Output float64 `json:"output" yaml:"output"`
|
|
}
|
|
|
|
// LimitConfig defines context and output limits for a custom model.
|
|
type LimitConfig struct {
|
|
Context int `json:"context" yaml:"context"`
|
|
Output int `json:"output" yaml:"output"`
|
|
}
|