141 lines
5.1 KiB
Go
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
|
|
}
|
|
}
|