Refactored, added comprehensive testing.
All checks were successful
Release / release (push) Successful in 3m17s
All checks were successful
Release / release (push) Successful in 3m17s
This commit is contained in:
789
main.go
789
main.go
@@ -1,154 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/api"
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
"git.wisehodl.dev/jay/aicli/input"
|
||||
"git.wisehodl.dev/jay/aicli/output"
|
||||
"git.wisehodl.dev/jay/aicli/prompt"
|
||||
"git.wisehodl.dev/jay/aicli/version"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const defaultPrompt = "Analyze the following:"
|
||||
|
||||
type stdinRole int
|
||||
|
||||
const (
|
||||
stdinAsPrompt stdinRole = iota
|
||||
stdinAsPrefixedContent
|
||||
stdinAsFile
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Protocol string
|
||||
URL string
|
||||
Key string
|
||||
Model string
|
||||
Fallbacks []string
|
||||
SystemText string
|
||||
PromptText string
|
||||
Files []FileData
|
||||
OutputPath string
|
||||
Quiet bool
|
||||
Verbose bool
|
||||
}
|
||||
|
||||
type FileData struct {
|
||||
Path string
|
||||
Content string
|
||||
}
|
||||
|
||||
type flagValues struct {
|
||||
files []string
|
||||
prompts []string
|
||||
promptFile string
|
||||
system string
|
||||
systemFile string
|
||||
key string
|
||||
keyFile string
|
||||
protocol string
|
||||
url string
|
||||
model string
|
||||
fallback string
|
||||
output string
|
||||
config string
|
||||
stdinFile bool
|
||||
quiet bool
|
||||
verbose bool
|
||||
showVersion bool
|
||||
}
|
||||
|
||||
const usageText = `Usage: aicli [OPTION]... [FILE]...
|
||||
Send files and prompts to LLM chat endpoints.
|
||||
|
||||
With no FILE, or when FILE is -, read standard input.
|
||||
|
||||
Global:
|
||||
--version display version information and exit
|
||||
|
||||
Input:
|
||||
-f, --file PATH input file (repeatable)
|
||||
-F, --stdin-file treat stdin as file contents
|
||||
-p, --prompt TEXT prompt text (repeatable, can be combined with --prompt-file)
|
||||
-pf, --prompt-file PATH prompt from file (combined with any --prompt flags)
|
||||
|
||||
System:
|
||||
-s, --system TEXT system prompt text
|
||||
-sf, --system-file PATH system prompt from file
|
||||
|
||||
API:
|
||||
-l, --protocol PROTO API protocol: openai, ollama (default: openai)
|
||||
-u, --url URL API endpoint (default: https://api.ppq.ai/chat/completions)
|
||||
-k, --key KEY API key (if present, --key-file is ignored)
|
||||
-kf, --key-file PATH API key from file (used only if --key is not provided)
|
||||
|
||||
Models:
|
||||
-m, --model NAME primary model (default: gpt-4o-mini)
|
||||
-b, --fallback NAMES comma-separated fallback models (default: gpt-4.1-mini)
|
||||
|
||||
Output:
|
||||
-o, --output PATH write to file instead of stdout
|
||||
-q, --quiet suppress progress output
|
||||
-v, --verbose enable debug logging
|
||||
|
||||
Config:
|
||||
-c, --config PATH YAML config file
|
||||
|
||||
Environment variables:
|
||||
AICLI_API_KEY API key
|
||||
AICLI_API_KEY_FILE Path to file containing API key (used only if AICLI_API_KEY is not set)
|
||||
AICLI_PROTOCOL API protocol
|
||||
AICLI_URL API endpoint
|
||||
AICLI_MODEL primary model
|
||||
AICLI_FALLBACK fallback models
|
||||
AICLI_SYSTEM system prompt
|
||||
AICLI_DEFAULT_PROMPT default prompt override
|
||||
AICLI_CONFIG_FILE Path to config file
|
||||
AICLI_PROMPT_FILE Path to prompt file
|
||||
AICLI_SYSTEM_FILE Path to system file
|
||||
|
||||
API Key precedence: --key flag > --key-file flag > AICLI_API_KEY > AICLI_API_KEY_FILE > config file
|
||||
|
||||
Examples:
|
||||
echo "What is Rust?" | aicli
|
||||
cat file.txt | aicli -F -p "Analyze this file"
|
||||
aicli -f main.go -p "Review this code"
|
||||
aicli -c ~/.aicli.yaml -f src/*.go -o analysis.md
|
||||
aicli -p "First prompt" -pf prompt.txt -p "Last prompt"
|
||||
`
|
||||
|
||||
func printUsage() {
|
||||
fmt.Fprint(os.Stderr, usageText)
|
||||
}
|
||||
|
||||
type fileList []string
|
||||
|
||||
func (f *fileList) String() string {
|
||||
return strings.Join(*f, ", ")
|
||||
}
|
||||
|
||||
func (f *fileList) Set(value string) error {
|
||||
*f = append(*f, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
type promptList []string
|
||||
|
||||
func (p *promptList) String() string {
|
||||
return strings.Join(*p, "\n")
|
||||
}
|
||||
|
||||
func (p *promptList) Set(value string) error {
|
||||
*p = append(*p, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
@@ -157,612 +20,64 @@ func main() {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
// Check for verbose flag early
|
||||
verbose := false
|
||||
for _, arg := range os.Args {
|
||||
if arg == "-v" || arg == "--verbose" {
|
||||
verbose = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check for config file in environment variable before parsing flags
|
||||
configFilePath := os.Getenv("AICLI_CONFIG_FILE")
|
||||
|
||||
flags := parseFlags()
|
||||
|
||||
if flags.showVersion {
|
||||
// Phase 1: Version check (early exit)
|
||||
if config.IsVersionRequest(os.Args[1:]) {
|
||||
fmt.Printf("aicli %s\n", version.GetVersion())
|
||||
return nil
|
||||
}
|
||||
|
||||
if flags.config == "" && configFilePath != "" {
|
||||
flags.config = configFilePath
|
||||
}
|
||||
|
||||
envVals := loadEnvVars(verbose)
|
||||
fileVals, err := loadConfigFile(flags.config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
merged := mergeConfigSources(verbose, flags, envVals, fileVals)
|
||||
if err := validateConfig(merged); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if promptFilePath := os.Getenv("AICLI_PROMPT_FILE"); promptFilePath != "" && flags.promptFile == "" {
|
||||
content, err := os.ReadFile(promptFilePath)
|
||||
if err != nil {
|
||||
if verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Failed to read AICLI_PROMPT_FILE at %s: %v\n", promptFilePath, err)
|
||||
}
|
||||
} else {
|
||||
merged.PromptText = string(content)
|
||||
}
|
||||
}
|
||||
|
||||
if systemFilePath := os.Getenv("AICLI_SYSTEM_FILE"); systemFilePath != "" && flags.systemFile == "" && flags.system == "" {
|
||||
content, err := os.ReadFile(systemFilePath)
|
||||
if err != nil {
|
||||
if verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Failed to read AICLI_SYSTEM_FILE at %s: %v\n", systemFilePath, err)
|
||||
}
|
||||
} else {
|
||||
merged.SystemText = string(content)
|
||||
}
|
||||
}
|
||||
|
||||
stdinContent, hasStdin := detectStdin()
|
||||
role := determineStdinRole(flags, hasStdin)
|
||||
|
||||
inputData, err := resolveInputStreams(merged, stdinContent, hasStdin, role, flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config := buildCompletePrompt(inputData)
|
||||
|
||||
if config.Verbose {
|
||||
logVerbose("Configuration resolved", config)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
response, usedModel, err := sendChatRequest(config)
|
||||
duration := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeOutput(response, usedModel, duration, config)
|
||||
}
|
||||
|
||||
func parseFlags() flagValues {
|
||||
fv := flagValues{}
|
||||
var files fileList
|
||||
var prompts promptList
|
||||
|
||||
flag.Usage = printUsage
|
||||
|
||||
flag.Var(&files, "f", "")
|
||||
flag.Var(&files, "file", "")
|
||||
flag.Var(&prompts, "p", "")
|
||||
flag.Var(&prompts, "prompt", "")
|
||||
flag.StringVar(&fv.promptFile, "pf", "", "")
|
||||
flag.StringVar(&fv.promptFile, "prompt-file", "", "")
|
||||
flag.StringVar(&fv.system, "s", "", "")
|
||||
flag.StringVar(&fv.system, "system", "", "")
|
||||
flag.StringVar(&fv.systemFile, "sf", "", "")
|
||||
flag.StringVar(&fv.systemFile, "system-file", "", "")
|
||||
flag.StringVar(&fv.key, "k", "", "")
|
||||
flag.StringVar(&fv.key, "key", "", "")
|
||||
flag.StringVar(&fv.keyFile, "kf", "", "")
|
||||
flag.StringVar(&fv.keyFile, "key-file", "", "")
|
||||
flag.StringVar(&fv.protocol, "l", "", "")
|
||||
flag.StringVar(&fv.protocol, "protocol", "", "")
|
||||
flag.StringVar(&fv.url, "u", "", "")
|
||||
flag.StringVar(&fv.url, "url", "", "")
|
||||
flag.StringVar(&fv.model, "m", "", "")
|
||||
flag.StringVar(&fv.model, "model", "", "")
|
||||
flag.StringVar(&fv.fallback, "b", "", "")
|
||||
flag.StringVar(&fv.fallback, "fallback", "", "")
|
||||
flag.StringVar(&fv.output, "o", "", "")
|
||||
flag.StringVar(&fv.output, "output", "", "")
|
||||
flag.StringVar(&fv.config, "c", "", "")
|
||||
flag.StringVar(&fv.config, "config", "", "")
|
||||
flag.BoolVar(&fv.stdinFile, "F", false, "")
|
||||
flag.BoolVar(&fv.stdinFile, "stdin-file", false, "")
|
||||
flag.BoolVar(&fv.quiet, "q", false, "")
|
||||
flag.BoolVar(&fv.quiet, "quiet", false, "")
|
||||
flag.BoolVar(&fv.verbose, "v", false, "")
|
||||
flag.BoolVar(&fv.verbose, "verbose", false, "")
|
||||
flag.BoolVar(&fv.showVersion, "version", false, "")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
fv.files = files
|
||||
fv.prompts = prompts
|
||||
|
||||
return fv
|
||||
}
|
||||
|
||||
func loadEnvVars(verbose bool) map[string]string {
|
||||
env := make(map[string]string)
|
||||
if val := os.Getenv("AICLI_PROTOCOL"); val != "" {
|
||||
env["protocol"] = val
|
||||
}
|
||||
if val := os.Getenv("AICLI_URL"); val != "" {
|
||||
env["url"] = val
|
||||
}
|
||||
if val := os.Getenv("AICLI_API_KEY"); val != "" {
|
||||
env["key"] = val
|
||||
}
|
||||
if env["key"] == "" {
|
||||
if val := os.Getenv("AICLI_API_KEY_FILE"); val != "" {
|
||||
content, err := os.ReadFile(val)
|
||||
if err != nil && verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Failed to read AICLI_API_KEY_FILE at %s: %v\n", val, err)
|
||||
} else {
|
||||
env["key"] = strings.TrimSpace(string(content))
|
||||
}
|
||||
}
|
||||
}
|
||||
if val := os.Getenv("AICLI_MODEL"); val != "" {
|
||||
env["model"] = val
|
||||
}
|
||||
if val := os.Getenv("AICLI_FALLBACK"); val != "" {
|
||||
env["fallback"] = val
|
||||
}
|
||||
if val := os.Getenv("AICLI_SYSTEM"); val != "" {
|
||||
env["system"] = val
|
||||
}
|
||||
if val := os.Getenv("AICLI_DEFAULT_PROMPT"); val != "" {
|
||||
env["prompt"] = val
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func loadConfigFile(path string) (map[string]interface{}, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
|
||||
var config map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("parse config file: %w", err)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func mergeConfigSources(verbose bool, flags flagValues, env map[string]string, file map[string]interface{}) Config {
|
||||
cfg := Config{
|
||||
Protocol: "openai",
|
||||
URL: "https://api.ppq.ai/chat/completions",
|
||||
Model: "gpt-4o-mini",
|
||||
Fallbacks: []string{"gpt-4.1-mini"},
|
||||
Quiet: flags.quiet,
|
||||
Verbose: flags.verbose,
|
||||
}
|
||||
|
||||
if env["protocol"] != "" {
|
||||
cfg.Protocol = env["protocol"]
|
||||
}
|
||||
if env["url"] != "" {
|
||||
cfg.URL = env["url"]
|
||||
}
|
||||
if env["key"] != "" {
|
||||
cfg.Key = env["key"]
|
||||
}
|
||||
if env["model"] != "" {
|
||||
cfg.Model = env["model"]
|
||||
}
|
||||
if env["fallback"] != "" {
|
||||
cfg.Fallbacks = strings.Split(env["fallback"], ",")
|
||||
}
|
||||
if env["system"] != "" {
|
||||
cfg.SystemText = env["system"]
|
||||
}
|
||||
|
||||
if file != nil {
|
||||
if v, ok := file["protocol"].(string); ok {
|
||||
cfg.Protocol = v
|
||||
}
|
||||
if v, ok := file["url"].(string); ok {
|
||||
cfg.URL = v
|
||||
}
|
||||
if v, ok := file["model"].(string); ok {
|
||||
cfg.Model = v
|
||||
}
|
||||
if v, ok := file["fallback"].(string); ok {
|
||||
cfg.Fallbacks = strings.Split(v, ",")
|
||||
}
|
||||
if v, ok := file["system_file"].(string); ok {
|
||||
content, err := os.ReadFile(v)
|
||||
if err != nil {
|
||||
if verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Failed to read system_file at %s: %v\n", v, err)
|
||||
}
|
||||
} else {
|
||||
cfg.SystemText = string(content)
|
||||
}
|
||||
}
|
||||
if v, ok := file["key_file"].(string); ok && cfg.Key == "" {
|
||||
content, err := os.ReadFile(v)
|
||||
if err != nil {
|
||||
if cfg.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Failed to read key_file at %s: %v\n", v, err)
|
||||
}
|
||||
} else {
|
||||
cfg.Key = strings.TrimSpace(string(content))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if flags.protocol != "" {
|
||||
cfg.Protocol = flags.protocol
|
||||
}
|
||||
if flags.url != "" {
|
||||
cfg.URL = flags.url
|
||||
}
|
||||
if flags.model != "" {
|
||||
cfg.Model = flags.model
|
||||
}
|
||||
if flags.fallback != "" {
|
||||
cfg.Fallbacks = strings.Split(flags.fallback, ",")
|
||||
}
|
||||
if flags.system != "" {
|
||||
cfg.SystemText = flags.system
|
||||
}
|
||||
if flags.systemFile != "" {
|
||||
content, err := os.ReadFile(flags.systemFile)
|
||||
if err != nil {
|
||||
if cfg.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Failed to read system file at %s: %v\n", flags.systemFile, err)
|
||||
}
|
||||
} else {
|
||||
cfg.SystemText = string(content)
|
||||
}
|
||||
}
|
||||
if flags.key != "" {
|
||||
cfg.Key = flags.key
|
||||
} else if flags.keyFile != "" {
|
||||
content, err := os.ReadFile(flags.keyFile)
|
||||
if err != nil {
|
||||
if cfg.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Failed to read key file at %s: %v\n", flags.keyFile, err)
|
||||
}
|
||||
} else {
|
||||
cfg.Key = strings.TrimSpace(string(content))
|
||||
}
|
||||
}
|
||||
if flags.output != "" {
|
||||
cfg.OutputPath = flags.output
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func validateConfig(cfg Config) error {
|
||||
if cfg.Key == "" {
|
||||
return fmt.Errorf("API key required: use --key, --key-file, AICLI_API_KEY, AICLI_API_KEY_FILE, or key_file in config")
|
||||
}
|
||||
if cfg.Protocol != "openai" && cfg.Protocol != "ollama" {
|
||||
return fmt.Errorf("protocol must be 'openai' or 'ollama', got: %s", cfg.Protocol)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func detectStdin() (string, bool) {
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if (stat.Mode() & os.ModeCharDevice) != 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return string(content), true
|
||||
}
|
||||
|
||||
func determineStdinRole(flags flagValues, hasStdin bool) stdinRole {
|
||||
if !hasStdin {
|
||||
return stdinAsPrompt
|
||||
}
|
||||
|
||||
if flags.stdinFile {
|
||||
return stdinAsFile
|
||||
}
|
||||
|
||||
hasExplicitPrompt := len(flags.prompts) > 0 || flags.promptFile != ""
|
||||
|
||||
if hasExplicitPrompt {
|
||||
return stdinAsPrefixedContent
|
||||
}
|
||||
|
||||
return stdinAsPrompt
|
||||
}
|
||||
|
||||
func resolveInputStreams(cfg Config, stdinContent string, hasStdin bool, role stdinRole, flags flagValues) (Config, error) {
|
||||
hasPromptFlag := len(flags.prompts) > 0 || flags.promptFile != ""
|
||||
hasFileFlag := len(flags.files) > 0
|
||||
|
||||
// Handle case where only stdin as file is provided
|
||||
if !hasPromptFlag && !hasFileFlag && hasStdin && flags.stdinFile {
|
||||
cfg.Files = append(cfg.Files, FileData{Path: "input", Content: stdinContent})
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
if !hasStdin && !hasFileFlag && !hasPromptFlag {
|
||||
return cfg, fmt.Errorf("no input provided: supply stdin, --file, or --prompt")
|
||||
}
|
||||
|
||||
for _, path := range flags.files {
|
||||
if path == "" {
|
||||
return cfg, fmt.Errorf("empty file path provided")
|
||||
}
|
||||
}
|
||||
|
||||
if flags.system != "" && flags.systemFile != "" {
|
||||
return cfg, fmt.Errorf("cannot use both --system and --system-file")
|
||||
}
|
||||
|
||||
if len(flags.prompts) > 0 {
|
||||
cfg.PromptText = strings.Join(flags.prompts, "\n")
|
||||
}
|
||||
|
||||
if flags.promptFile != "" {
|
||||
content, err := os.ReadFile(flags.promptFile)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("read prompt file: %w", err)
|
||||
}
|
||||
if cfg.PromptText != "" {
|
||||
cfg.PromptText += "\n\n" + string(content)
|
||||
} else {
|
||||
cfg.PromptText = string(content)
|
||||
}
|
||||
}
|
||||
|
||||
if hasStdin {
|
||||
switch role {
|
||||
case stdinAsPrompt:
|
||||
cfg.PromptText = stdinContent
|
||||
case stdinAsPrefixedContent:
|
||||
if cfg.PromptText != "" {
|
||||
cfg.PromptText += "\n\n" + stdinContent
|
||||
} else {
|
||||
cfg.PromptText = stdinContent
|
||||
}
|
||||
case stdinAsFile:
|
||||
cfg.Files = append(cfg.Files, FileData{Path: "input", Content: stdinContent})
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range flags.files {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("read file %s: %w", path, err)
|
||||
}
|
||||
cfg.Files = append(cfg.Files, FileData{Path: path, Content: string(content)})
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func buildCompletePrompt(inputData Config) Config {
|
||||
result := inputData
|
||||
promptParts := []string{}
|
||||
|
||||
// Use inputData's prompt if set, otherwise check for overrides
|
||||
if inputData.PromptText != "" {
|
||||
promptParts = append(promptParts, inputData.PromptText)
|
||||
} else if override := os.Getenv("AICLI_DEFAULT_PROMPT"); override != "" {
|
||||
promptParts = append(promptParts, override)
|
||||
} else if len(inputData.Files) > 0 {
|
||||
promptParts = append(promptParts, defaultPrompt)
|
||||
}
|
||||
|
||||
// Format files if present
|
||||
if len(inputData.Files) > 0 {
|
||||
fileSection := formatFiles(inputData.Files)
|
||||
if len(promptParts) > 0 {
|
||||
promptParts = append(promptParts, "", fileSection)
|
||||
} else {
|
||||
promptParts = append(promptParts, fileSection)
|
||||
}
|
||||
}
|
||||
|
||||
result.PromptText = strings.Join(promptParts, "\n")
|
||||
return result
|
||||
}
|
||||
|
||||
func formatFiles(files []FileData) string {
|
||||
var buf strings.Builder
|
||||
for i, f := range files {
|
||||
if i > 0 {
|
||||
buf.WriteString("\n\n")
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("File: %s\n\n```\n%s\n```", f.Path, f.Content))
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func sendChatRequest(cfg Config) (string, string, error) {
|
||||
models := append([]string{cfg.Model}, cfg.Fallbacks...)
|
||||
|
||||
for i, model := range models {
|
||||
if !cfg.Quiet && i > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Model %s failed, trying %s...\n", models[i-1], model)
|
||||
}
|
||||
|
||||
response, err := tryModel(cfg, model)
|
||||
if err == nil {
|
||||
return response, model, nil
|
||||
}
|
||||
|
||||
if !cfg.Quiet {
|
||||
fmt.Fprintf(os.Stderr, "Model %s failed: %v\n", model, err)
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("all models failed")
|
||||
}
|
||||
|
||||
func tryModel(cfg Config, model string) (string, error) {
|
||||
payload := buildPayload(cfg, model)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "Request payload: %s\n", string(body))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", cfg.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Key))
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "Response: %s\n", string(respBody))
|
||||
}
|
||||
|
||||
return parseResponse(respBody, cfg.Protocol)
|
||||
}
|
||||
|
||||
func buildPayload(cfg Config, model string) map[string]interface{} {
|
||||
if cfg.Protocol == "ollama" {
|
||||
payload := map[string]interface{}{
|
||||
"model": model,
|
||||
"prompt": cfg.PromptText,
|
||||
"stream": false,
|
||||
}
|
||||
if cfg.SystemText != "" {
|
||||
payload["system"] = cfg.SystemText
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
messages := []map[string]string{}
|
||||
if cfg.SystemText != "" {
|
||||
messages = append(messages, map[string]string{
|
||||
"role": "system",
|
||||
"content": cfg.SystemText,
|
||||
})
|
||||
}
|
||||
messages = append(messages, map[string]string{
|
||||
"role": "user",
|
||||
"content": cfg.PromptText,
|
||||
})
|
||||
|
||||
return map[string]interface{}{
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
}
|
||||
}
|
||||
|
||||
func parseResponse(body []byte, protocol string) (string, error) {
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
if protocol == "ollama" {
|
||||
if response, ok := result["response"].(string); ok {
|
||||
return response, nil
|
||||
}
|
||||
return "", fmt.Errorf("no response field in ollama response")
|
||||
}
|
||||
|
||||
choices, ok := result["choices"].([]interface{})
|
||||
if !ok || len(choices) == 0 {
|
||||
return "", fmt.Errorf("no choices in response")
|
||||
}
|
||||
|
||||
firstChoice, ok := choices[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid choice format")
|
||||
}
|
||||
|
||||
message, ok := firstChoice["message"].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no message in choice")
|
||||
}
|
||||
|
||||
content, ok := message["content"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no content in message")
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func writeOutput(response, model string, duration time.Duration, cfg Config) error {
|
||||
if cfg.OutputPath == "" {
|
||||
if !cfg.Quiet {
|
||||
fmt.Println("--- aicli ---")
|
||||
fmt.Println()
|
||||
fmt.Printf("Used model: %s\n", model)
|
||||
fmt.Printf("Query duration: %.1fs\n", duration.Seconds())
|
||||
fmt.Println()
|
||||
fmt.Println("--- response ---")
|
||||
fmt.Println()
|
||||
}
|
||||
fmt.Println(response)
|
||||
if config.IsHelpRequest(os.Args[1:]) {
|
||||
fmt.Fprint(os.Stderr, config.UsageText)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cfg.OutputPath, []byte(response), 0644); err != nil {
|
||||
return fmt.Errorf("write output file: %w", err)
|
||||
// Phase 2: Configuration resolution
|
||||
cfg, err := config.BuildConfig(os.Args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !cfg.Quiet {
|
||||
fmt.Printf("Used model: %s\n", model)
|
||||
fmt.Printf("Query duration: %.1fs\n", duration.Seconds())
|
||||
fmt.Printf("Wrote response to: %s\n", cfg.OutputPath)
|
||||
if cfg.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Configuration loaded\n")
|
||||
fmt.Fprintf(os.Stderr, " Protocol: %s\n", protocolString(cfg.Protocol))
|
||||
fmt.Fprintf(os.Stderr, " URL: %s\n", cfg.URL)
|
||||
fmt.Fprintf(os.Stderr, " Model: %s\n", cfg.Model)
|
||||
fmt.Fprintf(os.Stderr, " Fallbacks: %v\n", cfg.FallbackModels)
|
||||
}
|
||||
|
||||
return nil
|
||||
// Phase 3: Input collection
|
||||
stdinContent, hasStdin := input.DetectStdin()
|
||||
|
||||
inputData, err := input.ResolveInputs(cfg, stdinContent, hasStdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Input resolved: %d prompts, %d files\n",
|
||||
len(inputData.Prompts), len(inputData.Files))
|
||||
}
|
||||
|
||||
// Phase 4: Query construction
|
||||
query := prompt.ConstructQuery(inputData.Prompts, inputData.Files)
|
||||
|
||||
if cfg.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] Query length: %d bytes\n", len(query))
|
||||
}
|
||||
|
||||
// Phase 5: API communication
|
||||
response, model, duration, err := api.SendChatRequest(cfg, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Phase 6: Output delivery
|
||||
return output.WriteOutput(response, model, duration, cfg)
|
||||
}
|
||||
|
||||
func logVerbose(msg string, cfg Config) {
|
||||
fmt.Fprintf(os.Stderr, "[verbose] %s\n", msg)
|
||||
fmt.Fprintf(os.Stderr, " Protocol: %s\n", cfg.Protocol)
|
||||
fmt.Fprintf(os.Stderr, " URL: %s\n", cfg.URL)
|
||||
fmt.Fprintf(os.Stderr, " Model: %s\n", cfg.Model)
|
||||
fmt.Fprintf(os.Stderr, " Fallbacks: %v\n", cfg.Fallbacks)
|
||||
fmt.Fprintf(os.Stderr, " Files: %d\n", len(cfg.Files))
|
||||
func protocolString(p config.APIProtocol) string {
|
||||
if p == config.ProtocolOllama {
|
||||
return "ollama"
|
||||
}
|
||||
return "openai"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user