Files
BlackBean/pigo/internal/cli/btw/btw_config.go
T
2026-08-14 23:41:57 +08:00

141 lines
5.1 KiB
Go

// This file implements the /btw model/thinking override config (US-005, #282):
// an optional per-command config that lets a side thread use a different model
// and/or reasoning effort than the main session, without touching the main
// session's settings (mirrors pi-btw's pi-btw.json).
//
// The config lives at $PIGO_HOME/btw.json (or ~/.pigo/btw.json). It is read
// fresh on every /btw invocation, so editing it takes effect on the next call
// with no restart. A missing file, an empty object, or an absent field all mean
// "inherit the session default" silently — only a malformed file or an
// unusable model override produces a (non-fatal) warning.
package btw
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/run"
"github.com/smallnest/pigo/internal/cli/ui"
"github.com/smallnest/pigo/internal/provider"
)
// btwConfig is the on-disk shape of ~/.pigo/btw.json. Both fields are optional;
// an absent field (nil / empty) inherits the session default. Pointers/empty
// strings distinguish "not set" from a real value so a partial file still falls
// back per-field.
type btwConfig struct {
Model string `json:"model,omitempty"`
ThinkingLevel string `json:"thinkingLevel,omitempty"`
}
// BtwRunSettings is the resolved model/provider/thinking a side run uses. It is
// computed once per /btw invocation from the session defaults overlaid with
// btw.json, and passed down to AskSide so every turn of that invocation uses
// the same settings.
type BtwRunSettings struct {
Model string
ProviderName string
Provider provider.Provider
ThinkingLevel agentcore.ThinkingLevel
}
// btwConfigPath returns the path to the /btw override config, or "" when the
// config directory cannot be resolved (then the config is treated as absent).
func btwConfigPath() string {
dir := run.ConfigDir()
if dir == "" {
return ""
}
return filepath.Join(dir, "btw.json")
}
// loadBtwConfig reads and parses btw.json. A missing file returns a zero config
// with no error (inherit everything). A malformed file returns an error so the
// caller can warn and fall back. An empty object parses to a zero config.
func loadBtwConfig(path string) (btwConfig, error) {
if path == "" {
return btwConfig{}, nil
}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return btwConfig{}, nil
}
return btwConfig{}, err
}
var cfg btwConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return btwConfig{}, fmt.Errorf("parse %s: %w", path, err)
}
return cfg, nil
}
// ResolveBtwSettings computes the model/provider/thinking a side run should use.
// It starts from the session defaults (host.Live()) and overlays btw.json:
//
// - No config / empty object / absent fields → inherit the session values.
// - thinkingLevel set → validate and override (invalid value warns, falls back).
// - model set → resolve its provider (reusing resolveProvider like /model);
// if the model cannot be resolved/authenticated, warn on one line and fall
// back to the session model+provider.
//
// A malformed config file warns once and inherits everything. Nothing here
// mutates the session live config, so the override is confined to the side
// thread (FR-8).
func ResolveBtwSettings(out io.Writer, host cli.Host) BtwRunSettings {
live := host.Live()
s := BtwRunSettings{
Model: live.Model,
ProviderName: live.ProviderName,
Provider: live.Provider,
ThinkingLevel: live.ThinkingLevel,
}
cfg, err := loadBtwConfig(btwConfigPath())
if err != nil {
fmt.Fprintf(out, "%s\n", ui.Colorize(ui.Enabled(), ui.Dim, "btw: ignoring invalid btw.json: "+err.Error()))
return s
}
if lvl := strings.TrimSpace(cfg.ThinkingLevel); lvl != "" {
if v, ok := validThinkingLevel(lvl); ok {
s.ThinkingLevel = v
} else {
fmt.Fprintf(out, "%s\n", ui.Colorize(ui.Enabled(), ui.Dim, fmt.Sprintf("btw: ignoring invalid thinkingLevel %q, using %q", lvl, s.ThinkingLevel)))
}
}
if model := strings.TrimSpace(cfg.Model); model != "" && model != s.Model {
prov, providerName, perr := provider.ResolveProvider(model, live.BaseURL, live.Protocol, "", os.Getenv)
if perr != nil {
fmt.Fprintf(out, "%s\n", ui.Colorize(ui.Enabled(), ui.Dim, fmt.Sprintf("btw: cannot use model %q (%v), falling back to %q", model, perr, s.Model)))
} else {
s.Model = model
s.ProviderName = providerName
s.Provider = prov
}
}
return s
}
// validThinkingLevel reports whether s is one of the known reasoning-effort
// levels and returns the typed value. It mirrors the enum in agentcore so an
// invalid btw.json value can be rejected without importing the config layer.
func validThinkingLevel(s string) (agentcore.ThinkingLevel, bool) {
switch agentcore.ThinkingLevel(s) {
case agentcore.ThinkingOff, agentcore.ThinkingMinimal, agentcore.ThinkingLow,
agentcore.ThinkingMedium, agentcore.ThinkingHigh, agentcore.ThinkingXHigh, agentcore.ThinkingMax:
return agentcore.ThinkingLevel(s), true
default:
return "", false
}
}