apps/node 중심 구현 — TCP+JSON transport, Hexagonal Architecture, mock/cli adapter, fx DI, SQLite 실행 이력 저장. edge/control-plane/worker는 cobra placeholder. 유닛 테스트 및 통합 테스트 클라이언트 계획서 추가.
144 lines
3.4 KiB
Go
144 lines
3.4 KiB
Go
// Package cli provides an Adapter that runs external CLI tools as inference
|
|
// backends. Profiles (claude, gemini, codex, opencode) are configured in
|
|
// configs/node.yaml and select the command + args to execute.
|
|
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"iop/apps/node/internal/runtime"
|
|
"iop/packages/config"
|
|
)
|
|
|
|
const Name = "cli"
|
|
|
|
// known profiles — must match CLI tool names
|
|
var knownProfiles = []string{"claude", "gemini", "codex", "opencode"}
|
|
|
|
type CLI struct {
|
|
profiles map[string]config.CLIProfileConf
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func New(cfg config.CLIConf, logger *zap.Logger) *CLI {
|
|
return &CLI{
|
|
profiles: cfg.Profiles,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
func (c *CLI) Name() string { return Name }
|
|
|
|
func (c *CLI) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
|
models := make([]string, 0, len(c.profiles))
|
|
for name := range c.profiles {
|
|
models = append(models, name)
|
|
}
|
|
// Always advertise known profiles even if not in config yet.
|
|
for _, p := range knownProfiles {
|
|
if _, ok := c.profiles[p]; !ok {
|
|
models = append(models, p)
|
|
}
|
|
}
|
|
return runtime.Capabilities{
|
|
AdapterName: Name,
|
|
Models: models,
|
|
MaxConcurrency: 4,
|
|
}, nil
|
|
}
|
|
|
|
func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
|
|
profile, ok := c.profiles[spec.Model]
|
|
if !ok {
|
|
return fmt.Errorf("cli adapter: unknown profile %q", spec.Model)
|
|
}
|
|
|
|
prompt := extractPrompt(spec.Input)
|
|
args := append(append([]string{}, profile.Args...), prompt)
|
|
cmd := exec.CommandContext(ctx, profile.Command, args...)
|
|
|
|
// Pass profile env vars
|
|
if len(profile.Env) > 0 {
|
|
cmd.Env = append(cmd.Environ(), profile.Env...)
|
|
}
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("cli adapter: stdout pipe: %w", err)
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("cli adapter: stderr pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("cli adapter: start %q: %w", profile.Command, err)
|
|
}
|
|
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeStart,
|
|
Timestamp: time.Now(),
|
|
})
|
|
|
|
scanner := bufio.NewScanner(stdout)
|
|
outputTokens := 0
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
outputTokens += len(strings.Fields(line))
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeDelta,
|
|
Delta: line + "\n",
|
|
Timestamp: time.Now(),
|
|
})
|
|
}
|
|
|
|
// Collect stderr for error reporting
|
|
var errBuf strings.Builder
|
|
errScanner := bufio.NewScanner(stderr)
|
|
for errScanner.Scan() {
|
|
errBuf.WriteString(errScanner.Text())
|
|
errBuf.WriteByte('\n')
|
|
}
|
|
|
|
if err := cmd.Wait(); err != nil {
|
|
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeError,
|
|
Error: fmt.Sprintf("command failed: %v — %s", err, errBuf.String()),
|
|
Timestamp: time.Now(),
|
|
})
|
|
return fmt.Errorf("cli adapter: command exited with error: %w", err)
|
|
}
|
|
|
|
return sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeComplete,
|
|
Message: "cli execution complete",
|
|
Usage: &runtime.UsageStats{
|
|
InputTokens: len(strings.Fields(prompt)),
|
|
OutputTokens: outputTokens,
|
|
},
|
|
Timestamp: time.Now(),
|
|
})
|
|
}
|
|
|
|
func extractPrompt(input map[string]any) string {
|
|
if input == nil {
|
|
return ""
|
|
}
|
|
if v, ok := input["prompt"]; ok {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
}
|
|
return fmt.Sprintf("%v", input)
|
|
}
|