refactor: migrate LLM provider layer to any-llm-go adapter
Replace custom per-provider implementations (Anthropic, OpenAI, Codex, GitHub Copilot, etc.) with a single AnyLLMAdapter wrapping any-llm-go. Unify config from per-provider ProvidersConfig to a flat LLM section (model, api_key, base_url). Remove OAuth/auth layer, migration tooling, and hardcoded temperature defaults. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9f1a1177d8
commit
01353f6b00
38 changed files with 525 additions and 7890 deletions
|
|
@ -23,7 +23,6 @@ import (
|
||||||
|
|
||||||
"github.com/chzyer/readline"
|
"github.com/chzyer/readline"
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/agent"
|
"github.com/KarakuriAgent/clawdroid/pkg/agent"
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/auth"
|
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/bus"
|
"github.com/KarakuriAgent/clawdroid/pkg/bus"
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/channels"
|
"github.com/KarakuriAgent/clawdroid/pkg/channels"
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/config"
|
"github.com/KarakuriAgent/clawdroid/pkg/config"
|
||||||
|
|
@ -32,7 +31,6 @@ import (
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/health"
|
"github.com/KarakuriAgent/clawdroid/pkg/health"
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/heartbeat"
|
"github.com/KarakuriAgent/clawdroid/pkg/heartbeat"
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/logger"
|
"github.com/KarakuriAgent/clawdroid/pkg/logger"
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/migrate"
|
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/providers"
|
"github.com/KarakuriAgent/clawdroid/pkg/providers"
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/skills"
|
"github.com/KarakuriAgent/clawdroid/pkg/skills"
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/state"
|
"github.com/KarakuriAgent/clawdroid/pkg/state"
|
||||||
|
|
@ -136,10 +134,6 @@ func main() {
|
||||||
gatewayCmd()
|
gatewayCmd()
|
||||||
case "status":
|
case "status":
|
||||||
statusCmd()
|
statusCmd()
|
||||||
case "migrate":
|
|
||||||
migrateCmd()
|
|
||||||
case "auth":
|
|
||||||
authCmd()
|
|
||||||
case "cron":
|
case "cron":
|
||||||
cronCmd()
|
cronCmd()
|
||||||
case "skills":
|
case "skills":
|
||||||
|
|
@ -202,11 +196,9 @@ func printHelp() {
|
||||||
fmt.Println("Commands:")
|
fmt.Println("Commands:")
|
||||||
fmt.Println(" onboard Initialize clawdroid configuration and workspace")
|
fmt.Println(" onboard Initialize clawdroid configuration and workspace")
|
||||||
fmt.Println(" agent Interact with the agent directly")
|
fmt.Println(" agent Interact with the agent directly")
|
||||||
fmt.Println(" auth Manage authentication (login, logout, status)")
|
|
||||||
fmt.Println(" gateway Start clawdroid gateway")
|
fmt.Println(" gateway Start clawdroid gateway")
|
||||||
fmt.Println(" status Show clawdroid status")
|
fmt.Println(" status Show clawdroid status")
|
||||||
fmt.Println(" cron Manage scheduled tasks")
|
fmt.Println(" cron Manage scheduled tasks")
|
||||||
fmt.Println(" migrate Migrate from OpenClaw to ClawDroid")
|
|
||||||
fmt.Println(" skills Manage skills (install, list, remove)")
|
fmt.Println(" skills Manage skills (install, list, remove)")
|
||||||
fmt.Println(" version Show version information")
|
fmt.Println(" version Show version information")
|
||||||
}
|
}
|
||||||
|
|
@ -297,76 +289,6 @@ func createWorkspaceTemplates(workspace string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateCmd() {
|
|
||||||
if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") {
|
|
||||||
migrateHelp()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := migrate.Options{}
|
|
||||||
|
|
||||||
args := os.Args[2:]
|
|
||||||
for i := 0; i < len(args); i++ {
|
|
||||||
switch args[i] {
|
|
||||||
case "--dry-run":
|
|
||||||
opts.DryRun = true
|
|
||||||
case "--config-only":
|
|
||||||
opts.ConfigOnly = true
|
|
||||||
case "--workspace-only":
|
|
||||||
opts.WorkspaceOnly = true
|
|
||||||
case "--force":
|
|
||||||
opts.Force = true
|
|
||||||
case "--refresh":
|
|
||||||
opts.Refresh = true
|
|
||||||
case "--openclaw-home":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
opts.OpenClawHome = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "--clawdroid-home":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
opts.ClawDroidHome = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
fmt.Printf("Unknown flag: %s\n", args[i])
|
|
||||||
migrateHelp()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := migrate.Run(opts)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !opts.DryRun {
|
|
||||||
migrate.PrintSummary(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateHelp() {
|
|
||||||
fmt.Println("\nMigrate from OpenClaw to ClawDroid")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Usage: clawdroid migrate [options]")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Options:")
|
|
||||||
fmt.Println(" --dry-run Show what would be migrated without making changes")
|
|
||||||
fmt.Println(" --refresh Re-sync workspace files from OpenClaw (repeatable)")
|
|
||||||
fmt.Println(" --config-only Only migrate config, skip workspace files")
|
|
||||||
fmt.Println(" --workspace-only Only migrate workspace files, skip config")
|
|
||||||
fmt.Println(" --force Skip confirmation prompts")
|
|
||||||
fmt.Println(" --openclaw-home Override OpenClaw home directory (default: ~/.openclaw)")
|
|
||||||
fmt.Println(" --clawdroid-home Override ClawDroid home directory (default: ~/.clawdroid)")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Examples:")
|
|
||||||
fmt.Println(" clawdroid migrate Detect and migrate from OpenClaw")
|
|
||||||
fmt.Println(" clawdroid migrate --dry-run Show what would be migrated")
|
|
||||||
fmt.Println(" clawdroid migrate --refresh Re-sync workspace files")
|
|
||||||
fmt.Println(" clawdroid migrate --force Migrate without confirmation")
|
|
||||||
}
|
|
||||||
|
|
||||||
func agentCmd() {
|
func agentCmd() {
|
||||||
message := ""
|
message := ""
|
||||||
sessionKey := "cli:default"
|
sessionKey := "cli:default"
|
||||||
|
|
@ -597,8 +519,8 @@ func gatewayCmd() {
|
||||||
agentLoop.SetChannelManager(channelManager)
|
agentLoop.SetChannelManager(channelManager)
|
||||||
|
|
||||||
var transcriber *voice.GroqTranscriber
|
var transcriber *voice.GroqTranscriber
|
||||||
if cfg.Providers.Groq.APIKey != "" {
|
if cfg.STT.APIKey != "" {
|
||||||
transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey)
|
transcriber = voice.NewGroqTranscriber(cfg.STT.APIKey)
|
||||||
logger.InfoC("voice", "Groq voice transcription enabled")
|
logger.InfoC("voice", "Groq voice transcription enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -718,265 +640,17 @@ func statusCmd() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
|
fmt.Printf("Model: %s\n", cfg.LLM.Model)
|
||||||
|
if cfg.LLM.APIKey != "" {
|
||||||
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
|
fmt.Println("API Key: ✓")
|
||||||
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
|
|
||||||
hasOpenAI := cfg.Providers.OpenAI.APIKey != ""
|
|
||||||
hasGemini := cfg.Providers.Gemini.APIKey != ""
|
|
||||||
hasZhipu := cfg.Providers.Zhipu.APIKey != ""
|
|
||||||
hasGroq := cfg.Providers.Groq.APIKey != ""
|
|
||||||
hasVLLM := cfg.Providers.VLLM.APIBase != ""
|
|
||||||
|
|
||||||
status := func(enabled bool) string {
|
|
||||||
if enabled {
|
|
||||||
return "✓"
|
|
||||||
}
|
|
||||||
return "not set"
|
|
||||||
}
|
|
||||||
fmt.Println("OpenRouter API:", status(hasOpenRouter))
|
|
||||||
fmt.Println("Anthropic API:", status(hasAnthropic))
|
|
||||||
fmt.Println("OpenAI API:", status(hasOpenAI))
|
|
||||||
fmt.Println("Gemini API:", status(hasGemini))
|
|
||||||
fmt.Println("Zhipu API:", status(hasZhipu))
|
|
||||||
fmt.Println("Groq API:", status(hasGroq))
|
|
||||||
if hasVLLM {
|
|
||||||
fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase)
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("vLLM/Local: not set")
|
fmt.Println("API Key: not set")
|
||||||
}
|
}
|
||||||
|
if cfg.LLM.BaseURL != "" {
|
||||||
store, _ := auth.LoadStore()
|
fmt.Printf("Base URL: %s\n", cfg.LLM.BaseURL)
|
||||||
if store != nil && len(store.Credentials) > 0 {
|
|
||||||
fmt.Println("\nOAuth/Token Auth:")
|
|
||||||
for provider, cred := range store.Credentials {
|
|
||||||
status := "authenticated"
|
|
||||||
if cred.IsExpired() {
|
|
||||||
status = "expired"
|
|
||||||
} else if cred.NeedsRefresh() {
|
|
||||||
status = "needs refresh"
|
|
||||||
}
|
|
||||||
fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
if cfg.STT.APIKey != "" {
|
||||||
}
|
fmt.Println("STT API Key: ✓")
|
||||||
|
|
||||||
func authCmd() {
|
|
||||||
if len(os.Args) < 3 {
|
|
||||||
authHelp()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch os.Args[2] {
|
|
||||||
case "login":
|
|
||||||
authLoginCmd()
|
|
||||||
case "logout":
|
|
||||||
authLogoutCmd()
|
|
||||||
case "status":
|
|
||||||
authStatusCmd()
|
|
||||||
default:
|
|
||||||
fmt.Printf("Unknown auth command: %s\n", os.Args[2])
|
|
||||||
authHelp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func authHelp() {
|
|
||||||
fmt.Println("\nAuth commands:")
|
|
||||||
fmt.Println(" login Login via OAuth or paste token")
|
|
||||||
fmt.Println(" logout Remove stored credentials")
|
|
||||||
fmt.Println(" status Show current auth status")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Login options:")
|
|
||||||
fmt.Println(" --provider <name> Provider to login with (openai, anthropic)")
|
|
||||||
fmt.Println(" --device-code Use device code flow (for headless environments)")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Examples:")
|
|
||||||
fmt.Println(" clawdroid auth login --provider openai")
|
|
||||||
fmt.Println(" clawdroid auth login --provider openai --device-code")
|
|
||||||
fmt.Println(" clawdroid auth login --provider anthropic")
|
|
||||||
fmt.Println(" clawdroid auth logout --provider openai")
|
|
||||||
fmt.Println(" clawdroid auth status")
|
|
||||||
}
|
|
||||||
|
|
||||||
func authLoginCmd() {
|
|
||||||
provider := ""
|
|
||||||
useDeviceCode := false
|
|
||||||
|
|
||||||
args := os.Args[3:]
|
|
||||||
for i := 0; i < len(args); i++ {
|
|
||||||
switch args[i] {
|
|
||||||
case "--provider", "-p":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
provider = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "--device-code":
|
|
||||||
useDeviceCode = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if provider == "" {
|
|
||||||
fmt.Println("Error: --provider is required")
|
|
||||||
fmt.Println("Supported providers: openai, anthropic")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch provider {
|
|
||||||
case "openai":
|
|
||||||
authLoginOpenAI(useDeviceCode)
|
|
||||||
case "anthropic":
|
|
||||||
authLoginPasteToken(provider)
|
|
||||||
default:
|
|
||||||
fmt.Printf("Unsupported provider: %s\n", provider)
|
|
||||||
fmt.Println("Supported providers: openai, anthropic")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func authLoginOpenAI(useDeviceCode bool) {
|
|
||||||
cfg := auth.OpenAIOAuthConfig()
|
|
||||||
|
|
||||||
var cred *auth.AuthCredential
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if useDeviceCode {
|
|
||||||
cred, err = auth.LoginDeviceCode(cfg)
|
|
||||||
} else {
|
|
||||||
cred, err = auth.LoginBrowser(cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Login failed: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := auth.SetCredential("openai", cred); err != nil {
|
|
||||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
appCfg, err := loadConfig()
|
|
||||||
if err == nil {
|
|
||||||
appCfg.Providers.OpenAI.AuthMethod = "oauth"
|
|
||||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
|
||||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Login successful!")
|
|
||||||
if cred.AccountID != "" {
|
|
||||||
fmt.Printf("Account: %s\n", cred.AccountID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func authLoginPasteToken(provider string) {
|
|
||||||
cred, err := auth.LoginPasteToken(provider, os.Stdin)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Login failed: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := auth.SetCredential(provider, cred); err != nil {
|
|
||||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
appCfg, err := loadConfig()
|
|
||||||
if err == nil {
|
|
||||||
switch provider {
|
|
||||||
case "anthropic":
|
|
||||||
appCfg.Providers.Anthropic.AuthMethod = "token"
|
|
||||||
case "openai":
|
|
||||||
appCfg.Providers.OpenAI.AuthMethod = "token"
|
|
||||||
}
|
|
||||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
|
||||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Token saved for %s!\n", provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
func authLogoutCmd() {
|
|
||||||
provider := ""
|
|
||||||
|
|
||||||
args := os.Args[3:]
|
|
||||||
for i := 0; i < len(args); i++ {
|
|
||||||
switch args[i] {
|
|
||||||
case "--provider", "-p":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
provider = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if provider != "" {
|
|
||||||
if err := auth.DeleteCredential(provider); err != nil {
|
|
||||||
fmt.Printf("Failed to remove credentials: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
appCfg, err := loadConfig()
|
|
||||||
if err == nil {
|
|
||||||
switch provider {
|
|
||||||
case "openai":
|
|
||||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
|
||||||
case "anthropic":
|
|
||||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
|
||||||
}
|
|
||||||
config.SaveConfig(getConfigPath(), appCfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Logged out from %s\n", provider)
|
|
||||||
} else {
|
|
||||||
if err := auth.DeleteAllCredentials(); err != nil {
|
|
||||||
fmt.Printf("Failed to remove credentials: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
appCfg, err := loadConfig()
|
|
||||||
if err == nil {
|
|
||||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
|
||||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
|
||||||
config.SaveConfig(getConfigPath(), appCfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Logged out from all providers")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func authStatusCmd() {
|
|
||||||
store, err := auth.LoadStore()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error loading auth store: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(store.Credentials) == 0 {
|
|
||||||
fmt.Println("No authenticated providers.")
|
|
||||||
fmt.Println("Run: clawdroid auth login --provider <name>")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("\nAuthenticated Providers:")
|
|
||||||
fmt.Println("------------------------")
|
|
||||||
for provider, cred := range store.Credentials {
|
|
||||||
status := "active"
|
|
||||||
if cred.IsExpired() {
|
|
||||||
status = "expired"
|
|
||||||
} else if cred.NeedsRefresh() {
|
|
||||||
status = "needs refresh"
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" %s:\n", provider)
|
|
||||||
fmt.Printf(" Method: %s\n", cred.AuthMethod)
|
|
||||||
fmt.Printf(" Status: %s\n", status)
|
|
||||||
if cred.AccountID != "" {
|
|
||||||
fmt.Printf(" Account: %s\n", cred.AccountID)
|
|
||||||
}
|
|
||||||
if !cred.ExpiresAt.IsZero() {
|
|
||||||
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
{
|
{
|
||||||
|
"llm": {
|
||||||
|
"model": "zhipu/glm-4.7",
|
||||||
|
"api_key": "",
|
||||||
|
"base_url": ""
|
||||||
|
},
|
||||||
|
"stt": {
|
||||||
|
"api_key": ""
|
||||||
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.clawdroid/workspace",
|
"workspace": "~/.clawdroid/workspace",
|
||||||
"data_dir": "~/.clawdroid/data",
|
"data_dir": "~/.clawdroid/data",
|
||||||
"restrict_to_workspace": true,
|
"restrict_to_workspace": true,
|
||||||
"model": "glm-4.7",
|
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"context_window": 128000,
|
"context_window": 128000,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
|
|
@ -85,62 +92,6 @@
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
|
||||||
"anthropic": {
|
|
||||||
"api_key": "",
|
|
||||||
"api_base": ""
|
|
||||||
},
|
|
||||||
"openai": {
|
|
||||||
"api_key": "",
|
|
||||||
"api_base": ""
|
|
||||||
},
|
|
||||||
"openrouter": {
|
|
||||||
"api_key": "sk-or-v1-xxx",
|
|
||||||
"api_base": ""
|
|
||||||
},
|
|
||||||
"groq": {
|
|
||||||
"api_key": "gsk_xxx",
|
|
||||||
"api_base": ""
|
|
||||||
},
|
|
||||||
"zhipu": {
|
|
||||||
"api_key": "YOUR_ZHIPU_API_KEY",
|
|
||||||
"api_base": ""
|
|
||||||
},
|
|
||||||
"gemini": {
|
|
||||||
"api_key": "",
|
|
||||||
"api_base": ""
|
|
||||||
},
|
|
||||||
"vllm": {
|
|
||||||
"api_key": "",
|
|
||||||
"api_base": ""
|
|
||||||
},
|
|
||||||
"nvidia": {
|
|
||||||
"api_key": "nvapi-xxx",
|
|
||||||
"api_base": "",
|
|
||||||
"proxy": "http://127.0.0.1:7890"
|
|
||||||
},
|
|
||||||
"moonshot": {
|
|
||||||
"api_key": "sk-xxx",
|
|
||||||
"api_base": ""
|
|
||||||
},
|
|
||||||
"ollama": {
|
|
||||||
"api_key": "",
|
|
||||||
"api_base": "http://localhost:11434/v1"
|
|
||||||
},
|
|
||||||
"deepseek": {
|
|
||||||
"api_key": "",
|
|
||||||
"api_base": ""
|
|
||||||
},
|
|
||||||
"shengsuanyun": {
|
|
||||||
"api_key": "",
|
|
||||||
"api_base": ""
|
|
||||||
},
|
|
||||||
"github_copilot": {
|
|
||||||
"api_key": "",
|
|
||||||
"api_base": "",
|
|
||||||
"connect_mode": "stdio"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tools": {
|
"tools": {
|
||||||
"exec": {
|
"exec": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
|
|
|
||||||
26
go.mod
26
go.mod
|
|
@ -4,7 +4,6 @@ go 1.25.6
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/adhocore/gronx v1.19.6
|
github.com/adhocore/gronx v1.19.6
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1
|
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
github.com/chzyer/readline v1.5.1
|
github.com/chzyer/readline v1.5.1
|
||||||
|
|
@ -12,9 +11,9 @@ require (
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.1
|
github.com/modelcontextprotocol/go-sdk v1.3.1
|
||||||
|
github.com/mozilla-ai/any-llm-go v0.8.0
|
||||||
github.com/mymmrac/telego v1.6.0
|
github.com/mymmrac/telego v1.6.0
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||||
github.com/openai/openai-go/v3 v3.22.0
|
|
||||||
github.com/slack-go/slack v0.17.3
|
github.com/slack-go/slack v0.17.3
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tencent-connect/botgo v0.2.1
|
github.com/tencent-connect/botgo v0.2.1
|
||||||
|
|
@ -31,18 +30,32 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
cloud.google.com/go v0.116.0 // indirect
|
||||||
|
cloud.google.com/go/auth v0.9.3 // indirect
|
||||||
|
cloud.google.com/go/compute/metadata v0.5.0 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
|
github.com/anthropics/anthropic-sdk-go v1.21.0 // indirect
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
|
github.com/buger/jsonparser v1.1.1 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/github/copilot-sdk/go v0.1.23
|
|
||||||
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
|
github.com/google/s2a-go v0.1.8 // indirect
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||||
github.com/grbit/go-json v0.11.0 // indirect
|
github.com/grbit/go-json v0.11.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.4 // indirect
|
github.com/klauspost/compress v1.18.4 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
github.com/ollama/ollama v0.15.4 // indirect
|
||||||
|
github.com/openai/openai-go v1.12.0 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/tidwall/gjson v1.18.0 // indirect
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
github.com/tidwall/match v1.2.0 // indirect
|
github.com/tidwall/match v1.2.0 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
|
|
@ -51,9 +64,16 @@ require (
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||||
github.com/valyala/fastjson v1.6.7 // indirect
|
github.com/valyala/fastjson v1.6.7 // indirect
|
||||||
|
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||||
|
go.opencensus.io v0.24.0 // indirect
|
||||||
golang.org/x/arch v0.24.0 // indirect
|
golang.org/x/arch v0.24.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.50.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
google.golang.org/genai v1.45.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||||
|
google.golang.org/grpc v1.66.2 // indirect
|
||||||
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
110
go.sum
110
go.sum
|
|
@ -1,10 +1,22 @@
|
||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
||||||
|
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
|
||||||
|
cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
|
||||||
|
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
|
||||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
|
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
|
||||||
|
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
||||||
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
|
github.com/anthropics/anthropic-sdk-go v1.21.0 h1:sn2iMiUODSMtJTN5nGMOn+ayEpNMuL5khElzltSrEcE=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
github.com/anthropics/anthropic-sdk-go v1.21.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||||
|
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||||
|
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
|
@ -15,6 +27,7 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
||||||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||||
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||||
|
|
@ -23,17 +36,21 @@ github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI
|
||||||
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
|
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
|
||||||
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
||||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
|
||||||
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
|
||||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||||
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||||
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
||||||
|
|
@ -45,18 +62,29 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
|
@ -64,9 +92,14 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||||
|
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||||
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
|
@ -74,6 +107,7 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
||||||
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
||||||
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||||
|
|
@ -82,20 +116,27 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||||
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
|
github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw=
|
github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw=
|
||||||
|
github.com/mozilla-ai/any-llm-go v0.8.0 h1:QNM2yeMaFp3TnIX7+pJ1oxakfA2bbQtyH7pchQfSe+E=
|
||||||
|
github.com/mozilla-ai/any-llm-go v0.8.0/go.mod h1:hfidShiFrygKCzyMTMJWAUv6S5q7ZP/1qWK3Azc6RLU=
|
||||||
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
||||||
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
|
github.com/ollama/ollama v0.15.4 h1:y841GH5lsi5j5BTFyX/E+UOC3Yiw+JBfdjBVRGw+I0M=
|
||||||
|
github.com/ollama/ollama v0.15.4/go.mod h1:4Yn3jw2hZ4VqyJ1XciYawDRE8bzv4RT3JiVZR1kCfwE=
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||||
|
|
@ -104,14 +145,16 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
|
||||||
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||||
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
|
||||||
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
|
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
|
||||||
|
|
@ -152,6 +195,8 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy
|
||||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
||||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||||
|
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||||
|
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
|
@ -159,6 +204,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
|
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||||
|
|
@ -171,16 +218,25 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
||||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||||
|
|
@ -190,10 +246,12 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
|
@ -201,6 +259,7 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
|
@ -227,6 +286,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||||
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
|
@ -234,29 +295,56 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/genai v1.45.0 h1:s80ZpS42XW0zu/ogiOtenCio17nJ7reEFJjoCftukpA=
|
||||||
|
google.golang.org/genai v1.45.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
|
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
|
||||||
|
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
|
@ -271,3 +359,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ type AgentLoop struct {
|
||||||
provider providers.LLMProvider
|
provider providers.LLMProvider
|
||||||
workspace string
|
workspace string
|
||||||
model string
|
model string
|
||||||
maxTokens int // Maximum tokens for API response
|
maxTokens int // Maximum tokens for API response
|
||||||
|
temperature float64 // Temperature for LLM (0 = not sent)
|
||||||
contextWindow int // Maximum context window size in tokens (for summarization)
|
contextWindow int // Maximum context window size in tokens (for summarization)
|
||||||
maxIterations int
|
maxIterations int
|
||||||
sessions *session.SessionManager
|
sessions *session.SessionManager
|
||||||
|
|
@ -162,7 +163,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
||||||
toolsRegistry := createToolRegistry(workspace, restrict, cfg, msgBus, dataDir)
|
toolsRegistry := createToolRegistry(workspace, restrict, cfg, msgBus, dataDir)
|
||||||
|
|
||||||
// Create subagent manager with its own tool registry
|
// Create subagent manager with its own tool registry
|
||||||
subagentManager := tools.NewSubagentManager(provider, cfg.Agents.Defaults.Model, workspace, msgBus)
|
subagentManager := tools.NewSubagentManager(provider, cfg.LLM.Model, workspace, msgBus)
|
||||||
subagentTools := createToolRegistry(workspace, restrict, cfg, msgBus, dataDir)
|
subagentTools := createToolRegistry(workspace, restrict, cfg, msgBus, dataDir)
|
||||||
// Subagent doesn't need spawn/subagent tools to avoid recursion
|
// Subagent doesn't need spawn/subagent tools to avoid recursion
|
||||||
subagentManager.SetTools(subagentTools)
|
subagentManager.SetTools(subagentTools)
|
||||||
|
|
@ -236,8 +237,9 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
||||||
bus: msgBus,
|
bus: msgBus,
|
||||||
provider: provider,
|
provider: provider,
|
||||||
workspace: workspace,
|
workspace: workspace,
|
||||||
model: cfg.Agents.Defaults.Model,
|
model: cfg.LLM.Model,
|
||||||
maxTokens: cfg.Agents.Defaults.MaxTokens,
|
maxTokens: cfg.Agents.Defaults.MaxTokens,
|
||||||
|
temperature: cfg.Agents.Defaults.Temperature,
|
||||||
contextWindow: cfg.Agents.Defaults.ContextWindow,
|
contextWindow: cfg.Agents.Defaults.ContextWindow,
|
||||||
maxIterations: cfg.Agents.Defaults.MaxToolIterations,
|
maxIterations: cfg.Agents.Defaults.MaxToolIterations,
|
||||||
sessions: sessionsManager,
|
sessions: sessionsManager,
|
||||||
|
|
@ -709,7 +711,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
||||||
"messages_count": len(messages),
|
"messages_count": len(messages),
|
||||||
"tools_count": len(providerToolDefs),
|
"tools_count": len(providerToolDefs),
|
||||||
"max_tokens": al.maxTokens,
|
"max_tokens": al.maxTokens,
|
||||||
"temperature": 0.7,
|
"temperature": al.temperature,
|
||||||
"system_prompt_len": len(messages[0].Content),
|
"system_prompt_len": len(messages[0].Content),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -727,10 +729,13 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
||||||
// Retry loop for context/token errors
|
// Retry loop for context/token errors
|
||||||
maxRetries := 2
|
maxRetries := 2
|
||||||
for retry := 0; retry <= maxRetries; retry++ {
|
for retry := 0; retry <= maxRetries; retry++ {
|
||||||
response, err = al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{
|
llmOpts := map[string]interface{}{
|
||||||
"max_tokens": al.maxTokens,
|
"max_tokens": al.maxTokens,
|
||||||
"temperature": 0.7,
|
}
|
||||||
})
|
if al.temperature > 0 {
|
||||||
|
llmOpts["temperature"] = al.temperature
|
||||||
|
}
|
||||||
|
response, err = al.provider.Chat(ctx, messages, providerToolDefs, al.model, llmOpts)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
break // Success
|
break // Success
|
||||||
|
|
@ -1290,8 +1295,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
|
||||||
// Merge them
|
// Merge them
|
||||||
mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2)
|
mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2)
|
||||||
resp, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, al.model, map[string]interface{}{
|
resp, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, al.model, map[string]interface{}{
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
"temperature": 0.3,
|
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
finalSummary = resp.Content
|
finalSummary = resp.Content
|
||||||
|
|
@ -1327,8 +1331,7 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Messa
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, al.model, map[string]interface{}{
|
response, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, al.model, map[string]interface{}{
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
"temperature": 0.3,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,13 @@ func TestRecordLastChannel(t *testing.T) {
|
||||||
|
|
||||||
// Create test config
|
// Create test config
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -86,11 +88,13 @@ func TestRecordLastChatID(t *testing.T) {
|
||||||
|
|
||||||
// Create test config
|
// Create test config
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -133,11 +137,13 @@ func TestNewAgentLoop_StateInitialized(t *testing.T) {
|
||||||
|
|
||||||
// Create test config
|
// Create test config
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -171,11 +177,13 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -219,11 +227,13 @@ func TestToolContext_Updates(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -252,11 +262,13 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -298,11 +310,13 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -347,11 +361,13 @@ func TestCreateToolRegistry_ExecDisabled(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -388,11 +404,13 @@ func TestCreateToolRegistry_ExecEnabled(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -434,11 +452,13 @@ func TestCreateToolRegistry_I2CDisabled(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -473,11 +493,13 @@ func TestCreateToolRegistry_I2CEnabled(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -517,11 +539,13 @@ func TestCreateToolRegistry_SPIDisabled(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -556,11 +580,13 @@ func TestCreateToolRegistry_SPIEnabled(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -600,11 +626,13 @@ func TestAgentLoop_Stop(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -724,11 +752,13 @@ func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -768,11 +798,13 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -835,11 +867,13 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -932,11 +966,13 @@ func TestRetryLoop_CancelledContextSkipsCompression(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -995,11 +1031,13 @@ func TestForceCompression_ToolGroupBoundary(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -1082,11 +1120,13 @@ func TestForceCompression_MidOnAssistantWithToolCalls(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -1170,11 +1210,13 @@ func TestForceCompression_NoteUsesUserRole(t *testing.T) {
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
LLM: config.LLMConfig{
|
||||||
|
Model: "test-model",
|
||||||
|
},
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
DataDir: tmpDir,
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
|
||||||
|
|
@ -1,461 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type OAuthProviderConfig struct {
|
|
||||||
Issuer string
|
|
||||||
ClientID string
|
|
||||||
Scopes string
|
|
||||||
Originator string
|
|
||||||
Port int
|
|
||||||
}
|
|
||||||
|
|
||||||
func OpenAIOAuthConfig() OAuthProviderConfig {
|
|
||||||
return OAuthProviderConfig{
|
|
||||||
Issuer: "https://auth.openai.com",
|
|
||||||
ClientID: "app_EMoamEEZ73f0CkXaXp7hrann",
|
|
||||||
Scopes: "openid profile email offline_access",
|
|
||||||
Originator: "codex_cli_rs",
|
|
||||||
Port: 1455,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateState() (string, error) {
|
|
||||||
buf := make([]byte, 32)
|
|
||||||
if _, err := rand.Read(buf); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(buf), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|
||||||
pkce, err := GeneratePKCE()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("generating PKCE: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
state, err := generateState()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("generating state: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
redirectURI := fmt.Sprintf("http://localhost:%d/auth/callback", cfg.Port)
|
|
||||||
|
|
||||||
authURL := buildAuthorizeURL(cfg, pkce, state, redirectURI)
|
|
||||||
|
|
||||||
resultCh := make(chan callbackResult, 1)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Query().Get("state") != state {
|
|
||||||
resultCh <- callbackResult{err: fmt.Errorf("state mismatch")}
|
|
||||||
http.Error(w, "State mismatch", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
code := r.URL.Query().Get("code")
|
|
||||||
if code == "" {
|
|
||||||
errMsg := r.URL.Query().Get("error")
|
|
||||||
resultCh <- callbackResult{err: fmt.Errorf("no code received: %s", errMsg)}
|
|
||||||
http.Error(w, "No authorization code received", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
|
||||||
fmt.Fprint(w, "<html><body><h2>Authentication successful!</h2><p>You can close this window.</p></body></html>")
|
|
||||||
resultCh <- callbackResult{code: code}
|
|
||||||
})
|
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.Port))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
server := &http.Server{Handler: mux}
|
|
||||||
go server.Serve(listener)
|
|
||||||
defer func() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
server.Shutdown(ctx)
|
|
||||||
}()
|
|
||||||
|
|
||||||
fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL)
|
|
||||||
|
|
||||||
if err := openBrowser(authURL); err != nil {
|
|
||||||
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("If you're running in a headless environment, use: clawdroid auth login --provider openai --device-code")
|
|
||||||
fmt.Println("Waiting for authentication in browser...")
|
|
||||||
|
|
||||||
select {
|
|
||||||
case result := <-resultCh:
|
|
||||||
if result.err != nil {
|
|
||||||
return nil, result.err
|
|
||||||
}
|
|
||||||
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
|
||||||
case <-time.After(5 * time.Minute):
|
|
||||||
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type callbackResult struct {
|
|
||||||
code string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
type deviceCodeResponse struct {
|
|
||||||
DeviceAuthID string
|
|
||||||
UserCode string
|
|
||||||
Interval int
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
|
|
||||||
var raw struct {
|
|
||||||
DeviceAuthID string `json:"device_auth_id"`
|
|
||||||
UserCode string `json:"user_code"`
|
|
||||||
Interval json.RawMessage `json:"interval"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &raw); err != nil {
|
|
||||||
return deviceCodeResponse{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
interval, err := parseFlexibleInt(raw.Interval)
|
|
||||||
if err != nil {
|
|
||||||
return deviceCodeResponse{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return deviceCodeResponse{
|
|
||||||
DeviceAuthID: raw.DeviceAuthID,
|
|
||||||
UserCode: raw.UserCode,
|
|
||||||
Interval: interval,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFlexibleInt(raw json.RawMessage) (int, error) {
|
|
||||||
if len(raw) == 0 || string(raw) == "null" {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var interval int
|
|
||||||
if err := json.Unmarshal(raw, &interval); err == nil {
|
|
||||||
return interval, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var intervalStr string
|
|
||||||
if err := json.Unmarshal(raw, &intervalStr); err == nil {
|
|
||||||
intervalStr = strings.TrimSpace(intervalStr)
|
|
||||||
if intervalStr == "" {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
return strconv.Atoi(intervalStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0, fmt.Errorf("invalid integer value: %s", string(raw))
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|
||||||
reqBody, _ := json.Marshal(map[string]string{
|
|
||||||
"client_id": cfg.ClientID,
|
|
||||||
})
|
|
||||||
|
|
||||||
resp, err := http.Post(
|
|
||||||
cfg.Issuer+"/api/accounts/deviceauth/usercode",
|
|
||||||
"application/json",
|
|
||||||
strings.NewReader(string(reqBody)),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("requesting device code: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceResp, err := parseDeviceCodeResponse(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing device code response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if deviceResp.Interval < 1 {
|
|
||||||
deviceResp.Interval = 5
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n",
|
|
||||||
cfg.Issuer, deviceResp.UserCode)
|
|
||||||
|
|
||||||
deadline := time.After(15 * time.Minute)
|
|
||||||
ticker := time.NewTicker(time.Duration(deviceResp.Interval) * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-deadline:
|
|
||||||
return nil, fmt.Errorf("device code authentication timed out after 15 minutes")
|
|
||||||
case <-ticker.C:
|
|
||||||
cred, err := pollDeviceCode(cfg, deviceResp.DeviceAuthID, deviceResp.UserCode)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if cred != nil {
|
|
||||||
return cred, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) {
|
|
||||||
reqBody, _ := json.Marshal(map[string]string{
|
|
||||||
"device_auth_id": deviceAuthID,
|
|
||||||
"user_code": userCode,
|
|
||||||
})
|
|
||||||
|
|
||||||
resp, err := http.Post(
|
|
||||||
cfg.Issuer+"/api/accounts/deviceauth/token",
|
|
||||||
"application/json",
|
|
||||||
strings.NewReader(string(reqBody)),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("pending")
|
|
||||||
}
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
|
|
||||||
var tokenResp struct {
|
|
||||||
AuthorizationCode string `json:"authorization_code"`
|
|
||||||
CodeChallenge string `json:"code_challenge"`
|
|
||||||
CodeVerifier string `json:"code_verifier"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
redirectURI := cfg.Issuer + "/deviceauth/callback"
|
|
||||||
return exchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
|
|
||||||
}
|
|
||||||
|
|
||||||
func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|
||||||
if cred.RefreshToken == "" {
|
|
||||||
return nil, fmt.Errorf("no refresh token available")
|
|
||||||
}
|
|
||||||
|
|
||||||
data := url.Values{
|
|
||||||
"client_id": {cfg.ClientID},
|
|
||||||
"grant_type": {"refresh_token"},
|
|
||||||
"refresh_token": {cred.RefreshToken},
|
|
||||||
"scope": {"openid profile email"},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("refreshing token: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshed, err := parseTokenResponse(body, cred.Provider)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if refreshed.RefreshToken == "" {
|
|
||||||
refreshed.RefreshToken = cred.RefreshToken
|
|
||||||
}
|
|
||||||
if refreshed.AccountID == "" {
|
|
||||||
refreshed.AccountID = cred.AccountID
|
|
||||||
}
|
|
||||||
return refreshed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
|
|
||||||
return buildAuthorizeURL(cfg, pkce, state, redirectURI)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
|
|
||||||
params := url.Values{
|
|
||||||
"response_type": {"code"},
|
|
||||||
"client_id": {cfg.ClientID},
|
|
||||||
"redirect_uri": {redirectURI},
|
|
||||||
"scope": {cfg.Scopes},
|
|
||||||
"code_challenge": {pkce.CodeChallenge},
|
|
||||||
"code_challenge_method": {"S256"},
|
|
||||||
"id_token_add_organizations": {"true"},
|
|
||||||
"codex_cli_simplified_flow": {"true"},
|
|
||||||
"state": {state},
|
|
||||||
}
|
|
||||||
if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") {
|
|
||||||
params.Set("originator", "clawdroid")
|
|
||||||
}
|
|
||||||
if cfg.Originator != "" {
|
|
||||||
params.Set("originator", cfg.Originator)
|
|
||||||
}
|
|
||||||
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
|
|
||||||
data := url.Values{
|
|
||||||
"grant_type": {"authorization_code"},
|
|
||||||
"code": {code},
|
|
||||||
"redirect_uri": {redirectURI},
|
|
||||||
"client_id": {cfg.ClientID},
|
|
||||||
"code_verifier": {codeVerifier},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("exchanging code for tokens: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseTokenResponse(body, "openai")
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
|
|
||||||
var tokenResp struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
IDToken string `json:"id_token"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing token response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tokenResp.AccessToken == "" {
|
|
||||||
return nil, fmt.Errorf("no access token in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
var expiresAt time.Time
|
|
||||||
if tokenResp.ExpiresIn > 0 {
|
|
||||||
expiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
cred := &AuthCredential{
|
|
||||||
AccessToken: tokenResp.AccessToken,
|
|
||||||
RefreshToken: tokenResp.RefreshToken,
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
Provider: provider,
|
|
||||||
AuthMethod: "oauth",
|
|
||||||
}
|
|
||||||
|
|
||||||
if accountID := extractAccountID(tokenResp.IDToken); accountID != "" {
|
|
||||||
cred.AccountID = accountID
|
|
||||||
} else if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" {
|
|
||||||
cred.AccountID = accountID
|
|
||||||
} else if accountID := extractAccountID(tokenResp.IDToken); accountID != "" {
|
|
||||||
// Recent OpenAI OAuth responses may only include chatgpt_account_id in id_token claims.
|
|
||||||
cred.AccountID = accountID
|
|
||||||
}
|
|
||||||
|
|
||||||
return cred, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractAccountID(token string) string {
|
|
||||||
claims, err := parseJWTClaims(token)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if accountID, ok := claims["chatgpt_account_id"].(string); ok && accountID != "" {
|
|
||||||
return accountID
|
|
||||||
}
|
|
||||||
|
|
||||||
if accountID, ok := claims["https://api.openai.com/auth.chatgpt_account_id"].(string); ok && accountID != "" {
|
|
||||||
return accountID
|
|
||||||
}
|
|
||||||
|
|
||||||
if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok {
|
|
||||||
if accountID, ok := authClaim["chatgpt_account_id"].(string); ok && accountID != "" {
|
|
||||||
return accountID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if orgs, ok := claims["organizations"].([]interface{}); ok {
|
|
||||||
for _, org := range orgs {
|
|
||||||
if orgMap, ok := org.(map[string]interface{}); ok {
|
|
||||||
if accountID, ok := orgMap["id"].(string); ok && accountID != "" {
|
|
||||||
return accountID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseJWTClaims(token string) (map[string]interface{}, error) {
|
|
||||||
parts := strings.Split(token, ".")
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return nil, fmt.Errorf("token is not a JWT")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := parts[1]
|
|
||||||
switch len(payload) % 4 {
|
|
||||||
case 2:
|
|
||||||
payload += "=="
|
|
||||||
case 3:
|
|
||||||
payload += "="
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded, err := base64URLDecode(payload)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var claims map[string]interface{}
|
|
||||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func base64URLDecode(s string) ([]byte, error) {
|
|
||||||
s = strings.NewReplacer("-", "+", "_", "/").Replace(s)
|
|
||||||
return base64.StdEncoding.DecodeString(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func openBrowser(url string) error {
|
|
||||||
switch runtime.GOOS {
|
|
||||||
case "darwin":
|
|
||||||
return exec.Command("open", url).Start()
|
|
||||||
case "linux":
|
|
||||||
return exec.Command("xdg-open", url).Start()
|
|
||||||
case "windows":
|
|
||||||
return exec.Command("cmd", "/c", "start", url).Start()
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,373 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func makeJWTForClaims(t *testing.T, claims map[string]interface{}) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
|
||||||
payloadJSON, err := json.Marshal(claims)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal claims: %v", err)
|
|
||||||
}
|
|
||||||
payload := base64.RawURLEncoding.EncodeToString(payloadJSON)
|
|
||||||
return header + "." + payload + ".sig"
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildAuthorizeURL(t *testing.T) {
|
|
||||||
cfg := OAuthProviderConfig{
|
|
||||||
Issuer: "https://auth.example.com",
|
|
||||||
ClientID: "test-client-id",
|
|
||||||
Scopes: "openid profile",
|
|
||||||
Originator: "codex_cli_rs",
|
|
||||||
Port: 1455,
|
|
||||||
}
|
|
||||||
pkce := PKCECodes{
|
|
||||||
CodeVerifier: "test-verifier",
|
|
||||||
CodeChallenge: "test-challenge",
|
|
||||||
}
|
|
||||||
|
|
||||||
u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback")
|
|
||||||
|
|
||||||
if !strings.HasPrefix(u, "https://auth.example.com/oauth/authorize?") {
|
|
||||||
t.Errorf("URL does not start with expected prefix: %s", u)
|
|
||||||
}
|
|
||||||
if !strings.Contains(u, "client_id=test-client-id") {
|
|
||||||
t.Error("URL missing client_id")
|
|
||||||
}
|
|
||||||
if !strings.Contains(u, "code_challenge=test-challenge") {
|
|
||||||
t.Error("URL missing code_challenge")
|
|
||||||
}
|
|
||||||
if !strings.Contains(u, "code_challenge_method=S256") {
|
|
||||||
t.Error("URL missing code_challenge_method")
|
|
||||||
}
|
|
||||||
if !strings.Contains(u, "state=test-state") {
|
|
||||||
t.Error("URL missing state")
|
|
||||||
}
|
|
||||||
if !strings.Contains(u, "response_type=code") {
|
|
||||||
t.Error("URL missing response_type")
|
|
||||||
}
|
|
||||||
if !strings.Contains(u, "id_token_add_organizations=true") {
|
|
||||||
t.Error("URL missing id_token_add_organizations")
|
|
||||||
}
|
|
||||||
if !strings.Contains(u, "codex_cli_simplified_flow=true") {
|
|
||||||
t.Error("URL missing codex_cli_simplified_flow")
|
|
||||||
}
|
|
||||||
if !strings.Contains(u, "originator=codex_cli_rs") {
|
|
||||||
t.Error("URL missing originator")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildAuthorizeURLOpenAIExtras(t *testing.T) {
|
|
||||||
cfg := OpenAIOAuthConfig()
|
|
||||||
pkce := PKCECodes{CodeVerifier: "test-verifier", CodeChallenge: "test-challenge"}
|
|
||||||
|
|
||||||
u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback")
|
|
||||||
parsed, err := url.Parse(u)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("url.Parse() error: %v", err)
|
|
||||||
}
|
|
||||||
q := parsed.Query()
|
|
||||||
|
|
||||||
if q.Get("id_token_add_organizations") != "true" {
|
|
||||||
t.Errorf("id_token_add_organizations = %q, want true", q.Get("id_token_add_organizations"))
|
|
||||||
}
|
|
||||||
if q.Get("codex_cli_simplified_flow") != "true" {
|
|
||||||
t.Errorf("codex_cli_simplified_flow = %q, want true", q.Get("codex_cli_simplified_flow"))
|
|
||||||
}
|
|
||||||
if q.Get("originator") != "codex_cli_rs" {
|
|
||||||
t.Errorf("originator = %q, want codex_cli_rs", q.Get("originator"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTokenResponse(t *testing.T) {
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"access_token": "test-access-token",
|
|
||||||
"refresh_token": "test-refresh-token",
|
|
||||||
"expires_in": 3600,
|
|
||||||
"id_token": "test-id-token",
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(resp)
|
|
||||||
|
|
||||||
cred, err := parseTokenResponse(body, "openai")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseTokenResponse() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cred.AccessToken != "test-access-token" {
|
|
||||||
t.Errorf("AccessToken = %q, want %q", cred.AccessToken, "test-access-token")
|
|
||||||
}
|
|
||||||
if cred.RefreshToken != "test-refresh-token" {
|
|
||||||
t.Errorf("RefreshToken = %q, want %q", cred.RefreshToken, "test-refresh-token")
|
|
||||||
}
|
|
||||||
if cred.Provider != "openai" {
|
|
||||||
t.Errorf("Provider = %q, want %q", cred.Provider, "openai")
|
|
||||||
}
|
|
||||||
if cred.AuthMethod != "oauth" {
|
|
||||||
t.Errorf("AuthMethod = %q, want %q", cred.AuthMethod, "oauth")
|
|
||||||
}
|
|
||||||
if cred.ExpiresAt.IsZero() {
|
|
||||||
t.Error("ExpiresAt should not be zero")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
|
|
||||||
idToken := makeJWTForClaims(t, map[string]interface{}{"chatgpt_account_id": "acc-id-from-id-token"})
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"access_token": "opaque-access-token",
|
|
||||||
"refresh_token": "test-refresh-token",
|
|
||||||
"expires_in": 3600,
|
|
||||||
"id_token": idToken,
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(resp)
|
|
||||||
|
|
||||||
cred, err := parseTokenResponse(body, "openai")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseTokenResponse() error: %v", err)
|
|
||||||
}
|
|
||||||
if cred.AccountID != "acc-id-from-id-token" {
|
|
||||||
t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-id-from-id-token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractAccountIDFromOrganizationsFallback(t *testing.T) {
|
|
||||||
token := makeJWTForClaims(t, map[string]interface{}{
|
|
||||||
"organizations": []interface{}{
|
|
||||||
map[string]interface{}{"id": "org_from_orgs"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if got := extractAccountID(token); got != "org_from_orgs" {
|
|
||||||
t.Errorf("extractAccountID() = %q, want %q", got, "org_from_orgs")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTokenResponseNoAccessToken(t *testing.T) {
|
|
||||||
body := []byte(`{"refresh_token": "test"}`)
|
|
||||||
_, err := parseTokenResponse(body, "openai")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for missing access_token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
|
|
||||||
idToken := makeJWTWithAccountID("acc-from-id")
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"access_token": "not-a-jwt",
|
|
||||||
"refresh_token": "test-refresh-token",
|
|
||||||
"expires_in": 3600,
|
|
||||||
"id_token": idToken,
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(resp)
|
|
||||||
|
|
||||||
cred, err := parseTokenResponse(body, "openai")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseTokenResponse() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cred.AccountID != "acc-from-id" {
|
|
||||||
t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-from-id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeJWTWithAccountID(accountID string) string {
|
|
||||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
|
||||||
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`))
|
|
||||||
return header + "." + payload + ".sig"
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExchangeCodeForTokens(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path != "/oauth/token" {
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
r.ParseForm()
|
|
||||||
if r.FormValue("grant_type") != "authorization_code" {
|
|
||||||
http.Error(w, "invalid grant_type", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"access_token": "mock-access-token",
|
|
||||||
"refresh_token": "mock-refresh-token",
|
|
||||||
"expires_in": 3600,
|
|
||||||
}
|
|
||||||
json.NewEncoder(w).Encode(resp)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
cfg := OAuthProviderConfig{
|
|
||||||
Issuer: server.URL,
|
|
||||||
ClientID: "test-client",
|
|
||||||
Scopes: "openid",
|
|
||||||
Port: 1455,
|
|
||||||
}
|
|
||||||
|
|
||||||
cred, err := exchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("exchangeCodeForTokens() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cred.AccessToken != "mock-access-token" {
|
|
||||||
t.Errorf("AccessToken = %q, want %q", cred.AccessToken, "mock-access-token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshAccessToken(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path != "/oauth/token" {
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
r.ParseForm()
|
|
||||||
if r.FormValue("grant_type") != "refresh_token" {
|
|
||||||
http.Error(w, "invalid grant_type", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"access_token": "refreshed-access-token",
|
|
||||||
"refresh_token": "refreshed-refresh-token",
|
|
||||||
"expires_in": 3600,
|
|
||||||
}
|
|
||||||
json.NewEncoder(w).Encode(resp)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
cfg := OAuthProviderConfig{
|
|
||||||
Issuer: server.URL,
|
|
||||||
ClientID: "test-client",
|
|
||||||
}
|
|
||||||
|
|
||||||
cred := &AuthCredential{
|
|
||||||
AccessToken: "old-token",
|
|
||||||
RefreshToken: "old-refresh-token",
|
|
||||||
Provider: "openai",
|
|
||||||
AuthMethod: "oauth",
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshed, err := RefreshAccessToken(cred, cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("RefreshAccessToken() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if refreshed.AccessToken != "refreshed-access-token" {
|
|
||||||
t.Errorf("AccessToken = %q, want %q", refreshed.AccessToken, "refreshed-access-token")
|
|
||||||
}
|
|
||||||
if refreshed.RefreshToken != "refreshed-refresh-token" {
|
|
||||||
t.Errorf("RefreshToken = %q, want %q", refreshed.RefreshToken, "refreshed-refresh-token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshAccessTokenNoRefreshToken(t *testing.T) {
|
|
||||||
cfg := OpenAIOAuthConfig()
|
|
||||||
cred := &AuthCredential{
|
|
||||||
AccessToken: "old-token",
|
|
||||||
Provider: "openai",
|
|
||||||
AuthMethod: "oauth",
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := RefreshAccessToken(cred, cfg)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for missing refresh token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"access_token": "new-access-token-only",
|
|
||||||
"expires_in": 3600,
|
|
||||||
}
|
|
||||||
json.NewEncoder(w).Encode(resp)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
cfg := OAuthProviderConfig{Issuer: server.URL, ClientID: "test-client"}
|
|
||||||
cred := &AuthCredential{
|
|
||||||
AccessToken: "old-access",
|
|
||||||
RefreshToken: "existing-refresh",
|
|
||||||
AccountID: "acc_existing",
|
|
||||||
Provider: "openai",
|
|
||||||
AuthMethod: "oauth",
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshed, err := RefreshAccessToken(cred, cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("RefreshAccessToken() error: %v", err)
|
|
||||||
}
|
|
||||||
if refreshed.RefreshToken != "existing-refresh" {
|
|
||||||
t.Errorf("RefreshToken = %q, want %q", refreshed.RefreshToken, "existing-refresh")
|
|
||||||
}
|
|
||||||
if refreshed.AccountID != "acc_existing" {
|
|
||||||
t.Errorf("AccountID = %q, want %q", refreshed.AccountID, "acc_existing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenAIOAuthConfig(t *testing.T) {
|
|
||||||
cfg := OpenAIOAuthConfig()
|
|
||||||
if cfg.Issuer != "https://auth.openai.com" {
|
|
||||||
t.Errorf("Issuer = %q, want %q", cfg.Issuer, "https://auth.openai.com")
|
|
||||||
}
|
|
||||||
if cfg.ClientID == "" {
|
|
||||||
t.Error("ClientID is empty")
|
|
||||||
}
|
|
||||||
if cfg.Port != 1455 {
|
|
||||||
t.Errorf("Port = %d, want 1455", cfg.Port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDeviceCodeResponseIntervalAsNumber(t *testing.T) {
|
|
||||||
body := []byte(`{"device_auth_id":"abc","user_code":"DEF-1234","interval":5}`)
|
|
||||||
|
|
||||||
resp, err := parseDeviceCodeResponse(body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseDeviceCodeResponse() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.DeviceAuthID != "abc" {
|
|
||||||
t.Errorf("DeviceAuthID = %q, want %q", resp.DeviceAuthID, "abc")
|
|
||||||
}
|
|
||||||
if resp.UserCode != "DEF-1234" {
|
|
||||||
t.Errorf("UserCode = %q, want %q", resp.UserCode, "DEF-1234")
|
|
||||||
}
|
|
||||||
if resp.Interval != 5 {
|
|
||||||
t.Errorf("Interval = %d, want %d", resp.Interval, 5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDeviceCodeResponseIntervalAsString(t *testing.T) {
|
|
||||||
body := []byte(`{"device_auth_id":"abc","user_code":"DEF-1234","interval":"5"}`)
|
|
||||||
|
|
||||||
resp, err := parseDeviceCodeResponse(body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseDeviceCodeResponse() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.Interval != 5 {
|
|
||||||
t.Errorf("Interval = %d, want %d", resp.Interval, 5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDeviceCodeResponseInvalidInterval(t *testing.T) {
|
|
||||||
body := []byte(`{"device_auth_id":"abc","user_code":"DEF-1234","interval":"abc"}`)
|
|
||||||
|
|
||||||
if _, err := parseDeviceCodeResponse(body); err == nil {
|
|
||||||
t.Fatal("expected error for invalid interval")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PKCECodes struct {
|
|
||||||
CodeVerifier string
|
|
||||||
CodeChallenge string
|
|
||||||
}
|
|
||||||
|
|
||||||
func GeneratePKCE() (PKCECodes, error) {
|
|
||||||
buf := make([]byte, 64)
|
|
||||||
if _, err := rand.Read(buf); err != nil {
|
|
||||||
return PKCECodes{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
verifier := base64.RawURLEncoding.EncodeToString(buf)
|
|
||||||
|
|
||||||
hash := sha256.Sum256([]byte(verifier))
|
|
||||||
challenge := base64.RawURLEncoding.EncodeToString(hash[:])
|
|
||||||
|
|
||||||
return PKCECodes{
|
|
||||||
CodeVerifier: verifier,
|
|
||||||
CodeChallenge: challenge,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGeneratePKCE(t *testing.T) {
|
|
||||||
codes, err := GeneratePKCE()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GeneratePKCE() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if codes.CodeVerifier == "" {
|
|
||||||
t.Fatal("CodeVerifier is empty")
|
|
||||||
}
|
|
||||||
if codes.CodeChallenge == "" {
|
|
||||||
t.Fatal("CodeChallenge is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
verifierBytes, err := base64.RawURLEncoding.DecodeString(codes.CodeVerifier)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CodeVerifier is not valid base64url: %v", err)
|
|
||||||
}
|
|
||||||
if len(verifierBytes) != 64 {
|
|
||||||
t.Errorf("CodeVerifier decoded length = %d, want 64", len(verifierBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
hash := sha256.Sum256([]byte(codes.CodeVerifier))
|
|
||||||
expectedChallenge := base64.RawURLEncoding.EncodeToString(hash[:])
|
|
||||||
if codes.CodeChallenge != expectedChallenge {
|
|
||||||
t.Errorf("CodeChallenge = %q, want SHA256 of verifier = %q", codes.CodeChallenge, expectedChallenge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGeneratePKCEUniqueness(t *testing.T) {
|
|
||||||
codes1, err := GeneratePKCE()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GeneratePKCE() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
codes2, err := GeneratePKCE()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GeneratePKCE() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if codes1.CodeVerifier == codes2.CodeVerifier {
|
|
||||||
t.Error("two GeneratePKCE() calls produced identical verifiers")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AuthCredential struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
RefreshToken string `json:"refresh_token,omitempty"`
|
|
||||||
AccountID string `json:"account_id,omitempty"`
|
|
||||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
AuthMethod string `json:"auth_method"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AuthStore struct {
|
|
||||||
Credentials map[string]*AuthCredential `json:"credentials"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AuthCredential) IsExpired() bool {
|
|
||||||
if c.ExpiresAt.IsZero() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return time.Now().After(c.ExpiresAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AuthCredential) NeedsRefresh() bool {
|
|
||||||
if c.ExpiresAt.IsZero() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return time.Now().Add(5 * time.Minute).After(c.ExpiresAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func authFilePath() string {
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
return filepath.Join(home, ".clawdroid", "auth.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadStore() (*AuthStore, error) {
|
|
||||||
path := authFilePath()
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return &AuthStore{Credentials: make(map[string]*AuthCredential)}, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var store AuthStore
|
|
||||||
if err := json.Unmarshal(data, &store); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if store.Credentials == nil {
|
|
||||||
store.Credentials = make(map[string]*AuthCredential)
|
|
||||||
}
|
|
||||||
return &store, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SaveStore(store *AuthStore) error {
|
|
||||||
path := authFilePath()
|
|
||||||
dir := filepath.Dir(path)
|
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.MarshalIndent(store, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(path, data, 0600)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetCredential(provider string) (*AuthCredential, error) {
|
|
||||||
store, err := LoadStore()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
cred, ok := store.Credentials[provider]
|
|
||||||
if !ok {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return cred, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetCredential(provider string, cred *AuthCredential) error {
|
|
||||||
store, err := LoadStore()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
store.Credentials[provider] = cred
|
|
||||||
return SaveStore(store)
|
|
||||||
}
|
|
||||||
|
|
||||||
func DeleteCredential(provider string) error {
|
|
||||||
store, err := LoadStore()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
delete(store.Credentials, provider)
|
|
||||||
return SaveStore(store)
|
|
||||||
}
|
|
||||||
|
|
||||||
func DeleteAllCredentials() error {
|
|
||||||
path := authFilePath()
|
|
||||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAuthCredentialIsExpired(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
expiresAt time.Time
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"zero time", time.Time{}, false},
|
|
||||||
{"future", time.Now().Add(time.Hour), false},
|
|
||||||
{"past", time.Now().Add(-time.Hour), true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
c := &AuthCredential{ExpiresAt: tt.expiresAt}
|
|
||||||
if got := c.IsExpired(); got != tt.want {
|
|
||||||
t.Errorf("IsExpired() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthCredentialNeedsRefresh(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
expiresAt time.Time
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"zero time", time.Time{}, false},
|
|
||||||
{"far future", time.Now().Add(time.Hour), false},
|
|
||||||
{"within 5 min", time.Now().Add(3 * time.Minute), true},
|
|
||||||
{"already expired", time.Now().Add(-time.Minute), true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
c := &AuthCredential{ExpiresAt: tt.expiresAt}
|
|
||||||
if got := c.NeedsRefresh(); got != tt.want {
|
|
||||||
t.Errorf("NeedsRefresh() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStoreRoundtrip(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
origHome := os.Getenv("HOME")
|
|
||||||
t.Setenv("HOME", tmpDir)
|
|
||||||
defer os.Setenv("HOME", origHome)
|
|
||||||
|
|
||||||
cred := &AuthCredential{
|
|
||||||
AccessToken: "test-access-token",
|
|
||||||
RefreshToken: "test-refresh-token",
|
|
||||||
AccountID: "acct-123",
|
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Truncate(time.Second),
|
|
||||||
Provider: "openai",
|
|
||||||
AuthMethod: "oauth",
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := SetCredential("openai", cred); err != nil {
|
|
||||||
t.Fatalf("SetCredential() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
loaded, err := GetCredential("openai")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetCredential() error: %v", err)
|
|
||||||
}
|
|
||||||
if loaded == nil {
|
|
||||||
t.Fatal("GetCredential() returned nil")
|
|
||||||
}
|
|
||||||
if loaded.AccessToken != cred.AccessToken {
|
|
||||||
t.Errorf("AccessToken = %q, want %q", loaded.AccessToken, cred.AccessToken)
|
|
||||||
}
|
|
||||||
if loaded.RefreshToken != cred.RefreshToken {
|
|
||||||
t.Errorf("RefreshToken = %q, want %q", loaded.RefreshToken, cred.RefreshToken)
|
|
||||||
}
|
|
||||||
if loaded.Provider != cred.Provider {
|
|
||||||
t.Errorf("Provider = %q, want %q", loaded.Provider, cred.Provider)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStoreFilePermissions(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
origHome := os.Getenv("HOME")
|
|
||||||
t.Setenv("HOME", tmpDir)
|
|
||||||
defer os.Setenv("HOME", origHome)
|
|
||||||
|
|
||||||
cred := &AuthCredential{
|
|
||||||
AccessToken: "secret-token",
|
|
||||||
Provider: "openai",
|
|
||||||
AuthMethod: "oauth",
|
|
||||||
}
|
|
||||||
if err := SetCredential("openai", cred); err != nil {
|
|
||||||
t.Fatalf("SetCredential() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(tmpDir, ".clawdroid", "auth.json")
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Stat() error: %v", err)
|
|
||||||
}
|
|
||||||
perm := info.Mode().Perm()
|
|
||||||
if perm != 0600 {
|
|
||||||
t.Errorf("file permissions = %o, want 0600", perm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStoreMultiProvider(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
origHome := os.Getenv("HOME")
|
|
||||||
t.Setenv("HOME", tmpDir)
|
|
||||||
defer os.Setenv("HOME", origHome)
|
|
||||||
|
|
||||||
openaiCred := &AuthCredential{AccessToken: "openai-token", Provider: "openai", AuthMethod: "oauth"}
|
|
||||||
anthropicCred := &AuthCredential{AccessToken: "anthropic-token", Provider: "anthropic", AuthMethod: "token"}
|
|
||||||
|
|
||||||
if err := SetCredential("openai", openaiCred); err != nil {
|
|
||||||
t.Fatalf("SetCredential(openai) error: %v", err)
|
|
||||||
}
|
|
||||||
if err := SetCredential("anthropic", anthropicCred); err != nil {
|
|
||||||
t.Fatalf("SetCredential(anthropic) error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
loaded, err := GetCredential("openai")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetCredential(openai) error: %v", err)
|
|
||||||
}
|
|
||||||
if loaded.AccessToken != "openai-token" {
|
|
||||||
t.Errorf("openai token = %q, want %q", loaded.AccessToken, "openai-token")
|
|
||||||
}
|
|
||||||
|
|
||||||
loaded, err = GetCredential("anthropic")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetCredential(anthropic) error: %v", err)
|
|
||||||
}
|
|
||||||
if loaded.AccessToken != "anthropic-token" {
|
|
||||||
t.Errorf("anthropic token = %q, want %q", loaded.AccessToken, "anthropic-token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteCredential(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
origHome := os.Getenv("HOME")
|
|
||||||
t.Setenv("HOME", tmpDir)
|
|
||||||
defer os.Setenv("HOME", origHome)
|
|
||||||
|
|
||||||
cred := &AuthCredential{AccessToken: "to-delete", Provider: "openai", AuthMethod: "oauth"}
|
|
||||||
if err := SetCredential("openai", cred); err != nil {
|
|
||||||
t.Fatalf("SetCredential() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := DeleteCredential("openai"); err != nil {
|
|
||||||
t.Fatalf("DeleteCredential() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
loaded, err := GetCredential("openai")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetCredential() error: %v", err)
|
|
||||||
}
|
|
||||||
if loaded != nil {
|
|
||||||
t.Error("expected nil after delete")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadStoreEmpty(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
origHome := os.Getenv("HOME")
|
|
||||||
t.Setenv("HOME", tmpDir)
|
|
||||||
defer os.Setenv("HOME", origHome)
|
|
||||||
|
|
||||||
store, err := LoadStore()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadStore() error: %v", err)
|
|
||||||
}
|
|
||||||
if store == nil {
|
|
||||||
t.Fatal("LoadStore() returned nil")
|
|
||||||
}
|
|
||||||
if len(store.Credentials) != 0 {
|
|
||||||
t.Errorf("expected empty credentials, got %d", len(store.Credentials))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) {
|
|
||||||
fmt.Printf("Paste your API key or session token from %s:\n", providerDisplayName(provider))
|
|
||||||
fmt.Print("> ")
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(r)
|
|
||||||
if !scanner.Scan() {
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("reading token: %w", err)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("no input received")
|
|
||||||
}
|
|
||||||
|
|
||||||
token := strings.TrimSpace(scanner.Text())
|
|
||||||
if token == "" {
|
|
||||||
return nil, fmt.Errorf("token cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &AuthCredential{
|
|
||||||
AccessToken: token,
|
|
||||||
Provider: provider,
|
|
||||||
AuthMethod: "token",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func providerDisplayName(provider string) string {
|
|
||||||
switch provider {
|
|
||||||
case "anthropic":
|
|
||||||
return "console.anthropic.com"
|
|
||||||
case "openai":
|
|
||||||
return "platform.openai.com"
|
|
||||||
default:
|
|
||||||
return provider
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -78,9 +78,8 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error {
|
||||||
var response string
|
var response string
|
||||||
switch args {
|
switch args {
|
||||||
case "model":
|
case "model":
|
||||||
response = fmt.Sprintf("Current Model: %s (Provider: %s)",
|
response = fmt.Sprintf("Current Model: %s",
|
||||||
c.config.Agents.Defaults.Model,
|
c.config.LLM.Model)
|
||||||
c.config.Agents.Defaults.Provider)
|
|
||||||
case "channel":
|
case "channel":
|
||||||
response = "Current Channel: telegram"
|
response = "Current Channel: telegram"
|
||||||
default:
|
default:
|
||||||
|
|
@ -112,12 +111,8 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error {
|
||||||
var response string
|
var response string
|
||||||
switch args {
|
switch args {
|
||||||
case "models":
|
case "models":
|
||||||
provider := c.config.Agents.Defaults.Provider
|
response = fmt.Sprintf("Configured Model: %s\n\nTo change models, update config.json",
|
||||||
if provider == "" {
|
c.config.LLM.Model)
|
||||||
provider = "configured default"
|
|
||||||
}
|
|
||||||
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml",
|
|
||||||
c.config.Agents.Defaults.Model, provider)
|
|
||||||
|
|
||||||
case "channels":
|
case "channels":
|
||||||
var enabled []string
|
var enabled []string
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,21 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LLMConfig struct {
|
||||||
|
Model string `json:"model" env:"CLAWDROID_LLM_MODEL"`
|
||||||
|
APIKey string `json:"api_key" env:"CLAWDROID_LLM_API_KEY"`
|
||||||
|
BaseURL string `json:"base_url" env:"CLAWDROID_LLM_BASE_URL"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type STTConfig struct {
|
||||||
|
APIKey string `json:"api_key" env:"CLAWDROID_STT_API_KEY"`
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
LLM LLMConfig `json:"llm"`
|
||||||
|
STT STTConfig `json:"stt"`
|
||||||
Agents AgentsConfig `json:"agents"`
|
Agents AgentsConfig `json:"agents"`
|
||||||
Channels ChannelsConfig `json:"channels"`
|
Channels ChannelsConfig `json:"channels"`
|
||||||
Providers ProvidersConfig `json:"providers"`
|
|
||||||
Gateway GatewayConfig `json:"gateway"`
|
Gateway GatewayConfig `json:"gateway"`
|
||||||
Tools ToolsConfig `json:"tools"`
|
Tools ToolsConfig `json:"tools"`
|
||||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||||
|
|
@ -63,8 +74,6 @@ type AgentDefaults struct {
|
||||||
Workspace string `json:"workspace" env:"CLAWDROID_AGENTS_DEFAULTS_WORKSPACE"`
|
Workspace string `json:"workspace" env:"CLAWDROID_AGENTS_DEFAULTS_WORKSPACE"`
|
||||||
DataDir string `json:"data_dir" env:"CLAWDROID_AGENTS_DEFAULTS_DATA_DIR"`
|
DataDir string `json:"data_dir" env:"CLAWDROID_AGENTS_DEFAULTS_DATA_DIR"`
|
||||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"CLAWDROID_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"CLAWDROID_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||||
Provider string `json:"provider" env:"CLAWDROID_AGENTS_DEFAULTS_PROVIDER"`
|
|
||||||
Model string `json:"model" env:"CLAWDROID_AGENTS_DEFAULTS_MODEL"`
|
|
||||||
MaxTokens int `json:"max_tokens" env:"CLAWDROID_AGENTS_DEFAULTS_MAX_TOKENS"`
|
MaxTokens int `json:"max_tokens" env:"CLAWDROID_AGENTS_DEFAULTS_MAX_TOKENS"`
|
||||||
ContextWindow int `json:"context_window" env:"CLAWDROID_AGENTS_DEFAULTS_CONTEXT_WINDOW"`
|
ContextWindow int `json:"context_window" env:"CLAWDROID_AGENTS_DEFAULTS_CONTEXT_WINDOW"`
|
||||||
Temperature float64 `json:"temperature" env:"CLAWDROID_AGENTS_DEFAULTS_TEMPERATURE"`
|
Temperature float64 `json:"temperature" env:"CLAWDROID_AGENTS_DEFAULTS_TEMPERATURE"`
|
||||||
|
|
@ -183,30 +192,6 @@ type RateLimitsConfig struct {
|
||||||
MaxRequestsPerMinute int `json:"max_requests_per_minute" env:"CLAWDROID_RATE_LIMITS_MAX_REQUESTS_PER_MINUTE"` // 0 = unlimited
|
MaxRequestsPerMinute int `json:"max_requests_per_minute" env:"CLAWDROID_RATE_LIMITS_MAX_REQUESTS_PER_MINUTE"` // 0 = unlimited
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProvidersConfig struct {
|
|
||||||
Anthropic ProviderConfig `json:"anthropic"`
|
|
||||||
OpenAI ProviderConfig `json:"openai"`
|
|
||||||
OpenRouter ProviderConfig `json:"openrouter"`
|
|
||||||
Groq ProviderConfig `json:"groq"`
|
|
||||||
Zhipu ProviderConfig `json:"zhipu"`
|
|
||||||
VLLM ProviderConfig `json:"vllm"`
|
|
||||||
Gemini ProviderConfig `json:"gemini"`
|
|
||||||
Nvidia ProviderConfig `json:"nvidia"`
|
|
||||||
Ollama ProviderConfig `json:"ollama"`
|
|
||||||
Moonshot ProviderConfig `json:"moonshot"`
|
|
||||||
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
|
|
||||||
DeepSeek ProviderConfig `json:"deepseek"`
|
|
||||||
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProviderConfig struct {
|
|
||||||
APIKey string `json:"api_key" env:"CLAWDROID_PROVIDERS_{{.Name}}_API_KEY"`
|
|
||||||
APIBase string `json:"api_base" env:"CLAWDROID_PROVIDERS_{{.Name}}_API_BASE"`
|
|
||||||
Proxy string `json:"proxy,omitempty" env:"CLAWDROID_PROVIDERS_{{.Name}}_PROXY"`
|
|
||||||
AuthMethod string `json:"auth_method,omitempty" env:"CLAWDROID_PROVIDERS_{{.Name}}_AUTH_METHOD"`
|
|
||||||
ConnectMode string `json:"connect_mode,omitempty" env:"CLAWDROID_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GatewayConfig struct {
|
type GatewayConfig struct {
|
||||||
Host string `json:"host" env:"CLAWDROID_GATEWAY_HOST"`
|
Host string `json:"host" env:"CLAWDROID_GATEWAY_HOST"`
|
||||||
Port int `json:"port" env:"CLAWDROID_GATEWAY_PORT"`
|
Port int `json:"port" env:"CLAWDROID_GATEWAY_PORT"`
|
||||||
|
|
@ -274,16 +259,18 @@ type ToolsConfig struct {
|
||||||
|
|
||||||
func DefaultConfig() *Config {
|
func DefaultConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
|
LLM: LLMConfig{
|
||||||
|
Model: "zhipu/glm-4.7",
|
||||||
|
},
|
||||||
|
STT: STTConfig{},
|
||||||
Agents: AgentsConfig{
|
Agents: AgentsConfig{
|
||||||
Defaults: AgentDefaults{
|
Defaults: AgentDefaults{
|
||||||
Workspace: "~/.clawdroid/workspace",
|
Workspace: "~/.clawdroid/workspace",
|
||||||
DataDir: "~/.clawdroid/data",
|
DataDir: "~/.clawdroid/data",
|
||||||
RestrictToWorkspace: true,
|
RestrictToWorkspace: true,
|
||||||
Provider: "",
|
|
||||||
Model: "glm-4.7",
|
|
||||||
MaxTokens: 8192,
|
MaxTokens: 8192,
|
||||||
ContextWindow: 128000,
|
ContextWindow: 128000,
|
||||||
Temperature: 0.7,
|
Temperature: 0,
|
||||||
MaxToolIterations: 20,
|
MaxToolIterations: 20,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -360,18 +347,6 @@ func DefaultConfig() *Config {
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
|
||||||
Anthropic: ProviderConfig{},
|
|
||||||
OpenAI: ProviderConfig{},
|
|
||||||
OpenRouter: ProviderConfig{},
|
|
||||||
Groq: ProviderConfig{},
|
|
||||||
Zhipu: ProviderConfig{},
|
|
||||||
VLLM: ProviderConfig{},
|
|
||||||
Gemini: ProviderConfig{},
|
|
||||||
Nvidia: ProviderConfig{},
|
|
||||||
Moonshot: ProviderConfig{},
|
|
||||||
ShengSuanYun: ProviderConfig{},
|
|
||||||
},
|
|
||||||
Gateway: GatewayConfig{
|
Gateway: GatewayConfig{
|
||||||
Host: "127.0.0.1",
|
Host: "127.0.0.1",
|
||||||
Port: 18790,
|
Port: 18790,
|
||||||
|
|
@ -470,54 +445,6 @@ func (c *Config) DataPath() string {
|
||||||
return expandHome(c.Agents.Defaults.DataDir)
|
return expandHome(c.Agents.Defaults.DataDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetAPIKey() string {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
if c.Providers.OpenRouter.APIKey != "" {
|
|
||||||
return c.Providers.OpenRouter.APIKey
|
|
||||||
}
|
|
||||||
if c.Providers.Anthropic.APIKey != "" {
|
|
||||||
return c.Providers.Anthropic.APIKey
|
|
||||||
}
|
|
||||||
if c.Providers.OpenAI.APIKey != "" {
|
|
||||||
return c.Providers.OpenAI.APIKey
|
|
||||||
}
|
|
||||||
if c.Providers.Gemini.APIKey != "" {
|
|
||||||
return c.Providers.Gemini.APIKey
|
|
||||||
}
|
|
||||||
if c.Providers.Zhipu.APIKey != "" {
|
|
||||||
return c.Providers.Zhipu.APIKey
|
|
||||||
}
|
|
||||||
if c.Providers.Groq.APIKey != "" {
|
|
||||||
return c.Providers.Groq.APIKey
|
|
||||||
}
|
|
||||||
if c.Providers.VLLM.APIKey != "" {
|
|
||||||
return c.Providers.VLLM.APIKey
|
|
||||||
}
|
|
||||||
if c.Providers.ShengSuanYun.APIKey != "" {
|
|
||||||
return c.Providers.ShengSuanYun.APIKey
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Config) GetAPIBase() string {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
if c.Providers.OpenRouter.APIKey != "" {
|
|
||||||
if c.Providers.OpenRouter.APIBase != "" {
|
|
||||||
return c.Providers.OpenRouter.APIBase
|
|
||||||
}
|
|
||||||
return "https://openrouter.ai/api/v1"
|
|
||||||
}
|
|
||||||
if c.Providers.Zhipu.APIKey != "" {
|
|
||||||
return c.Providers.Zhipu.APIBase
|
|
||||||
}
|
|
||||||
if c.Providers.VLLM.APIKey != "" && c.Providers.VLLM.APIBase != "" {
|
|
||||||
return c.Providers.VLLM.APIBase
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func expandHome(path string) string {
|
func expandHome(path string) string {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return path
|
return path
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ func TestDefaultConfig_WorkspacePath(t *testing.T) {
|
||||||
func TestDefaultConfig_Model(t *testing.T) {
|
func TestDefaultConfig_Model(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
if cfg.Agents.Defaults.Model == "" {
|
if cfg.LLM.Model == "" {
|
||||||
t.Error("Model should not be empty")
|
t.Error("LLM.Model should not be empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,31 +75,18 @@ func TestDefaultConfig_Gateway(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDefaultConfig_Providers verifies provider structure
|
// TestDefaultConfig_LLM verifies LLM config defaults
|
||||||
func TestDefaultConfig_Providers(t *testing.T) {
|
func TestDefaultConfig_LLM(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
// Verify all providers are empty by default
|
if cfg.LLM.APIKey != "" {
|
||||||
if cfg.Providers.Anthropic.APIKey != "" {
|
t.Error("LLM API key should be empty by default")
|
||||||
t.Error("Anthropic API key should be empty by default")
|
|
||||||
}
|
}
|
||||||
if cfg.Providers.OpenAI.APIKey != "" {
|
if cfg.LLM.BaseURL != "" {
|
||||||
t.Error("OpenAI API key should be empty by default")
|
t.Error("LLM BaseURL should be empty by default")
|
||||||
}
|
}
|
||||||
if cfg.Providers.OpenRouter.APIKey != "" {
|
if cfg.LLM.Model != "zhipu/glm-4.7" {
|
||||||
t.Error("OpenRouter API key should be empty by default")
|
t.Errorf("LLM Model = %q, want %q", cfg.LLM.Model, "zhipu/glm-4.7")
|
||||||
}
|
|
||||||
if cfg.Providers.Groq.APIKey != "" {
|
|
||||||
t.Error("Groq API key should be empty by default")
|
|
||||||
}
|
|
||||||
if cfg.Providers.Zhipu.APIKey != "" {
|
|
||||||
t.Error("Zhipu API key should be empty by default")
|
|
||||||
}
|
|
||||||
if cfg.Providers.VLLM.APIKey != "" {
|
|
||||||
t.Error("VLLM API key should be empty by default")
|
|
||||||
}
|
|
||||||
if cfg.Providers.Gemini.APIKey != "" {
|
|
||||||
t.Error("Gemini API key should be empty by default")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,8 +221,8 @@ func TestConfig_Complete(t *testing.T) {
|
||||||
if cfg.Agents.Defaults.Workspace == "" {
|
if cfg.Agents.Defaults.Workspace == "" {
|
||||||
t.Error("Workspace should not be empty")
|
t.Error("Workspace should not be empty")
|
||||||
}
|
}
|
||||||
if cfg.Agents.Defaults.Model == "" {
|
if cfg.LLM.Model == "" {
|
||||||
t.Error("Model should not be empty")
|
t.Error("LLM.Model should not be empty")
|
||||||
}
|
}
|
||||||
if cfg.Agents.Defaults.Temperature == 0 {
|
if cfg.Agents.Defaults.Temperature == 0 {
|
||||||
t.Error("Temperature should have default value")
|
t.Error("Temperature should have default value")
|
||||||
|
|
|
||||||
|
|
@ -1,382 +0,0 @@
|
||||||
package migrate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
var supportedProviders = map[string]bool{
|
|
||||||
"anthropic": true,
|
|
||||||
"openai": true,
|
|
||||||
"openrouter": true,
|
|
||||||
"groq": true,
|
|
||||||
"zhipu": true,
|
|
||||||
"vllm": true,
|
|
||||||
"gemini": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
var supportedChannels = map[string]bool{
|
|
||||||
"telegram": true,
|
|
||||||
"discord": true,
|
|
||||||
"whatsapp": true,
|
|
||||||
"feishu": true,
|
|
||||||
"qq": true,
|
|
||||||
"dingtalk": true,
|
|
||||||
"maixcam": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
func findOpenClawConfig(openclawHome string) (string, error) {
|
|
||||||
candidates := []string{
|
|
||||||
filepath.Join(openclawHome, "openclaw.json"),
|
|
||||||
filepath.Join(openclawHome, "config.json"),
|
|
||||||
}
|
|
||||||
for _, p := range candidates {
|
|
||||||
if _, err := os.Stat(p); err == nil {
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", openclawHome)
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadOpenClawConfig(configPath string) (map[string]interface{}, error) {
|
|
||||||
data, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("reading OpenClaw config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var raw map[string]interface{}
|
|
||||||
if err := json.Unmarshal(data, &raw); err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing OpenClaw config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
converted := convertKeysToSnake(raw)
|
|
||||||
result, ok := converted.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unexpected config format")
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error) {
|
|
||||||
cfg := config.DefaultConfig()
|
|
||||||
var warnings []string
|
|
||||||
|
|
||||||
if agents, ok := getMap(data, "agents"); ok {
|
|
||||||
if defaults, ok := getMap(agents, "defaults"); ok {
|
|
||||||
if v, ok := getString(defaults, "model"); ok {
|
|
||||||
cfg.Agents.Defaults.Model = v
|
|
||||||
}
|
|
||||||
if v, ok := getFloat(defaults, "max_tokens"); ok {
|
|
||||||
cfg.Agents.Defaults.MaxTokens = int(v)
|
|
||||||
}
|
|
||||||
if v, ok := getFloat(defaults, "temperature"); ok {
|
|
||||||
cfg.Agents.Defaults.Temperature = v
|
|
||||||
}
|
|
||||||
if v, ok := getFloat(defaults, "max_tool_iterations"); ok {
|
|
||||||
cfg.Agents.Defaults.MaxToolIterations = int(v)
|
|
||||||
}
|
|
||||||
if v, ok := getString(defaults, "workspace"); ok {
|
|
||||||
cfg.Agents.Defaults.Workspace = rewriteWorkspacePath(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if providers, ok := getMap(data, "providers"); ok {
|
|
||||||
for name, val := range providers {
|
|
||||||
pMap, ok := val.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
apiKey, _ := getString(pMap, "api_key")
|
|
||||||
apiBase, _ := getString(pMap, "api_base")
|
|
||||||
|
|
||||||
if !supportedProviders[name] {
|
|
||||||
if apiKey != "" || apiBase != "" {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("Provider '%s' not supported in ClawDroid, skipping", name))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
pc := config.ProviderConfig{APIKey: apiKey, APIBase: apiBase}
|
|
||||||
switch name {
|
|
||||||
case "anthropic":
|
|
||||||
cfg.Providers.Anthropic = pc
|
|
||||||
case "openai":
|
|
||||||
cfg.Providers.OpenAI = pc
|
|
||||||
case "openrouter":
|
|
||||||
cfg.Providers.OpenRouter = pc
|
|
||||||
case "groq":
|
|
||||||
cfg.Providers.Groq = pc
|
|
||||||
case "zhipu":
|
|
||||||
cfg.Providers.Zhipu = pc
|
|
||||||
case "vllm":
|
|
||||||
cfg.Providers.VLLM = pc
|
|
||||||
case "gemini":
|
|
||||||
cfg.Providers.Gemini = pc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if channels, ok := getMap(data, "channels"); ok {
|
|
||||||
for name, val := range channels {
|
|
||||||
cMap, ok := val.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !supportedChannels[name] {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("Channel '%s' not supported in ClawDroid, skipping", name))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
enabled, _ := getBool(cMap, "enabled")
|
|
||||||
allowFrom := getStringSlice(cMap, "allow_from")
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case "telegram":
|
|
||||||
cfg.Channels.Telegram.Enabled = enabled
|
|
||||||
cfg.Channels.Telegram.AllowFrom = allowFrom
|
|
||||||
if v, ok := getString(cMap, "token"); ok {
|
|
||||||
cfg.Channels.Telegram.Token = v
|
|
||||||
}
|
|
||||||
case "discord":
|
|
||||||
cfg.Channels.Discord.Enabled = enabled
|
|
||||||
cfg.Channels.Discord.AllowFrom = allowFrom
|
|
||||||
if v, ok := getString(cMap, "token"); ok {
|
|
||||||
cfg.Channels.Discord.Token = v
|
|
||||||
}
|
|
||||||
case "whatsapp":
|
|
||||||
cfg.Channels.WhatsApp.Enabled = enabled
|
|
||||||
cfg.Channels.WhatsApp.AllowFrom = allowFrom
|
|
||||||
if v, ok := getString(cMap, "bridge_url"); ok {
|
|
||||||
cfg.Channels.WhatsApp.BridgeURL = v
|
|
||||||
}
|
|
||||||
case "feishu":
|
|
||||||
cfg.Channels.Feishu.Enabled = enabled
|
|
||||||
cfg.Channels.Feishu.AllowFrom = allowFrom
|
|
||||||
if v, ok := getString(cMap, "app_id"); ok {
|
|
||||||
cfg.Channels.Feishu.AppID = v
|
|
||||||
}
|
|
||||||
if v, ok := getString(cMap, "app_secret"); ok {
|
|
||||||
cfg.Channels.Feishu.AppSecret = v
|
|
||||||
}
|
|
||||||
if v, ok := getString(cMap, "encrypt_key"); ok {
|
|
||||||
cfg.Channels.Feishu.EncryptKey = v
|
|
||||||
}
|
|
||||||
if v, ok := getString(cMap, "verification_token"); ok {
|
|
||||||
cfg.Channels.Feishu.VerificationToken = v
|
|
||||||
}
|
|
||||||
case "qq":
|
|
||||||
cfg.Channels.QQ.Enabled = enabled
|
|
||||||
cfg.Channels.QQ.AllowFrom = allowFrom
|
|
||||||
if v, ok := getString(cMap, "app_id"); ok {
|
|
||||||
cfg.Channels.QQ.AppID = v
|
|
||||||
}
|
|
||||||
if v, ok := getString(cMap, "app_secret"); ok {
|
|
||||||
cfg.Channels.QQ.AppSecret = v
|
|
||||||
}
|
|
||||||
case "dingtalk":
|
|
||||||
cfg.Channels.DingTalk.Enabled = enabled
|
|
||||||
cfg.Channels.DingTalk.AllowFrom = allowFrom
|
|
||||||
if v, ok := getString(cMap, "client_id"); ok {
|
|
||||||
cfg.Channels.DingTalk.ClientID = v
|
|
||||||
}
|
|
||||||
if v, ok := getString(cMap, "client_secret"); ok {
|
|
||||||
cfg.Channels.DingTalk.ClientSecret = v
|
|
||||||
}
|
|
||||||
case "maixcam":
|
|
||||||
cfg.Channels.MaixCam.Enabled = enabled
|
|
||||||
cfg.Channels.MaixCam.AllowFrom = allowFrom
|
|
||||||
if v, ok := getString(cMap, "host"); ok {
|
|
||||||
cfg.Channels.MaixCam.Host = v
|
|
||||||
}
|
|
||||||
if v, ok := getFloat(cMap, "port"); ok {
|
|
||||||
cfg.Channels.MaixCam.Port = int(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if gateway, ok := getMap(data, "gateway"); ok {
|
|
||||||
if v, ok := getString(gateway, "host"); ok {
|
|
||||||
cfg.Gateway.Host = v
|
|
||||||
}
|
|
||||||
if v, ok := getFloat(gateway, "port"); ok {
|
|
||||||
cfg.Gateway.Port = int(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if tools, ok := getMap(data, "tools"); ok {
|
|
||||||
if web, ok := getMap(tools, "web"); ok {
|
|
||||||
// Migrate old "search" config to "brave" if api_key is present
|
|
||||||
if search, ok := getMap(web, "search"); ok {
|
|
||||||
if v, ok := getString(search, "api_key"); ok {
|
|
||||||
cfg.Tools.Web.Brave.APIKey = v
|
|
||||||
if v != "" {
|
|
||||||
cfg.Tools.Web.Brave.Enabled = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v, ok := getFloat(search, "max_results"); ok {
|
|
||||||
cfg.Tools.Web.Brave.MaxResults = int(v)
|
|
||||||
cfg.Tools.Web.DuckDuckGo.MaxResults = int(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, warnings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func MergeConfig(existing, incoming *config.Config) *config.Config {
|
|
||||||
if existing.Providers.Anthropic.APIKey == "" {
|
|
||||||
existing.Providers.Anthropic = incoming.Providers.Anthropic
|
|
||||||
}
|
|
||||||
if existing.Providers.OpenAI.APIKey == "" {
|
|
||||||
existing.Providers.OpenAI = incoming.Providers.OpenAI
|
|
||||||
}
|
|
||||||
if existing.Providers.OpenRouter.APIKey == "" {
|
|
||||||
existing.Providers.OpenRouter = incoming.Providers.OpenRouter
|
|
||||||
}
|
|
||||||
if existing.Providers.Groq.APIKey == "" {
|
|
||||||
existing.Providers.Groq = incoming.Providers.Groq
|
|
||||||
}
|
|
||||||
if existing.Providers.Zhipu.APIKey == "" {
|
|
||||||
existing.Providers.Zhipu = incoming.Providers.Zhipu
|
|
||||||
}
|
|
||||||
if existing.Providers.VLLM.APIKey == "" && existing.Providers.VLLM.APIBase == "" {
|
|
||||||
existing.Providers.VLLM = incoming.Providers.VLLM
|
|
||||||
}
|
|
||||||
if existing.Providers.Gemini.APIKey == "" {
|
|
||||||
existing.Providers.Gemini = incoming.Providers.Gemini
|
|
||||||
}
|
|
||||||
|
|
||||||
if !existing.Channels.Telegram.Enabled && incoming.Channels.Telegram.Enabled {
|
|
||||||
existing.Channels.Telegram = incoming.Channels.Telegram
|
|
||||||
}
|
|
||||||
if !existing.Channels.Discord.Enabled && incoming.Channels.Discord.Enabled {
|
|
||||||
existing.Channels.Discord = incoming.Channels.Discord
|
|
||||||
}
|
|
||||||
if !existing.Channels.WhatsApp.Enabled && incoming.Channels.WhatsApp.Enabled {
|
|
||||||
existing.Channels.WhatsApp = incoming.Channels.WhatsApp
|
|
||||||
}
|
|
||||||
if !existing.Channels.Feishu.Enabled && incoming.Channels.Feishu.Enabled {
|
|
||||||
existing.Channels.Feishu = incoming.Channels.Feishu
|
|
||||||
}
|
|
||||||
if !existing.Channels.QQ.Enabled && incoming.Channels.QQ.Enabled {
|
|
||||||
existing.Channels.QQ = incoming.Channels.QQ
|
|
||||||
}
|
|
||||||
if !existing.Channels.DingTalk.Enabled && incoming.Channels.DingTalk.Enabled {
|
|
||||||
existing.Channels.DingTalk = incoming.Channels.DingTalk
|
|
||||||
}
|
|
||||||
if !existing.Channels.MaixCam.Enabled && incoming.Channels.MaixCam.Enabled {
|
|
||||||
existing.Channels.MaixCam = incoming.Channels.MaixCam
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing.Tools.Web.Brave.APIKey == "" {
|
|
||||||
existing.Tools.Web.Brave = incoming.Tools.Web.Brave
|
|
||||||
}
|
|
||||||
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
func camelToSnake(s string) string {
|
|
||||||
var result strings.Builder
|
|
||||||
for i, r := range s {
|
|
||||||
if unicode.IsUpper(r) {
|
|
||||||
if i > 0 {
|
|
||||||
prev := rune(s[i-1])
|
|
||||||
if unicode.IsLower(prev) || unicode.IsDigit(prev) {
|
|
||||||
result.WriteRune('_')
|
|
||||||
} else if unicode.IsUpper(prev) && i+1 < len(s) && unicode.IsLower(rune(s[i+1])) {
|
|
||||||
result.WriteRune('_')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.WriteRune(unicode.ToLower(r))
|
|
||||||
} else {
|
|
||||||
result.WriteRune(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertKeysToSnake(data interface{}) interface{} {
|
|
||||||
switch v := data.(type) {
|
|
||||||
case map[string]interface{}:
|
|
||||||
result := make(map[string]interface{}, len(v))
|
|
||||||
for key, val := range v {
|
|
||||||
result[camelToSnake(key)] = convertKeysToSnake(val)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
case []interface{}:
|
|
||||||
result := make([]interface{}, len(v))
|
|
||||||
for i, val := range v {
|
|
||||||
result[i] = convertKeysToSnake(val)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
default:
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func rewriteWorkspacePath(path string) string {
|
|
||||||
path = strings.Replace(path, ".openclaw", ".clawdroid", 1)
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func getMap(data map[string]interface{}, key string) (map[string]interface{}, bool) {
|
|
||||||
v, ok := data[key]
|
|
||||||
if !ok {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
m, ok := v.(map[string]interface{})
|
|
||||||
return m, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func getString(data map[string]interface{}, key string) (string, bool) {
|
|
||||||
v, ok := data[key]
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
s, ok := v.(string)
|
|
||||||
return s, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func getFloat(data map[string]interface{}, key string) (float64, bool) {
|
|
||||||
v, ok := data[key]
|
|
||||||
if !ok {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
f, ok := v.(float64)
|
|
||||||
return f, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBool(data map[string]interface{}, key string) (bool, bool) {
|
|
||||||
v, ok := data[key]
|
|
||||||
if !ok {
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
b, ok := v.(bool)
|
|
||||||
return b, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func getStringSlice(data map[string]interface{}, key string) []string {
|
|
||||||
v, ok := data[key]
|
|
||||||
if !ok {
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
arr, ok := v.([]interface{})
|
|
||||||
if !ok {
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
result := make([]string, 0, len(arr))
|
|
||||||
for _, item := range arr {
|
|
||||||
if s, ok := item.(string); ok {
|
|
||||||
result = append(result, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
@ -1,394 +0,0 @@
|
||||||
package migrate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ActionType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
ActionCopy ActionType = iota
|
|
||||||
ActionSkip
|
|
||||||
ActionBackup
|
|
||||||
ActionConvertConfig
|
|
||||||
ActionCreateDir
|
|
||||||
ActionMergeConfig
|
|
||||||
)
|
|
||||||
|
|
||||||
type Options struct {
|
|
||||||
DryRun bool
|
|
||||||
ConfigOnly bool
|
|
||||||
WorkspaceOnly bool
|
|
||||||
Force bool
|
|
||||||
Refresh bool
|
|
||||||
OpenClawHome string
|
|
||||||
ClawDroidHome string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Action struct {
|
|
||||||
Type ActionType
|
|
||||||
Source string
|
|
||||||
Destination string
|
|
||||||
Description string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Result struct {
|
|
||||||
FilesCopied int
|
|
||||||
FilesSkipped int
|
|
||||||
BackupsCreated int
|
|
||||||
ConfigMigrated bool
|
|
||||||
DirsCreated int
|
|
||||||
Warnings []string
|
|
||||||
Errors []error
|
|
||||||
}
|
|
||||||
|
|
||||||
func Run(opts Options) (*Result, error) {
|
|
||||||
if opts.ConfigOnly && opts.WorkspaceOnly {
|
|
||||||
return nil, fmt.Errorf("--config-only and --workspace-only are mutually exclusive")
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.Refresh {
|
|
||||||
opts.WorkspaceOnly = true
|
|
||||||
}
|
|
||||||
|
|
||||||
openclawHome, err := resolveOpenClawHome(opts.OpenClawHome)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
clawDroidHome, err := resolveClawDroidHome(opts.ClawDroidHome)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(openclawHome); os.IsNotExist(err) {
|
|
||||||
return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome)
|
|
||||||
}
|
|
||||||
|
|
||||||
actions, warnings, err := Plan(opts, openclawHome, clawDroidHome)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Migrating from OpenClaw to ClawDroid")
|
|
||||||
fmt.Printf(" Source: %s\n", openclawHome)
|
|
||||||
fmt.Printf(" Destination: %s\n", clawDroidHome)
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
if opts.DryRun {
|
|
||||||
PrintPlan(actions, warnings)
|
|
||||||
return &Result{Warnings: warnings}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if !opts.Force {
|
|
||||||
PrintPlan(actions, warnings)
|
|
||||||
if !Confirm() {
|
|
||||||
fmt.Println("Aborted.")
|
|
||||||
return &Result{Warnings: warnings}, nil
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
result := Execute(actions, openclawHome, clawDroidHome)
|
|
||||||
result.Warnings = warnings
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Plan(opts Options, openclawHome, clawDroidHome string) ([]Action, []string, error) {
|
|
||||||
var actions []Action
|
|
||||||
var warnings []string
|
|
||||||
|
|
||||||
force := opts.Force || opts.Refresh
|
|
||||||
|
|
||||||
if !opts.WorkspaceOnly {
|
|
||||||
configPath, err := findOpenClawConfig(openclawHome)
|
|
||||||
if err != nil {
|
|
||||||
if opts.ConfigOnly {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
warnings = append(warnings, fmt.Sprintf("Config migration skipped: %v", err))
|
|
||||||
} else {
|
|
||||||
actions = append(actions, Action{
|
|
||||||
Type: ActionConvertConfig,
|
|
||||||
Source: configPath,
|
|
||||||
Destination: filepath.Join(clawDroidHome, "config.json"),
|
|
||||||
Description: "convert OpenClaw config to ClawDroid format",
|
|
||||||
})
|
|
||||||
|
|
||||||
data, err := LoadOpenClawConfig(configPath)
|
|
||||||
if err == nil {
|
|
||||||
_, configWarnings, _ := ConvertConfig(data)
|
|
||||||
warnings = append(warnings, configWarnings...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !opts.ConfigOnly {
|
|
||||||
srcWorkspace := resolveWorkspace(openclawHome)
|
|
||||||
dstWorkspace := resolveWorkspace(clawDroidHome)
|
|
||||||
|
|
||||||
if _, err := os.Stat(srcWorkspace); err == nil {
|
|
||||||
wsActions, err := PlanWorkspaceMigration(srcWorkspace, dstWorkspace, force)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("planning workspace migration: %w", err)
|
|
||||||
}
|
|
||||||
actions = append(actions, wsActions...)
|
|
||||||
} else {
|
|
||||||
warnings = append(warnings, "OpenClaw workspace directory not found, skipping workspace migration")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions, warnings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Execute(actions []Action, openclawHome, clawDroidHome string) *Result {
|
|
||||||
result := &Result{}
|
|
||||||
|
|
||||||
for _, action := range actions {
|
|
||||||
switch action.Type {
|
|
||||||
case ActionConvertConfig:
|
|
||||||
if err := executeConfigMigration(action.Source, action.Destination, clawDroidHome); err != nil {
|
|
||||||
result.Errors = append(result.Errors, fmt.Errorf("config migration: %w", err))
|
|
||||||
fmt.Printf(" ✗ Config migration failed: %v\n", err)
|
|
||||||
} else {
|
|
||||||
result.ConfigMigrated = true
|
|
||||||
fmt.Printf(" ✓ Converted config: %s\n", action.Destination)
|
|
||||||
}
|
|
||||||
case ActionCreateDir:
|
|
||||||
if err := os.MkdirAll(action.Destination, 0755); err != nil {
|
|
||||||
result.Errors = append(result.Errors, err)
|
|
||||||
} else {
|
|
||||||
result.DirsCreated++
|
|
||||||
}
|
|
||||||
case ActionBackup:
|
|
||||||
bakPath := action.Destination + ".bak"
|
|
||||||
if err := copyFile(action.Destination, bakPath); err != nil {
|
|
||||||
result.Errors = append(result.Errors, fmt.Errorf("backup %s: %w", action.Destination, err))
|
|
||||||
fmt.Printf(" ✗ Backup failed: %s\n", action.Destination)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result.BackupsCreated++
|
|
||||||
fmt.Printf(" ✓ Backed up %s -> %s.bak\n", filepath.Base(action.Destination), filepath.Base(action.Destination))
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil {
|
|
||||||
result.Errors = append(result.Errors, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := copyFile(action.Source, action.Destination); err != nil {
|
|
||||||
result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err))
|
|
||||||
fmt.Printf(" ✗ Copy failed: %s\n", action.Source)
|
|
||||||
} else {
|
|
||||||
result.FilesCopied++
|
|
||||||
fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
|
|
||||||
}
|
|
||||||
case ActionCopy:
|
|
||||||
if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil {
|
|
||||||
result.Errors = append(result.Errors, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := copyFile(action.Source, action.Destination); err != nil {
|
|
||||||
result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err))
|
|
||||||
fmt.Printf(" ✗ Copy failed: %s\n", action.Source)
|
|
||||||
} else {
|
|
||||||
result.FilesCopied++
|
|
||||||
fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
|
|
||||||
}
|
|
||||||
case ActionSkip:
|
|
||||||
result.FilesSkipped++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func executeConfigMigration(srcConfigPath, dstConfigPath, clawDroidHome string) error {
|
|
||||||
data, err := LoadOpenClawConfig(srcConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
incoming, _, err := ConvertConfig(data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(dstConfigPath); err == nil {
|
|
||||||
existing, err := config.LoadConfig(dstConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("loading existing ClawDroid config: %w", err)
|
|
||||||
}
|
|
||||||
incoming = MergeConfig(existing, incoming)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return config.SaveConfig(dstConfigPath, incoming)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Confirm() bool {
|
|
||||||
fmt.Print("Proceed with migration? (y/n): ")
|
|
||||||
var response string
|
|
||||||
fmt.Scanln(&response)
|
|
||||||
return strings.ToLower(strings.TrimSpace(response)) == "y"
|
|
||||||
}
|
|
||||||
|
|
||||||
func PrintPlan(actions []Action, warnings []string) {
|
|
||||||
fmt.Println("Planned actions:")
|
|
||||||
copies := 0
|
|
||||||
skips := 0
|
|
||||||
backups := 0
|
|
||||||
configCount := 0
|
|
||||||
|
|
||||||
for _, action := range actions {
|
|
||||||
switch action.Type {
|
|
||||||
case ActionConvertConfig:
|
|
||||||
fmt.Printf(" [config] %s -> %s\n", action.Source, action.Destination)
|
|
||||||
configCount++
|
|
||||||
case ActionCopy:
|
|
||||||
fmt.Printf(" [copy] %s\n", filepath.Base(action.Source))
|
|
||||||
copies++
|
|
||||||
case ActionBackup:
|
|
||||||
fmt.Printf(" [backup] %s (exists, will backup and overwrite)\n", filepath.Base(action.Destination))
|
|
||||||
backups++
|
|
||||||
copies++
|
|
||||||
case ActionSkip:
|
|
||||||
if action.Description != "" {
|
|
||||||
fmt.Printf(" [skip] %s (%s)\n", filepath.Base(action.Source), action.Description)
|
|
||||||
}
|
|
||||||
skips++
|
|
||||||
case ActionCreateDir:
|
|
||||||
fmt.Printf(" [mkdir] %s\n", action.Destination)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(warnings) > 0 {
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Warnings:")
|
|
||||||
for _, w := range warnings {
|
|
||||||
fmt.Printf(" - %s\n", w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Printf("%d files to copy, %d configs to convert, %d backups needed, %d skipped\n",
|
|
||||||
copies, configCount, backups, skips)
|
|
||||||
}
|
|
||||||
|
|
||||||
func PrintSummary(result *Result) {
|
|
||||||
fmt.Println()
|
|
||||||
parts := []string{}
|
|
||||||
if result.FilesCopied > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%d files copied", result.FilesCopied))
|
|
||||||
}
|
|
||||||
if result.ConfigMigrated {
|
|
||||||
parts = append(parts, "1 config converted")
|
|
||||||
}
|
|
||||||
if result.BackupsCreated > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%d backups created", result.BackupsCreated))
|
|
||||||
}
|
|
||||||
if result.FilesSkipped > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%d files skipped", result.FilesSkipped))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(parts) > 0 {
|
|
||||||
fmt.Printf("Migration complete! %s.\n", strings.Join(parts, ", "))
|
|
||||||
} else {
|
|
||||||
fmt.Println("Migration complete! No actions taken.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(result.Errors) > 0 {
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Printf("%d errors occurred:\n", len(result.Errors))
|
|
||||||
for _, e := range result.Errors {
|
|
||||||
fmt.Printf(" - %v\n", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveOpenClawHome(override string) (string, error) {
|
|
||||||
if override != "" {
|
|
||||||
return expandHome(override), nil
|
|
||||||
}
|
|
||||||
if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" {
|
|
||||||
return expandHome(envHome), nil
|
|
||||||
}
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("resolving home directory: %w", err)
|
|
||||||
}
|
|
||||||
return filepath.Join(home, ".openclaw"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveClawDroidHome(override string) (string, error) {
|
|
||||||
if override != "" {
|
|
||||||
return expandHome(override), nil
|
|
||||||
}
|
|
||||||
if envHome := os.Getenv("CLAWDROID_HOME"); envHome != "" {
|
|
||||||
return expandHome(envHome), nil
|
|
||||||
}
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("resolving home directory: %w", err)
|
|
||||||
}
|
|
||||||
return filepath.Join(home, ".clawdroid"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveWorkspace(homeDir string) string {
|
|
||||||
return filepath.Join(homeDir, "workspace")
|
|
||||||
}
|
|
||||||
|
|
||||||
func expandHome(path string) string {
|
|
||||||
if path == "" {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
if path[0] == '~' {
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
if len(path) > 1 && path[1] == '/' {
|
|
||||||
return home + path[1:]
|
|
||||||
}
|
|
||||||
return home
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func backupFile(path string) error {
|
|
||||||
bakPath := path + ".bak"
|
|
||||||
return copyFile(path, bakPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyFile(src, dst string) error {
|
|
||||||
srcFile, err := os.Open(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer srcFile.Close()
|
|
||||||
|
|
||||||
info, err := srcFile.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer dstFile.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(dstFile, srcFile)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func relPath(path, base string) string {
|
|
||||||
rel, err := filepath.Rel(base, path)
|
|
||||||
if err != nil {
|
|
||||||
return filepath.Base(path)
|
|
||||||
}
|
|
||||||
return rel
|
|
||||||
}
|
|
||||||
|
|
@ -1,854 +0,0 @@
|
||||||
package migrate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCamelToSnake(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"simple", "apiKey", "api_key"},
|
|
||||||
{"two words", "apiBase", "api_base"},
|
|
||||||
{"three words", "maxToolIterations", "max_tool_iterations"},
|
|
||||||
{"already snake", "api_key", "api_key"},
|
|
||||||
{"single word", "enabled", "enabled"},
|
|
||||||
{"all lower", "model", "model"},
|
|
||||||
{"consecutive caps", "apiURL", "api_url"},
|
|
||||||
{"starts upper", "Model", "model"},
|
|
||||||
{"bridge url", "bridgeUrl", "bridge_url"},
|
|
||||||
{"client id", "clientId", "client_id"},
|
|
||||||
{"app secret", "appSecret", "app_secret"},
|
|
||||||
{"verification token", "verificationToken", "verification_token"},
|
|
||||||
{"allow from", "allowFrom", "allow_from"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := camelToSnake(tt.input)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("camelToSnake(%q) = %q, want %q", tt.input, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertKeysToSnake(t *testing.T) {
|
|
||||||
input := map[string]interface{}{
|
|
||||||
"apiKey": "test-key",
|
|
||||||
"apiBase": "https://example.com",
|
|
||||||
"nested": map[string]interface{}{
|
|
||||||
"maxTokens": float64(8192),
|
|
||||||
"allowFrom": []interface{}{"user1", "user2"},
|
|
||||||
"deeperLevel": map[string]interface{}{
|
|
||||||
"clientId": "abc",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := convertKeysToSnake(input)
|
|
||||||
m, ok := result.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected map[string]interface{}")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := m["api_key"]; !ok {
|
|
||||||
t.Error("expected key 'api_key' after conversion")
|
|
||||||
}
|
|
||||||
if _, ok := m["api_base"]; !ok {
|
|
||||||
t.Error("expected key 'api_base' after conversion")
|
|
||||||
}
|
|
||||||
|
|
||||||
nested, ok := m["nested"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected nested map")
|
|
||||||
}
|
|
||||||
if _, ok := nested["max_tokens"]; !ok {
|
|
||||||
t.Error("expected key 'max_tokens' in nested map")
|
|
||||||
}
|
|
||||||
if _, ok := nested["allow_from"]; !ok {
|
|
||||||
t.Error("expected key 'allow_from' in nested map")
|
|
||||||
}
|
|
||||||
|
|
||||||
deeper, ok := nested["deeper_level"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected deeper_level map")
|
|
||||||
}
|
|
||||||
if _, ok := deeper["client_id"]; !ok {
|
|
||||||
t.Error("expected key 'client_id' in deeper level")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadOpenClawConfig(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
|
||||||
|
|
||||||
openclawConfig := map[string]interface{}{
|
|
||||||
"providers": map[string]interface{}{
|
|
||||||
"anthropic": map[string]interface{}{
|
|
||||||
"apiKey": "sk-ant-test123",
|
|
||||||
"apiBase": "https://api.anthropic.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"agents": map[string]interface{}{
|
|
||||||
"defaults": map[string]interface{}{
|
|
||||||
"maxTokens": float64(4096),
|
|
||||||
"model": "claude-3-opus",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.Marshal(openclawConfig)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := LoadOpenClawConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadOpenClawConfig: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
providers, ok := result["providers"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected providers map")
|
|
||||||
}
|
|
||||||
anthropic, ok := providers["anthropic"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected anthropic map")
|
|
||||||
}
|
|
||||||
if anthropic["api_key"] != "sk-ant-test123" {
|
|
||||||
t.Errorf("api_key = %v, want sk-ant-test123", anthropic["api_key"])
|
|
||||||
}
|
|
||||||
|
|
||||||
agents, ok := result["agents"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected agents map")
|
|
||||||
}
|
|
||||||
defaults, ok := agents["defaults"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected defaults map")
|
|
||||||
}
|
|
||||||
if defaults["max_tokens"] != float64(4096) {
|
|
||||||
t.Errorf("max_tokens = %v, want 4096", defaults["max_tokens"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertConfig(t *testing.T) {
|
|
||||||
t.Run("providers mapping", func(t *testing.T) {
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"providers": map[string]interface{}{
|
|
||||||
"anthropic": map[string]interface{}{
|
|
||||||
"api_key": "sk-ant-test",
|
|
||||||
"api_base": "https://api.anthropic.com",
|
|
||||||
},
|
|
||||||
"openrouter": map[string]interface{}{
|
|
||||||
"api_key": "sk-or-test",
|
|
||||||
},
|
|
||||||
"groq": map[string]interface{}{
|
|
||||||
"api_key": "gsk-test",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, warnings, err := ConvertConfig(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConvertConfig: %v", err)
|
|
||||||
}
|
|
||||||
if len(warnings) != 0 {
|
|
||||||
t.Errorf("expected no warnings, got %v", warnings)
|
|
||||||
}
|
|
||||||
if cfg.Providers.Anthropic.APIKey != "sk-ant-test" {
|
|
||||||
t.Errorf("Anthropic.APIKey = %q, want %q", cfg.Providers.Anthropic.APIKey, "sk-ant-test")
|
|
||||||
}
|
|
||||||
if cfg.Providers.OpenRouter.APIKey != "sk-or-test" {
|
|
||||||
t.Errorf("OpenRouter.APIKey = %q, want %q", cfg.Providers.OpenRouter.APIKey, "sk-or-test")
|
|
||||||
}
|
|
||||||
if cfg.Providers.Groq.APIKey != "gsk-test" {
|
|
||||||
t.Errorf("Groq.APIKey = %q, want %q", cfg.Providers.Groq.APIKey, "gsk-test")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("unsupported provider warning", func(t *testing.T) {
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"providers": map[string]interface{}{
|
|
||||||
"deepseek": map[string]interface{}{
|
|
||||||
"api_key": "sk-deep-test",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, warnings, err := ConvertConfig(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConvertConfig: %v", err)
|
|
||||||
}
|
|
||||||
if len(warnings) != 1 {
|
|
||||||
t.Fatalf("expected 1 warning, got %d", len(warnings))
|
|
||||||
}
|
|
||||||
if warnings[0] != "Provider 'deepseek' not supported in ClawDroid, skipping" {
|
|
||||||
t.Errorf("unexpected warning: %s", warnings[0])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("channels mapping", func(t *testing.T) {
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"channels": map[string]interface{}{
|
|
||||||
"telegram": map[string]interface{}{
|
|
||||||
"enabled": true,
|
|
||||||
"token": "tg-token-123",
|
|
||||||
"allow_from": []interface{}{"user1"},
|
|
||||||
},
|
|
||||||
"discord": map[string]interface{}{
|
|
||||||
"enabled": true,
|
|
||||||
"token": "disc-token-456",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, _, err := ConvertConfig(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConvertConfig: %v", err)
|
|
||||||
}
|
|
||||||
if !cfg.Channels.Telegram.Enabled {
|
|
||||||
t.Error("Telegram should be enabled")
|
|
||||||
}
|
|
||||||
if cfg.Channels.Telegram.Token != "tg-token-123" {
|
|
||||||
t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token, "tg-token-123")
|
|
||||||
}
|
|
||||||
if len(cfg.Channels.Telegram.AllowFrom) != 1 || cfg.Channels.Telegram.AllowFrom[0] != "user1" {
|
|
||||||
t.Errorf("Telegram.AllowFrom = %v, want [user1]", cfg.Channels.Telegram.AllowFrom)
|
|
||||||
}
|
|
||||||
if !cfg.Channels.Discord.Enabled {
|
|
||||||
t.Error("Discord should be enabled")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("unsupported channel warning", func(t *testing.T) {
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"channels": map[string]interface{}{
|
|
||||||
"email": map[string]interface{}{
|
|
||||||
"enabled": true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, warnings, err := ConvertConfig(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConvertConfig: %v", err)
|
|
||||||
}
|
|
||||||
if len(warnings) != 1 {
|
|
||||||
t.Fatalf("expected 1 warning, got %d", len(warnings))
|
|
||||||
}
|
|
||||||
if warnings[0] != "Channel 'email' not supported in ClawDroid, skipping" {
|
|
||||||
t.Errorf("unexpected warning: %s", warnings[0])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("agent defaults", func(t *testing.T) {
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"agents": map[string]interface{}{
|
|
||||||
"defaults": map[string]interface{}{
|
|
||||||
"model": "claude-3-opus",
|
|
||||||
"max_tokens": float64(4096),
|
|
||||||
"temperature": 0.5,
|
|
||||||
"max_tool_iterations": float64(10),
|
|
||||||
"workspace": "~/.openclaw/workspace",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, _, err := ConvertConfig(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConvertConfig: %v", err)
|
|
||||||
}
|
|
||||||
if cfg.Agents.Defaults.Model != "claude-3-opus" {
|
|
||||||
t.Errorf("Model = %q, want %q", cfg.Agents.Defaults.Model, "claude-3-opus")
|
|
||||||
}
|
|
||||||
if cfg.Agents.Defaults.MaxTokens != 4096 {
|
|
||||||
t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 4096)
|
|
||||||
}
|
|
||||||
if cfg.Agents.Defaults.Temperature != 0.5 {
|
|
||||||
t.Errorf("Temperature = %f, want %f", cfg.Agents.Defaults.Temperature, 0.5)
|
|
||||||
}
|
|
||||||
if cfg.Agents.Defaults.Workspace != "~/.clawdroid/workspace" {
|
|
||||||
t.Errorf("Workspace = %q, want %q", cfg.Agents.Defaults.Workspace, "~/.clawdroid/workspace")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("empty config", func(t *testing.T) {
|
|
||||||
data := map[string]interface{}{}
|
|
||||||
|
|
||||||
cfg, warnings, err := ConvertConfig(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ConvertConfig: %v", err)
|
|
||||||
}
|
|
||||||
if len(warnings) != 0 {
|
|
||||||
t.Errorf("expected no warnings, got %v", warnings)
|
|
||||||
}
|
|
||||||
if cfg.Agents.Defaults.Model != "glm-4.7" {
|
|
||||||
t.Errorf("default model should be glm-4.7, got %q", cfg.Agents.Defaults.Model)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeConfig(t *testing.T) {
|
|
||||||
t.Run("fills empty fields", func(t *testing.T) {
|
|
||||||
existing := config.DefaultConfig()
|
|
||||||
incoming := config.DefaultConfig()
|
|
||||||
incoming.Providers.Anthropic.APIKey = "sk-ant-incoming"
|
|
||||||
incoming.Providers.OpenRouter.APIKey = "sk-or-incoming"
|
|
||||||
|
|
||||||
result := MergeConfig(existing, incoming)
|
|
||||||
if result.Providers.Anthropic.APIKey != "sk-ant-incoming" {
|
|
||||||
t.Errorf("Anthropic.APIKey = %q, want %q", result.Providers.Anthropic.APIKey, "sk-ant-incoming")
|
|
||||||
}
|
|
||||||
if result.Providers.OpenRouter.APIKey != "sk-or-incoming" {
|
|
||||||
t.Errorf("OpenRouter.APIKey = %q, want %q", result.Providers.OpenRouter.APIKey, "sk-or-incoming")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("preserves existing non-empty fields", func(t *testing.T) {
|
|
||||||
existing := config.DefaultConfig()
|
|
||||||
existing.Providers.Anthropic.APIKey = "sk-ant-existing"
|
|
||||||
|
|
||||||
incoming := config.DefaultConfig()
|
|
||||||
incoming.Providers.Anthropic.APIKey = "sk-ant-incoming"
|
|
||||||
incoming.Providers.OpenAI.APIKey = "sk-oai-incoming"
|
|
||||||
|
|
||||||
result := MergeConfig(existing, incoming)
|
|
||||||
if result.Providers.Anthropic.APIKey != "sk-ant-existing" {
|
|
||||||
t.Errorf("Anthropic.APIKey should be preserved, got %q", result.Providers.Anthropic.APIKey)
|
|
||||||
}
|
|
||||||
if result.Providers.OpenAI.APIKey != "sk-oai-incoming" {
|
|
||||||
t.Errorf("OpenAI.APIKey should be filled, got %q", result.Providers.OpenAI.APIKey)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("merges enabled channels", func(t *testing.T) {
|
|
||||||
existing := config.DefaultConfig()
|
|
||||||
incoming := config.DefaultConfig()
|
|
||||||
incoming.Channels.Telegram.Enabled = true
|
|
||||||
incoming.Channels.Telegram.Token = "tg-token"
|
|
||||||
|
|
||||||
result := MergeConfig(existing, incoming)
|
|
||||||
if !result.Channels.Telegram.Enabled {
|
|
||||||
t.Error("Telegram should be enabled after merge")
|
|
||||||
}
|
|
||||||
if result.Channels.Telegram.Token != "tg-token" {
|
|
||||||
t.Errorf("Telegram.Token = %q, want %q", result.Channels.Telegram.Token, "tg-token")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("preserves existing enabled channels", func(t *testing.T) {
|
|
||||||
existing := config.DefaultConfig()
|
|
||||||
existing.Channels.Telegram.Enabled = true
|
|
||||||
existing.Channels.Telegram.Token = "existing-token"
|
|
||||||
|
|
||||||
incoming := config.DefaultConfig()
|
|
||||||
incoming.Channels.Telegram.Enabled = true
|
|
||||||
incoming.Channels.Telegram.Token = "incoming-token"
|
|
||||||
|
|
||||||
result := MergeConfig(existing, incoming)
|
|
||||||
if result.Channels.Telegram.Token != "existing-token" {
|
|
||||||
t.Errorf("Telegram.Token should be preserved, got %q", result.Channels.Telegram.Token)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanWorkspaceMigration(t *testing.T) {
|
|
||||||
t.Run("copies available files", func(t *testing.T) {
|
|
||||||
srcDir := t.TempDir()
|
|
||||||
dstDir := t.TempDir()
|
|
||||||
|
|
||||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644)
|
|
||||||
os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0644)
|
|
||||||
os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0644)
|
|
||||||
|
|
||||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanWorkspaceMigration: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
copyCount := 0
|
|
||||||
skipCount := 0
|
|
||||||
for _, a := range actions {
|
|
||||||
if a.Type == ActionCopy {
|
|
||||||
copyCount++
|
|
||||||
}
|
|
||||||
if a.Type == ActionSkip {
|
|
||||||
skipCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if copyCount != 3 {
|
|
||||||
t.Errorf("expected 3 copies, got %d", copyCount)
|
|
||||||
}
|
|
||||||
if skipCount != 2 {
|
|
||||||
t.Errorf("expected 2 skips (TOOLS.md, HEARTBEAT.md), got %d", skipCount)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("plans backup for existing destination files", func(t *testing.T) {
|
|
||||||
srcDir := t.TempDir()
|
|
||||||
dstDir := t.TempDir()
|
|
||||||
|
|
||||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644)
|
|
||||||
os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0644)
|
|
||||||
|
|
||||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanWorkspaceMigration: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
backupCount := 0
|
|
||||||
for _, a := range actions {
|
|
||||||
if a.Type == ActionBackup && filepath.Base(a.Destination) == "AGENTS.md" {
|
|
||||||
backupCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if backupCount != 1 {
|
|
||||||
t.Errorf("expected 1 backup action for AGENTS.md, got %d", backupCount)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("force skips backup", func(t *testing.T) {
|
|
||||||
srcDir := t.TempDir()
|
|
||||||
dstDir := t.TempDir()
|
|
||||||
|
|
||||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644)
|
|
||||||
os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0644)
|
|
||||||
|
|
||||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanWorkspaceMigration: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, a := range actions {
|
|
||||||
if a.Type == ActionBackup {
|
|
||||||
t.Error("expected no backup actions with force=true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("handles memory directory", func(t *testing.T) {
|
|
||||||
srcDir := t.TempDir()
|
|
||||||
dstDir := t.TempDir()
|
|
||||||
|
|
||||||
memDir := filepath.Join(srcDir, "memory")
|
|
||||||
os.MkdirAll(memDir, 0755)
|
|
||||||
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0644)
|
|
||||||
|
|
||||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanWorkspaceMigration: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hasCopy := false
|
|
||||||
hasDir := false
|
|
||||||
for _, a := range actions {
|
|
||||||
if a.Type == ActionCopy && filepath.Base(a.Source) == "MEMORY.md" {
|
|
||||||
hasCopy = true
|
|
||||||
}
|
|
||||||
if a.Type == ActionCreateDir {
|
|
||||||
hasDir = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasCopy {
|
|
||||||
t.Error("expected copy action for memory/MEMORY.md")
|
|
||||||
}
|
|
||||||
if !hasDir {
|
|
||||||
t.Error("expected create dir action for memory/")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("handles skills directory", func(t *testing.T) {
|
|
||||||
srcDir := t.TempDir()
|
|
||||||
dstDir := t.TempDir()
|
|
||||||
|
|
||||||
skillDir := filepath.Join(srcDir, "skills", "weather")
|
|
||||||
os.MkdirAll(skillDir, 0755)
|
|
||||||
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0644)
|
|
||||||
|
|
||||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PlanWorkspaceMigration: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hasCopy := false
|
|
||||||
for _, a := range actions {
|
|
||||||
if a.Type == ActionCopy && filepath.Base(a.Source) == "SKILL.md" {
|
|
||||||
hasCopy = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasCopy {
|
|
||||||
t.Error("expected copy action for skills/weather/SKILL.md")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindOpenClawConfig(t *testing.T) {
|
|
||||||
t.Run("finds openclaw.json", func(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
|
||||||
os.WriteFile(configPath, []byte("{}"), 0644)
|
|
||||||
|
|
||||||
found, err := findOpenClawConfig(tmpDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("findOpenClawConfig: %v", err)
|
|
||||||
}
|
|
||||||
if found != configPath {
|
|
||||||
t.Errorf("found %q, want %q", found, configPath)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("falls back to config.json", func(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configPath := filepath.Join(tmpDir, "config.json")
|
|
||||||
os.WriteFile(configPath, []byte("{}"), 0644)
|
|
||||||
|
|
||||||
found, err := findOpenClawConfig(tmpDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("findOpenClawConfig: %v", err)
|
|
||||||
}
|
|
||||||
if found != configPath {
|
|
||||||
t.Errorf("found %q, want %q", found, configPath)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("prefers openclaw.json over config.json", func(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
openclawPath := filepath.Join(tmpDir, "openclaw.json")
|
|
||||||
os.WriteFile(openclawPath, []byte("{}"), 0644)
|
|
||||||
os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0644)
|
|
||||||
|
|
||||||
found, err := findOpenClawConfig(tmpDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("findOpenClawConfig: %v", err)
|
|
||||||
}
|
|
||||||
if found != openclawPath {
|
|
||||||
t.Errorf("should prefer openclaw.json, got %q", found)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("error when no config found", func(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
|
|
||||||
_, err := findOpenClawConfig(tmpDir)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error when no config found")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRewriteWorkspacePath(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"default path", "~/.openclaw/workspace", "~/.clawdroid/workspace"},
|
|
||||||
{"custom path", "/custom/path", "/custom/path"},
|
|
||||||
{"empty", "", ""},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := rewriteWorkspacePath(tt.input)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("rewriteWorkspacePath(%q) = %q, want %q", tt.input, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunDryRun(t *testing.T) {
|
|
||||||
openclawHome := t.TempDir()
|
|
||||||
clawDroidHome := t.TempDir()
|
|
||||||
|
|
||||||
wsDir := filepath.Join(openclawHome, "workspace")
|
|
||||||
os.MkdirAll(wsDir, 0755)
|
|
||||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
|
|
||||||
os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0644)
|
|
||||||
|
|
||||||
configData := map[string]interface{}{
|
|
||||||
"providers": map[string]interface{}{
|
|
||||||
"anthropic": map[string]interface{}{
|
|
||||||
"apiKey": "test-key",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
data, _ := json.Marshal(configData)
|
|
||||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
|
||||||
|
|
||||||
opts := Options{
|
|
||||||
DryRun: true,
|
|
||||||
OpenClawHome: openclawHome,
|
|
||||||
ClawDroidHome: clawDroidHome,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := Run(opts)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Run: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
clawWs := filepath.Join(clawDroidHome, "workspace")
|
|
||||||
if _, err := os.Stat(filepath.Join(clawWs, "SOUL.md")); !os.IsNotExist(err) {
|
|
||||||
t.Error("dry run should not create files")
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(clawDroidHome, "config.json")); !os.IsNotExist(err) {
|
|
||||||
t.Error("dry run should not create config")
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = result
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunFullMigration(t *testing.T) {
|
|
||||||
openclawHome := t.TempDir()
|
|
||||||
clawDroidHome := t.TempDir()
|
|
||||||
|
|
||||||
wsDir := filepath.Join(openclawHome, "workspace")
|
|
||||||
os.MkdirAll(wsDir, 0755)
|
|
||||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0644)
|
|
||||||
os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644)
|
|
||||||
os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0644)
|
|
||||||
|
|
||||||
memDir := filepath.Join(wsDir, "memory")
|
|
||||||
os.MkdirAll(memDir, 0755)
|
|
||||||
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0644)
|
|
||||||
|
|
||||||
configData := map[string]interface{}{
|
|
||||||
"providers": map[string]interface{}{
|
|
||||||
"anthropic": map[string]interface{}{
|
|
||||||
"apiKey": "sk-ant-migrate-test",
|
|
||||||
},
|
|
||||||
"openrouter": map[string]interface{}{
|
|
||||||
"apiKey": "sk-or-migrate-test",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"channels": map[string]interface{}{
|
|
||||||
"telegram": map[string]interface{}{
|
|
||||||
"enabled": true,
|
|
||||||
"token": "tg-migrate-test",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
data, _ := json.Marshal(configData)
|
|
||||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
|
||||||
|
|
||||||
opts := Options{
|
|
||||||
Force: true,
|
|
||||||
OpenClawHome: openclawHome,
|
|
||||||
ClawDroidHome: clawDroidHome,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := Run(opts)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Run: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
clawWs := filepath.Join(clawDroidHome, "workspace")
|
|
||||||
|
|
||||||
soulData, err := os.ReadFile(filepath.Join(clawWs, "SOUL.md"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading SOUL.md: %v", err)
|
|
||||||
}
|
|
||||||
if string(soulData) != "# Soul from OpenClaw" {
|
|
||||||
t.Errorf("SOUL.md content = %q, want %q", string(soulData), "# Soul from OpenClaw")
|
|
||||||
}
|
|
||||||
|
|
||||||
agentsData, err := os.ReadFile(filepath.Join(clawWs, "AGENTS.md"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading AGENTS.md: %v", err)
|
|
||||||
}
|
|
||||||
if string(agentsData) != "# Agents from OpenClaw" {
|
|
||||||
t.Errorf("AGENTS.md content = %q", string(agentsData))
|
|
||||||
}
|
|
||||||
|
|
||||||
memData, err := os.ReadFile(filepath.Join(clawWs, "memory", "MEMORY.md"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading memory/MEMORY.md: %v", err)
|
|
||||||
}
|
|
||||||
if string(memData) != "# Memory notes" {
|
|
||||||
t.Errorf("MEMORY.md content = %q", string(memData))
|
|
||||||
}
|
|
||||||
|
|
||||||
clawConfig, err := config.LoadConfig(filepath.Join(clawDroidHome, "config.json"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("loading ClawDroid config: %v", err)
|
|
||||||
}
|
|
||||||
if clawConfig.Providers.Anthropic.APIKey != "sk-ant-migrate-test" {
|
|
||||||
t.Errorf("Anthropic.APIKey = %q, want %q", clawConfig.Providers.Anthropic.APIKey, "sk-ant-migrate-test")
|
|
||||||
}
|
|
||||||
if clawConfig.Providers.OpenRouter.APIKey != "sk-or-migrate-test" {
|
|
||||||
t.Errorf("OpenRouter.APIKey = %q, want %q", clawConfig.Providers.OpenRouter.APIKey, "sk-or-migrate-test")
|
|
||||||
}
|
|
||||||
if !clawConfig.Channels.Telegram.Enabled {
|
|
||||||
t.Error("Telegram should be enabled")
|
|
||||||
}
|
|
||||||
if clawConfig.Channels.Telegram.Token != "tg-migrate-test" {
|
|
||||||
t.Errorf("Telegram.Token = %q, want %q", clawConfig.Channels.Telegram.Token, "tg-migrate-test")
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.FilesCopied < 3 {
|
|
||||||
t.Errorf("expected at least 3 files copied, got %d", result.FilesCopied)
|
|
||||||
}
|
|
||||||
if !result.ConfigMigrated {
|
|
||||||
t.Error("config should have been migrated")
|
|
||||||
}
|
|
||||||
if len(result.Errors) > 0 {
|
|
||||||
t.Errorf("expected no errors, got %v", result.Errors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunOpenClawNotFound(t *testing.T) {
|
|
||||||
opts := Options{
|
|
||||||
OpenClawHome: "/nonexistent/path/to/openclaw",
|
|
||||||
ClawDroidHome: t.TempDir(),
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := Run(opts)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error when OpenClaw not found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunMutuallyExclusiveFlags(t *testing.T) {
|
|
||||||
opts := Options{
|
|
||||||
ConfigOnly: true,
|
|
||||||
WorkspaceOnly: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := Run(opts)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for mutually exclusive flags")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBackupFile(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
filePath := filepath.Join(tmpDir, "test.md")
|
|
||||||
os.WriteFile(filePath, []byte("original content"), 0644)
|
|
||||||
|
|
||||||
if err := backupFile(filePath); err != nil {
|
|
||||||
t.Fatalf("backupFile: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bakPath := filePath + ".bak"
|
|
||||||
bakData, err := os.ReadFile(bakPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading backup: %v", err)
|
|
||||||
}
|
|
||||||
if string(bakData) != "original content" {
|
|
||||||
t.Errorf("backup content = %q, want %q", string(bakData), "original content")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCopyFile(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
srcPath := filepath.Join(tmpDir, "src.md")
|
|
||||||
dstPath := filepath.Join(tmpDir, "dst.md")
|
|
||||||
|
|
||||||
os.WriteFile(srcPath, []byte("file content"), 0644)
|
|
||||||
|
|
||||||
if err := copyFile(srcPath, dstPath); err != nil {
|
|
||||||
t.Fatalf("copyFile: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(dstPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading copy: %v", err)
|
|
||||||
}
|
|
||||||
if string(data) != "file content" {
|
|
||||||
t.Errorf("copy content = %q, want %q", string(data), "file content")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunConfigOnly(t *testing.T) {
|
|
||||||
openclawHome := t.TempDir()
|
|
||||||
clawDroidHome := t.TempDir()
|
|
||||||
|
|
||||||
wsDir := filepath.Join(openclawHome, "workspace")
|
|
||||||
os.MkdirAll(wsDir, 0755)
|
|
||||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
|
|
||||||
|
|
||||||
configData := map[string]interface{}{
|
|
||||||
"providers": map[string]interface{}{
|
|
||||||
"anthropic": map[string]interface{}{
|
|
||||||
"apiKey": "sk-config-only",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
data, _ := json.Marshal(configData)
|
|
||||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
|
||||||
|
|
||||||
opts := Options{
|
|
||||||
Force: true,
|
|
||||||
ConfigOnly: true,
|
|
||||||
OpenClawHome: openclawHome,
|
|
||||||
ClawDroidHome: clawDroidHome,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := Run(opts)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Run: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result.ConfigMigrated {
|
|
||||||
t.Error("config should have been migrated")
|
|
||||||
}
|
|
||||||
|
|
||||||
clawWs := filepath.Join(clawDroidHome, "workspace")
|
|
||||||
if _, err := os.Stat(filepath.Join(clawWs, "SOUL.md")); !os.IsNotExist(err) {
|
|
||||||
t.Error("config-only should not copy workspace files")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunWorkspaceOnly(t *testing.T) {
|
|
||||||
openclawHome := t.TempDir()
|
|
||||||
clawDroidHome := t.TempDir()
|
|
||||||
|
|
||||||
wsDir := filepath.Join(openclawHome, "workspace")
|
|
||||||
os.MkdirAll(wsDir, 0755)
|
|
||||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
|
|
||||||
|
|
||||||
configData := map[string]interface{}{
|
|
||||||
"providers": map[string]interface{}{
|
|
||||||
"anthropic": map[string]interface{}{
|
|
||||||
"apiKey": "sk-ws-only",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
data, _ := json.Marshal(configData)
|
|
||||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
|
||||||
|
|
||||||
opts := Options{
|
|
||||||
Force: true,
|
|
||||||
WorkspaceOnly: true,
|
|
||||||
OpenClawHome: openclawHome,
|
|
||||||
ClawDroidHome: clawDroidHome,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := Run(opts)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Run: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.ConfigMigrated {
|
|
||||||
t.Error("workspace-only should not migrate config")
|
|
||||||
}
|
|
||||||
|
|
||||||
clawWs := filepath.Join(clawDroidHome, "workspace")
|
|
||||||
soulData, err := os.ReadFile(filepath.Join(clawWs, "SOUL.md"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading SOUL.md: %v", err)
|
|
||||||
}
|
|
||||||
if string(soulData) != "# Soul" {
|
|
||||||
t.Errorf("SOUL.md content = %q", string(soulData))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
package migrate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
var migrateableFiles = []string{
|
|
||||||
"AGENTS.md",
|
|
||||||
"SOUL.md",
|
|
||||||
"USER.md",
|
|
||||||
"TOOLS.md",
|
|
||||||
"HEARTBEAT.md",
|
|
||||||
}
|
|
||||||
|
|
||||||
var migrateableDirs = []string{
|
|
||||||
"memory",
|
|
||||||
"skills",
|
|
||||||
}
|
|
||||||
|
|
||||||
func PlanWorkspaceMigration(srcWorkspace, dstWorkspace string, force bool) ([]Action, error) {
|
|
||||||
var actions []Action
|
|
||||||
|
|
||||||
for _, filename := range migrateableFiles {
|
|
||||||
src := filepath.Join(srcWorkspace, filename)
|
|
||||||
dst := filepath.Join(dstWorkspace, filename)
|
|
||||||
action := planFileCopy(src, dst, force)
|
|
||||||
if action.Type != ActionSkip || action.Description != "" {
|
|
||||||
actions = append(actions, action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, dirname := range migrateableDirs {
|
|
||||||
srcDir := filepath.Join(srcWorkspace, dirname)
|
|
||||||
if _, err := os.Stat(srcDir); os.IsNotExist(err) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dirActions, err := planDirCopy(srcDir, filepath.Join(dstWorkspace, dirname), force)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
actions = append(actions, dirActions...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func planFileCopy(src, dst string, force bool) Action {
|
|
||||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
|
||||||
return Action{
|
|
||||||
Type: ActionSkip,
|
|
||||||
Source: src,
|
|
||||||
Destination: dst,
|
|
||||||
Description: "source file not found",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, dstExists := os.Stat(dst)
|
|
||||||
if dstExists == nil && !force {
|
|
||||||
return Action{
|
|
||||||
Type: ActionBackup,
|
|
||||||
Source: src,
|
|
||||||
Destination: dst,
|
|
||||||
Description: "destination exists, will backup and overwrite",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Action{
|
|
||||||
Type: ActionCopy,
|
|
||||||
Source: src,
|
|
||||||
Destination: dst,
|
|
||||||
Description: "copy file",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) {
|
|
||||||
var actions []Action
|
|
||||||
|
|
||||||
err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
relPath, err := filepath.Rel(srcDir, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
dst := filepath.Join(dstDir, relPath)
|
|
||||||
|
|
||||||
if info.IsDir() {
|
|
||||||
actions = append(actions, Action{
|
|
||||||
Type: ActionCreateDir,
|
|
||||||
Destination: dst,
|
|
||||||
Description: "create directory",
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
action := planFileCopy(path, dst, force)
|
|
||||||
actions = append(actions, action)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
return actions, err
|
|
||||||
}
|
|
||||||
260
pkg/providers/anyllm_adapter.go
Normal file
260
pkg/providers/anyllm_adapter.go
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
anyllm "github.com/mozilla-ai/any-llm-go"
|
||||||
|
"github.com/mozilla-ai/any-llm-go/providers/anthropic"
|
||||||
|
"github.com/mozilla-ai/any-llm-go/providers/deepseek"
|
||||||
|
"github.com/mozilla-ai/any-llm-go/providers/gemini"
|
||||||
|
"github.com/mozilla-ai/any-llm-go/providers/groq"
|
||||||
|
"github.com/mozilla-ai/any-llm-go/providers/llamacpp"
|
||||||
|
"github.com/mozilla-ai/any-llm-go/providers/llamafile"
|
||||||
|
"github.com/mozilla-ai/any-llm-go/providers/mistral"
|
||||||
|
"github.com/mozilla-ai/any-llm-go/providers/ollama"
|
||||||
|
"github.com/mozilla-ai/any-llm-go/providers/openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnyLLMAdapter wraps an any-llm-go provider to implement LLMProvider.
|
||||||
|
type AnyLLMAdapter struct {
|
||||||
|
provider anyllm.Provider // any-llm-go Provider interface
|
||||||
|
defaultModel string // e.g. "openai/gpt-5.2-chat-latest"
|
||||||
|
modelName string // e.g. "gpt-5.2-chat-latest" (passed per-request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseModel splits "provider/model_name" at the first "/".
|
||||||
|
func parseModel(model string) (providerName, modelName string) {
|
||||||
|
idx := strings.Index(model, "/")
|
||||||
|
if idx == -1 {
|
||||||
|
return "", model
|
||||||
|
}
|
||||||
|
return model[:idx], model[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// providerAliases maps convenience names to canonical provider names.
|
||||||
|
var providerAliases = map[string]string{
|
||||||
|
"claude": "anthropic",
|
||||||
|
"google": "gemini",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnyLLMAdapter creates an AnyLLMAdapter from a model string (provider/model_name),
|
||||||
|
// an API key, and an optional base URL override.
|
||||||
|
func NewAnyLLMAdapter(model, apiKey, baseURL string) (*AnyLLMAdapter, error) {
|
||||||
|
providerName, modelName := parseModel(model)
|
||||||
|
|
||||||
|
// Apply aliases
|
||||||
|
if canonical, ok := providerAliases[providerName]; ok {
|
||||||
|
providerName = canonical
|
||||||
|
}
|
||||||
|
|
||||||
|
if providerName == "" {
|
||||||
|
return nil, fmt.Errorf("model must be in provider/model_name format (e.g. openai/gpt-4): %s", model)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := createAnyLLMProvider(providerName, apiKey, baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating provider %q: %w", providerName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AnyLLMAdapter{
|
||||||
|
provider: p,
|
||||||
|
defaultModel: model,
|
||||||
|
modelName: modelName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createAnyLLMProvider creates the appropriate any-llm-go provider by name.
|
||||||
|
func createAnyLLMProvider(name, apiKey, baseURL string) (anyllm.Provider, error) {
|
||||||
|
var opts []anyllm.Option
|
||||||
|
if apiKey != "" {
|
||||||
|
opts = append(opts, anyllm.WithAPIKey(apiKey))
|
||||||
|
}
|
||||||
|
if baseURL != "" {
|
||||||
|
opts = append(opts, anyllm.WithBaseURL(baseURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "anthropic":
|
||||||
|
return anthropic.New(opts...)
|
||||||
|
case "deepseek":
|
||||||
|
return deepseek.New(opts...)
|
||||||
|
case "gemini":
|
||||||
|
return gemini.New(opts...)
|
||||||
|
case "groq":
|
||||||
|
return groq.New(opts...)
|
||||||
|
case "llamacpp":
|
||||||
|
return llamacpp.New(opts...)
|
||||||
|
case "llamafile":
|
||||||
|
return llamafile.New(opts...)
|
||||||
|
case "mistral":
|
||||||
|
return mistral.New(opts...)
|
||||||
|
case "ollama":
|
||||||
|
return ollama.New(opts...)
|
||||||
|
case "openai":
|
||||||
|
return openai.New(opts...)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported provider %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat implements LLMProvider.
|
||||||
|
func (a *AnyLLMAdapter) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||||
|
params := anyllm.CompletionParams{
|
||||||
|
Model: a.modelName,
|
||||||
|
Messages: convertMessagesToAnyLLM(messages),
|
||||||
|
Tools: convertToolsToAnyLLM(tools),
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := options["max_tokens"].(type) {
|
||||||
|
case int:
|
||||||
|
params.MaxTokens = &v
|
||||||
|
case float64:
|
||||||
|
mt := int(v)
|
||||||
|
params.MaxTokens = &mt
|
||||||
|
}
|
||||||
|
if temperature, ok := options["temperature"].(float64); ok {
|
||||||
|
params.Temperature = &temperature
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := a.provider.Completion(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertAnyLLMResult(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultModel implements LLMProvider.
|
||||||
|
func (a *AnyLLMAdapter) GetDefaultModel() string {
|
||||||
|
return a.defaultModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertMessagesToAnyLLM converts internal messages to any-llm-go messages.
|
||||||
|
func convertMessagesToAnyLLM(messages []Message) []anyllm.Message {
|
||||||
|
result := make([]anyllm.Message, 0, len(messages))
|
||||||
|
for _, msg := range messages {
|
||||||
|
m := anyllm.Message{
|
||||||
|
Role: msg.Role,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool result messages
|
||||||
|
if msg.ToolCallID != "" {
|
||||||
|
m.Role = anyllm.RoleTool
|
||||||
|
m.Content = msg.Content
|
||||||
|
m.ToolCallID = msg.ToolCallID
|
||||||
|
result = append(result, m)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build content: plain text or multimodal
|
||||||
|
if len(msg.Media) > 0 {
|
||||||
|
// Multimodal: text + images as ContentPart slice
|
||||||
|
var parts []anyllm.ContentPart
|
||||||
|
if msg.Content != "" {
|
||||||
|
parts = append(parts, anyllm.ContentPart{
|
||||||
|
Type: "text",
|
||||||
|
Text: msg.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, mediaURL := range msg.Media {
|
||||||
|
parts = append(parts, anyllm.ContentPart{
|
||||||
|
Type: "image_url",
|
||||||
|
ImageURL: &anyllm.ImageURL{URL: mediaURL},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
m.Content = parts
|
||||||
|
} else {
|
||||||
|
m.Content = msg.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assistant messages with tool calls
|
||||||
|
if len(msg.ToolCalls) > 0 {
|
||||||
|
m.ToolCalls = make([]anyllm.ToolCall, 0, len(msg.ToolCalls))
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
name := ""
|
||||||
|
args := ""
|
||||||
|
if tc.Function != nil {
|
||||||
|
name = tc.Function.Name
|
||||||
|
args = tc.Function.Arguments
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = tc.Name
|
||||||
|
}
|
||||||
|
if args == "" && tc.Arguments != nil {
|
||||||
|
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||||
|
args = string(argsJSON)
|
||||||
|
}
|
||||||
|
m.ToolCalls = append(m.ToolCalls, anyllm.ToolCall{
|
||||||
|
ID: tc.ID,
|
||||||
|
Type: "function",
|
||||||
|
Function: anyllm.FunctionCall{
|
||||||
|
Name: name,
|
||||||
|
Arguments: args,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, m)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertToolsToAnyLLM converts internal tool definitions to any-llm-go tools.
|
||||||
|
func convertToolsToAnyLLM(tools []ToolDefinition) []anyllm.Tool {
|
||||||
|
result := make([]anyllm.Tool, 0, len(tools))
|
||||||
|
for _, t := range tools {
|
||||||
|
result = append(result, anyllm.Tool{
|
||||||
|
Type: "function",
|
||||||
|
Function: anyllm.Function{
|
||||||
|
Name: t.Function.Name,
|
||||||
|
Description: t.Function.Description,
|
||||||
|
Parameters: t.Function.Parameters,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertAnyLLMResult converts an any-llm-go ChatCompletion to our LLMResponse.
|
||||||
|
func convertAnyLLMResult(result *anyllm.ChatCompletion) *LLMResponse {
|
||||||
|
if len(result.Choices) == 0 {
|
||||||
|
return &LLMResponse{}
|
||||||
|
}
|
||||||
|
|
||||||
|
choice := result.Choices[0]
|
||||||
|
resp := &LLMResponse{
|
||||||
|
Content: choice.Message.ContentString(),
|
||||||
|
FinishReason: choice.FinishReason,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert tool calls
|
||||||
|
for _, tc := range choice.Message.ToolCalls {
|
||||||
|
var args map[string]interface{}
|
||||||
|
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||||||
|
|
||||||
|
resp.ToolCalls = append(resp.ToolCalls, ToolCall{
|
||||||
|
ID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Arguments: args,
|
||||||
|
Function: &FunctionCall{
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Arguments: tc.Function.Arguments,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert usage
|
||||||
|
if result.Usage != nil {
|
||||||
|
resp.Usage = &UsageInfo{
|
||||||
|
PromptTokens: result.Usage.PromptTokens,
|
||||||
|
CompletionTokens: result.Usage.CompletionTokens,
|
||||||
|
TotalTokens: result.Usage.TotalTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess.
|
|
||||||
type ClaudeCliProvider struct {
|
|
||||||
command string
|
|
||||||
workspace string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClaudeCliProvider creates a new Claude CLI provider.
|
|
||||||
func NewClaudeCliProvider(workspace string) *ClaudeCliProvider {
|
|
||||||
return &ClaudeCliProvider{
|
|
||||||
command: "claude",
|
|
||||||
workspace: workspace,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chat implements LLMProvider.Chat by executing the claude CLI.
|
|
||||||
func (p *ClaudeCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
|
||||||
systemPrompt := p.buildSystemPrompt(messages, tools)
|
|
||||||
prompt := p.messagesToPrompt(messages)
|
|
||||||
|
|
||||||
args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"}
|
|
||||||
if systemPrompt != "" {
|
|
||||||
args = append(args, "--system-prompt", systemPrompt)
|
|
||||||
}
|
|
||||||
if model != "" && model != "claude-code" {
|
|
||||||
args = append(args, "--model", model)
|
|
||||||
}
|
|
||||||
args = append(args, "-") // read from stdin
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, p.command, args...)
|
|
||||||
if p.workspace != "" {
|
|
||||||
cmd.Dir = p.workspace
|
|
||||||
}
|
|
||||||
cmd.Stdin = bytes.NewReader([]byte(prompt))
|
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
cmd.Stdout = &stdout
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
if stderrStr := stderr.String(); stderrStr != "" {
|
|
||||||
return nil, fmt.Errorf("claude cli error: %s", stderrStr)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("claude cli error: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return p.parseClaudeCliResponse(stdout.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultModel returns the default model identifier.
|
|
||||||
func (p *ClaudeCliProvider) GetDefaultModel() string {
|
|
||||||
return "claude-code"
|
|
||||||
}
|
|
||||||
|
|
||||||
// messagesToPrompt converts messages to a CLI-compatible prompt string.
|
|
||||||
func (p *ClaudeCliProvider) messagesToPrompt(messages []Message) string {
|
|
||||||
var parts []string
|
|
||||||
|
|
||||||
for _, msg := range messages {
|
|
||||||
switch msg.Role {
|
|
||||||
case "system":
|
|
||||||
// handled via --system-prompt flag
|
|
||||||
case "user":
|
|
||||||
parts = append(parts, "User: "+msg.Content)
|
|
||||||
case "assistant":
|
|
||||||
parts = append(parts, "Assistant: "+msg.Content)
|
|
||||||
case "tool":
|
|
||||||
parts = append(parts, fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simplify single user message
|
|
||||||
if len(parts) == 1 && strings.HasPrefix(parts[0], "User: ") {
|
|
||||||
return strings.TrimPrefix(parts[0], "User: ")
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(parts, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildSystemPrompt combines system messages and tool definitions.
|
|
||||||
func (p *ClaudeCliProvider) buildSystemPrompt(messages []Message, tools []ToolDefinition) string {
|
|
||||||
var parts []string
|
|
||||||
|
|
||||||
for _, msg := range messages {
|
|
||||||
if msg.Role == "system" {
|
|
||||||
parts = append(parts, msg.Content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tools) > 0 {
|
|
||||||
parts = append(parts, p.buildToolsPrompt(tools))
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(parts, "\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildToolsPrompt creates the tool definitions section for the system prompt.
|
|
||||||
func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
sb.WriteString("## Available Tools\n\n")
|
|
||||||
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
|
|
||||||
sb.WriteString("```json\n")
|
|
||||||
sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`)
|
|
||||||
sb.WriteString("\n```\n\n")
|
|
||||||
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
|
|
||||||
sb.WriteString("### Tool Definitions:\n\n")
|
|
||||||
|
|
||||||
for _, tool := range tools {
|
|
||||||
if tool.Type != "function" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name))
|
|
||||||
if tool.Function.Description != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description))
|
|
||||||
}
|
|
||||||
if len(tool.Function.Parameters) > 0 {
|
|
||||||
paramsJSON, _ := json.Marshal(tool.Function.Parameters)
|
|
||||||
sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON)))
|
|
||||||
}
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseClaudeCliResponse parses the JSON output from the claude CLI.
|
|
||||||
func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) {
|
|
||||||
var resp claudeCliJSONResponse
|
|
||||||
if err := json.Unmarshal([]byte(output), &resp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse claude cli response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.IsError {
|
|
||||||
return nil, fmt.Errorf("claude cli returned error: %s", resp.Result)
|
|
||||||
}
|
|
||||||
|
|
||||||
toolCalls := p.extractToolCalls(resp.Result)
|
|
||||||
|
|
||||||
finishReason := "stop"
|
|
||||||
content := resp.Result
|
|
||||||
if len(toolCalls) > 0 {
|
|
||||||
finishReason = "tool_calls"
|
|
||||||
content = p.stripToolCallsJSON(resp.Result)
|
|
||||||
}
|
|
||||||
|
|
||||||
var usage *UsageInfo
|
|
||||||
if resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0 {
|
|
||||||
usage = &UsageInfo{
|
|
||||||
PromptTokens: resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens,
|
|
||||||
CompletionTokens: resp.Usage.OutputTokens,
|
|
||||||
TotalTokens: resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens + resp.Usage.OutputTokens,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &LLMResponse{
|
|
||||||
Content: strings.TrimSpace(content),
|
|
||||||
ToolCalls: toolCalls,
|
|
||||||
FinishReason: finishReason,
|
|
||||||
Usage: usage,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractToolCalls delegates to the shared extractToolCallsFromText function.
|
|
||||||
func (p *ClaudeCliProvider) extractToolCalls(text string) []ToolCall {
|
|
||||||
return extractToolCallsFromText(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripToolCallsJSON delegates to the shared stripToolCallsFromText function.
|
|
||||||
func (p *ClaudeCliProvider) stripToolCallsJSON(text string) string {
|
|
||||||
return stripToolCallsFromText(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// findMatchingBrace finds the index after the closing brace matching the opening brace at pos.
|
|
||||||
func findMatchingBrace(text string, pos int) int {
|
|
||||||
depth := 0
|
|
||||||
for i := pos; i < len(text); i++ {
|
|
||||||
if text[i] == '{' {
|
|
||||||
depth++
|
|
||||||
} else if text[i] == '}' {
|
|
||||||
depth--
|
|
||||||
if depth == 0 {
|
|
||||||
return i + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pos
|
|
||||||
}
|
|
||||||
|
|
||||||
// claudeCliJSONResponse represents the JSON output from the claude CLI.
|
|
||||||
// Matches the real claude CLI v2.x output format.
|
|
||||||
type claudeCliJSONResponse struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Subtype string `json:"subtype"`
|
|
||||||
IsError bool `json:"is_error"`
|
|
||||||
Result string `json:"result"`
|
|
||||||
SessionID string `json:"session_id"`
|
|
||||||
TotalCostUSD float64 `json:"total_cost_usd"`
|
|
||||||
DurationMS int `json:"duration_ms"`
|
|
||||||
DurationAPI int `json:"duration_api_ms"`
|
|
||||||
NumTurns int `json:"num_turns"`
|
|
||||||
Usage claudeCliUsageInfo `json:"usage"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// claudeCliUsageInfo represents token usage from the claude CLI response.
|
|
||||||
type claudeCliUsageInfo struct {
|
|
||||||
InputTokens int `json:"input_tokens"`
|
|
||||||
OutputTokens int `json:"output_tokens"`
|
|
||||||
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
|
||||||
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
//go:build integration
|
|
||||||
|
|
||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
exec "os/exec"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestIntegration_RealClaudeCLI tests the ClaudeCliProvider with a real claude CLI.
|
|
||||||
// Run with: go test -tags=integration ./pkg/providers/...
|
|
||||||
func TestIntegration_RealClaudeCLI(t *testing.T) {
|
|
||||||
// Check if claude CLI is available
|
|
||||||
path, err := exec.LookPath("claude")
|
|
||||||
if err != nil {
|
|
||||||
t.Skip("claude CLI not found in PATH, skipping integration test")
|
|
||||||
}
|
|
||||||
t.Logf("Using claude CLI at: %s", path)
|
|
||||||
|
|
||||||
p := NewClaudeCliProvider(t.TempDir())
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
resp, err := p.Chat(ctx, []Message{
|
|
||||||
{Role: "user", Content: "Respond with only the word 'pong'. Nothing else."},
|
|
||||||
}, nil, "", nil)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chat() with real CLI error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify response structure
|
|
||||||
if resp.Content == "" {
|
|
||||||
t.Error("Content is empty")
|
|
||||||
}
|
|
||||||
if resp.FinishReason != "stop" {
|
|
||||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
|
||||||
}
|
|
||||||
if resp.Usage == nil {
|
|
||||||
t.Error("Usage should not be nil from real CLI")
|
|
||||||
} else {
|
|
||||||
if resp.Usage.PromptTokens == 0 {
|
|
||||||
t.Error("PromptTokens should be > 0")
|
|
||||||
}
|
|
||||||
if resp.Usage.CompletionTokens == 0 {
|
|
||||||
t.Error("CompletionTokens should be > 0")
|
|
||||||
}
|
|
||||||
t.Logf("Usage: prompt=%d, completion=%d, total=%d",
|
|
||||||
resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Response content: %q", resp.Content)
|
|
||||||
|
|
||||||
// Loose check - should contain "pong" somewhere (model might capitalize or add punctuation)
|
|
||||||
if !strings.Contains(strings.ToLower(resp.Content), "pong") {
|
|
||||||
t.Errorf("Content = %q, expected to contain 'pong'", resp.Content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIntegration_RealClaudeCLI_WithSystemPrompt(t *testing.T) {
|
|
||||||
if _, err := exec.LookPath("claude"); err != nil {
|
|
||||||
t.Skip("claude CLI not found in PATH")
|
|
||||||
}
|
|
||||||
|
|
||||||
p := NewClaudeCliProvider(t.TempDir())
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
resp, err := p.Chat(ctx, []Message{
|
|
||||||
{Role: "system", Content: "You are a calculator. Only respond with numbers. No text."},
|
|
||||||
{Role: "user", Content: "What is 2+2?"},
|
|
||||||
}, nil, "", nil)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chat() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Response: %q", resp.Content)
|
|
||||||
|
|
||||||
if !strings.Contains(resp.Content, "4") {
|
|
||||||
t.Errorf("Content = %q, expected to contain '4'", resp.Content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIntegration_RealClaudeCLI_ParsesRealJSON(t *testing.T) {
|
|
||||||
if _, err := exec.LookPath("claude"); err != nil {
|
|
||||||
t.Skip("claude CLI not found in PATH")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run claude directly and verify our parser handles real output
|
|
||||||
cmd := exec.Command("claude", "-p", "--output-format", "json",
|
|
||||||
"--dangerously-skip-permissions", "--no-chrome", "--no-session-persistence", "-")
|
|
||||||
cmd.Stdin = strings.NewReader("Say hi")
|
|
||||||
cmd.Dir = t.TempDir()
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("claude CLI failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Raw CLI output: %s", string(output))
|
|
||||||
|
|
||||||
// Verify our parser can handle real output
|
|
||||||
p := NewClaudeCliProvider("")
|
|
||||||
resp, err := p.parseClaudeCliResponse(string(output))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseClaudeCliResponse() failed on real CLI output: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.Content == "" {
|
|
||||||
t.Error("parsed Content is empty")
|
|
||||||
}
|
|
||||||
if resp.FinishReason != "stop" {
|
|
||||||
t.Errorf("FinishReason = %q, want stop", resp.FinishReason)
|
|
||||||
}
|
|
||||||
if resp.Usage == nil {
|
|
||||||
t.Error("Usage should not be nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Parsed: content=%q, finish=%s, usage=%+v", resp.Content, resp.FinishReason, resp.Usage)
|
|
||||||
}
|
|
||||||
|
|
@ -1,981 +0,0 @@
|
||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- Compile-time interface check ---
|
|
||||||
|
|
||||||
var _ LLMProvider = (*ClaudeCliProvider)(nil)
|
|
||||||
|
|
||||||
// --- Helper: create mock CLI scripts ---
|
|
||||||
|
|
||||||
// createMockCLI creates a temporary script that simulates the claude CLI.
|
|
||||||
// Uses files for stdout/stderr to avoid shell quoting issues with JSON.
|
|
||||||
func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string {
|
|
||||||
t.Helper()
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("mock CLI scripts not supported on Windows")
|
|
||||||
}
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
if stdout != "" {
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if stderr != "" {
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString("#!/bin/sh\n")
|
|
||||||
if stderr != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf("cat '%s/stderr.txt' >&2\n", dir))
|
|
||||||
}
|
|
||||||
if stdout != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf("cat '%s/stdout.txt'\n", dir))
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf("exit %d\n", exitCode))
|
|
||||||
|
|
||||||
script := filepath.Join(dir, "claude")
|
|
||||||
if err := os.WriteFile(script, []byte(sb.String()), 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return script
|
|
||||||
}
|
|
||||||
|
|
||||||
// createSlowMockCLI creates a script that sleeps before responding (for context cancellation tests).
|
|
||||||
func createSlowMockCLI(t *testing.T, sleepSeconds int) string {
|
|
||||||
t.Helper()
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("mock CLI scripts not supported on Windows")
|
|
||||||
}
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
script := filepath.Join(dir, "claude")
|
|
||||||
content := fmt.Sprintf("#!/bin/sh\nsleep %d\necho '{\"type\":\"result\",\"result\":\"late\"}'\n", sleepSeconds)
|
|
||||||
if err := os.WriteFile(script, []byte(content), 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return script
|
|
||||||
}
|
|
||||||
|
|
||||||
// createArgCaptureCLI creates a script that captures CLI args to a file, then outputs JSON.
|
|
||||||
func createArgCaptureCLI(t *testing.T, argsFile string) string {
|
|
||||||
t.Helper()
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("mock CLI scripts not supported on Windows")
|
|
||||||
}
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
script := filepath.Join(dir, "claude")
|
|
||||||
content := fmt.Sprintf(`#!/bin/sh
|
|
||||||
echo "$@" > '%s'
|
|
||||||
cat <<'EOFMOCK'
|
|
||||||
{"type":"result","result":"ok","session_id":"test"}
|
|
||||||
EOFMOCK
|
|
||||||
`, argsFile)
|
|
||||||
if err := os.WriteFile(script, []byte(content), 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return script
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Constructor tests ---
|
|
||||||
|
|
||||||
func TestNewClaudeCliProvider(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/test/workspace")
|
|
||||||
if p == nil {
|
|
||||||
t.Fatal("NewClaudeCliProvider returned nil")
|
|
||||||
}
|
|
||||||
if p.workspace != "/test/workspace" {
|
|
||||||
t.Errorf("workspace = %q, want %q", p.workspace, "/test/workspace")
|
|
||||||
}
|
|
||||||
if p.command != "claude" {
|
|
||||||
t.Errorf("command = %q, want %q", p.command, "claude")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewClaudeCliProvider_EmptyWorkspace(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("")
|
|
||||||
if p.workspace != "" {
|
|
||||||
t.Errorf("workspace = %q, want empty", p.workspace)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- GetDefaultModel tests ---
|
|
||||||
|
|
||||||
func TestClaudeCliProvider_GetDefaultModel(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
if got := p.GetDefaultModel(); got != "claude-code" {
|
|
||||||
t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-code")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Chat() tests ---
|
|
||||||
|
|
||||||
func TestChat_Success(t *testing.T) {
|
|
||||||
mockJSON := `{"type":"result","subtype":"success","is_error":false,"result":"Hello from mock!","session_id":"sess_123","total_cost_usd":0.005,"duration_ms":200,"duration_api_ms":150,"num_turns":1,"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":100,"cache_read_input_tokens":0}}`
|
|
||||||
script := createMockCLI(t, mockJSON, "", 0)
|
|
||||||
|
|
||||||
p := NewClaudeCliProvider(t.TempDir())
|
|
||||||
p.command = script
|
|
||||||
|
|
||||||
resp, err := p.Chat(context.Background(), []Message{
|
|
||||||
{Role: "user", Content: "Hello"},
|
|
||||||
}, nil, "", nil)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chat() error = %v", err)
|
|
||||||
}
|
|
||||||
if resp.Content != "Hello from mock!" {
|
|
||||||
t.Errorf("Content = %q, want %q", resp.Content, "Hello from mock!")
|
|
||||||
}
|
|
||||||
if resp.FinishReason != "stop" {
|
|
||||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
|
||||||
}
|
|
||||||
if len(resp.ToolCalls) != 0 {
|
|
||||||
t.Errorf("ToolCalls len = %d, want 0", len(resp.ToolCalls))
|
|
||||||
}
|
|
||||||
if resp.Usage == nil {
|
|
||||||
t.Fatal("Usage should not be nil")
|
|
||||||
}
|
|
||||||
if resp.Usage.PromptTokens != 110 { // 10 + 100 + 0
|
|
||||||
t.Errorf("PromptTokens = %d, want 110", resp.Usage.PromptTokens)
|
|
||||||
}
|
|
||||||
if resp.Usage.CompletionTokens != 5 {
|
|
||||||
t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens)
|
|
||||||
}
|
|
||||||
if resp.Usage.TotalTokens != 115 { // 110 + 5
|
|
||||||
t.Errorf("TotalTokens = %d, want 115", resp.Usage.TotalTokens)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChat_IsErrorResponse(t *testing.T) {
|
|
||||||
mockJSON := `{"type":"result","subtype":"error","is_error":true,"result":"Rate limit exceeded","session_id":"s1","total_cost_usd":0}`
|
|
||||||
script := createMockCLI(t, mockJSON, "", 0)
|
|
||||||
|
|
||||||
p := NewClaudeCliProvider(t.TempDir())
|
|
||||||
p.command = script
|
|
||||||
|
|
||||||
_, err := p.Chat(context.Background(), []Message{
|
|
||||||
{Role: "user", Content: "Hello"},
|
|
||||||
}, nil, "", nil)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Chat() expected error when is_error=true")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "Rate limit exceeded") {
|
|
||||||
t.Errorf("error = %q, want to contain 'Rate limit exceeded'", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChat_WithToolCallsInResponse(t *testing.T) {
|
|
||||||
mockJSON := `{"type":"result","subtype":"success","is_error":false,"result":"Checking weather.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"NYC\\\"}\"}}]}","session_id":"s1","total_cost_usd":0.01,"usage":{"input_tokens":5,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}`
|
|
||||||
script := createMockCLI(t, mockJSON, "", 0)
|
|
||||||
|
|
||||||
p := NewClaudeCliProvider(t.TempDir())
|
|
||||||
p.command = script
|
|
||||||
|
|
||||||
resp, err := p.Chat(context.Background(), []Message{
|
|
||||||
{Role: "user", Content: "What's the weather?"},
|
|
||||||
}, nil, "", nil)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chat() error = %v", err)
|
|
||||||
}
|
|
||||||
if resp.FinishReason != "tool_calls" {
|
|
||||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
|
||||||
}
|
|
||||||
if len(resp.ToolCalls) != 1 {
|
|
||||||
t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls))
|
|
||||||
}
|
|
||||||
if resp.ToolCalls[0].Name != "get_weather" {
|
|
||||||
t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "get_weather")
|
|
||||||
}
|
|
||||||
if resp.ToolCalls[0].Arguments["location"] != "NYC" {
|
|
||||||
t.Errorf("ToolCalls[0].Arguments[location] = %v, want NYC", resp.ToolCalls[0].Arguments["location"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChat_StderrError(t *testing.T) {
|
|
||||||
script := createMockCLI(t, "", "Error: rate limited", 1)
|
|
||||||
|
|
||||||
p := NewClaudeCliProvider(t.TempDir())
|
|
||||||
p.command = script
|
|
||||||
|
|
||||||
_, err := p.Chat(context.Background(), []Message{
|
|
||||||
{Role: "user", Content: "Hello"},
|
|
||||||
}, nil, "", nil)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Chat() expected error")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "rate limited") {
|
|
||||||
t.Errorf("error = %q, want to contain 'rate limited'", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChat_NonZeroExitNoStderr(t *testing.T) {
|
|
||||||
script := createMockCLI(t, "", "", 1)
|
|
||||||
|
|
||||||
p := NewClaudeCliProvider(t.TempDir())
|
|
||||||
p.command = script
|
|
||||||
|
|
||||||
_, err := p.Chat(context.Background(), []Message{
|
|
||||||
{Role: "user", Content: "Hello"},
|
|
||||||
}, nil, "", nil)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Chat() expected error for non-zero exit")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "claude cli error") {
|
|
||||||
t.Errorf("error = %q, want to contain 'claude cli error'", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChat_CommandNotFound(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider(t.TempDir())
|
|
||||||
p.command = "/nonexistent/claude-binary-that-does-not-exist"
|
|
||||||
|
|
||||||
_, err := p.Chat(context.Background(), []Message{
|
|
||||||
{Role: "user", Content: "Hello"},
|
|
||||||
}, nil, "", nil)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Chat() expected error for missing command")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChat_InvalidResponseJSON(t *testing.T) {
|
|
||||||
script := createMockCLI(t, "not valid json at all", "", 0)
|
|
||||||
|
|
||||||
p := NewClaudeCliProvider(t.TempDir())
|
|
||||||
p.command = script
|
|
||||||
|
|
||||||
_, err := p.Chat(context.Background(), []Message{
|
|
||||||
{Role: "user", Content: "Hello"},
|
|
||||||
}, nil, "", nil)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Chat() expected error for invalid JSON")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "failed to parse claude cli response") {
|
|
||||||
t.Errorf("error = %q, want to contain 'failed to parse claude cli response'", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChat_ContextCancellation(t *testing.T) {
|
|
||||||
script := createSlowMockCLI(t, 2) // sleep 2s
|
|
||||||
|
|
||||||
p := NewClaudeCliProvider(t.TempDir())
|
|
||||||
p.command = script
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
_, err := p.Chat(ctx, []Message{
|
|
||||||
{Role: "user", Content: "Hello"},
|
|
||||||
}, nil, "", nil)
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Chat() expected error on context cancellation")
|
|
||||||
}
|
|
||||||
// Should fail well before the full 2s sleep completes
|
|
||||||
if elapsed > 3*time.Second {
|
|
||||||
t.Errorf("Chat() took %v, expected to fail faster via context cancellation", elapsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChat_PassesSystemPromptFlag(t *testing.T) {
|
|
||||||
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
|
||||||
script := createArgCaptureCLI(t, argsFile)
|
|
||||||
|
|
||||||
p := NewClaudeCliProvider(t.TempDir())
|
|
||||||
p.command = script
|
|
||||||
|
|
||||||
_, err := p.Chat(context.Background(), []Message{
|
|
||||||
{Role: "system", Content: "Be helpful."},
|
|
||||||
{Role: "user", Content: "Hi"},
|
|
||||||
}, nil, "", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chat() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
argsBytes, err := os.ReadFile(argsFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read args file: %v", err)
|
|
||||||
}
|
|
||||||
args := string(argsBytes)
|
|
||||||
if !strings.Contains(args, "--system-prompt") {
|
|
||||||
t.Errorf("CLI args missing --system-prompt, got: %s", args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChat_PassesModelFlag(t *testing.T) {
|
|
||||||
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
|
||||||
script := createArgCaptureCLI(t, argsFile)
|
|
||||||
|
|
||||||
p := NewClaudeCliProvider(t.TempDir())
|
|
||||||
p.command = script
|
|
||||||
|
|
||||||
_, err := p.Chat(context.Background(), []Message{
|
|
||||||
{Role: "user", Content: "Hi"},
|
|
||||||
}, nil, "claude-sonnet-4-5-20250929", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chat() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
argsBytes, _ := os.ReadFile(argsFile)
|
|
||||||
args := string(argsBytes)
|
|
||||||
if !strings.Contains(args, "--model") {
|
|
||||||
t.Errorf("CLI args missing --model, got: %s", args)
|
|
||||||
}
|
|
||||||
if !strings.Contains(args, "claude-sonnet-4-5-20250929") {
|
|
||||||
t.Errorf("CLI args missing model name, got: %s", args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChat_SkipsModelFlagForClaudeCode(t *testing.T) {
|
|
||||||
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
|
||||||
script := createArgCaptureCLI(t, argsFile)
|
|
||||||
|
|
||||||
p := NewClaudeCliProvider(t.TempDir())
|
|
||||||
p.command = script
|
|
||||||
|
|
||||||
_, err := p.Chat(context.Background(), []Message{
|
|
||||||
{Role: "user", Content: "Hi"},
|
|
||||||
}, nil, "claude-code", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chat() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
argsBytes, _ := os.ReadFile(argsFile)
|
|
||||||
args := string(argsBytes)
|
|
||||||
if strings.Contains(args, "--model") {
|
|
||||||
t.Errorf("CLI args should NOT contain --model for claude-code, got: %s", args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChat_SkipsModelFlagForEmptyModel(t *testing.T) {
|
|
||||||
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
|
||||||
script := createArgCaptureCLI(t, argsFile)
|
|
||||||
|
|
||||||
p := NewClaudeCliProvider(t.TempDir())
|
|
||||||
p.command = script
|
|
||||||
|
|
||||||
_, err := p.Chat(context.Background(), []Message{
|
|
||||||
{Role: "user", Content: "Hi"},
|
|
||||||
}, nil, "", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chat() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
argsBytes, _ := os.ReadFile(argsFile)
|
|
||||||
args := string(argsBytes)
|
|
||||||
if strings.Contains(args, "--model") {
|
|
||||||
t.Errorf("CLI args should NOT contain --model for empty model, got: %s", args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) {
|
|
||||||
mockJSON := `{"type":"result","result":"ok","session_id":"s"}`
|
|
||||||
script := createMockCLI(t, mockJSON, "", 0)
|
|
||||||
|
|
||||||
p := NewClaudeCliProvider("")
|
|
||||||
p.command = script
|
|
||||||
|
|
||||||
resp, err := p.Chat(context.Background(), []Message{
|
|
||||||
{Role: "user", Content: "Hello"},
|
|
||||||
}, nil, "", nil)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chat() with empty workspace error = %v", err)
|
|
||||||
}
|
|
||||||
if resp.Content != "ok" {
|
|
||||||
t.Errorf("Content = %q, want %q", resp.Content, "ok")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- CreateProvider factory tests ---
|
|
||||||
|
|
||||||
func TestCreateProvider_ClaudeCli(t *testing.T) {
|
|
||||||
cfg := config.DefaultConfig()
|
|
||||||
cfg.Agents.Defaults.Provider = "claude-cli"
|
|
||||||
cfg.Agents.Defaults.Workspace = "/test/ws"
|
|
||||||
|
|
||||||
provider, err := CreateProvider(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateProvider(claude-cli) error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cliProvider, ok := provider.(*ClaudeCliProvider)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("CreateProvider(claude-cli) returned %T, want *ClaudeCliProvider", provider)
|
|
||||||
}
|
|
||||||
if cliProvider.workspace != "/test/ws" {
|
|
||||||
t.Errorf("workspace = %q, want %q", cliProvider.workspace, "/test/ws")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateProvider_ClaudeCode(t *testing.T) {
|
|
||||||
cfg := config.DefaultConfig()
|
|
||||||
cfg.Agents.Defaults.Provider = "claude-code"
|
|
||||||
|
|
||||||
provider, err := CreateProvider(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateProvider(claude-code) error = %v", err)
|
|
||||||
}
|
|
||||||
if _, ok := provider.(*ClaudeCliProvider); !ok {
|
|
||||||
t.Fatalf("CreateProvider(claude-code) returned %T, want *ClaudeCliProvider", provider)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateProvider_ClaudeCodec(t *testing.T) {
|
|
||||||
cfg := config.DefaultConfig()
|
|
||||||
cfg.Agents.Defaults.Provider = "claudecode"
|
|
||||||
|
|
||||||
provider, err := CreateProvider(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateProvider(claudecode) error = %v", err)
|
|
||||||
}
|
|
||||||
if _, ok := provider.(*ClaudeCliProvider); !ok {
|
|
||||||
t.Fatalf("CreateProvider(claudecode) returned %T, want *ClaudeCliProvider", provider)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) {
|
|
||||||
cfg := config.DefaultConfig()
|
|
||||||
cfg.Agents.Defaults.Provider = "claude-cli"
|
|
||||||
cfg.Agents.Defaults.Workspace = ""
|
|
||||||
|
|
||||||
provider, err := CreateProvider(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateProvider error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cliProvider, ok := provider.(*ClaudeCliProvider)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("returned %T, want *ClaudeCliProvider", provider)
|
|
||||||
}
|
|
||||||
if cliProvider.workspace != "." {
|
|
||||||
t.Errorf("workspace = %q, want %q (default)", cliProvider.workspace, ".")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- messagesToPrompt tests ---
|
|
||||||
|
|
||||||
func TestMessagesToPrompt_SingleUser(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "user", Content: "Hello"},
|
|
||||||
}
|
|
||||||
got := p.messagesToPrompt(messages)
|
|
||||||
want := "Hello"
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("messagesToPrompt() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMessagesToPrompt_Conversation(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "user", Content: "Hi"},
|
|
||||||
{Role: "assistant", Content: "Hello!"},
|
|
||||||
{Role: "user", Content: "How are you?"},
|
|
||||||
}
|
|
||||||
got := p.messagesToPrompt(messages)
|
|
||||||
want := "User: Hi\nAssistant: Hello!\nUser: How are you?"
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("messagesToPrompt() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMessagesToPrompt_WithSystemMessage(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "system", Content: "You are helpful."},
|
|
||||||
{Role: "user", Content: "Hello"},
|
|
||||||
}
|
|
||||||
got := p.messagesToPrompt(messages)
|
|
||||||
want := "Hello"
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("messagesToPrompt() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMessagesToPrompt_WithToolResults(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "user", Content: "What's the weather?"},
|
|
||||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_123"},
|
|
||||||
}
|
|
||||||
got := p.messagesToPrompt(messages)
|
|
||||||
if !strings.Contains(got, "[Tool Result for call_123]") {
|
|
||||||
t.Errorf("messagesToPrompt() missing tool result marker, got %q", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, `{"temp": 72}`) {
|
|
||||||
t.Errorf("messagesToPrompt() missing tool result content, got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMessagesToPrompt_EmptyMessages(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
got := p.messagesToPrompt(nil)
|
|
||||||
if got != "" {
|
|
||||||
t.Errorf("messagesToPrompt(nil) = %q, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMessagesToPrompt_OnlySystemMessages(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "system", Content: "System 1"},
|
|
||||||
{Role: "system", Content: "System 2"},
|
|
||||||
}
|
|
||||||
got := p.messagesToPrompt(messages)
|
|
||||||
if got != "" {
|
|
||||||
t.Errorf("messagesToPrompt() with only system msgs = %q, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- buildSystemPrompt tests ---
|
|
||||||
|
|
||||||
func TestBuildSystemPrompt_NoSystemNoTools(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "user", Content: "Hi"},
|
|
||||||
}
|
|
||||||
got := p.buildSystemPrompt(messages, nil)
|
|
||||||
if got != "" {
|
|
||||||
t.Errorf("buildSystemPrompt() = %q, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildSystemPrompt_SystemOnly(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "system", Content: "You are helpful."},
|
|
||||||
{Role: "user", Content: "Hi"},
|
|
||||||
}
|
|
||||||
got := p.buildSystemPrompt(messages, nil)
|
|
||||||
if got != "You are helpful." {
|
|
||||||
t.Errorf("buildSystemPrompt() = %q, want %q", got, "You are helpful.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildSystemPrompt_MultipleSystemMessages(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "system", Content: "You are helpful."},
|
|
||||||
{Role: "system", Content: "Be concise."},
|
|
||||||
{Role: "user", Content: "Hi"},
|
|
||||||
}
|
|
||||||
got := p.buildSystemPrompt(messages, nil)
|
|
||||||
if !strings.Contains(got, "You are helpful.") {
|
|
||||||
t.Error("missing first system message")
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "Be concise.") {
|
|
||||||
t.Error("missing second system message")
|
|
||||||
}
|
|
||||||
// Should be joined with double newline
|
|
||||||
want := "You are helpful.\n\nBe concise."
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("buildSystemPrompt() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildSystemPrompt_WithTools(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "system", Content: "You are helpful."},
|
|
||||||
}
|
|
||||||
tools := []ToolDefinition{
|
|
||||||
{
|
|
||||||
Type: "function",
|
|
||||||
Function: ToolFunctionDefinition{
|
|
||||||
Name: "get_weather",
|
|
||||||
Description: "Get weather for a location",
|
|
||||||
Parameters: map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"location": map[string]interface{}{"type": "string"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
got := p.buildSystemPrompt(messages, tools)
|
|
||||||
if !strings.Contains(got, "You are helpful.") {
|
|
||||||
t.Error("buildSystemPrompt() missing system message")
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "get_weather") {
|
|
||||||
t.Error("buildSystemPrompt() missing tool definition")
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "Available Tools") {
|
|
||||||
t.Error("buildSystemPrompt() missing tools header")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildSystemPrompt_ToolsOnlyNoSystem(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
tools := []ToolDefinition{
|
|
||||||
{
|
|
||||||
Type: "function",
|
|
||||||
Function: ToolFunctionDefinition{
|
|
||||||
Name: "test_tool",
|
|
||||||
Description: "A test tool",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
got := p.buildSystemPrompt(nil, tools)
|
|
||||||
if !strings.Contains(got, "test_tool") {
|
|
||||||
t.Error("should include tool definitions even without system messages")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- buildToolsPrompt tests ---
|
|
||||||
|
|
||||||
func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
tools := []ToolDefinition{
|
|
||||||
{Type: "other", Function: ToolFunctionDefinition{Name: "skip_me"}},
|
|
||||||
{Type: "function", Function: ToolFunctionDefinition{Name: "include_me", Description: "Included"}},
|
|
||||||
}
|
|
||||||
got := p.buildToolsPrompt(tools)
|
|
||||||
if strings.Contains(got, "skip_me") {
|
|
||||||
t.Error("buildToolsPrompt() should skip non-function tools")
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "include_me") {
|
|
||||||
t.Error("buildToolsPrompt() should include function tools")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildToolsPrompt_NoDescription(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
tools := []ToolDefinition{
|
|
||||||
{Type: "function", Function: ToolFunctionDefinition{Name: "bare_tool"}},
|
|
||||||
}
|
|
||||||
got := p.buildToolsPrompt(tools)
|
|
||||||
if !strings.Contains(got, "bare_tool") {
|
|
||||||
t.Error("should include tool name")
|
|
||||||
}
|
|
||||||
if strings.Contains(got, "Description:") {
|
|
||||||
t.Error("should not include Description: line when empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildToolsPrompt_NoParameters(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
tools := []ToolDefinition{
|
|
||||||
{Type: "function", Function: ToolFunctionDefinition{
|
|
||||||
Name: "no_params_tool",
|
|
||||||
Description: "A tool with no parameters",
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
got := p.buildToolsPrompt(tools)
|
|
||||||
if strings.Contains(got, "Parameters:") {
|
|
||||||
t.Error("should not include Parameters: section when nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- parseClaudeCliResponse tests ---
|
|
||||||
|
|
||||||
func TestParseClaudeCliResponse_TextOnly(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
output := `{"type":"result","subtype":"success","is_error":false,"result":"Hello, world!","session_id":"abc123","total_cost_usd":0.01,"duration_ms":500,"usage":{"input_tokens":10,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}`
|
|
||||||
|
|
||||||
resp, err := p.parseClaudeCliResponse(output)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseClaudeCliResponse() error = %v", err)
|
|
||||||
}
|
|
||||||
if resp.Content != "Hello, world!" {
|
|
||||||
t.Errorf("Content = %q, want %q", resp.Content, "Hello, world!")
|
|
||||||
}
|
|
||||||
if resp.FinishReason != "stop" {
|
|
||||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
|
||||||
}
|
|
||||||
if len(resp.ToolCalls) != 0 {
|
|
||||||
t.Errorf("ToolCalls = %d, want 0", len(resp.ToolCalls))
|
|
||||||
}
|
|
||||||
if resp.Usage == nil {
|
|
||||||
t.Fatal("Usage should not be nil")
|
|
||||||
}
|
|
||||||
if resp.Usage.PromptTokens != 10 {
|
|
||||||
t.Errorf("PromptTokens = %d, want 10", resp.Usage.PromptTokens)
|
|
||||||
}
|
|
||||||
if resp.Usage.CompletionTokens != 20 {
|
|
||||||
t.Errorf("CompletionTokens = %d, want 20", resp.Usage.CompletionTokens)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseClaudeCliResponse_EmptyResult(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
output := `{"type":"result","subtype":"success","is_error":false,"result":"","session_id":"abc"}`
|
|
||||||
|
|
||||||
resp, err := p.parseClaudeCliResponse(output)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error = %v", err)
|
|
||||||
}
|
|
||||||
if resp.Content != "" {
|
|
||||||
t.Errorf("Content = %q, want empty", resp.Content)
|
|
||||||
}
|
|
||||||
if resp.FinishReason != "stop" {
|
|
||||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseClaudeCliResponse_IsError(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
output := `{"type":"result","subtype":"error","is_error":true,"result":"Something went wrong","session_id":"abc"}`
|
|
||||||
|
|
||||||
_, err := p.parseClaudeCliResponse(output)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error when is_error=true")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "Something went wrong") {
|
|
||||||
t.Errorf("error = %q, want to contain 'Something went wrong'", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseClaudeCliResponse_NoUsage(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
output := `{"type":"result","subtype":"success","is_error":false,"result":"hi","session_id":"s"}`
|
|
||||||
|
|
||||||
resp, err := p.parseClaudeCliResponse(output)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error = %v", err)
|
|
||||||
}
|
|
||||||
if resp.Usage != nil {
|
|
||||||
t.Errorf("Usage should be nil when no tokens, got %+v", resp.Usage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseClaudeCliResponse_InvalidJSON(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
_, err := p.parseClaudeCliResponse("not json")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for invalid JSON")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "failed to parse claude cli response") {
|
|
||||||
t.Errorf("error = %q, want to contain 'failed to parse claude cli response'", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseClaudeCliResponse_WithToolCalls(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
output := `{"type":"result","subtype":"success","is_error":false,"result":"Let me check.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"Tokyo\\\"}\"}}]}","session_id":"abc123","total_cost_usd":0.01}`
|
|
||||||
|
|
||||||
resp, err := p.parseClaudeCliResponse(output)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error = %v", err)
|
|
||||||
}
|
|
||||||
if resp.FinishReason != "tool_calls" {
|
|
||||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
|
||||||
}
|
|
||||||
if len(resp.ToolCalls) != 1 {
|
|
||||||
t.Fatalf("ToolCalls = %d, want 1", len(resp.ToolCalls))
|
|
||||||
}
|
|
||||||
tc := resp.ToolCalls[0]
|
|
||||||
if tc.Name != "get_weather" {
|
|
||||||
t.Errorf("Name = %q, want %q", tc.Name, "get_weather")
|
|
||||||
}
|
|
||||||
if tc.Function == nil {
|
|
||||||
t.Fatal("Function is nil")
|
|
||||||
}
|
|
||||||
if tc.Function.Name != "get_weather" {
|
|
||||||
t.Errorf("Function.Name = %q, want %q", tc.Function.Name, "get_weather")
|
|
||||||
}
|
|
||||||
if tc.Arguments["location"] != "Tokyo" {
|
|
||||||
t.Errorf("Arguments[location] = %v, want Tokyo", tc.Arguments["location"])
|
|
||||||
}
|
|
||||||
if strings.Contains(resp.Content, "tool_calls") {
|
|
||||||
t.Errorf("Content should not contain tool_calls JSON, got %q", resp.Content)
|
|
||||||
}
|
|
||||||
if resp.Content != "Let me check." {
|
|
||||||
t.Errorf("Content = %q, want %q", resp.Content, "Let me check.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseClaudeCliResponse_WhitespaceResult(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
output := `{"type":"result","subtype":"success","is_error":false,"result":" hello \n ","session_id":"s"}`
|
|
||||||
|
|
||||||
resp, err := p.parseClaudeCliResponse(output)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error = %v", err)
|
|
||||||
}
|
|
||||||
if resp.Content != "hello" {
|
|
||||||
t.Errorf("Content = %q, want %q (should be trimmed)", resp.Content, "hello")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- extractToolCalls tests ---
|
|
||||||
|
|
||||||
func TestExtractToolCalls_NoToolCalls(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
got := p.extractToolCalls("Just a regular response.")
|
|
||||||
if len(got) != 0 {
|
|
||||||
t.Errorf("extractToolCalls() = %d, want 0", len(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractToolCalls_WithToolCalls(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
text := `Here's the result:
|
|
||||||
{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}`
|
|
||||||
|
|
||||||
got := p.extractToolCalls(text)
|
|
||||||
if len(got) != 1 {
|
|
||||||
t.Fatalf("extractToolCalls() = %d, want 1", len(got))
|
|
||||||
}
|
|
||||||
if got[0].ID != "call_1" {
|
|
||||||
t.Errorf("ID = %q, want %q", got[0].ID, "call_1")
|
|
||||||
}
|
|
||||||
if got[0].Name != "test" {
|
|
||||||
t.Errorf("Name = %q, want %q", got[0].Name, "test")
|
|
||||||
}
|
|
||||||
if got[0].Type != "function" {
|
|
||||||
t.Errorf("Type = %q, want %q", got[0].Type, "function")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractToolCalls_InvalidJSON(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
got := p.extractToolCalls(`{"tool_calls":invalid}`)
|
|
||||||
if len(got) != 0 {
|
|
||||||
t.Errorf("extractToolCalls() with invalid JSON = %d, want 0", len(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractToolCalls_MultipleToolCalls(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
text := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"/tmp/out\",\"content\":\"hello\"}"}}]}`
|
|
||||||
|
|
||||||
got := p.extractToolCalls(text)
|
|
||||||
if len(got) != 2 {
|
|
||||||
t.Fatalf("extractToolCalls() = %d, want 2", len(got))
|
|
||||||
}
|
|
||||||
if got[0].Name != "read_file" {
|
|
||||||
t.Errorf("[0].Name = %q, want %q", got[0].Name, "read_file")
|
|
||||||
}
|
|
||||||
if got[1].Name != "write_file" {
|
|
||||||
t.Errorf("[1].Name = %q, want %q", got[1].Name, "write_file")
|
|
||||||
}
|
|
||||||
// Verify arguments were parsed
|
|
||||||
if got[0].Arguments["path"] != "/tmp/test" {
|
|
||||||
t.Errorf("[0].Arguments[path] = %v, want /tmp/test", got[0].Arguments["path"])
|
|
||||||
}
|
|
||||||
if got[1].Arguments["content"] != "hello" {
|
|
||||||
t.Errorf("[1].Arguments[content] = %v, want hello", got[1].Arguments["content"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractToolCalls_UnmatchedBrace(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
got := p.extractToolCalls(`{"tool_calls":[{"id":"call_1"`)
|
|
||||||
if len(got) != 0 {
|
|
||||||
t.Errorf("extractToolCalls() with unmatched brace = %d, want 0", len(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractToolCalls_ToolCallArgumentsParsing(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{\"num\":42,\"flag\":true,\"name\":\"test\"}"}}]}`
|
|
||||||
|
|
||||||
got := p.extractToolCalls(text)
|
|
||||||
if len(got) != 1 {
|
|
||||||
t.Fatalf("len = %d, want 1", len(got))
|
|
||||||
}
|
|
||||||
// Verify different argument types
|
|
||||||
if got[0].Arguments["num"] != float64(42) {
|
|
||||||
t.Errorf("Arguments[num] = %v (%T), want 42", got[0].Arguments["num"], got[0].Arguments["num"])
|
|
||||||
}
|
|
||||||
if got[0].Arguments["flag"] != true {
|
|
||||||
t.Errorf("Arguments[flag] = %v, want true", got[0].Arguments["flag"])
|
|
||||||
}
|
|
||||||
if got[0].Arguments["name"] != "test" {
|
|
||||||
t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"])
|
|
||||||
}
|
|
||||||
// Verify raw arguments string is preserved in FunctionCall
|
|
||||||
if got[0].Function.Arguments == "" {
|
|
||||||
t.Error("Function.Arguments should contain raw JSON string")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- stripToolCallsJSON tests ---
|
|
||||||
|
|
||||||
func TestStripToolCallsJSON(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
text := `Let me check the weather.
|
|
||||||
{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}
|
|
||||||
Done.`
|
|
||||||
|
|
||||||
got := p.stripToolCallsJSON(text)
|
|
||||||
if strings.Contains(got, "tool_calls") {
|
|
||||||
t.Errorf("should remove tool_calls JSON, got %q", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "Let me check the weather.") {
|
|
||||||
t.Errorf("should keep text before, got %q", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "Done.") {
|
|
||||||
t.Errorf("should keep text after, got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStripToolCallsJSON_NoToolCalls(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
text := "Just regular text."
|
|
||||||
got := p.stripToolCallsJSON(text)
|
|
||||||
if got != text {
|
|
||||||
t.Errorf("stripToolCallsJSON() = %q, want %q", got, text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStripToolCallsJSON_OnlyToolCalls(t *testing.T) {
|
|
||||||
p := NewClaudeCliProvider("/workspace")
|
|
||||||
text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{}"}}]}`
|
|
||||||
got := p.stripToolCallsJSON(text)
|
|
||||||
if got != "" {
|
|
||||||
t.Errorf("stripToolCallsJSON() = %q, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- findMatchingBrace tests ---
|
|
||||||
|
|
||||||
func TestFindMatchingBrace(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
text string
|
|
||||||
pos int
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{`{"a":1}`, 0, 7},
|
|
||||||
{`{"a":{"b":2}}`, 0, 13},
|
|
||||||
{`text {"a":1} more`, 5, 12},
|
|
||||||
{`{unclosed`, 0, 0}, // no match returns pos
|
|
||||||
{`{}`, 0, 2}, // empty object
|
|
||||||
{`{{{}}}`, 0, 6}, // deeply nested
|
|
||||||
{`{"a":"b{c}d"}`, 0, 13}, // braces in strings (simplified matcher)
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := findMatchingBrace(tt.text, tt.pos)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("findMatchingBrace(%q, %d) = %d, want %d", tt.text, tt.pos, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/anthropics/anthropic-sdk-go"
|
|
||||||
"github.com/anthropics/anthropic-sdk-go/option"
|
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/auth"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ClaudeProvider struct {
|
|
||||||
client *anthropic.Client
|
|
||||||
tokenSource func() (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClaudeProvider(token string) *ClaudeProvider {
|
|
||||||
client := anthropic.NewClient(
|
|
||||||
option.WithAuthToken(token),
|
|
||||||
option.WithBaseURL("https://api.anthropic.com"),
|
|
||||||
)
|
|
||||||
return &ClaudeProvider{client: &client}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider {
|
|
||||||
p := NewClaudeProvider(token)
|
|
||||||
p.tokenSource = tokenSource
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
|
||||||
var opts []option.RequestOption
|
|
||||||
if p.tokenSource != nil {
|
|
||||||
tok, err := p.tokenSource()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("refreshing token: %w", err)
|
|
||||||
}
|
|
||||||
opts = append(opts, option.WithAuthToken(tok))
|
|
||||||
}
|
|
||||||
|
|
||||||
params, err := buildClaudeParams(messages, tools, model, options)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := p.client.Messages.New(ctx, params, opts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("claude API call: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseClaudeResponse(resp), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ClaudeProvider) GetDefaultModel() string {
|
|
||||||
return "claude-sonnet-4-5-20250929"
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildClaudeParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) {
|
|
||||||
var system []anthropic.TextBlockParam
|
|
||||||
var anthropicMessages []anthropic.MessageParam
|
|
||||||
|
|
||||||
for _, msg := range messages {
|
|
||||||
switch msg.Role {
|
|
||||||
case "system":
|
|
||||||
system = append(system, anthropic.TextBlockParam{Text: msg.Content})
|
|
||||||
case "user":
|
|
||||||
if msg.ToolCallID != "" {
|
|
||||||
anthropicMessages = append(anthropicMessages,
|
|
||||||
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
anthropicMessages = append(anthropicMessages,
|
|
||||||
anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case "assistant":
|
|
||||||
if len(msg.ToolCalls) > 0 {
|
|
||||||
var blocks []anthropic.ContentBlockParamUnion
|
|
||||||
if msg.Content != "" {
|
|
||||||
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
|
|
||||||
}
|
|
||||||
for _, tc := range msg.ToolCalls {
|
|
||||||
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
|
|
||||||
}
|
|
||||||
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
|
|
||||||
} else {
|
|
||||||
anthropicMessages = append(anthropicMessages,
|
|
||||||
anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case "tool":
|
|
||||||
anthropicMessages = append(anthropicMessages,
|
|
||||||
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
maxTokens := int64(4096)
|
|
||||||
if mt, ok := options["max_tokens"].(int); ok {
|
|
||||||
maxTokens = int64(mt)
|
|
||||||
}
|
|
||||||
|
|
||||||
params := anthropic.MessageNewParams{
|
|
||||||
Model: anthropic.Model(model),
|
|
||||||
Messages: anthropicMessages,
|
|
||||||
MaxTokens: maxTokens,
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(system) > 0 {
|
|
||||||
params.System = system
|
|
||||||
}
|
|
||||||
|
|
||||||
if temp, ok := options["temperature"].(float64); ok {
|
|
||||||
params.Temperature = anthropic.Float(temp)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tools) > 0 {
|
|
||||||
params.Tools = translateToolsForClaude(tools)
|
|
||||||
}
|
|
||||||
|
|
||||||
return params, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func translateToolsForClaude(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
|
||||||
result := make([]anthropic.ToolUnionParam, 0, len(tools))
|
|
||||||
for _, t := range tools {
|
|
||||||
tool := anthropic.ToolParam{
|
|
||||||
Name: t.Function.Name,
|
|
||||||
InputSchema: anthropic.ToolInputSchemaParam{
|
|
||||||
Properties: t.Function.Parameters["properties"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if desc := t.Function.Description; desc != "" {
|
|
||||||
tool.Description = anthropic.String(desc)
|
|
||||||
}
|
|
||||||
if req, ok := t.Function.Parameters["required"].([]interface{}); ok {
|
|
||||||
required := make([]string, 0, len(req))
|
|
||||||
for _, r := range req {
|
|
||||||
if s, ok := r.(string); ok {
|
|
||||||
required = append(required, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tool.InputSchema.Required = required
|
|
||||||
}
|
|
||||||
result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseClaudeResponse(resp *anthropic.Message) *LLMResponse {
|
|
||||||
var content string
|
|
||||||
var toolCalls []ToolCall
|
|
||||||
|
|
||||||
for _, block := range resp.Content {
|
|
||||||
switch block.Type {
|
|
||||||
case "text":
|
|
||||||
tb := block.AsText()
|
|
||||||
content += tb.Text
|
|
||||||
case "tool_use":
|
|
||||||
tu := block.AsToolUse()
|
|
||||||
var args map[string]interface{}
|
|
||||||
if err := json.Unmarshal(tu.Input, &args); err != nil {
|
|
||||||
args = map[string]interface{}{"raw": string(tu.Input)}
|
|
||||||
}
|
|
||||||
toolCalls = append(toolCalls, ToolCall{
|
|
||||||
ID: tu.ID,
|
|
||||||
Name: tu.Name,
|
|
||||||
Arguments: args,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
finishReason := "stop"
|
|
||||||
switch resp.StopReason {
|
|
||||||
case anthropic.StopReasonToolUse:
|
|
||||||
finishReason = "tool_calls"
|
|
||||||
case anthropic.StopReasonMaxTokens:
|
|
||||||
finishReason = "length"
|
|
||||||
case anthropic.StopReasonEndTurn:
|
|
||||||
finishReason = "stop"
|
|
||||||
}
|
|
||||||
|
|
||||||
return &LLMResponse{
|
|
||||||
Content: content,
|
|
||||||
ToolCalls: toolCalls,
|
|
||||||
FinishReason: finishReason,
|
|
||||||
Usage: &UsageInfo{
|
|
||||||
PromptTokens: int(resp.Usage.InputTokens),
|
|
||||||
CompletionTokens: int(resp.Usage.OutputTokens),
|
|
||||||
TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createClaudeTokenSource() func() (string, error) {
|
|
||||||
return func() (string, error) {
|
|
||||||
cred, err := auth.GetCredential("anthropic")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("loading auth credentials: %w", err)
|
|
||||||
}
|
|
||||||
if cred == nil {
|
|
||||||
return "", fmt.Errorf("no credentials for anthropic. Run: clawdroid auth login --provider anthropic")
|
|
||||||
}
|
|
||||||
return cred.AccessToken, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/anthropics/anthropic-sdk-go"
|
|
||||||
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBuildClaudeParams_BasicMessage(t *testing.T) {
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "user", Content: "Hello"},
|
|
||||||
}
|
|
||||||
params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{
|
|
||||||
"max_tokens": 1024,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("buildClaudeParams() error: %v", err)
|
|
||||||
}
|
|
||||||
if string(params.Model) != "claude-sonnet-4-5-20250929" {
|
|
||||||
t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-5-20250929")
|
|
||||||
}
|
|
||||||
if params.MaxTokens != 1024 {
|
|
||||||
t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
|
|
||||||
}
|
|
||||||
if len(params.Messages) != 1 {
|
|
||||||
t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildClaudeParams_SystemMessage(t *testing.T) {
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "system", Content: "You are helpful"},
|
|
||||||
{Role: "user", Content: "Hi"},
|
|
||||||
}
|
|
||||||
params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("buildClaudeParams() error: %v", err)
|
|
||||||
}
|
|
||||||
if len(params.System) != 1 {
|
|
||||||
t.Fatalf("len(System) = %d, want 1", len(params.System))
|
|
||||||
}
|
|
||||||
if params.System[0].Text != "You are helpful" {
|
|
||||||
t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful")
|
|
||||||
}
|
|
||||||
if len(params.Messages) != 1 {
|
|
||||||
t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildClaudeParams_ToolCallMessage(t *testing.T) {
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "user", Content: "What's the weather?"},
|
|
||||||
{
|
|
||||||
Role: "assistant",
|
|
||||||
Content: "",
|
|
||||||
ToolCalls: []ToolCall{
|
|
||||||
{
|
|
||||||
ID: "call_1",
|
|
||||||
Name: "get_weather",
|
|
||||||
Arguments: map[string]interface{}{"city": "SF"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
|
|
||||||
}
|
|
||||||
params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("buildClaudeParams() error: %v", err)
|
|
||||||
}
|
|
||||||
if len(params.Messages) != 3 {
|
|
||||||
t.Fatalf("len(Messages) = %d, want 3", len(params.Messages))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildClaudeParams_WithTools(t *testing.T) {
|
|
||||||
tools := []ToolDefinition{
|
|
||||||
{
|
|
||||||
Type: "function",
|
|
||||||
Function: ToolFunctionDefinition{
|
|
||||||
Name: "get_weather",
|
|
||||||
Description: "Get weather for a city",
|
|
||||||
Parameters: map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"city": map[string]interface{}{"type": "string"},
|
|
||||||
},
|
|
||||||
"required": []interface{}{"city"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
params, err := buildClaudeParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("buildClaudeParams() error: %v", err)
|
|
||||||
}
|
|
||||||
if len(params.Tools) != 1 {
|
|
||||||
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseClaudeResponse_TextOnly(t *testing.T) {
|
|
||||||
resp := &anthropic.Message{
|
|
||||||
Content: []anthropic.ContentBlockUnion{},
|
|
||||||
Usage: anthropic.Usage{
|
|
||||||
InputTokens: 10,
|
|
||||||
OutputTokens: 20,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
result := parseClaudeResponse(resp)
|
|
||||||
if result.Usage.PromptTokens != 10 {
|
|
||||||
t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens)
|
|
||||||
}
|
|
||||||
if result.Usage.CompletionTokens != 20 {
|
|
||||||
t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens)
|
|
||||||
}
|
|
||||||
if result.FinishReason != "stop" {
|
|
||||||
t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseClaudeResponse_StopReasons(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
stopReason anthropic.StopReason
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{anthropic.StopReasonEndTurn, "stop"},
|
|
||||||
{anthropic.StopReasonMaxTokens, "length"},
|
|
||||||
{anthropic.StopReasonToolUse, "tool_calls"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
resp := &anthropic.Message{
|
|
||||||
StopReason: tt.stopReason,
|
|
||||||
}
|
|
||||||
result := parseClaudeResponse(resp)
|
|
||||||
if result.FinishReason != tt.want {
|
|
||||||
t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path != "/v1/messages" {
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var reqBody map[string]interface{}
|
|
||||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"id": "msg_test",
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"model": reqBody["model"],
|
|
||||||
"stop_reason": "end_turn",
|
|
||||||
"content": []map[string]interface{}{
|
|
||||||
{"type": "text", "text": "Hello! How can I help you?"},
|
|
||||||
},
|
|
||||||
"usage": map[string]interface{}{
|
|
||||||
"input_tokens": 15,
|
|
||||||
"output_tokens": 8,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(resp)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
provider := NewClaudeProvider("test-token")
|
|
||||||
provider.client = createAnthropicTestClient(server.URL, "test-token")
|
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
|
||||||
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chat() error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Content != "Hello! How can I help you?" {
|
|
||||||
t.Errorf("Content = %q, want %q", resp.Content, "Hello! How can I help you?")
|
|
||||||
}
|
|
||||||
if resp.FinishReason != "stop" {
|
|
||||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
|
||||||
}
|
|
||||||
if resp.Usage.PromptTokens != 15 {
|
|
||||||
t.Errorf("PromptTokens = %d, want 15", resp.Usage.PromptTokens)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClaudeProvider_GetDefaultModel(t *testing.T) {
|
|
||||||
p := NewClaudeProvider("test-token")
|
|
||||||
if got := p.GetDefaultModel(); got != "claude-sonnet-4-5-20250929" {
|
|
||||||
t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4-5-20250929")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createAnthropicTestClient(baseURL, token string) *anthropic.Client {
|
|
||||||
c := anthropic.NewClient(
|
|
||||||
anthropicoption.WithAuthToken(token),
|
|
||||||
anthropicoption.WithBaseURL(baseURL),
|
|
||||||
)
|
|
||||||
return &c
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CodexCliAuth represents the ~/.codex/auth.json file structure.
|
|
||||||
type CodexCliAuth struct {
|
|
||||||
Tokens struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
AccountID string `json:"account_id"`
|
|
||||||
} `json:"tokens"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadCodexCliCredentials reads OAuth tokens from the Codex CLI's auth.json file.
|
|
||||||
// Expiry is estimated as file modification time + 1 hour (same approach as moltbot).
|
|
||||||
func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Time, err error) {
|
|
||||||
authPath, err := resolveCodexAuthPath()
|
|
||||||
if err != nil {
|
|
||||||
return "", "", time.Time{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(authPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", time.Time{}, fmt.Errorf("reading %s: %w", authPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var auth CodexCliAuth
|
|
||||||
if err := json.Unmarshal(data, &auth); err != nil {
|
|
||||||
return "", "", time.Time{}, fmt.Errorf("parsing %s: %w", authPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if auth.Tokens.AccessToken == "" {
|
|
||||||
return "", "", time.Time{}, fmt.Errorf("no access_token in %s", authPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
stat, err := os.Stat(authPath)
|
|
||||||
if err != nil {
|
|
||||||
expiresAt = time.Now().Add(time.Hour)
|
|
||||||
} else {
|
|
||||||
expiresAt = stat.ModTime().Add(time.Hour)
|
|
||||||
}
|
|
||||||
|
|
||||||
return auth.Tokens.AccessToken, auth.Tokens.AccountID, expiresAt, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateCodexCliTokenSource creates a token source that reads from ~/.codex/auth.json.
|
|
||||||
// This allows the existing CodexProvider to reuse Codex CLI credentials.
|
|
||||||
func CreateCodexCliTokenSource() func() (string, string, error) {
|
|
||||||
return func() (string, string, error) {
|
|
||||||
token, accountID, expiresAt, err := ReadCodexCliCredentials()
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("reading codex cli credentials: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if time.Now().After(expiresAt) {
|
|
||||||
return "", "", fmt.Errorf("codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login")
|
|
||||||
}
|
|
||||||
|
|
||||||
return token, accountID, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveCodexAuthPath() (string, error) {
|
|
||||||
codexHome := os.Getenv("CODEX_HOME")
|
|
||||||
if codexHome == "" {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("getting home dir: %w", err)
|
|
||||||
}
|
|
||||||
codexHome = filepath.Join(home, ".codex")
|
|
||||||
}
|
|
||||||
return filepath.Join(codexHome, "auth.json"), nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestReadCodexCliCredentials_Valid(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
authPath := filepath.Join(tmpDir, "auth.json")
|
|
||||||
|
|
||||||
authJSON := `{
|
|
||||||
"tokens": {
|
|
||||||
"access_token": "test-access-token",
|
|
||||||
"refresh_token": "test-refresh-token",
|
|
||||||
"account_id": "org-test123"
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("CODEX_HOME", tmpDir)
|
|
||||||
|
|
||||||
token, accountID, expiresAt, err := ReadCodexCliCredentials()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadCodexCliCredentials() error: %v", err)
|
|
||||||
}
|
|
||||||
if token != "test-access-token" {
|
|
||||||
t.Errorf("token = %q, want %q", token, "test-access-token")
|
|
||||||
}
|
|
||||||
if accountID != "org-test123" {
|
|
||||||
t.Errorf("accountID = %q, want %q", accountID, "org-test123")
|
|
||||||
}
|
|
||||||
// Expiry should be within ~1 hour from now (file was just written)
|
|
||||||
if expiresAt.Before(time.Now()) {
|
|
||||||
t.Errorf("expiresAt = %v, should be in the future", expiresAt)
|
|
||||||
}
|
|
||||||
if expiresAt.After(time.Now().Add(2 * time.Hour)) {
|
|
||||||
t.Errorf("expiresAt = %v, should be within ~1 hour", expiresAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadCodexCliCredentials_MissingFile(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
t.Setenv("CODEX_HOME", tmpDir)
|
|
||||||
|
|
||||||
_, _, _, err := ReadCodexCliCredentials()
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for missing auth.json")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadCodexCliCredentials_EmptyToken(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
authPath := filepath.Join(tmpDir, "auth.json")
|
|
||||||
|
|
||||||
authJSON := `{"tokens": {"access_token": "", "refresh_token": "r", "account_id": "a"}}`
|
|
||||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("CODEX_HOME", tmpDir)
|
|
||||||
|
|
||||||
_, _, _, err := ReadCodexCliCredentials()
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for empty access_token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadCodexCliCredentials_InvalidJSON(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
authPath := filepath.Join(tmpDir, "auth.json")
|
|
||||||
|
|
||||||
if err := os.WriteFile(authPath, []byte("not json"), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("CODEX_HOME", tmpDir)
|
|
||||||
|
|
||||||
_, _, _, err := ReadCodexCliCredentials()
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for invalid JSON")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadCodexCliCredentials_NoAccountID(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
authPath := filepath.Join(tmpDir, "auth.json")
|
|
||||||
|
|
||||||
authJSON := `{"tokens": {"access_token": "tok123", "refresh_token": "ref456"}}`
|
|
||||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("CODEX_HOME", tmpDir)
|
|
||||||
|
|
||||||
token, accountID, _, err := ReadCodexCliCredentials()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if token != "tok123" {
|
|
||||||
t.Errorf("token = %q, want %q", token, "tok123")
|
|
||||||
}
|
|
||||||
if accountID != "" {
|
|
||||||
t.Errorf("accountID = %q, want empty", accountID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadCodexCliCredentials_CodexHomeEnv(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
customDir := filepath.Join(tmpDir, "custom-codex")
|
|
||||||
if err := os.MkdirAll(customDir, 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
authJSON := `{"tokens": {"access_token": "custom-token", "refresh_token": "r"}}`
|
|
||||||
if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("CODEX_HOME", customDir)
|
|
||||||
|
|
||||||
token, _, _, err := ReadCodexCliCredentials()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if token != "custom-token" {
|
|
||||||
t.Errorf("token = %q, want %q", token, "custom-token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateCodexCliTokenSource_Valid(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
authPath := filepath.Join(tmpDir, "auth.json")
|
|
||||||
|
|
||||||
authJSON := `{"tokens": {"access_token": "fresh-token", "refresh_token": "r", "account_id": "acc"}}`
|
|
||||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("CODEX_HOME", tmpDir)
|
|
||||||
|
|
||||||
source := CreateCodexCliTokenSource()
|
|
||||||
token, accountID, err := source()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("token source error: %v", err)
|
|
||||||
}
|
|
||||||
if token != "fresh-token" {
|
|
||||||
t.Errorf("token = %q, want %q", token, "fresh-token")
|
|
||||||
}
|
|
||||||
if accountID != "acc" {
|
|
||||||
t.Errorf("accountID = %q, want %q", accountID, "acc")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateCodexCliTokenSource_Expired(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
authPath := filepath.Join(tmpDir, "auth.json")
|
|
||||||
|
|
||||||
authJSON := `{"tokens": {"access_token": "old-token", "refresh_token": "r"}}`
|
|
||||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set file modification time to 2 hours ago
|
|
||||||
oldTime := time.Now().Add(-2 * time.Hour)
|
|
||||||
if err := os.Chtimes(authPath, oldTime, oldTime); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("CODEX_HOME", tmpDir)
|
|
||||||
|
|
||||||
source := CreateCodexCliTokenSource()
|
|
||||||
_, _, err := source()
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for expired credentials")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess.
|
|
||||||
type CodexCliProvider struct {
|
|
||||||
command string
|
|
||||||
workspace string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCodexCliProvider creates a new Codex CLI provider.
|
|
||||||
func NewCodexCliProvider(workspace string) *CodexCliProvider {
|
|
||||||
return &CodexCliProvider{
|
|
||||||
command: "codex",
|
|
||||||
workspace: workspace,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode.
|
|
||||||
func (p *CodexCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
|
||||||
if p.command == "" {
|
|
||||||
return nil, fmt.Errorf("codex command not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt := p.buildPrompt(messages, tools)
|
|
||||||
|
|
||||||
args := []string{
|
|
||||||
"exec",
|
|
||||||
"--json",
|
|
||||||
"--dangerously-bypass-approvals-and-sandbox",
|
|
||||||
"--skip-git-repo-check",
|
|
||||||
"--color", "never",
|
|
||||||
}
|
|
||||||
if model != "" && model != "codex-cli" {
|
|
||||||
args = append(args, "-m", model)
|
|
||||||
}
|
|
||||||
if p.workspace != "" {
|
|
||||||
args = append(args, "-C", p.workspace)
|
|
||||||
}
|
|
||||||
args = append(args, "-") // read prompt from stdin
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, p.command, args...)
|
|
||||||
cmd.Stdin = bytes.NewReader([]byte(prompt))
|
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
cmd.Stdout = &stdout
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
err := cmd.Run()
|
|
||||||
|
|
||||||
// Parse JSONL from stdout even if exit code is non-zero,
|
|
||||||
// because codex writes diagnostic noise to stderr (e.g. rollout errors)
|
|
||||||
// but still produces valid JSONL output.
|
|
||||||
if stdoutStr := stdout.String(); stdoutStr != "" {
|
|
||||||
resp, parseErr := p.parseJSONLEvents(stdoutStr)
|
|
||||||
if parseErr == nil && resp != nil && (resp.Content != "" || len(resp.ToolCalls) > 0) {
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() == context.Canceled {
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
if stderrStr := stderr.String(); stderrStr != "" {
|
|
||||||
return nil, fmt.Errorf("codex cli error: %s", stderrStr)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("codex cli error: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return p.parseJSONLEvents(stdout.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultModel returns the default model identifier.
|
|
||||||
func (p *CodexCliProvider) GetDefaultModel() string {
|
|
||||||
return "codex-cli"
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildPrompt converts messages to a prompt string for the Codex CLI.
|
|
||||||
// System messages are prepended as instructions since Codex CLI has no --system-prompt flag.
|
|
||||||
func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinition) string {
|
|
||||||
var systemParts []string
|
|
||||||
var conversationParts []string
|
|
||||||
|
|
||||||
for _, msg := range messages {
|
|
||||||
switch msg.Role {
|
|
||||||
case "system":
|
|
||||||
systemParts = append(systemParts, msg.Content)
|
|
||||||
case "user":
|
|
||||||
conversationParts = append(conversationParts, msg.Content)
|
|
||||||
case "assistant":
|
|
||||||
conversationParts = append(conversationParts, "Assistant: "+msg.Content)
|
|
||||||
case "tool":
|
|
||||||
conversationParts = append(conversationParts,
|
|
||||||
fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
if len(systemParts) > 0 {
|
|
||||||
sb.WriteString("## System Instructions\n\n")
|
|
||||||
sb.WriteString(strings.Join(systemParts, "\n\n"))
|
|
||||||
sb.WriteString("\n\n## Task\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tools) > 0 {
|
|
||||||
sb.WriteString(p.buildToolsPrompt(tools))
|
|
||||||
sb.WriteString("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simplify single user message (no prefix)
|
|
||||||
if len(conversationParts) == 1 && len(systemParts) == 0 && len(tools) == 0 {
|
|
||||||
return conversationParts[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString(strings.Join(conversationParts, "\n"))
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildToolsPrompt creates a tool definitions section for the prompt.
|
|
||||||
func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
sb.WriteString("## Available Tools\n\n")
|
|
||||||
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
|
|
||||||
sb.WriteString("```json\n")
|
|
||||||
sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`)
|
|
||||||
sb.WriteString("\n```\n\n")
|
|
||||||
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
|
|
||||||
sb.WriteString("### Tool Definitions:\n\n")
|
|
||||||
|
|
||||||
for _, tool := range tools {
|
|
||||||
if tool.Type != "function" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name))
|
|
||||||
if tool.Function.Description != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description))
|
|
||||||
}
|
|
||||||
if len(tool.Function.Parameters) > 0 {
|
|
||||||
paramsJSON, _ := json.Marshal(tool.Function.Parameters)
|
|
||||||
sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON)))
|
|
||||||
}
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// codexEvent represents a single JSONL event from `codex exec --json`.
|
|
||||||
type codexEvent struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
ThreadID string `json:"thread_id,omitempty"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
Item *codexEventItem `json:"item,omitempty"`
|
|
||||||
Usage *codexUsage `json:"usage,omitempty"`
|
|
||||||
Error *codexEventErr `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type codexEventItem struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text string `json:"text,omitempty"`
|
|
||||||
Command string `json:"command,omitempty"`
|
|
||||||
Status string `json:"status,omitempty"`
|
|
||||||
ExitCode *int `json:"exit_code,omitempty"`
|
|
||||||
Output string `json:"output,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type codexUsage struct {
|
|
||||||
InputTokens int `json:"input_tokens"`
|
|
||||||
CachedInputTokens int `json:"cached_input_tokens"`
|
|
||||||
OutputTokens int `json:"output_tokens"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type codexEventErr struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseJSONLEvents processes the JSONL output from codex exec --json.
|
|
||||||
func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error) {
|
|
||||||
var contentParts []string
|
|
||||||
var usage *UsageInfo
|
|
||||||
var lastError string
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var event codexEvent
|
|
||||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
||||||
continue // skip malformed lines
|
|
||||||
}
|
|
||||||
|
|
||||||
switch event.Type {
|
|
||||||
case "item.completed":
|
|
||||||
if event.Item != nil && event.Item.Type == "agent_message" && event.Item.Text != "" {
|
|
||||||
contentParts = append(contentParts, event.Item.Text)
|
|
||||||
}
|
|
||||||
case "turn.completed":
|
|
||||||
if event.Usage != nil {
|
|
||||||
promptTokens := event.Usage.InputTokens + event.Usage.CachedInputTokens
|
|
||||||
usage = &UsageInfo{
|
|
||||||
PromptTokens: promptTokens,
|
|
||||||
CompletionTokens: event.Usage.OutputTokens,
|
|
||||||
TotalTokens: promptTokens + event.Usage.OutputTokens,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "error":
|
|
||||||
lastError = event.Message
|
|
||||||
case "turn.failed":
|
|
||||||
if event.Error != nil {
|
|
||||||
lastError = event.Error.Message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastError != "" && len(contentParts) == 0 {
|
|
||||||
return nil, fmt.Errorf("codex cli: %s", lastError)
|
|
||||||
}
|
|
||||||
|
|
||||||
content := strings.Join(contentParts, "\n")
|
|
||||||
|
|
||||||
// Extract tool calls from response text (same pattern as ClaudeCliProvider)
|
|
||||||
toolCalls := extractToolCallsFromText(content)
|
|
||||||
|
|
||||||
finishReason := "stop"
|
|
||||||
if len(toolCalls) > 0 {
|
|
||||||
finishReason = "tool_calls"
|
|
||||||
content = stripToolCallsFromText(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &LLMResponse{
|
|
||||||
Content: strings.TrimSpace(content),
|
|
||||||
ToolCalls: toolCalls,
|
|
||||||
FinishReason: finishReason,
|
|
||||||
Usage: usage,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,585 +0,0 @@
|
||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- JSONL Event Parsing Tests ---
|
|
||||||
|
|
||||||
func TestParseJSONLEvents_AgentMessage(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
events := `{"type":"thread.started","thread_id":"abc-123"}
|
|
||||||
{"type":"turn.started"}
|
|
||||||
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Hello from Codex!"}}
|
|
||||||
{"type":"turn.completed","usage":{"input_tokens":100,"cached_input_tokens":50,"output_tokens":20}}`
|
|
||||||
|
|
||||||
resp, err := p.parseJSONLEvents(events)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseJSONLEvents() error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Content != "Hello from Codex!" {
|
|
||||||
t.Errorf("Content = %q, want %q", resp.Content, "Hello from Codex!")
|
|
||||||
}
|
|
||||||
if resp.FinishReason != "stop" {
|
|
||||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
|
||||||
}
|
|
||||||
if resp.Usage == nil {
|
|
||||||
t.Fatal("Usage should not be nil")
|
|
||||||
}
|
|
||||||
if resp.Usage.PromptTokens != 150 {
|
|
||||||
t.Errorf("PromptTokens = %d, want 150", resp.Usage.PromptTokens)
|
|
||||||
}
|
|
||||||
if resp.Usage.CompletionTokens != 20 {
|
|
||||||
t.Errorf("CompletionTokens = %d, want 20", resp.Usage.CompletionTokens)
|
|
||||||
}
|
|
||||||
if resp.Usage.TotalTokens != 170 {
|
|
||||||
t.Errorf("TotalTokens = %d, want 170", resp.Usage.TotalTokens)
|
|
||||||
}
|
|
||||||
if len(resp.ToolCalls) != 0 {
|
|
||||||
t.Errorf("ToolCalls should be empty, got %d", len(resp.ToolCalls))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseJSONLEvents_ToolCallExtraction(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
toolCallText := `Let me read that file.
|
|
||||||
{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test.txt\"}"}}]}`
|
|
||||||
// Build valid JSONL by marshaling the event
|
|
||||||
item := codexEvent{
|
|
||||||
Type: "item.completed",
|
|
||||||
Item: &codexEventItem{ID: "item_1", Type: "agent_message", Text: toolCallText},
|
|
||||||
}
|
|
||||||
itemJSON, _ := json.Marshal(item)
|
|
||||||
usageEvt := `{"type":"turn.completed","usage":{"input_tokens":50,"cached_input_tokens":0,"output_tokens":20}}`
|
|
||||||
events := `{"type":"turn.started"}` + "\n" + string(itemJSON) + "\n" + usageEvt
|
|
||||||
|
|
||||||
resp, err := p.parseJSONLEvents(events)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseJSONLEvents() error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.FinishReason != "tool_calls" {
|
|
||||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
|
||||||
}
|
|
||||||
if len(resp.ToolCalls) != 1 {
|
|
||||||
t.Fatalf("ToolCalls count = %d, want 1", len(resp.ToolCalls))
|
|
||||||
}
|
|
||||||
if resp.ToolCalls[0].Name != "read_file" {
|
|
||||||
t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "read_file")
|
|
||||||
}
|
|
||||||
if resp.ToolCalls[0].ID != "call_1" {
|
|
||||||
t.Errorf("ToolCalls[0].ID = %q, want %q", resp.ToolCalls[0].ID, "call_1")
|
|
||||||
}
|
|
||||||
if resp.ToolCalls[0].Function.Arguments != `{"path":"/tmp/test.txt"}` {
|
|
||||||
t.Errorf("ToolCalls[0].Function.Arguments = %q", resp.ToolCalls[0].Function.Arguments)
|
|
||||||
}
|
|
||||||
// Content should have the tool call JSON stripped
|
|
||||||
if strings.Contains(resp.Content, "tool_calls") {
|
|
||||||
t.Errorf("Content should not contain tool_calls JSON, got: %q", resp.Content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseJSONLEvents_MultipleToolCalls(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
toolCallText := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"a.txt\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"b.txt\",\"content\":\"hello\"}"}}]}`
|
|
||||||
item := codexEvent{
|
|
||||||
Type: "item.completed",
|
|
||||||
Item: &codexEventItem{ID: "item_1", Type: "agent_message", Text: toolCallText},
|
|
||||||
}
|
|
||||||
itemJSON, _ := json.Marshal(item)
|
|
||||||
events := `{"type":"turn.started"}` + "\n" + string(itemJSON) + "\n" + `{"type":"turn.completed"}`
|
|
||||||
|
|
||||||
resp, err := p.parseJSONLEvents(events)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseJSONLEvents() error: %v", err)
|
|
||||||
}
|
|
||||||
if len(resp.ToolCalls) != 2 {
|
|
||||||
t.Fatalf("ToolCalls count = %d, want 2", len(resp.ToolCalls))
|
|
||||||
}
|
|
||||||
if resp.ToolCalls[0].Name != "read_file" {
|
|
||||||
t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "read_file")
|
|
||||||
}
|
|
||||||
if resp.ToolCalls[1].Name != "write_file" {
|
|
||||||
t.Errorf("ToolCalls[1].Name = %q, want %q", resp.ToolCalls[1].Name, "write_file")
|
|
||||||
}
|
|
||||||
if resp.FinishReason != "tool_calls" {
|
|
||||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseJSONLEvents_MultipleMessages(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
events := `{"type":"turn.started"}
|
|
||||||
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"First part."}}
|
|
||||||
{"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"ls","status":"completed"}}
|
|
||||||
{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Second part."}}
|
|
||||||
{"type":"turn.completed"}`
|
|
||||||
|
|
||||||
resp, err := p.parseJSONLEvents(events)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseJSONLEvents() error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Content != "First part.\nSecond part." {
|
|
||||||
t.Errorf("Content = %q, want %q", resp.Content, "First part.\nSecond part.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseJSONLEvents_ErrorEvent(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
events := `{"type":"thread.started","thread_id":"abc"}
|
|
||||||
{"type":"turn.started"}
|
|
||||||
{"type":"error","message":"token expired"}
|
|
||||||
{"type":"turn.failed","error":{"message":"token expired"}}`
|
|
||||||
|
|
||||||
_, err := p.parseJSONLEvents(events)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "token expired") {
|
|
||||||
t.Errorf("error = %q, want to contain 'token expired'", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseJSONLEvents_TurnFailed(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
events := `{"type":"turn.started"}
|
|
||||||
{"type":"turn.failed","error":{"message":"rate limit exceeded"}}`
|
|
||||||
|
|
||||||
_, err := p.parseJSONLEvents(events)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "rate limit exceeded") {
|
|
||||||
t.Errorf("error = %q, want to contain 'rate limit exceeded'", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseJSONLEvents_ErrorWithContent(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
// If there's an error but also content, return the content (partial success)
|
|
||||||
events := `{"type":"turn.started"}
|
|
||||||
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Partial result."}}
|
|
||||||
{"type":"error","message":"connection reset"}
|
|
||||||
{"type":"turn.failed","error":{"message":"connection reset"}}`
|
|
||||||
|
|
||||||
resp, err := p.parseJSONLEvents(events)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("should not error when content exists: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Content != "Partial result." {
|
|
||||||
t.Errorf("Content = %q, want %q", resp.Content, "Partial result.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseJSONLEvents_EmptyOutput(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
resp, err := p.parseJSONLEvents("")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("empty output should not error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Content != "" {
|
|
||||||
t.Errorf("Content = %q, want empty", resp.Content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseJSONLEvents_MalformedLines(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
events := `not json at all
|
|
||||||
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Good line."}}
|
|
||||||
another bad line
|
|
||||||
{"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":5}}`
|
|
||||||
|
|
||||||
resp, err := p.parseJSONLEvents(events)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("should skip malformed lines: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Content != "Good line." {
|
|
||||||
t.Errorf("Content = %q, want %q", resp.Content, "Good line.")
|
|
||||||
}
|
|
||||||
if resp.Usage == nil || resp.Usage.TotalTokens != 15 {
|
|
||||||
t.Errorf("Usage.TotalTokens = %v, want 15", resp.Usage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseJSONLEvents_CommandExecution(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
events := `{"type":"turn.started"}
|
|
||||||
{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","status":"in_progress"}}
|
|
||||||
{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","status":"completed","exit_code":0,"output":"file1.go\nfile2.go"}}
|
|
||||||
{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"Found 2 files."}}
|
|
||||||
{"type":"turn.completed"}`
|
|
||||||
|
|
||||||
resp, err := p.parseJSONLEvents(events)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseJSONLEvents() error: %v", err)
|
|
||||||
}
|
|
||||||
// command_execution items should be skipped; only agent_message text is returned
|
|
||||||
if resp.Content != "Found 2 files." {
|
|
||||||
t.Errorf("Content = %q, want %q", resp.Content, "Found 2 files.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseJSONLEvents_NoUsage(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
events := `{"type":"turn.started"}
|
|
||||||
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"No usage info."}}
|
|
||||||
{"type":"turn.completed"}`
|
|
||||||
|
|
||||||
resp, err := p.parseJSONLEvents(events)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseJSONLEvents() error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Usage != nil {
|
|
||||||
t.Errorf("Usage should be nil when turn.completed has no usage, got %+v", resp.Usage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Prompt Building Tests ---
|
|
||||||
|
|
||||||
func TestBuildPrompt_SystemAsInstructions(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "system", Content: "You are helpful."},
|
|
||||||
{Role: "user", Content: "Hi there"},
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt := p.buildPrompt(messages, nil)
|
|
||||||
|
|
||||||
if !strings.Contains(prompt, "## System Instructions") {
|
|
||||||
t.Error("prompt should contain '## System Instructions'")
|
|
||||||
}
|
|
||||||
if !strings.Contains(prompt, "You are helpful.") {
|
|
||||||
t.Error("prompt should contain system content")
|
|
||||||
}
|
|
||||||
if !strings.Contains(prompt, "## Task") {
|
|
||||||
t.Error("prompt should contain '## Task'")
|
|
||||||
}
|
|
||||||
if !strings.Contains(prompt, "Hi there") {
|
|
||||||
t.Error("prompt should contain user message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildPrompt_NoSystem(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "user", Content: "Just a question"},
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt := p.buildPrompt(messages, nil)
|
|
||||||
|
|
||||||
if strings.Contains(prompt, "## System Instructions") {
|
|
||||||
t.Error("prompt should not contain system instructions header")
|
|
||||||
}
|
|
||||||
if prompt != "Just a question" {
|
|
||||||
t.Errorf("prompt = %q, want %q", prompt, "Just a question")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildPrompt_WithTools(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "user", Content: "Get weather"},
|
|
||||||
}
|
|
||||||
tools := []ToolDefinition{
|
|
||||||
{
|
|
||||||
Type: "function",
|
|
||||||
Function: ToolFunctionDefinition{
|
|
||||||
Name: "get_weather",
|
|
||||||
Description: "Get current weather",
|
|
||||||
Parameters: map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"city": map[string]interface{}{"type": "string"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt := p.buildPrompt(messages, tools)
|
|
||||||
|
|
||||||
if !strings.Contains(prompt, "## Available Tools") {
|
|
||||||
t.Error("prompt should contain tools section")
|
|
||||||
}
|
|
||||||
if !strings.Contains(prompt, "get_weather") {
|
|
||||||
t.Error("prompt should contain tool name")
|
|
||||||
}
|
|
||||||
if !strings.Contains(prompt, "Get current weather") {
|
|
||||||
t.Error("prompt should contain tool description")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildPrompt_MultipleMessages(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "user", Content: "Hello"},
|
|
||||||
{Role: "assistant", Content: "Hi! How can I help?"},
|
|
||||||
{Role: "user", Content: "Tell me about Go"},
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt := p.buildPrompt(messages, nil)
|
|
||||||
|
|
||||||
if !strings.Contains(prompt, "Hello") {
|
|
||||||
t.Error("prompt should contain first user message")
|
|
||||||
}
|
|
||||||
if !strings.Contains(prompt, "Assistant: Hi! How can I help?") {
|
|
||||||
t.Error("prompt should contain assistant message with prefix")
|
|
||||||
}
|
|
||||||
if !strings.Contains(prompt, "Tell me about Go") {
|
|
||||||
t.Error("prompt should contain second user message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildPrompt_ToolResults(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "user", Content: "Weather?"},
|
|
||||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt := p.buildPrompt(messages, nil)
|
|
||||||
|
|
||||||
if !strings.Contains(prompt, "[Tool Result for call_1]") {
|
|
||||||
t.Error("prompt should contain tool result")
|
|
||||||
}
|
|
||||||
if !strings.Contains(prompt, `{"temp": 72}`) {
|
|
||||||
t.Error("prompt should contain tool result content")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildPrompt_SystemAndTools(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{}
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "system", Content: "Be concise."},
|
|
||||||
{Role: "user", Content: "Do something"},
|
|
||||||
}
|
|
||||||
tools := []ToolDefinition{
|
|
||||||
{
|
|
||||||
Type: "function",
|
|
||||||
Function: ToolFunctionDefinition{
|
|
||||||
Name: "my_tool",
|
|
||||||
Description: "A tool",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt := p.buildPrompt(messages, tools)
|
|
||||||
|
|
||||||
// System instructions should come first
|
|
||||||
sysIdx := strings.Index(prompt, "## System Instructions")
|
|
||||||
toolIdx := strings.Index(prompt, "## Available Tools")
|
|
||||||
taskIdx := strings.Index(prompt, "## Task")
|
|
||||||
|
|
||||||
if sysIdx == -1 || toolIdx == -1 || taskIdx == -1 {
|
|
||||||
t.Fatal("prompt should contain all sections")
|
|
||||||
}
|
|
||||||
if sysIdx >= taskIdx {
|
|
||||||
t.Error("system instructions should come before task")
|
|
||||||
}
|
|
||||||
if taskIdx >= toolIdx {
|
|
||||||
t.Error("task section should come before tools in the output")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- CLI Argument Tests ---
|
|
||||||
|
|
||||||
func TestCodexCliProvider_GetDefaultModel(t *testing.T) {
|
|
||||||
p := NewCodexCliProvider("")
|
|
||||||
if got := p.GetDefaultModel(); got != "codex-cli" {
|
|
||||||
t.Errorf("GetDefaultModel() = %q, want %q", got, "codex-cli")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Mock CLI Integration Test ---
|
|
||||||
|
|
||||||
func createMockCodexCLI(t *testing.T, events []string) string {
|
|
||||||
t.Helper()
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
scriptPath := filepath.Join(tmpDir, "codex")
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString("#!/bin/bash\n")
|
|
||||||
for _, event := range events {
|
|
||||||
sb.WriteString(fmt.Sprintf("echo '%s'\n", event))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(scriptPath, []byte(sb.String()), 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return scriptPath
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCodexCliProvider_MockCLI_Success(t *testing.T) {
|
|
||||||
scriptPath := createMockCodexCLI(t, []string{
|
|
||||||
`{"type":"thread.started","thread_id":"test-123"}`,
|
|
||||||
`{"type":"turn.started"}`,
|
|
||||||
`{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Mock response from Codex CLI"}}`,
|
|
||||||
`{"type":"turn.completed","usage":{"input_tokens":50,"cached_input_tokens":10,"output_tokens":15}}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
p := &CodexCliProvider{
|
|
||||||
command: scriptPath,
|
|
||||||
workspace: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
|
||||||
resp, err := p.Chat(context.Background(), messages, nil, "", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chat() error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Content != "Mock response from Codex CLI" {
|
|
||||||
t.Errorf("Content = %q, want %q", resp.Content, "Mock response from Codex CLI")
|
|
||||||
}
|
|
||||||
if resp.Usage == nil {
|
|
||||||
t.Fatal("Usage should not be nil")
|
|
||||||
}
|
|
||||||
if resp.Usage.PromptTokens != 60 {
|
|
||||||
t.Errorf("PromptTokens = %d, want 60", resp.Usage.PromptTokens)
|
|
||||||
}
|
|
||||||
if resp.Usage.CompletionTokens != 15 {
|
|
||||||
t.Errorf("CompletionTokens = %d, want 15", resp.Usage.CompletionTokens)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCodexCliProvider_MockCLI_Error(t *testing.T) {
|
|
||||||
scriptPath := createMockCodexCLI(t, []string{
|
|
||||||
`{"type":"thread.started","thread_id":"test-err"}`,
|
|
||||||
`{"type":"turn.started"}`,
|
|
||||||
`{"type":"error","message":"auth token expired"}`,
|
|
||||||
`{"type":"turn.failed","error":{"message":"auth token expired"}}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
p := &CodexCliProvider{
|
|
||||||
command: scriptPath,
|
|
||||||
workspace: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
|
||||||
_, err := p.Chat(context.Background(), messages, nil, "", nil)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "auth token expired") {
|
|
||||||
t.Errorf("error = %q, want to contain 'auth token expired'", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) {
|
|
||||||
// Mock script that captures args to verify model flag is passed
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
scriptPath := filepath.Join(tmpDir, "codex")
|
|
||||||
script := `#!/bin/bash
|
|
||||||
# Write args to a file for verification
|
|
||||||
echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `"
|
|
||||||
echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}'
|
|
||||||
echo '{"type":"turn.completed"}'`
|
|
||||||
|
|
||||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
p := &CodexCliProvider{
|
|
||||||
command: scriptPath,
|
|
||||||
workspace: "/tmp/test-workspace",
|
|
||||||
}
|
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "test"}}
|
|
||||||
_, err := p.Chat(context.Background(), messages, nil, "gpt-5.2-codex", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chat() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the args
|
|
||||||
argsData, err := os.ReadFile(filepath.Join(tmpDir, "args.txt"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading args: %v", err)
|
|
||||||
}
|
|
||||||
args := string(argsData)
|
|
||||||
|
|
||||||
if !strings.Contains(args, "-m gpt-5.2-codex") {
|
|
||||||
t.Errorf("args should contain model flag, got: %s", args)
|
|
||||||
}
|
|
||||||
if !strings.Contains(args, "-C /tmp/test-workspace") {
|
|
||||||
t.Errorf("args should contain workspace flag, got: %s", args)
|
|
||||||
}
|
|
||||||
if !strings.Contains(args, "--json") {
|
|
||||||
t.Errorf("args should contain --json, got: %s", args)
|
|
||||||
}
|
|
||||||
if !strings.Contains(args, "--dangerously-bypass-approvals-and-sandbox") {
|
|
||||||
t.Errorf("args should contain bypass flag, got: %s", args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) {
|
|
||||||
// Script that sleeps forever
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
scriptPath := filepath.Join(tmpDir, "codex")
|
|
||||||
script := "#!/bin/bash\nsleep 60"
|
|
||||||
|
|
||||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
p := &CodexCliProvider{
|
|
||||||
command: scriptPath,
|
|
||||||
workspace: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel() // cancel immediately
|
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "test"}}
|
|
||||||
_, err := p.Chat(ctx, messages, nil, "", nil)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error on canceled context")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCodexCliProvider_EmptyCommand(t *testing.T) {
|
|
||||||
p := &CodexCliProvider{command: ""}
|
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "test"}}
|
|
||||||
_, err := p.Chat(context.Background(), messages, nil, "", nil)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for empty command")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Integration Test (requires real codex CLI with valid auth) ---
|
|
||||||
|
|
||||||
func TestCodexCliProvider_Integration(t *testing.T) {
|
|
||||||
if os.Getenv("CLAWDROID_INTEGRATION_TESTS") == "" {
|
|
||||||
t.Skip("skipping integration test (set CLAWDROID_INTEGRATION_TESTS=1 to enable)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify codex is available
|
|
||||||
codexPath, err := exec.LookPath("codex")
|
|
||||||
if err != nil {
|
|
||||||
t.Skip("codex CLI not found in PATH")
|
|
||||||
}
|
|
||||||
|
|
||||||
p := &CodexCliProvider{
|
|
||||||
command: codexPath,
|
|
||||||
workspace: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "user", Content: "Respond with just the word 'hello' and nothing else."},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := p.Chat(context.Background(), messages, nil, "", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chat() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lower := strings.ToLower(strings.TrimSpace(resp.Content))
|
|
||||||
if !strings.Contains(lower, "hello") {
|
|
||||||
t.Errorf("Content = %q, expected to contain 'hello'", resp.Content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,367 +0,0 @@
|
||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/openai/openai-go/v3"
|
|
||||||
"github.com/openai/openai-go/v3/option"
|
|
||||||
"github.com/openai/openai-go/v3/responses"
|
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/auth"
|
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
const codexDefaultModel = "gpt-5.2"
|
|
||||||
const codexDefaultInstructions = "You are Codex, a coding assistant."
|
|
||||||
|
|
||||||
type CodexProvider struct {
|
|
||||||
client *openai.Client
|
|
||||||
accountID string
|
|
||||||
tokenSource func() (string, string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultCodexInstructions = "You are Codex, a coding assistant."
|
|
||||||
|
|
||||||
func NewCodexProvider(token, accountID string) *CodexProvider {
|
|
||||||
opts := []option.RequestOption{
|
|
||||||
option.WithBaseURL("https://chatgpt.com/backend-api/codex"),
|
|
||||||
option.WithAPIKey(token),
|
|
||||||
option.WithHeader("originator", "codex_cli_rs"),
|
|
||||||
option.WithHeader("OpenAI-Beta", "responses=experimental"),
|
|
||||||
}
|
|
||||||
if accountID != "" {
|
|
||||||
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
|
|
||||||
}
|
|
||||||
client := openai.NewClient(opts...)
|
|
||||||
return &CodexProvider{
|
|
||||||
client: &client,
|
|
||||||
accountID: accountID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCodexProviderWithTokenSource(token, accountID string, tokenSource func() (string, string, error)) *CodexProvider {
|
|
||||||
p := NewCodexProvider(token, accountID)
|
|
||||||
p.tokenSource = tokenSource
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
|
||||||
var opts []option.RequestOption
|
|
||||||
accountID := p.accountID
|
|
||||||
resolvedModel, fallbackReason := resolveCodexModel(model)
|
|
||||||
if fallbackReason != "" {
|
|
||||||
logger.WarnCF("provider.codex", "Requested model is not compatible with Codex backend, using fallback", map[string]interface{}{
|
|
||||||
"requested_model": model,
|
|
||||||
"resolved_model": resolvedModel,
|
|
||||||
"reason": fallbackReason,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if p.tokenSource != nil {
|
|
||||||
tok, accID, err := p.tokenSource()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("refreshing token: %w", err)
|
|
||||||
}
|
|
||||||
opts = append(opts, option.WithAPIKey(tok))
|
|
||||||
if accID != "" {
|
|
||||||
accountID = accID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if accountID != "" {
|
|
||||||
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
|
|
||||||
} else {
|
|
||||||
logger.WarnCF("provider.codex", "No account id found for Codex request; backend may reject with 400", map[string]interface{}{
|
|
||||||
"requested_model": model,
|
|
||||||
"resolved_model": resolvedModel,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
params := buildCodexParams(messages, tools, resolvedModel, options)
|
|
||||||
|
|
||||||
stream := p.client.Responses.NewStreaming(ctx, params, opts...)
|
|
||||||
defer stream.Close()
|
|
||||||
|
|
||||||
var resp *responses.Response
|
|
||||||
for stream.Next() {
|
|
||||||
evt := stream.Current()
|
|
||||||
if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" {
|
|
||||||
evtResp := evt.Response
|
|
||||||
if evtResp.ID != "" {
|
|
||||||
copy := evtResp
|
|
||||||
resp = ©
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err := stream.Err()
|
|
||||||
if err != nil {
|
|
||||||
fields := map[string]interface{}{
|
|
||||||
"requested_model": model,
|
|
||||||
"resolved_model": resolvedModel,
|
|
||||||
"messages_count": len(messages),
|
|
||||||
"tools_count": len(tools),
|
|
||||||
"account_id_present": accountID != "",
|
|
||||||
"error": err.Error(),
|
|
||||||
}
|
|
||||||
var apiErr *openai.Error
|
|
||||||
if errors.As(err, &apiErr) {
|
|
||||||
fields["status_code"] = apiErr.StatusCode
|
|
||||||
fields["api_type"] = apiErr.Type
|
|
||||||
fields["api_code"] = apiErr.Code
|
|
||||||
fields["api_param"] = apiErr.Param
|
|
||||||
fields["api_message"] = apiErr.Message
|
|
||||||
if apiErr.StatusCode == 400 {
|
|
||||||
fields["hint"] = "verify account id header and model compatibility for codex backend"
|
|
||||||
}
|
|
||||||
if apiErr.Response != nil {
|
|
||||||
fields["request_id"] = apiErr.Response.Header.Get("x-request-id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.ErrorCF("provider.codex", "Codex API call failed", fields)
|
|
||||||
return nil, fmt.Errorf("codex API call: %w", err)
|
|
||||||
}
|
|
||||||
if resp == nil {
|
|
||||||
fields := map[string]interface{}{
|
|
||||||
"requested_model": model,
|
|
||||||
"resolved_model": resolvedModel,
|
|
||||||
"messages_count": len(messages),
|
|
||||||
"tools_count": len(tools),
|
|
||||||
"account_id_present": accountID != "",
|
|
||||||
}
|
|
||||||
logger.ErrorCF("provider.codex", "Codex stream ended without completed response event", fields)
|
|
||||||
return nil, fmt.Errorf("codex API call: stream ended without completed response")
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseCodexResponse(resp), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *CodexProvider) GetDefaultModel() string {
|
|
||||||
return codexDefaultModel
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveCodexModel(model string) (string, string) {
|
|
||||||
m := strings.ToLower(strings.TrimSpace(model))
|
|
||||||
if m == "" {
|
|
||||||
return codexDefaultModel, "empty model"
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(m, "openai/") {
|
|
||||||
m = strings.TrimPrefix(m, "openai/")
|
|
||||||
} else if strings.Contains(m, "/") {
|
|
||||||
return codexDefaultModel, "non-openai model namespace"
|
|
||||||
}
|
|
||||||
|
|
||||||
unsupportedPrefixes := []string{
|
|
||||||
"glm",
|
|
||||||
"claude",
|
|
||||||
"anthropic",
|
|
||||||
"gemini",
|
|
||||||
"google",
|
|
||||||
"moonshot",
|
|
||||||
"kimi",
|
|
||||||
"qwen",
|
|
||||||
"deepseek",
|
|
||||||
"llama",
|
|
||||||
"meta-llama",
|
|
||||||
"mistral",
|
|
||||||
"grok",
|
|
||||||
"xai",
|
|
||||||
"zhipu",
|
|
||||||
}
|
|
||||||
for _, prefix := range unsupportedPrefixes {
|
|
||||||
if strings.HasPrefix(m, prefix) {
|
|
||||||
return codexDefaultModel, "unsupported model prefix"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(m, "gpt-") || strings.HasPrefix(m, "o3") || strings.HasPrefix(m, "o4") {
|
|
||||||
return m, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return codexDefaultModel, "unsupported model family"
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) responses.ResponseNewParams {
|
|
||||||
var inputItems responses.ResponseInputParam
|
|
||||||
var instructions string
|
|
||||||
|
|
||||||
for _, msg := range messages {
|
|
||||||
switch msg.Role {
|
|
||||||
case "system":
|
|
||||||
instructions = msg.Content
|
|
||||||
case "user":
|
|
||||||
if msg.ToolCallID != "" {
|
|
||||||
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
|
||||||
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
|
|
||||||
CallID: msg.ToolCallID,
|
|
||||||
Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
|
||||||
OfMessage: &responses.EasyInputMessageParam{
|
|
||||||
Role: responses.EasyInputMessageRoleUser,
|
|
||||||
Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
case "assistant":
|
|
||||||
if len(msg.ToolCalls) > 0 {
|
|
||||||
if msg.Content != "" {
|
|
||||||
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
|
||||||
OfMessage: &responses.EasyInputMessageParam{
|
|
||||||
Role: responses.EasyInputMessageRoleAssistant,
|
|
||||||
Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for _, tc := range msg.ToolCalls {
|
|
||||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
|
||||||
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
|
||||||
OfFunctionCall: &responses.ResponseFunctionToolCallParam{
|
|
||||||
CallID: tc.ID,
|
|
||||||
Name: tc.Name,
|
|
||||||
Arguments: string(argsJSON),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
|
||||||
OfMessage: &responses.EasyInputMessageParam{
|
|
||||||
Role: responses.EasyInputMessageRoleAssistant,
|
|
||||||
Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
case "tool":
|
|
||||||
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
|
||||||
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
|
|
||||||
CallID: msg.ToolCallID,
|
|
||||||
Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
params := responses.ResponseNewParams{
|
|
||||||
Model: model,
|
|
||||||
Input: responses.ResponseNewParamsInputUnion{
|
|
||||||
OfInputItemList: inputItems,
|
|
||||||
},
|
|
||||||
Instructions: openai.Opt(instructions),
|
|
||||||
Store: openai.Opt(false),
|
|
||||||
}
|
|
||||||
|
|
||||||
if instructions != "" {
|
|
||||||
params.Instructions = openai.Opt(instructions)
|
|
||||||
} else {
|
|
||||||
// ChatGPT Codex backend requires instructions to be present.
|
|
||||||
params.Instructions = openai.Opt(defaultCodexInstructions)
|
|
||||||
}
|
|
||||||
|
|
||||||
if maxTokens, ok := options["max_tokens"].(int); ok {
|
|
||||||
params.MaxOutputTokens = openai.Opt(int64(maxTokens))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tools) > 0 {
|
|
||||||
params.Tools = translateToolsForCodex(tools)
|
|
||||||
}
|
|
||||||
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam {
|
|
||||||
result := make([]responses.ToolUnionParam, 0, len(tools))
|
|
||||||
for _, t := range tools {
|
|
||||||
ft := responses.FunctionToolParam{
|
|
||||||
Name: t.Function.Name,
|
|
||||||
Parameters: t.Function.Parameters,
|
|
||||||
Strict: openai.Opt(false),
|
|
||||||
}
|
|
||||||
if t.Function.Description != "" {
|
|
||||||
ft.Description = openai.Opt(t.Function.Description)
|
|
||||||
}
|
|
||||||
result = append(result, responses.ToolUnionParam{OfFunction: &ft})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCodexResponse(resp *responses.Response) *LLMResponse {
|
|
||||||
var content strings.Builder
|
|
||||||
var toolCalls []ToolCall
|
|
||||||
|
|
||||||
for _, item := range resp.Output {
|
|
||||||
switch item.Type {
|
|
||||||
case "message":
|
|
||||||
for _, c := range item.Content {
|
|
||||||
if c.Type == "output_text" {
|
|
||||||
content.WriteString(c.Text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "function_call":
|
|
||||||
var args map[string]interface{}
|
|
||||||
if err := json.Unmarshal([]byte(item.Arguments), &args); err != nil {
|
|
||||||
args = map[string]interface{}{"raw": item.Arguments}
|
|
||||||
}
|
|
||||||
toolCalls = append(toolCalls, ToolCall{
|
|
||||||
ID: item.CallID,
|
|
||||||
Name: item.Name,
|
|
||||||
Arguments: args,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
finishReason := "stop"
|
|
||||||
if len(toolCalls) > 0 {
|
|
||||||
finishReason = "tool_calls"
|
|
||||||
}
|
|
||||||
if resp.Status == "incomplete" {
|
|
||||||
finishReason = "length"
|
|
||||||
}
|
|
||||||
|
|
||||||
var usage *UsageInfo
|
|
||||||
if resp.Usage.TotalTokens > 0 {
|
|
||||||
usage = &UsageInfo{
|
|
||||||
PromptTokens: int(resp.Usage.InputTokens),
|
|
||||||
CompletionTokens: int(resp.Usage.OutputTokens),
|
|
||||||
TotalTokens: int(resp.Usage.TotalTokens),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &LLMResponse{
|
|
||||||
Content: content.String(),
|
|
||||||
ToolCalls: toolCalls,
|
|
||||||
FinishReason: finishReason,
|
|
||||||
Usage: usage,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createCodexTokenSource() func() (string, string, error) {
|
|
||||||
return func() (string, string, error) {
|
|
||||||
cred, err := auth.GetCredential("openai")
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("loading auth credentials: %w", err)
|
|
||||||
}
|
|
||||||
if cred == nil {
|
|
||||||
return "", "", fmt.Errorf("no credentials for openai. Run: clawdroid auth login --provider openai")
|
|
||||||
}
|
|
||||||
|
|
||||||
if cred.AuthMethod == "oauth" && cred.NeedsRefresh() && cred.RefreshToken != "" {
|
|
||||||
oauthCfg := auth.OpenAIOAuthConfig()
|
|
||||||
refreshed, err := auth.RefreshAccessToken(cred, oauthCfg)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("refreshing token: %w", err)
|
|
||||||
}
|
|
||||||
if refreshed.AccountID == "" {
|
|
||||||
refreshed.AccountID = cred.AccountID
|
|
||||||
}
|
|
||||||
if err := auth.SetCredential("openai", refreshed); err != nil {
|
|
||||||
return "", "", fmt.Errorf("saving refreshed token: %w", err)
|
|
||||||
}
|
|
||||||
return refreshed.AccessToken, refreshed.AccountID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return cred.AccessToken, cred.AccountID, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,469 +0,0 @@
|
||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/openai/openai-go/v3"
|
|
||||||
openaiopt "github.com/openai/openai-go/v3/option"
|
|
||||||
"github.com/openai/openai-go/v3/responses"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBuildCodexParams_BasicMessage(t *testing.T) {
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "user", Content: "Hello"},
|
|
||||||
}
|
|
||||||
params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{
|
|
||||||
"max_tokens": 2048,
|
|
||||||
"temperature": 0.7,
|
|
||||||
})
|
|
||||||
if params.Model != "gpt-4o" {
|
|
||||||
t.Errorf("Model = %q, want %q", params.Model, "gpt-4o")
|
|
||||||
}
|
|
||||||
if !params.Instructions.Valid() {
|
|
||||||
t.Fatal("Instructions should be set")
|
|
||||||
}
|
|
||||||
if params.Instructions.Or("") != defaultCodexInstructions {
|
|
||||||
t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), defaultCodexInstructions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildCodexParams_SystemAsInstructions(t *testing.T) {
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "system", Content: "You are helpful"},
|
|
||||||
{Role: "user", Content: "Hi"},
|
|
||||||
}
|
|
||||||
params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{})
|
|
||||||
if !params.Instructions.Valid() {
|
|
||||||
t.Fatal("Instructions should be set")
|
|
||||||
}
|
|
||||||
if params.Instructions.Or("") != "You are helpful" {
|
|
||||||
t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), "You are helpful")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildCodexParams_ToolCallConversation(t *testing.T) {
|
|
||||||
messages := []Message{
|
|
||||||
{Role: "user", Content: "What's the weather?"},
|
|
||||||
{
|
|
||||||
Role: "assistant",
|
|
||||||
ToolCalls: []ToolCall{
|
|
||||||
{ID: "call_1", Name: "get_weather", Arguments: map[string]interface{}{"city": "SF"}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
|
|
||||||
}
|
|
||||||
params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{})
|
|
||||||
if params.Input.OfInputItemList == nil {
|
|
||||||
t.Fatal("Input.OfInputItemList should not be nil")
|
|
||||||
}
|
|
||||||
if len(params.Input.OfInputItemList) != 3 {
|
|
||||||
t.Errorf("len(Input items) = %d, want 3", len(params.Input.OfInputItemList))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildCodexParams_WithTools(t *testing.T) {
|
|
||||||
tools := []ToolDefinition{
|
|
||||||
{
|
|
||||||
Type: "function",
|
|
||||||
Function: ToolFunctionDefinition{
|
|
||||||
Name: "get_weather",
|
|
||||||
Description: "Get weather",
|
|
||||||
Parameters: map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"city": map[string]interface{}{"type": "string"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{})
|
|
||||||
if len(params.Tools) != 1 {
|
|
||||||
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
|
|
||||||
}
|
|
||||||
if params.Tools[0].OfFunction == nil {
|
|
||||||
t.Fatal("Tool should be a function tool")
|
|
||||||
}
|
|
||||||
if params.Tools[0].OfFunction.Name != "get_weather" {
|
|
||||||
t.Errorf("Tool name = %q, want %q", params.Tools[0].OfFunction.Name, "get_weather")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildCodexParams_StoreIsFalse(t *testing.T) {
|
|
||||||
params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{})
|
|
||||||
if !params.Store.Valid() || params.Store.Or(true) != false {
|
|
||||||
t.Error("Store should be explicitly set to false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseCodexResponse_TextOutput(t *testing.T) {
|
|
||||||
respJSON := `{
|
|
||||||
"id": "resp_test",
|
|
||||||
"object": "response",
|
|
||||||
"status": "completed",
|
|
||||||
"output": [
|
|
||||||
{
|
|
||||||
"id": "msg_1",
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"status": "completed",
|
|
||||||
"content": [
|
|
||||||
{"type": "output_text", "text": "Hello there!"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": 10,
|
|
||||||
"output_tokens": 5,
|
|
||||||
"total_tokens": 15,
|
|
||||||
"input_tokens_details": {"cached_tokens": 0},
|
|
||||||
"output_tokens_details": {"reasoning_tokens": 0}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
var resp responses.Response
|
|
||||||
if err := json.Unmarshal([]byte(respJSON), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := parseCodexResponse(&resp)
|
|
||||||
if result.Content != "Hello there!" {
|
|
||||||
t.Errorf("Content = %q, want %q", result.Content, "Hello there!")
|
|
||||||
}
|
|
||||||
if result.FinishReason != "stop" {
|
|
||||||
t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop")
|
|
||||||
}
|
|
||||||
if result.Usage.TotalTokens != 15 {
|
|
||||||
t.Errorf("TotalTokens = %d, want 15", result.Usage.TotalTokens)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseCodexResponse_FunctionCall(t *testing.T) {
|
|
||||||
respJSON := `{
|
|
||||||
"id": "resp_test",
|
|
||||||
"object": "response",
|
|
||||||
"status": "completed",
|
|
||||||
"output": [
|
|
||||||
{
|
|
||||||
"id": "fc_1",
|
|
||||||
"type": "function_call",
|
|
||||||
"call_id": "call_abc",
|
|
||||||
"name": "get_weather",
|
|
||||||
"arguments": "{\"city\":\"SF\"}",
|
|
||||||
"status": "completed"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": 10,
|
|
||||||
"output_tokens": 8,
|
|
||||||
"total_tokens": 18,
|
|
||||||
"input_tokens_details": {"cached_tokens": 0},
|
|
||||||
"output_tokens_details": {"reasoning_tokens": 0}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
var resp responses.Response
|
|
||||||
if err := json.Unmarshal([]byte(respJSON), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := parseCodexResponse(&resp)
|
|
||||||
if len(result.ToolCalls) != 1 {
|
|
||||||
t.Fatalf("len(ToolCalls) = %d, want 1", len(result.ToolCalls))
|
|
||||||
}
|
|
||||||
tc := result.ToolCalls[0]
|
|
||||||
if tc.Name != "get_weather" {
|
|
||||||
t.Errorf("ToolCall.Name = %q, want %q", tc.Name, "get_weather")
|
|
||||||
}
|
|
||||||
if tc.ID != "call_abc" {
|
|
||||||
t.Errorf("ToolCall.ID = %q, want %q", tc.ID, "call_abc")
|
|
||||||
}
|
|
||||||
if tc.Arguments["city"] != "SF" {
|
|
||||||
t.Errorf("ToolCall.Arguments[city] = %v, want SF", tc.Arguments["city"])
|
|
||||||
}
|
|
||||||
if result.FinishReason != "tool_calls" {
|
|
||||||
t.Errorf("FinishReason = %q, want %q", result.FinishReason, "tool_calls")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCodexProvider_ChatRoundTrip(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path != "/responses" {
|
|
||||||
http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Header.Get("Chatgpt-Account-Id") != "acc-123" {
|
|
||||||
http.Error(w, "missing account id", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var reqBody map[string]interface{}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
|
||||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if reqBody["stream"] != true {
|
|
||||||
http.Error(w, "stream must be true", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"id": "resp_test",
|
|
||||||
"object": "response",
|
|
||||||
"status": "completed",
|
|
||||||
"output": []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"id": "msg_1",
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"status": "completed",
|
|
||||||
"content": []map[string]interface{}{
|
|
||||||
{"type": "output_text", "text": "Hi from Codex!"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"usage": map[string]interface{}{
|
|
||||||
"input_tokens": 12,
|
|
||||||
"output_tokens": 6,
|
|
||||||
"total_tokens": 18,
|
|
||||||
"input_tokens_details": map[string]interface{}{"cached_tokens": 0},
|
|
||||||
"output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
writeCompletedSSE(w, resp)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
provider := NewCodexProvider("test-token", "acc-123")
|
|
||||||
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
|
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
|
||||||
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{"max_tokens": 1024})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chat() error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Content != "Hi from Codex!" {
|
|
||||||
t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!")
|
|
||||||
}
|
|
||||||
if resp.FinishReason != "stop" {
|
|
||||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
|
||||||
}
|
|
||||||
if resp.Usage.TotalTokens != 18 {
|
|
||||||
t.Errorf("TotalTokens = %d, want 18", resp.Usage.TotalTokens)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path != "/responses" {
|
|
||||||
http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Header.Get("Authorization") != "Bearer refreshed-token" {
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Header.Get("Chatgpt-Account-Id") != "acc-123" {
|
|
||||||
http.Error(w, "missing account id", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var reqBody map[string]interface{}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
|
||||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, ok := reqBody["instructions"]; !ok {
|
|
||||||
http.Error(w, "missing instructions", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if reqBody["instructions"] == "" {
|
|
||||||
http.Error(w, "instructions must not be empty", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, ok := reqBody["temperature"]; ok {
|
|
||||||
http.Error(w, "temperature is not supported", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if reqBody["stream"] != true {
|
|
||||||
http.Error(w, "stream must be true", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"id": "resp_test",
|
|
||||||
"object": "response",
|
|
||||||
"status": "completed",
|
|
||||||
"output": []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"id": "msg_1",
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"status": "completed",
|
|
||||||
"content": []map[string]interface{}{
|
|
||||||
{"type": "output_text", "text": "Hi from Codex!"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"usage": map[string]interface{}{
|
|
||||||
"input_tokens": 8,
|
|
||||||
"output_tokens": 4,
|
|
||||||
"total_tokens": 12,
|
|
||||||
"input_tokens_details": map[string]interface{}{"cached_tokens": 0},
|
|
||||||
"output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
writeCompletedSSE(w, resp)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
provider := NewCodexProvider("stale-token", "acc-123")
|
|
||||||
provider.client = createOpenAITestClient(server.URL, "stale-token", "")
|
|
||||||
provider.tokenSource = func() (string, string, error) {
|
|
||||||
return "refreshed-token", "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
|
||||||
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{"temperature": 0.7})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chat() error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Content != "Hi from Codex!" {
|
|
||||||
t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path != "/responses" {
|
|
||||||
http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var reqBody map[string]interface{}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
|
||||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if reqBody["model"] != codexDefaultModel {
|
|
||||||
http.Error(w, "unsupported model", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if reqBody["stream"] != true {
|
|
||||||
http.Error(w, "stream must be true", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if reqBody["instructions"] != codexDefaultInstructions {
|
|
||||||
http.Error(w, "missing default instructions", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"id": "resp_test",
|
|
||||||
"object": "response",
|
|
||||||
"status": "completed",
|
|
||||||
"output": []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"id": "msg_1",
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"status": "completed",
|
|
||||||
"content": []map[string]interface{}{
|
|
||||||
{"type": "output_text", "text": "Hi from Codex!"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"usage": map[string]interface{}{
|
|
||||||
"input_tokens": 8,
|
|
||||||
"output_tokens": 4,
|
|
||||||
"total_tokens": 12,
|
|
||||||
"input_tokens_details": map[string]interface{}{"cached_tokens": 0},
|
|
||||||
"output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
writeCompletedSSE(w, resp)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
provider := NewCodexProvider("test-token", "acc-123")
|
|
||||||
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
|
|
||||||
|
|
||||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
|
||||||
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.2", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chat() error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Content != "Hi from Codex!" {
|
|
||||||
t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCodexProvider_GetDefaultModel(t *testing.T) {
|
|
||||||
p := NewCodexProvider("test-token", "")
|
|
||||||
if got := p.GetDefaultModel(); got != codexDefaultModel {
|
|
||||||
t.Errorf("GetDefaultModel() = %q, want %q", got, codexDefaultModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveCodexModel(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
wantModel string
|
|
||||||
wantFallback bool
|
|
||||||
}{
|
|
||||||
{name: "empty", input: "", wantModel: codexDefaultModel, wantFallback: true},
|
|
||||||
{name: "unsupported namespace", input: "anthropic/claude-3.5", wantModel: codexDefaultModel, wantFallback: true},
|
|
||||||
{name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true},
|
|
||||||
{name: "openai prefix", input: "openai/gpt-5.2", wantModel: "gpt-5.2", wantFallback: false},
|
|
||||||
{name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
gotModel, reason := resolveCodexModel(tt.input)
|
|
||||||
if gotModel != tt.wantModel {
|
|
||||||
t.Fatalf("resolveCodexModel(%q) model = %q, want %q", tt.input, gotModel, tt.wantModel)
|
|
||||||
}
|
|
||||||
if tt.wantFallback && reason == "" {
|
|
||||||
t.Fatalf("resolveCodexModel(%q) expected fallback reason", tt.input)
|
|
||||||
}
|
|
||||||
if !tt.wantFallback && reason != "" {
|
|
||||||
t.Fatalf("resolveCodexModel(%q) unexpected fallback reason: %q", tt.input, reason)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createOpenAITestClient(baseURL, token, accountID string) *openai.Client {
|
|
||||||
opts := []openaiopt.RequestOption{
|
|
||||||
openaiopt.WithBaseURL(baseURL),
|
|
||||||
openaiopt.WithAPIKey(token),
|
|
||||||
}
|
|
||||||
if accountID != "" {
|
|
||||||
opts = append(opts, openaiopt.WithHeader("Chatgpt-Account-Id", accountID))
|
|
||||||
}
|
|
||||||
c := openai.NewClient(opts...)
|
|
||||||
return &c
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeCompletedSSE(w http.ResponseWriter, response map[string]interface{}) {
|
|
||||||
event := map[string]interface{}{
|
|
||||||
"type": "response.completed",
|
|
||||||
"sequence_number": 1,
|
|
||||||
"response": response,
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(event)
|
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
fmt.Fprintf(w, "event: response.completed\n")
|
|
||||||
fmt.Fprintf(w, "data: %s\n\n", string(b))
|
|
||||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
json "encoding/json"
|
|
||||||
|
|
||||||
copilot "github.com/github/copilot-sdk/go"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GitHubCopilotProvider struct {
|
|
||||||
uri string
|
|
||||||
connectMode string // `stdio` or `grpc``
|
|
||||||
|
|
||||||
session *copilot.Session
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {
|
|
||||||
|
|
||||||
var session *copilot.Session
|
|
||||||
if connectMode == "" {
|
|
||||||
connectMode = "grpc"
|
|
||||||
}
|
|
||||||
switch connectMode {
|
|
||||||
|
|
||||||
case "stdio":
|
|
||||||
//todo
|
|
||||||
case "grpc":
|
|
||||||
client := copilot.NewClient(&copilot.ClientOptions{
|
|
||||||
CLIUrl: uri,
|
|
||||||
})
|
|
||||||
if err := client.Start(context.Background()); err != nil {
|
|
||||||
return nil, fmt.Errorf("Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details")
|
|
||||||
}
|
|
||||||
defer client.Stop()
|
|
||||||
session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{
|
|
||||||
Model: model,
|
|
||||||
Hooks: &copilot.SessionHooks{},
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return &GitHubCopilotProvider{
|
|
||||||
uri: uri,
|
|
||||||
connectMode: connectMode,
|
|
||||||
session: session,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chat sends a chat request to GitHub Copilot
|
|
||||||
func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
|
||||||
type tempMessage struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
out := make([]tempMessage, 0, len(messages))
|
|
||||||
|
|
||||||
for _, msg := range messages {
|
|
||||||
out = append(out, tempMessage{
|
|
||||||
Role: msg.Role,
|
|
||||||
Content: msg.Content,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fullcontent, _ := json.Marshal(out)
|
|
||||||
|
|
||||||
content, _ := p.session.Send(ctx, copilot.MessageOptions{
|
|
||||||
Prompt: string(fullcontent),
|
|
||||||
})
|
|
||||||
|
|
||||||
return &LLMResponse{
|
|
||||||
FinishReason: "stop",
|
|
||||||
Content: content,
|
|
||||||
}, nil
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GitHubCopilotProvider) GetDefaultModel() string {
|
|
||||||
|
|
||||||
return "gpt-4.1"
|
|
||||||
}
|
|
||||||
|
|
@ -1,498 +0,0 @@
|
||||||
// ClawDroid - Ultra-lightweight personal AI agent
|
|
||||||
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
|
|
||||||
// License: MIT
|
|
||||||
//
|
|
||||||
// Copyright (c) 2026 ClawDroid contributors
|
|
||||||
|
|
||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/auth"
|
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
type HTTPProvider struct {
|
|
||||||
apiKey string
|
|
||||||
apiBase string
|
|
||||||
providerName string
|
|
||||||
httpClient *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHTTPProvider(apiKey, apiBase, proxy, providerName string) *HTTPProvider {
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: 120 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
if proxy != "" {
|
|
||||||
proxyURL, err := url.Parse(proxy)
|
|
||||||
if err == nil {
|
|
||||||
client.Transport = &http.Transport{
|
|
||||||
Proxy: http.ProxyURL(proxyURL),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &HTTPProvider{
|
|
||||||
apiKey: apiKey,
|
|
||||||
apiBase: strings.TrimRight(apiBase, "/"),
|
|
||||||
providerName: providerName,
|
|
||||||
httpClient: client,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
|
||||||
if p.apiBase == "" {
|
|
||||||
return nil, fmt.Errorf("API base not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip provider prefix from model name (e.g., moonshot/kimi-k2.5 -> kimi-k2.5, groq/openai/gpt-oss-120b -> openai/gpt-oss-120b, ollama/qwen2.5:14b -> qwen2.5:14b)
|
|
||||||
if idx := strings.Index(model, "/"); idx != -1 {
|
|
||||||
prefix := model[:idx]
|
|
||||||
if prefix == "moonshot" || prefix == "nvidia" || prefix == "groq" || prefix == "ollama" {
|
|
||||||
model = model[idx+1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
requestBody := map[string]interface{}{
|
|
||||||
"model": model,
|
|
||||||
"messages": p.buildAPIMessages(messages),
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tools) > 0 {
|
|
||||||
requestBody["tools"] = tools
|
|
||||||
requestBody["tool_choice"] = "auto"
|
|
||||||
}
|
|
||||||
|
|
||||||
if maxTokens, ok := options["max_tokens"].(int); ok {
|
|
||||||
if p.providerName == "openai" || p.providerName == "zhipu" || p.providerName == "glm" {
|
|
||||||
requestBody["max_completion_tokens"] = maxTokens
|
|
||||||
} else {
|
|
||||||
requestBody["max_tokens"] = maxTokens
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if temperature, ok := options["temperature"].(float64); ok {
|
|
||||||
lowerModel := strings.ToLower(model)
|
|
||||||
// OpenAI reasoning models (o1/o3/o4, gpt-5.x) and Kimi k2 only support temperature=1
|
|
||||||
if p.providerName == "openai" || (strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2")) {
|
|
||||||
// Don't send temperature; let the API use its default
|
|
||||||
} else {
|
|
||||||
requestBody["temperature"] = temperature
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonData, err := json.Marshal(requestBody)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
if p.apiKey != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := p.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
return p.parseResponse(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildAPIMessages converts internal Message slice to OpenAI API format.
|
|
||||||
// If a user message has Media, content becomes an array of text + image_url objects.
|
|
||||||
func (p *HTTPProvider) buildAPIMessages(messages []Message) []map[string]interface{} {
|
|
||||||
result := make([]map[string]interface{}, 0, len(messages))
|
|
||||||
|
|
||||||
for _, msg := range messages {
|
|
||||||
m := map[string]interface{}{
|
|
||||||
"role": msg.Role,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Messages with media get the array-style content (user or tool)
|
|
||||||
if len(msg.Media) > 0 && (msg.Role == "user" || msg.Role == "tool") {
|
|
||||||
parts := make([]map[string]interface{}, 0, 1+len(msg.Media))
|
|
||||||
if msg.Content != "" {
|
|
||||||
parts = append(parts, map[string]interface{}{
|
|
||||||
"type": "text",
|
|
||||||
"text": msg.Content,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for _, dataURL := range msg.Media {
|
|
||||||
parts = append(parts, map[string]interface{}{
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": map[string]string{
|
|
||||||
"url": dataURL,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
m["content"] = parts
|
|
||||||
} else {
|
|
||||||
m["content"] = msg.Content
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(msg.ToolCalls) > 0 {
|
|
||||||
m["tool_calls"] = msg.ToolCalls
|
|
||||||
}
|
|
||||||
if msg.ToolCallID != "" {
|
|
||||||
m["tool_call_id"] = msg.ToolCallID
|
|
||||||
}
|
|
||||||
|
|
||||||
result = append(result, m)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) {
|
|
||||||
var apiResponse struct {
|
|
||||||
Choices []struct {
|
|
||||||
Message struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
ToolCalls []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Function *struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Arguments string `json:"arguments"`
|
|
||||||
} `json:"function"`
|
|
||||||
} `json:"tool_calls"`
|
|
||||||
} `json:"message"`
|
|
||||||
FinishReason string `json:"finish_reason"`
|
|
||||||
} `json:"choices"`
|
|
||||||
Usage *UsageInfo `json:"usage"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(apiResponse.Choices) == 0 {
|
|
||||||
return &LLMResponse{
|
|
||||||
Content: "",
|
|
||||||
FinishReason: "stop",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
choice := apiResponse.Choices[0]
|
|
||||||
|
|
||||||
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
|
|
||||||
for _, tc := range choice.Message.ToolCalls {
|
|
||||||
arguments := make(map[string]interface{})
|
|
||||||
name := ""
|
|
||||||
|
|
||||||
// Handle OpenAI format with nested function object
|
|
||||||
if tc.Type == "function" && tc.Function != nil {
|
|
||||||
name = tc.Function.Name
|
|
||||||
if tc.Function.Arguments != "" {
|
|
||||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
|
|
||||||
arguments["raw"] = tc.Function.Arguments
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if tc.Function != nil {
|
|
||||||
// Legacy format without type field
|
|
||||||
name = tc.Function.Name
|
|
||||||
if tc.Function.Arguments != "" {
|
|
||||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
|
|
||||||
arguments["raw"] = tc.Function.Arguments
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toolCalls = append(toolCalls, ToolCall{
|
|
||||||
ID: tc.ID,
|
|
||||||
Name: name,
|
|
||||||
Arguments: arguments,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return &LLMResponse{
|
|
||||||
Content: choice.Message.Content,
|
|
||||||
ToolCalls: toolCalls,
|
|
||||||
FinishReason: choice.FinishReason,
|
|
||||||
Usage: apiResponse.Usage,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTPProvider) GetDefaultModel() string {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func createClaudeAuthProvider() (LLMProvider, error) {
|
|
||||||
cred, err := auth.GetCredential("anthropic")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("loading auth credentials: %w", err)
|
|
||||||
}
|
|
||||||
if cred == nil {
|
|
||||||
return nil, fmt.Errorf("no credentials for anthropic. Run: clawdroid auth login --provider anthropic")
|
|
||||||
}
|
|
||||||
return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func createCodexAuthProvider() (LLMProvider, error) {
|
|
||||||
cred, err := auth.GetCredential("openai")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("loading auth credentials: %w", err)
|
|
||||||
}
|
|
||||||
if cred == nil {
|
|
||||||
return nil, fmt.Errorf("no credentials for openai. Run: clawdroid auth login --provider openai")
|
|
||||||
}
|
|
||||||
return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateProvider(cfg *config.Config) (LLMProvider, error) {
|
|
||||||
model := cfg.Agents.Defaults.Model
|
|
||||||
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
|
|
||||||
|
|
||||||
var apiKey, apiBase, proxy string
|
|
||||||
|
|
||||||
lowerModel := strings.ToLower(model)
|
|
||||||
|
|
||||||
// First, try to use explicitly configured provider
|
|
||||||
if providerName != "" {
|
|
||||||
switch providerName {
|
|
||||||
case "groq":
|
|
||||||
if cfg.Providers.Groq.APIKey != "" {
|
|
||||||
apiKey = cfg.Providers.Groq.APIKey
|
|
||||||
apiBase = cfg.Providers.Groq.APIBase
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.groq.com/openai/v1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "openai", "gpt":
|
|
||||||
if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" {
|
|
||||||
if cfg.Providers.OpenAI.AuthMethod == "codex-cli" {
|
|
||||||
return NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource()), nil
|
|
||||||
}
|
|
||||||
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
|
|
||||||
return createCodexAuthProvider()
|
|
||||||
}
|
|
||||||
apiKey = cfg.Providers.OpenAI.APIKey
|
|
||||||
apiBase = cfg.Providers.OpenAI.APIBase
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.openai.com/v1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "anthropic", "claude":
|
|
||||||
if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" {
|
|
||||||
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
|
|
||||||
return createClaudeAuthProvider()
|
|
||||||
}
|
|
||||||
apiKey = cfg.Providers.Anthropic.APIKey
|
|
||||||
apiBase = cfg.Providers.Anthropic.APIBase
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.anthropic.com/v1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "openrouter":
|
|
||||||
if cfg.Providers.OpenRouter.APIKey != "" {
|
|
||||||
apiKey = cfg.Providers.OpenRouter.APIKey
|
|
||||||
if cfg.Providers.OpenRouter.APIBase != "" {
|
|
||||||
apiBase = cfg.Providers.OpenRouter.APIBase
|
|
||||||
} else {
|
|
||||||
apiBase = "https://openrouter.ai/api/v1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "zhipu", "glm":
|
|
||||||
if cfg.Providers.Zhipu.APIKey != "" {
|
|
||||||
apiKey = cfg.Providers.Zhipu.APIKey
|
|
||||||
apiBase = cfg.Providers.Zhipu.APIBase
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://open.bigmodel.cn/api/paas/v4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "gemini", "google":
|
|
||||||
if cfg.Providers.Gemini.APIKey != "" {
|
|
||||||
apiKey = cfg.Providers.Gemini.APIKey
|
|
||||||
apiBase = cfg.Providers.Gemini.APIBase
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://generativelanguage.googleapis.com/v1beta"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "vllm":
|
|
||||||
if cfg.Providers.VLLM.APIBase != "" {
|
|
||||||
apiKey = cfg.Providers.VLLM.APIKey
|
|
||||||
apiBase = cfg.Providers.VLLM.APIBase
|
|
||||||
}
|
|
||||||
case "shengsuanyun":
|
|
||||||
if cfg.Providers.ShengSuanYun.APIKey != "" {
|
|
||||||
apiKey = cfg.Providers.ShengSuanYun.APIKey
|
|
||||||
apiBase = cfg.Providers.ShengSuanYun.APIBase
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://router.shengsuanyun.com/api/v1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "claude-cli", "claudecode", "claude-code":
|
|
||||||
workspace := cfg.WorkspacePath()
|
|
||||||
if workspace == "" {
|
|
||||||
workspace = "."
|
|
||||||
}
|
|
||||||
return NewClaudeCliProvider(workspace), nil
|
|
||||||
case "codex-cli", "codex-code":
|
|
||||||
workspace := cfg.WorkspacePath()
|
|
||||||
if workspace == "" {
|
|
||||||
workspace = "."
|
|
||||||
}
|
|
||||||
return NewCodexCliProvider(workspace), nil
|
|
||||||
case "deepseek":
|
|
||||||
if cfg.Providers.DeepSeek.APIKey != "" {
|
|
||||||
apiKey = cfg.Providers.DeepSeek.APIKey
|
|
||||||
apiBase = cfg.Providers.DeepSeek.APIBase
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.deepseek.com/v1"
|
|
||||||
}
|
|
||||||
if model != "deepseek-chat" && model != "deepseek-reasoner" {
|
|
||||||
model = "deepseek-chat"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "github_copilot", "copilot":
|
|
||||||
if cfg.Providers.GitHubCopilot.APIBase != "" {
|
|
||||||
apiBase = cfg.Providers.GitHubCopilot.APIBase
|
|
||||||
} else {
|
|
||||||
apiBase = "localhost:4321"
|
|
||||||
}
|
|
||||||
return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: detect provider from model name
|
|
||||||
if apiKey == "" && apiBase == "" {
|
|
||||||
switch {
|
|
||||||
case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "":
|
|
||||||
apiKey = cfg.Providers.Moonshot.APIKey
|
|
||||||
apiBase = cfg.Providers.Moonshot.APIBase
|
|
||||||
proxy = cfg.Providers.Moonshot.Proxy
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.moonshot.cn/v1"
|
|
||||||
}
|
|
||||||
|
|
||||||
case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"):
|
|
||||||
apiKey = cfg.Providers.OpenRouter.APIKey
|
|
||||||
proxy = cfg.Providers.OpenRouter.Proxy
|
|
||||||
if cfg.Providers.OpenRouter.APIBase != "" {
|
|
||||||
apiBase = cfg.Providers.OpenRouter.APIBase
|
|
||||||
} else {
|
|
||||||
apiBase = "https://openrouter.ai/api/v1"
|
|
||||||
}
|
|
||||||
|
|
||||||
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""):
|
|
||||||
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
|
|
||||||
return createClaudeAuthProvider()
|
|
||||||
}
|
|
||||||
apiKey = cfg.Providers.Anthropic.APIKey
|
|
||||||
apiBase = cfg.Providers.Anthropic.APIBase
|
|
||||||
proxy = cfg.Providers.Anthropic.Proxy
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.anthropic.com/v1"
|
|
||||||
}
|
|
||||||
|
|
||||||
case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""):
|
|
||||||
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
|
|
||||||
return createCodexAuthProvider()
|
|
||||||
}
|
|
||||||
apiKey = cfg.Providers.OpenAI.APIKey
|
|
||||||
apiBase = cfg.Providers.OpenAI.APIBase
|
|
||||||
proxy = cfg.Providers.OpenAI.Proxy
|
|
||||||
providerName = "openai"
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.openai.com/v1"
|
|
||||||
}
|
|
||||||
|
|
||||||
case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "":
|
|
||||||
apiKey = cfg.Providers.Gemini.APIKey
|
|
||||||
apiBase = cfg.Providers.Gemini.APIBase
|
|
||||||
proxy = cfg.Providers.Gemini.Proxy
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://generativelanguage.googleapis.com/v1beta"
|
|
||||||
}
|
|
||||||
|
|
||||||
case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "":
|
|
||||||
apiKey = cfg.Providers.Zhipu.APIKey
|
|
||||||
apiBase = cfg.Providers.Zhipu.APIBase
|
|
||||||
proxy = cfg.Providers.Zhipu.Proxy
|
|
||||||
providerName = "zhipu"
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://open.bigmodel.cn/api/paas/v4"
|
|
||||||
}
|
|
||||||
|
|
||||||
case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "":
|
|
||||||
apiKey = cfg.Providers.Groq.APIKey
|
|
||||||
apiBase = cfg.Providers.Groq.APIBase
|
|
||||||
proxy = cfg.Providers.Groq.Proxy
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://api.groq.com/openai/v1"
|
|
||||||
}
|
|
||||||
|
|
||||||
case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "":
|
|
||||||
apiKey = cfg.Providers.Nvidia.APIKey
|
|
||||||
apiBase = cfg.Providers.Nvidia.APIBase
|
|
||||||
proxy = cfg.Providers.Nvidia.Proxy
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "https://integrate.api.nvidia.com/v1"
|
|
||||||
}
|
|
||||||
case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "":
|
|
||||||
fmt.Println("Ollama provider selected based on model name prefix")
|
|
||||||
apiKey = cfg.Providers.Ollama.APIKey
|
|
||||||
apiBase = cfg.Providers.Ollama.APIBase
|
|
||||||
proxy = cfg.Providers.Ollama.Proxy
|
|
||||||
if apiBase == "" {
|
|
||||||
apiBase = "http://localhost:11434/v1"
|
|
||||||
}
|
|
||||||
fmt.Println("Ollama apiBase:", apiBase)
|
|
||||||
case cfg.Providers.VLLM.APIBase != "":
|
|
||||||
apiKey = cfg.Providers.VLLM.APIKey
|
|
||||||
apiBase = cfg.Providers.VLLM.APIBase
|
|
||||||
proxy = cfg.Providers.VLLM.Proxy
|
|
||||||
|
|
||||||
default:
|
|
||||||
if cfg.Providers.OpenRouter.APIKey != "" {
|
|
||||||
apiKey = cfg.Providers.OpenRouter.APIKey
|
|
||||||
proxy = cfg.Providers.OpenRouter.Proxy
|
|
||||||
if cfg.Providers.OpenRouter.APIBase != "" {
|
|
||||||
apiBase = cfg.Providers.OpenRouter.APIBase
|
|
||||||
} else {
|
|
||||||
apiBase = "https://openrouter.ai/api/v1"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("no API key configured for model: %s", model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if apiKey == "" && !strings.HasPrefix(model, "bedrock/") {
|
|
||||||
return nil, fmt.Errorf("no API key configured for provider (model: %s)", model)
|
|
||||||
}
|
|
||||||
|
|
||||||
if apiBase == "" {
|
|
||||||
return nil, fmt.Errorf("no API base configured for provider (model: %s)", model)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NewHTTPProvider(apiKey, apiBase, proxy, providerName), nil
|
|
||||||
}
|
|
||||||
12
pkg/providers/provider.go
Normal file
12
pkg/providers/provider.go
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/KarakuriAgent/clawdroid/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateProvider is the single entry point for constructing an LLMProvider.
|
||||||
|
// When replacing the underlying LLM library, modify only this function
|
||||||
|
// and the adapter it delegates to (currently AnyLLMAdapter).
|
||||||
|
func CreateProvider(cfg *config.Config) (LLMProvider, error) {
|
||||||
|
return NewAnyLLMAdapter(cfg.LLM.Model, cfg.LLM.APIKey, cfg.LLM.BaseURL)
|
||||||
|
}
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// extractToolCallsFromText parses tool call JSON from response text.
|
|
||||||
// Both ClaudeCliProvider and CodexCliProvider use this to extract
|
|
||||||
// tool calls that the model outputs in its response text.
|
|
||||||
func extractToolCallsFromText(text string) []ToolCall {
|
|
||||||
start := strings.Index(text, `{"tool_calls"`)
|
|
||||||
if start == -1 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
end := findMatchingBrace(text, start)
|
|
||||||
if end == start {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonStr := text[start:end]
|
|
||||||
|
|
||||||
var wrapper struct {
|
|
||||||
ToolCalls []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Function struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Arguments string `json:"arguments"`
|
|
||||||
} `json:"function"`
|
|
||||||
} `json:"tool_calls"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal([]byte(jsonStr), &wrapper); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var result []ToolCall
|
|
||||||
for _, tc := range wrapper.ToolCalls {
|
|
||||||
var args map[string]interface{}
|
|
||||||
json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
|
||||||
|
|
||||||
result = append(result, ToolCall{
|
|
||||||
ID: tc.ID,
|
|
||||||
Type: tc.Type,
|
|
||||||
Name: tc.Function.Name,
|
|
||||||
Arguments: args,
|
|
||||||
Function: &FunctionCall{
|
|
||||||
Name: tc.Function.Name,
|
|
||||||
Arguments: tc.Function.Arguments,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripToolCallsFromText removes tool call JSON from response text.
|
|
||||||
func stripToolCallsFromText(text string) string {
|
|
||||||
start := strings.Index(text, `{"tool_calls"`)
|
|
||||||
if start == -1 {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
end := findMatchingBrace(text, start)
|
|
||||||
if end == start {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(text[:start] + text[end:])
|
|
||||||
}
|
|
||||||
|
|
@ -132,7 +132,6 @@ After completing the task, provide a clear summary of what was done.`
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
LLMOptions: map[string]any{
|
LLMOptions: map[string]any{
|
||||||
"max_tokens": 4096,
|
"max_tokens": 4096,
|
||||||
"temperature": 0.7,
|
|
||||||
},
|
},
|
||||||
}, messages, task.OriginChannel, task.OriginChatID)
|
}, messages, task.OriginChannel, task.OriginChatID)
|
||||||
|
|
||||||
|
|
@ -290,7 +289,6 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{})
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
LLMOptions: map[string]any{
|
LLMOptions: map[string]any{
|
||||||
"max_tokens": 4096,
|
"max_tokens": 4096,
|
||||||
"temperature": 0.7,
|
|
||||||
},
|
},
|
||||||
}, messages, t.originChannel, t.originChatID)
|
}, messages, t.originChannel, t.originChatID)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,7 @@ func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []provider
|
||||||
llmOpts := config.LLMOptions
|
llmOpts := config.LLMOptions
|
||||||
if llmOpts == nil {
|
if llmOpts == nil {
|
||||||
llmOpts = map[string]any{
|
llmOpts = map[string]any{
|
||||||
"max_tokens": 4096,
|
"max_tokens": 4096,
|
||||||
"temperature": 0.7,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue