mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
refactor: address code audit findings across SDK, cmd, and internals
- Remove deprecated GenerateWithLoopAndStreaming and TreeManager AppendFantasyMessage / AddFantasyMessages / GetFantasyMessages to close the SDK leakage caused by the kit.TreeManager type alias - Switch extensionAPI method signatures to local Extension* aliases so pkg.go.dev signatures no longer expose internal package names - Bundle runNormalMode dependencies into a runModeDeps struct, shrinking the runNonInteractive and runInteractive call sites from 40+ positional args to (ctx, deps) - Add generic subscribeTyped[E Event] helper and collapse ~30 typed OnXxx wrappers in pkg/kit/events.go onto it (public signatures unchanged) - Extract setupBashPipes / interpretBashExit in internal/core/bash.go to deduplicate the buffered and streaming execution paths - Extract resolveAutoRouteAPIKey and wrapProviderErr helpers in internal/models/providers.go and uniformly apply them across every createXxxProvider site - Reimplement internal/extensions/watcher.go as a thin wrapper over the general-purpose internal/watcher.ContentWatcher, eliminating ~130 LOC of duplicated fsnotify logic while preserving the existing test API - Add ctx.Err() pre-flight checks in executeRead / Write / Edit / Ls so cancellation actually short-circuits pure file-IO tools
This commit is contained in:
+3
-41
@@ -169,9 +169,9 @@ type RetryHandler func(attempt int, err error)
|
||||
type PrepareStepHandler func(stepNumber int, messages []fantasy.Message) []fantasy.Message
|
||||
|
||||
// GenerateCallbacks consolidates all callback functions for
|
||||
// GenerateWithLoopAndStreaming into a single struct. This replaces the previous
|
||||
// 16+ positional callback parameters, making it easier to add new callbacks
|
||||
// without breaking existing callers (new fields default to nil).
|
||||
// GenerateWithCallbacks into a single struct, replacing what was previously
|
||||
// 16+ positional callback parameters. New fields default to nil, so adding
|
||||
// new callbacks does not break existing callers.
|
||||
type GenerateCallbacks struct {
|
||||
OnToolCall ToolCallHandler
|
||||
OnToolExecution ToolExecutionHandler
|
||||
@@ -522,44 +522,6 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateWithLoopAndStreaming processes messages using the agent with streaming and callbacks.
|
||||
// The agent handles the tool call loop internally.
|
||||
//
|
||||
// Deprecated: Use GenerateWithCallbacks instead, which takes a GenerateCallbacks
|
||||
// struct and is easier to extend with new callbacks.
|
||||
func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fantasy.Message,
|
||||
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
|
||||
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
|
||||
onStreamingResponse StreamingResponseHandler,
|
||||
onReasoningDelta ReasoningDeltaHandler,
|
||||
onReasoningComplete ReasoningCompleteHandler,
|
||||
onToolOutput ToolOutputHandler,
|
||||
onStepMessages StepMessagesHandler,
|
||||
onStepUsage StepUsageHandler,
|
||||
onPasswordPrompt PasswordPromptHandler,
|
||||
onToolCallStart ToolCallStartHandler,
|
||||
onToolCallDelta ToolCallDeltaHandler,
|
||||
onToolCallEnd ToolCallEndHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
return a.GenerateWithCallbacks(ctx, messages, GenerateCallbacks{
|
||||
OnToolCall: onToolCall,
|
||||
OnToolExecution: onToolExecution,
|
||||
OnToolResult: onToolResult,
|
||||
OnResponse: onResponse,
|
||||
OnToolCallContent: onToolCallContent,
|
||||
OnStreamingResponse: onStreamingResponse,
|
||||
OnReasoningDelta: onReasoningDelta,
|
||||
OnReasoningComplete: onReasoningComplete,
|
||||
OnToolOutput: onToolOutput,
|
||||
OnStepMessages: onStepMessages,
|
||||
OnStepUsage: onStepUsage,
|
||||
OnPasswordPrompt: onPasswordPrompt,
|
||||
OnToolCallStart: onToolCallStart,
|
||||
OnToolCallDelta: onToolCallDelta,
|
||||
OnToolCallEnd: onToolCallEnd,
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateWithCallbacks processes messages using the agent with streaming and callbacks.
|
||||
// The agent handles the tool call loop internally. We map the rich callback system
|
||||
// to kit's existing callback interface for UI integration.
|
||||
|
||||
+57
-63
@@ -249,34 +249,37 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
return executeBashBuffered(cmdCtx, call, cmd, sudoPassword)
|
||||
}
|
||||
|
||||
// executeBashBuffered collects all output before returning (original behavior).
|
||||
// It uses explicit pipes (not cmd.Stdout) so that cmd.WaitDelay can forcibly
|
||||
// close them when grandchild processes hold pipe handles open after the
|
||||
// direct child exits.
|
||||
func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd, sudoPassword string) (fantasy.ToolResponse, error) {
|
||||
// setupBashPipes opens stdout/stderr pipes (plus an optional sudo stdin),
|
||||
// starts the command, and asynchronously writes the sudo password if any.
|
||||
// Returns the readers ready for the caller to consume. If setup fails,
|
||||
// errResp is non-nil and the readers must not be used; the caller should
|
||||
// return the response directly.
|
||||
func setupBashPipes(cmd *exec.Cmd, sudoPassword string) (stdout, stderr io.Reader, errResp *fantasy.ToolResponse) {
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
|
||||
r := fantasy.NewTextErrorResponse("failed to create stdout pipe")
|
||||
return nil, nil, &r
|
||||
}
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
|
||||
r := fantasy.NewTextErrorResponse("failed to create stderr pipe")
|
||||
return nil, nil, &r
|
||||
}
|
||||
|
||||
// If we have a sudo password, create a stdin pipe and write the password
|
||||
var stdinPipe io.WriteCloser
|
||||
if sudoPassword != "" {
|
||||
stdinPipe, err = cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stdin pipe"), nil
|
||||
r := fantasy.NewTextErrorResponse("failed to create stdin pipe")
|
||||
return nil, nil, &r
|
||||
}
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
|
||||
r := fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err))
|
||||
return nil, nil, &r
|
||||
}
|
||||
|
||||
// Write password to stdin if needed, then close stdin
|
||||
if sudoPassword != "" && stdinPipe != nil {
|
||||
go func() {
|
||||
defer func() { _ = stdinPipe.Close() }()
|
||||
@@ -284,19 +287,49 @@ func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exe
|
||||
}()
|
||||
}
|
||||
|
||||
return stdoutPipe, stderrPipe, nil
|
||||
}
|
||||
|
||||
// interpretBashExit decodes cmd.Wait()'s error into an exit code, mapping
|
||||
// context-deadline-exceeded to a friendly "command timed out" response.
|
||||
// errResp is non-nil only when the caller should short-circuit and return
|
||||
// it directly (e.g. timeout).
|
||||
func interpretBashExit(waitErr error, cmdCtx context.Context) (exitCode int, errResp *fantasy.ToolResponse) {
|
||||
if waitErr == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||
return exitErr.ExitCode(), nil
|
||||
}
|
||||
if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
r := fantasy.NewTextErrorResponse("command timed out")
|
||||
return 0, &r
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// executeBashBuffered collects all output before returning (original behavior).
|
||||
// It uses explicit pipes (not cmd.Stdout) so that cmd.WaitDelay can forcibly
|
||||
// close them when grandchild processes hold pipe handles open after the
|
||||
// direct child exits.
|
||||
func executeBashBuffered(cmdCtx context.Context, _ fantasy.ToolCall, cmd *exec.Cmd, sudoPassword string) (fantasy.ToolResponse, error) {
|
||||
stdoutPipe, stderrPipe, errResp := setupBashPipes(cmd, sudoPassword)
|
||||
if errResp != nil {
|
||||
return *errResp, nil
|
||||
}
|
||||
|
||||
// Read pipes concurrently
|
||||
var wg sync.WaitGroup
|
||||
var stdout, stderr strings.Builder
|
||||
var stdoutErr, stderrErr error
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, stdoutErr = io.Copy(&stdout, stdoutPipe)
|
||||
_, _ = io.Copy(&stdout, stdoutPipe)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, stderrErr = io.Copy(&stderr, stderrPipe)
|
||||
_, _ = io.Copy(&stderr, stderrPipe)
|
||||
}()
|
||||
|
||||
// Wait for the process to exit first. cmd.WaitDelay ensures that if
|
||||
@@ -307,18 +340,9 @@ func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exe
|
||||
// Wait for pipe readers to finish draining.
|
||||
wg.Wait()
|
||||
|
||||
// Ignore pipe read errors caused by WaitDelay force-closing —
|
||||
// we still have whatever was read before the close.
|
||||
_ = stdoutErr
|
||||
_ = stderrErr
|
||||
|
||||
exitCode := 0
|
||||
if waitErr != nil {
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
return fantasy.NewTextErrorResponse("command timed out"), nil
|
||||
}
|
||||
exitCode, errResp := interpretBashExit(waitErr, cmdCtx)
|
||||
if errResp != nil {
|
||||
return *errResp, nil
|
||||
}
|
||||
|
||||
return buildBashResponse(stdout.String(), stderr.String(), exitCode)
|
||||
@@ -326,35 +350,9 @@ func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exe
|
||||
|
||||
// executeBashStreaming streams output as it arrives via the callback.
|
||||
func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd, outputCallback ToolOutputCallback, sudoPassword string) (fantasy.ToolResponse, error) {
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
|
||||
}
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
|
||||
}
|
||||
|
||||
// If we have a sudo password, create a stdin pipe
|
||||
var stdinPipe io.WriteCloser
|
||||
if sudoPassword != "" {
|
||||
stdinPipe, err = cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stdin pipe"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Start command execution
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
|
||||
}
|
||||
|
||||
// Write password to stdin if needed, then close stdin
|
||||
if sudoPassword != "" && stdinPipe != nil {
|
||||
go func() {
|
||||
defer func() { _ = stdinPipe.Close() }()
|
||||
_, _ = io.WriteString(stdinPipe, sudoPassword+"\n")
|
||||
}()
|
||||
stdoutPipe, stderrPipe, errResp := setupBashPipes(cmd, sudoPassword)
|
||||
if errResp != nil {
|
||||
return *errResp, nil
|
||||
}
|
||||
|
||||
// Stream stdout and stderr concurrently
|
||||
@@ -391,20 +389,16 @@ func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *ex
|
||||
// Wait for the process to exit. cmd.WaitDelay ensures that if pipes
|
||||
// remain open (held by grandchild processes), they'll be forcibly closed
|
||||
// after the grace period, which unblocks the scanners above.
|
||||
err = cmd.Wait()
|
||||
waitErr := cmd.Wait()
|
||||
|
||||
// Wait for the pipe readers to finish draining. This will complete
|
||||
// quickly since cmd.Wait() (with WaitDelay) has already ensured
|
||||
// the pipes are closed.
|
||||
wg.Wait()
|
||||
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
return fantasy.NewTextErrorResponse("command timed out"), nil
|
||||
}
|
||||
exitCode, errResp := interpretBashExit(waitErr, cmdCtx)
|
||||
if errResp != nil {
|
||||
return *errResp, nil
|
||||
}
|
||||
|
||||
return buildBashResponse(strings.Join(stdoutChunks, "\n"), strings.Join(stderrChunks, "\n"), exitCode)
|
||||
|
||||
@@ -83,6 +83,9 @@ func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
}
|
||||
|
||||
func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fantasy.ToolResponse{}, err
|
||||
}
|
||||
var args editArgs
|
||||
if err := parseArgs(call.Input, &args); err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to parse arguments: " + err.Error()), nil
|
||||
|
||||
@@ -42,6 +42,9 @@ func NewLsTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
}
|
||||
|
||||
func executeLs(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fantasy.ToolResponse{}, err
|
||||
}
|
||||
var args lsArgs
|
||||
_ = parseArgs(call.Input, &args) // optional args
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ func NewReadTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
}
|
||||
|
||||
func executeRead(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fantasy.ToolResponse{}, err
|
||||
}
|
||||
var args readArgs
|
||||
if err := parseArgs(call.Input, &args); err != nil {
|
||||
return fantasy.NewTextErrorResponse("path parameter is required"), nil
|
||||
|
||||
@@ -41,6 +41,9 @@ func NewWriteTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
}
|
||||
|
||||
func executeWrite(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fantasy.ToolResponse{}, err
|
||||
}
|
||||
var args writeArgs
|
||||
if err := parseArgs(call.Input, &args); err != nil {
|
||||
return fantasy.NewTextErrorResponse("path and content parameters are required"), nil
|
||||
|
||||
+24
-157
@@ -1,143 +1,32 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/mark3labs/kit/internal/watcher"
|
||||
)
|
||||
|
||||
// Watcher monitors extension directories for file changes and triggers
|
||||
// a reload callback when .go files are created, modified, or removed.
|
||||
// It uses fsnotify for kernel-level file notifications (inotify on Linux,
|
||||
// kqueue on macOS) with debouncing to coalesce rapid editor writes.
|
||||
type Watcher struct {
|
||||
watcher *fsnotify.Watcher
|
||||
onReload func()
|
||||
debounce time.Duration
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
// Watcher monitors extension directories for .go file changes and triggers
|
||||
// a reload callback when changes are detected. It is implemented in terms
|
||||
// of the general-purpose internal/watcher.ContentWatcher.
|
||||
//
|
||||
// Type-aliasing here lets existing call sites (cmd/root.go and the
|
||||
// watcher_test.go suite) keep using `extensions.NewWatcher` / `*Watcher`
|
||||
// without knowing about the underlying implementation.
|
||||
type Watcher = watcher.ContentWatcher
|
||||
|
||||
// NewWatcher creates a file watcher that monitors the given directories
|
||||
// for .go file changes. When a change is detected (after debouncing),
|
||||
// onReload is called. The watcher must be started with Start() and
|
||||
// stopped with Close().
|
||||
func NewWatcher(dirs []string, onReload func()) (*Watcher, error) {
|
||||
fsw, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating file watcher: %w", err)
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
// Watch the directory itself.
|
||||
if err := fsw.Add(dir); err != nil {
|
||||
log.Printf("DEBUG watcher: skipping directory: dir=%s err=%v", dir, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Also watch immediate subdirectories (for */main.go pattern).
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
subdir := filepath.Join(dir, entry.Name())
|
||||
if err := fsw.Add(subdir); err != nil {
|
||||
log.Printf("DEBUG watcher: skipping subdirectory: dir=%s err=%v", subdir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &Watcher{
|
||||
watcher: fsw,
|
||||
onReload: onReload,
|
||||
debounce: 300 * time.Millisecond,
|
||||
done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start begins watching for file changes. It blocks until the context
|
||||
// is cancelled or Close() is called. Typically called in a goroutine.
|
||||
func (w *Watcher) Start(ctx context.Context) {
|
||||
w.mu.Lock()
|
||||
ctx, w.cancel = context.WithCancel(ctx)
|
||||
w.mu.Unlock()
|
||||
|
||||
defer close(w.done)
|
||||
|
||||
var timer *time.Timer
|
||||
var timerC <-chan time.Time
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
return
|
||||
|
||||
case event, ok := <-w.watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Only care about .go files.
|
||||
if !strings.HasSuffix(event.Name, ".go") {
|
||||
continue
|
||||
}
|
||||
|
||||
// React to write, create, remove, rename events.
|
||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("DEBUG watcher: file changed: file=%s op=%s", event.Name, event.Op)
|
||||
|
||||
// Debounce: reset timer on each event.
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
timer = time.NewTimer(w.debounce)
|
||||
timerC = timer.C
|
||||
|
||||
case <-timerC:
|
||||
timerC = nil
|
||||
timer = nil
|
||||
log.Printf("DEBUG watcher: reloading extensions")
|
||||
w.onReload()
|
||||
|
||||
case err, ok := <-w.watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Printf("WARN watcher: error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the watcher and releases resources.
|
||||
func (w *Watcher) Close() error {
|
||||
w.mu.Lock()
|
||||
cancel := w.cancel
|
||||
w.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
|
||||
// Wait for the event loop to finish.
|
||||
<-w.done
|
||||
return w.watcher.Close()
|
||||
return watcher.New(watcher.Options{
|
||||
Dirs: dirs,
|
||||
Extensions: []string{".go"},
|
||||
OnReload: onReload,
|
||||
Label: "extensions",
|
||||
})
|
||||
}
|
||||
|
||||
// WatchedDirs returns the directories to watch for extension changes.
|
||||
@@ -146,47 +35,25 @@ func (w *Watcher) Close() error {
|
||||
// point to directories are also included; explicit file paths cause
|
||||
// their parent directory to be watched instead.
|
||||
func WatchedDirs(extraPaths []string) []string {
|
||||
var dirs []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
add := func(dir string) {
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if seen[abs] {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the directory exists.
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil || !info.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
seen[abs] = true
|
||||
dirs = append(dirs, abs)
|
||||
standard := []string{
|
||||
globalExtensionsDir(),
|
||||
filepath.Join(".kit", "extensions"),
|
||||
}
|
||||
|
||||
// Global extensions dir.
|
||||
add(globalExtensionsDir())
|
||||
|
||||
// Project-local extensions dir.
|
||||
add(filepath.Join(".kit", "extensions"))
|
||||
|
||||
// Explicit paths that are directories.
|
||||
// Filter explicit paths into directories (passed through) and files
|
||||
// (parent dir watched) for CollectDirs to dedupe.
|
||||
var extras []string
|
||||
for _, p := range extraPaths {
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
add(p)
|
||||
extras = append(extras, p)
|
||||
} else {
|
||||
// For explicit files, watch the parent directory.
|
||||
add(filepath.Dir(p))
|
||||
extras = append(extras, filepath.Dir(p))
|
||||
}
|
||||
}
|
||||
|
||||
return dirs
|
||||
return watcher.CollectDirs(standard, extras)
|
||||
}
|
||||
|
||||
@@ -398,6 +398,24 @@ func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, mo
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAutoRouteAPIKey looks up the API key for an auto-routed provider,
|
||||
// returning a uniform error message when none can be resolved.
|
||||
func resolveAutoRouteAPIKey(config *ProviderConfig, info *ProviderInfo) (string, error) {
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return "", fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
info.Name, strings.Join(info.Env, " / "))
|
||||
}
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
// wrapProviderErr produces the uniform "failed to create X provider/model: %w"
|
||||
// error wrap used by every createXxxProvider path. kind is typically
|
||||
// "provider" or "model".
|
||||
func wrapProviderErr(name, kind string, err error) error {
|
||||
return fmt.Errorf("failed to create %s %s: %w", name, kind, err)
|
||||
}
|
||||
|
||||
// createAutoRoutedOpenAICompatProvider creates an openaicompat provider using
|
||||
// the api URL and env vars from models.dev.
|
||||
func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
|
||||
@@ -409,10 +427,9 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
|
||||
return nil, fmt.Errorf("provider %s requires --provider-url (no API URL in database)", info.ID)
|
||||
}
|
||||
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
info.Name, strings.Join(info.Env, " / "))
|
||||
apiKey, err := resolveAutoRouteAPIKey(config, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var opts []openaicompat.Option
|
||||
@@ -426,12 +443,12 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
|
||||
|
||||
p, err := openaicompat.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -442,10 +459,9 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
|
||||
func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
|
||||
clearConflictingAnthropicSamplingParams(config)
|
||||
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
info.Name, strings.Join(info.Env, " / "))
|
||||
apiKey, err := resolveAutoRouteAPIKey(config, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var opts []anthropic.Option
|
||||
@@ -464,12 +480,12 @@ func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConf
|
||||
|
||||
p, err := anthropic.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -478,10 +494,9 @@ func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConf
|
||||
// createAutoRoutedOpenAIProvider creates an openai provider for
|
||||
// third-party providers with openai-compatible APIs.
|
||||
func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
info.Name, strings.Join(info.Env, " / "))
|
||||
apiKey, err := resolveAutoRouteAPIKey(config, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var opts []openai.Option
|
||||
@@ -498,12 +513,12 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig,
|
||||
|
||||
p, err := openai.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "model", err)
|
||||
}
|
||||
|
||||
providerOpts := buildOpenAIProviderOptions(config, modelName)
|
||||
@@ -522,10 +537,9 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig,
|
||||
// path that the proxy rejects. In that case we install a transport that
|
||||
// strips the injected segment so the proxy's own version is used.
|
||||
func createAutoRoutedGoogleProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
info.Name, strings.Join(info.Env, " / "))
|
||||
apiKey, err := resolveAutoRouteAPIKey(config, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts := []google.Option{
|
||||
@@ -550,12 +564,12 @@ func createAutoRoutedGoogleProvider(ctx context.Context, config *ProviderConfig,
|
||||
|
||||
p, err := google.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -859,12 +873,12 @@ func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelN
|
||||
|
||||
provider, err := anthropic.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Anthropic provider: %w", err)
|
||||
return nil, wrapProviderErr("Anthropic", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Anthropic model: %w", err)
|
||||
return nil, wrapProviderErr("Anthropic", "model", err)
|
||||
}
|
||||
|
||||
// Build provider options for extended thinking (reasoning budget).
|
||||
@@ -901,12 +915,12 @@ func createVertexAnthropicProvider(ctx context.Context, config *ProviderConfig,
|
||||
|
||||
provider, err := anthropic.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Vertex Anthropic provider: %w", err)
|
||||
return nil, wrapProviderErr("Vertex Anthropic", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Vertex Anthropic model: %w", err)
|
||||
return nil, wrapProviderErr("Vertex Anthropic", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -974,12 +988,12 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
provider, err := openai.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenAI provider: %w", err)
|
||||
return nil, wrapProviderErr("OpenAI", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenAI model: %w", err)
|
||||
return nil, wrapProviderErr("OpenAI", "model", err)
|
||||
}
|
||||
|
||||
// Build provider options for OpenAI Responses API reasoning models.
|
||||
@@ -1015,12 +1029,12 @@ func createOpenAICodexProvider(ctx context.Context, config *ProviderConfig, mode
|
||||
|
||||
provider, err := openai.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenAI Codex provider: %w", err)
|
||||
return nil, wrapProviderErr("OpenAI Codex", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenAI Codex model: %w", err)
|
||||
return nil, wrapProviderErr("OpenAI Codex", "model", err)
|
||||
}
|
||||
|
||||
providerOpts := buildCodexProviderOptions(config, modelName)
|
||||
@@ -1133,12 +1147,12 @@ func createGoogleProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
provider, err := google.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Google provider: %w", err)
|
||||
return nil, wrapProviderErr("Google", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Google model: %w", err)
|
||||
return nil, wrapProviderErr("Google", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -1171,12 +1185,12 @@ func createAzureProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
provider, err := azure.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Azure OpenAI provider: %w", err)
|
||||
return nil, wrapProviderErr("Azure OpenAI", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Azure OpenAI model: %w", err)
|
||||
return nil, wrapProviderErr("Azure OpenAI", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -1196,12 +1210,12 @@ func createOpenRouterProvider(ctx context.Context, config *ProviderConfig, model
|
||||
|
||||
provider, err := openrouter.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenRouter provider: %w", err)
|
||||
return nil, wrapProviderErr("OpenRouter", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenRouter model: %w", err)
|
||||
return nil, wrapProviderErr("OpenRouter", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -1213,12 +1227,12 @@ func createBedrockProvider(ctx context.Context, config *ProviderConfig, modelNam
|
||||
// Bedrock uses AWS SDK default credential chain (env vars, shared config, etc.)
|
||||
provider, err := bedrock.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Bedrock provider: %w", err)
|
||||
return nil, wrapProviderErr("Bedrock", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Bedrock model: %w", err)
|
||||
return nil, wrapProviderErr("Bedrock", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -1242,12 +1256,12 @@ func createVercelProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
provider, err := vercel.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Vercel provider: %w", err)
|
||||
return nil, wrapProviderErr("Vercel", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Vercel model: %w", err)
|
||||
return nil, wrapProviderErr("Vercel", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -1300,12 +1314,12 @@ func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
p, err := openai.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create custom provider: %w", err)
|
||||
return nil, wrapProviderErr("custom", "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create custom model: %w", err)
|
||||
return nil, wrapProviderErr("custom", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -1349,12 +1363,12 @@ func createOllamaProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
provider, err := openaicompat.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Ollama provider: %w", err)
|
||||
return nil, wrapProviderErr("Ollama", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Ollama model: %w", err)
|
||||
return nil, wrapProviderErr("Ollama", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{
|
||||
|
||||
@@ -458,11 +458,6 @@ func (tm *TreeManager) AppendLLMMessage(msg fantasy.Message) (string, error) {
|
||||
return tm.AppendMessage(message.FromLLMMessage(msg))
|
||||
}
|
||||
|
||||
// Deprecated: Use AppendLLMMessage instead.
|
||||
func (tm *TreeManager) AppendFantasyMessage(msg fantasy.Message) (string, error) {
|
||||
return tm.AppendLLMMessage(msg)
|
||||
}
|
||||
|
||||
// AppendModelChange records a model/provider change.
|
||||
func (tm *TreeManager) AppendModelChange(provider, modelID string) (string, error) {
|
||||
tm.mu.Lock()
|
||||
@@ -1170,11 +1165,6 @@ func (tm *TreeManager) AddLLMMessages(msgs []fantasy.Message) error {
|
||||
return tm.flushLocked()
|
||||
}
|
||||
|
||||
// Deprecated: Use AddLLMMessages instead.
|
||||
func (tm *TreeManager) AddFantasyMessages(msgs []fantasy.Message) error {
|
||||
return tm.AddLLMMessages(msgs)
|
||||
}
|
||||
|
||||
// GetLLMMessages builds the context and returns just the messages.
|
||||
// This satisfies the same conceptual role as the old Manager.GetMessages().
|
||||
func (tm *TreeManager) GetLLMMessages() []fantasy.Message {
|
||||
@@ -1182,11 +1172,6 @@ func (tm *TreeManager) GetLLMMessages() []fantasy.Message {
|
||||
return msgs
|
||||
}
|
||||
|
||||
// Deprecated: Use GetLLMMessages instead.
|
||||
func (tm *TreeManager) GetFantasyMessages() []fantasy.Message {
|
||||
return tm.GetLLMMessages()
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
// addEntryToIndex adds an entry to the in-memory indices.
|
||||
|
||||
Reference in New Issue
Block a user