diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 4e587525..f17335ea 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -821,6 +821,70 @@ type Options struct { Tools []Tool // Custom tool set. If empty, AllTools() is used. ExtraTools []Tool // Additional tools added alongside core/MCP/extension tools. + // Generation parameters. These override the corresponding values from + // .kit.yml / KIT_* environment variables. Leaving a field at its + // zero/nil value means "use the configured default", which in turn + // falls back to per-model defaults (modelSettings / customModels) and + // finally to the SDK defaults registered in setSDKDefaults(). + // + // Pointer types are used for sampling parameters so the SDK can + // distinguish "explicitly set to 0" from "leave alone". + + // MaxTokens overrides the maximum output tokens per LLM response. + // 0 = use the configured default (SDK default is 4096). Bump this + // when generating long outputs (HTML artifacts, large refactors, + // etc.) to avoid silent truncation mid-tool-call. The cap also + // applies after model switches via [Kit.SetModel]. + MaxTokens int + + // ThinkingLevel sets the reasoning effort for models that support + // extended thinking. Valid values: "off", "low", "medium", "high". + // "" = use the configured default (SDK default is "off"). Use + // [Kit.SetThinkingLevel] to change at runtime. + ThinkingLevel string + + // Temperature controls sampling randomness (typically 0.0–2.0). + // nil = leave provider/per-model default in place. Pointer type + // so explicit 0.0 (deterministic) is distinguishable from "unset". + Temperature *float32 + + // TopP is the nucleus-sampling cutoff (0.0–1.0). + // nil = leave provider/per-model default in place. + TopP *float32 + + // TopK limits sampling to the top K tokens. + // nil = leave provider/per-model default in place. + TopK *int32 + + // FrequencyPenalty discourages repeated tokens (OpenAI-family models). + // nil = leave provider/per-model default in place. + FrequencyPenalty *float32 + + // PresencePenalty discourages repeating topics (OpenAI-family models). + // nil = leave provider/per-model default in place. + PresencePenalty *float32 + + // Provider configuration. These override values normally read from + // .kit.yml or provider-specific environment variables. Useful when + // loading credentials from a secrets manager, pointing at custom + // OpenAI-compatible endpoints (LiteLLM, vLLM, Azure OpenAI, internal + // proxies), or running against self-hosted infrastructure. + + // ProviderAPIKey overrides the API key used to authenticate with the + // model provider. "" = use the value from config or the + // provider-specific environment variable. + ProviderAPIKey string + + // ProviderURL overrides the provider endpoint. "" = use the provider's + // default URL. + ProviderURL string + + // TLSSkipVerify disables TLS certificate verification on provider + // HTTP clients. Only set this for self-signed certificates in + // development. Once enabled here it cannot be disabled via Options + // (use the config file or env var to opt back out). + TLSSkipVerify bool + // SkipConfig, when true, skips loading .kit.yml configuration files. // Viper defaults (setSDKDefaults) and environment variables (KIT_*) // are still applied. Use this for fully programmatic configuration. @@ -1047,6 +1111,47 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { } viper.Set("stream", opts.Streaming) + // Generation parameter overrides. Each Options field, when set, + // is pushed into viper here so the existing downstream code + // (BuildProviderConfig, SetModel, modelSettings lookups) picks + // it up uniformly. Pointer-typed sampling params use viper.Set + // only when non-nil so that nil means "leave provider/per-model + // default in place" (BuildProviderConfig keys off viper.IsSet). + if opts.MaxTokens > 0 { + viper.Set("max-tokens", opts.MaxTokens) + } + if opts.ThinkingLevel != "" { + viper.Set("thinking-level", opts.ThinkingLevel) + } + if opts.Temperature != nil { + viper.Set("temperature", *opts.Temperature) + } + if opts.TopP != nil { + viper.Set("top-p", *opts.TopP) + } + if opts.TopK != nil { + viper.Set("top-k", *opts.TopK) + } + if opts.FrequencyPenalty != nil { + viper.Set("frequency-penalty", *opts.FrequencyPenalty) + } + if opts.PresencePenalty != nil { + viper.Set("presence-penalty", *opts.PresencePenalty) + } + + // Provider overrides. TLSSkipVerify only takes effect when true — + // callers wanting to force-disable should use the config file or + // env var instead. + if opts.ProviderAPIKey != "" { + viper.Set("provider-api-key", opts.ProviderAPIKey) + } + if opts.ProviderURL != "" { + viper.Set("provider-url", opts.ProviderURL) + } + if opts.TLSSkipVerify { + viper.Set("tls-skip-verify", true) + } + // Resolve working directory for context/skill discovery. cwd = opts.SessionDir if cwd == "" { diff --git a/pkg/kit/kit_test.go b/pkg/kit/kit_test.go index 346cb1b7..0810637e 100644 --- a/pkg/kit/kit_test.go +++ b/pkg/kit/kit_test.go @@ -54,6 +54,79 @@ func TestNewWithOptions(t *testing.T) { } } +// TestNewWithGenerationOptions verifies that the SDK-only generation +// parameter overrides on Options propagate all the way through to the +// agent without requiring any viper.Set workarounds in caller code. +func TestNewWithGenerationOptions(t *testing.T) { + if os.Getenv("ANTHROPIC_API_KEY") == "" { + t.Skip("Skipping test: ANTHROPIC_API_KEY not set") + } + + ctx := context.Background() + + // MaxTokens override — keep ThinkingLevel off so Anthropic's thinking + // budget doesn't auto-bump MaxTokens above what we configured. + t.Run("MaxTokens", func(t *testing.T) { + const want = 12345 + host, err := kit.New(ctx, &kit.Options{ + Model: "anthropic/claude-sonnet-4-5-20250929", + Quiet: true, + MaxTokens: want, + }) + if err != nil { + t.Fatalf("Failed to create Kit: %v", err) + } + defer func() { _ = host.Close() }() + + if got := host.MaxTokens(); got != want { + t.Errorf("Options.MaxTokens=%d did not propagate; Kit.MaxTokens()=%d", want, got) + } + }) + + // ThinkingLevel override — verified via the public getter, which + // reads back the configured (not provider-derived) level. + t.Run("ThinkingLevel", func(t *testing.T) { + const want = "high" + host, err := kit.New(ctx, &kit.Options{ + Model: "anthropic/claude-sonnet-4-5-20250929", + Quiet: true, + ThinkingLevel: want, + }) + if err != nil { + t.Fatalf("Failed to create Kit: %v", err) + } + defer func() { _ = host.Close() }() + + if got := host.GetThinkingLevel(); got != want { + t.Errorf("Options.ThinkingLevel=%q did not propagate; Kit.GetThinkingLevel()=%q", want, got) + } + }) +} + +// TestNewWithProviderOptions verifies that programmatic provider overrides +// (API key, URL) take effect without env vars or config files. +func TestNewWithProviderOptions(t *testing.T) { + if os.Getenv("ANTHROPIC_API_KEY") == "" { + t.Skip("Skipping test: ANTHROPIC_API_KEY not set") + } + + ctx := context.Background() + + // Use the real key but pass it via Options instead of env. Kit should + // authenticate successfully — proving the override reached the provider. + apiKey := os.Getenv("ANTHROPIC_API_KEY") + + host, err := kit.New(ctx, &kit.Options{ + Model: "anthropic/claude-sonnet-4-5-20250929", + Quiet: true, + ProviderAPIKey: apiKey, + }) + if err != nil { + t.Fatalf("Failed to create Kit with ProviderAPIKey option: %v", err) + } + defer func() { _ = host.Close() }() +} + func TestSessionManagement(t *testing.T) { if os.Getenv("ANTHROPIC_API_KEY") == "" { t.Skip("Skipping test: ANTHROPIC_API_KEY not set")