mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
e8e99b19a8
* Remove dead code: 5 unused symbols across internal packages
- internal/models: LoadModelSettingsFromConfig (zero refs)
- internal/prompts: PromptTemplate.ExpandWithArgs (zero refs)
- internal/app: NewMessageStore (tests migrated to NewMessageStoreWithMessages)
- internal/config: HasEnvVars (+ its test)
- internal/core: ContextWithSudoPassword (test migrated to context.WithValue)
* pkg/kit: use TreeManager alias in exported signatures
NewTreeManagerAdapter and InitTreeSession now spell their signatures with
the public kit.TreeManager alias instead of internal/session.TreeManager,
so go doc renders domain types rather than internal paths.
* Consolidate tool-kind classification into internal/extensions
coreToolKinds + toolKindFor were duplicated verbatim in
internal/extensions/wrapper.go and pkg/kit/events.go, risking silent
divergence between extension events and SDK events. Single source of
truth now lives in internal/extensions/toolkinds.go; pkg/kit re-exports
the constants.
* Consolidate Anthropic OAuth detection and usage-tracker refresh
The 'is the active Anthropic credential a stored OAuth token' check was
copy-pasted at 5 sites, all prefix-matching the magic string
'stored OAuth' produced in internal/auth. Now:
- internal/auth: new CredentialSourceOAuth constant + IsAnthropicOAuth()
- internal/ui: new UpdateUsageTrackerForModel(); CreateUsageTracker and
SetupCLI share lookupTrackableModel (SetupCLI no longer re-inlines the
tracker construction)
- cmd/root.go + cmd/extension_context.go: verbatim-duplicated tracker
refresh blocks replaced with ui.UpdateUsageTrackerForModel
- pkg/kit isAnthropicOAuth delegates to auth.IsAnthropicOAuth
- internal/models compares source against the constant
* pkg/kit: consolidate model-path helpers and argument tokenizer
- ExtractModelFromPath mis-parsed model IDs containing '/' (e.g.
'openrouter/meta/llama' -> 'meta'); it now delegates to
RemoveProviderFromModel and is deprecated alongside
ExtractProviderFromPath (-> GetCurrentProvider)
- parseFields delegated to prompts.ParseCommandArgs so extension argument
parsing and builtin prompt-template parsing share one quote/escape
grammar; ParseCommandArgs now also splits on tabs (superset of both
previous tokenizers)
* Unify the two {{variable}} template engines
internal/skills and pkg/kit/template_bridge each had their own grammar:
skills rejected '{{ name }}' (whitespace) but allowed digit-first names;
the bridge was the opposite. A template behaved differently depending on
whether it was loaded as a skill prompt or via the extension API.
internal/skills is now the single engine using the superset grammar
(\{\{\s*(\w+)\s*\}\}); pkg/kit ParseTemplate/RenderTemplate are thin
adapters over it. Expand is now regex-based so whitespace placeholders
expand consistently; missing variables are still left as-is.
* internal/ui: extract switchModel helper for model-switch flow
The model-selector handler (ModelSelectedMsg) and /model slash command
duplicated the full switch sequence (thinking-level fallback, setModel,
display-state update, preference persistence, ModelChange emit) and had
already drifted in ordering. Both now call a single switchModel method.
Display state is still updated directly (no prog.Send from Update).
* extbridge: extract shared BaseContext for extension wiring
cmd/extension_context.go and internal/acpserver/session.go each built a
giant extensions.Context literal, duplicating ~15 delegation closures
(GetContextStats, GetMessages, AppendEntry, options, SetModel core,
Complete, SpawnSubagent, ...) that had to be kept in sync by hand. New
data-access fields had to be wired in both places or ACP-mode extensions
silently got nil function fields.
extbridge.BaseContext now provides the headless half; both call sites
overlay only their UI-specific closures. As a side effect ACP mode gains
previously-missing APIs (state, tree navigation, skills, template
parsing, model resolution) that were nil before. The interactive TUI
keeps its exact SetModel/ReloadExtensions ordering via overrides.
* internal/tools: extract withOAuthRetry and marshalToolResult helpers
ExecuteTool repeated the OAuth-error/re-auth/retry stanza verbatim twice
(sync and task-augmented paths) and the marshal-and-wrap stanza four
times. Both are now single helpers with identical error strings, so a
fix to OAuth retry or error categorization applies everywhere at once.
* internal/ui: extract buildShareFile with defer-based cleanup
handleShareCommand repeated the close/remove/print/return cleanup chain
four times across its temp-file write error paths. File assembly now
lives in buildShareFile with a single deferred cleanup on error.
* cmd: extract flag validation, preference restore, and provider-URL routing from runNormalMode
runNormalMode opened with ~150 lines of policy logic (flag-combination
validation, persisted model/thinking-level preference restoration, and
two subtle --provider-url model-rewrite rules). These are now standalone
functions (validateModeFlags, restorePersistedPreferences,
applyProviderURLRouting) so the routing policy is independently readable
and testable. Behaviour unchanged; ordering preserved.
* fix: address review findings on SDK godoc and nil guard
- pkg/kit: remove internal package paths from exported godoc on
ParseTemplate and the ToolKind* constants (SDK doc surface must not
reference internal packages)
- internal/tools: guard marshalToolResult against a nil CallToolResult
(json.Marshal(nil) succeeds as 'null', then result.IsError panics if
a client returns nil result with nil error)
Skipped the TreeNode Children deep-copy suggestion: the slice already
comes from TreeManager.GetChildren which returns a fresh copy per call
into a throwaway intermediate, so no internal state is exposed.
328 lines
11 KiB
Go
328 lines
11 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
|
|
}
|
|
|
|
// 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"`
|
|
}
|