Optimize PicoClaw for Render Free Tier with OpenRouter exclusive provider
Co-authored-by: izaac49 <256148789+izaac49@users.noreply.github.com>
This commit is contained in:
parent
f5234da1cd
commit
bb42f2a7e6
24 changed files with 82 additions and 3451 deletions
11
Dockerfile
11
Dockerfile
|
|
@ -18,5 +18,14 @@ RUN go build -tags netgo -ldflags "-s -w" -o app ./cmd/picoclaw
|
|||
# Final stage: minimal runtime image
|
||||
FROM debian:bookworm-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install ca-certificates for HTTPS requests
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/app .
|
||||
CMD ["./app", "gateway"]
|
||||
COPY --from=builder /app/config.json /app/config.json
|
||||
|
||||
# Use standard gateway port
|
||||
EXPOSE 18790
|
||||
|
||||
CMD ["./app", "gateway", "--config", "/app/config.json"]
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -122,7 +122,6 @@ make install
|
|||
|
||||
> [!TIP]
|
||||
> Set your API key in `~/.picoclaw/config.json`.
|
||||
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||
> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month)
|
||||
|
||||
**1. Initialize**
|
||||
|
|
@ -138,7 +137,7 @@ picoclaw onboard
|
|||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"model": "openrouter",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
|
|
@ -163,7 +162,6 @@ picoclaw onboard
|
|||
|
||||
**3. Get API Keys**
|
||||
|
||||
- **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||
- **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
||||
|
||||
> **Note**: See `config.example.json` for a complete configuration template.
|
||||
|
|
@ -361,12 +359,9 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
|
|||
### Providers
|
||||
|
||||
> [!NOTE]
|
||||
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
||||
|
||||
| Provider | Purpose | Get API Key |
|
||||
|----------|---------|-------------|
|
||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
|
||||
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||
|
|
@ -374,7 +369,6 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
|
|||
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
|
||||
<details>
|
||||
<summary><b>Zhipu</b></summary>
|
||||
|
||||
**1. Get API key and base URL**
|
||||
|
||||
|
|
@ -387,7 +381,7 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
|
|||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"model": "openrouter",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
|
|
@ -525,7 +519,6 @@ To enable web search:
|
|||
|
||||
### Getting content filtering errors
|
||||
|
||||
Some providers (like Zhipu) have content filtering. Try rephrasing your query or use a different model.
|
||||
|
||||
### Telegram bot says "Conflict: terminated by other getUpdates"
|
||||
|
||||
|
|
@ -538,6 +531,4 @@ This happens when another instance of the bot is running. Make sure only one `pi
|
|||
| Service | Free Tier | Use Case |
|
||||
|---------|-----------|-----------|
|
||||
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
||||
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
||||
| **Brave Search** | 2000 queries/month | Web search functionality |
|
||||
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import (
|
|||
|
||||
"github.com/chzyer/readline"
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
|
|
@ -31,7 +30,6 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
"github.com/sipeed/picoclaw/pkg/voice"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -109,8 +107,6 @@ func main() {
|
|||
statusCmd()
|
||||
case "migrate":
|
||||
migrateCmd()
|
||||
case "auth":
|
||||
authCmd()
|
||||
case "cron":
|
||||
cronCmd()
|
||||
case "skills":
|
||||
|
|
@ -129,8 +125,7 @@ func main() {
|
|||
|
||||
workspace := cfg.WorkspacePath()
|
||||
installer := skills.NewSkillInstaller(workspace)
|
||||
// 获取全局配置目录和内置 skills 目录
|
||||
globalDir := filepath.Dir(getConfigPath())
|
||||
globalDir := filepath.Dir(filepath.Dir(getConfigPath())) // Adjusted for typical /app/config.json
|
||||
globalSkillsDir := filepath.Join(globalDir, "skills")
|
||||
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
|
||||
skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
|
||||
|
|
@ -178,7 +173,6 @@ func printHelp() {
|
|||
fmt.Println("Commands:")
|
||||
fmt.Println(" onboard Initialize picoclaw configuration and workspace")
|
||||
fmt.Println(" agent Interact with the agent directly")
|
||||
fmt.Println(" auth Manage authentication (login, logout, status)")
|
||||
fmt.Println(" gateway Start picoclaw gateway")
|
||||
fmt.Println(" status Show picoclaw status")
|
||||
fmt.Println(" cron Manage scheduled tasks")
|
||||
|
|
@ -216,7 +210,7 @@ func onboard() {
|
|||
|
||||
fmt.Printf("%s picoclaw is ready!\n", logo)
|
||||
fmt.Println("\nNext steps:")
|
||||
fmt.Println(" 1. Add your API key to", configPath)
|
||||
fmt.Println(" 1. Add your OpenRouter API key to", configPath)
|
||||
fmt.Println(" Get one at: https://openrouter.ai/keys")
|
||||
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
||||
}
|
||||
|
|
@ -238,7 +232,7 @@ You are a helpful AI assistant. Be concise, accurate, and friendly.
|
|||
`,
|
||||
"SOUL.md": `# Soul
|
||||
|
||||
I am picoclaw, a lightweight AI assistant powered by AI.
|
||||
I am picoclaw, a lightweight AI assistant powered by OpenRouter.
|
||||
|
||||
## Personality
|
||||
|
||||
|
|
@ -282,23 +276,23 @@ Information about user goes here.
|
|||
PicoClaw 🦞
|
||||
|
||||
## Description
|
||||
Ultra-lightweight personal AI assistant written in Go, inspired by nanobot.
|
||||
Ultra-lightweight personal AI assistant written in Go, powered exclusively by OpenRouter.
|
||||
|
||||
## Version
|
||||
0.1.0
|
||||
|
||||
## Purpose
|
||||
- Provide intelligent AI assistance with minimal resource usage
|
||||
- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.)
|
||||
- Use OpenRouter for high-quality LLM access
|
||||
- Enable easy customization through skills system
|
||||
- Run on minimal hardware ($10 boards, <10MB RAM)
|
||||
- Run efficiently on Render Free Tier
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Web search and content fetching
|
||||
- File system operations (read, write, edit)
|
||||
- Shell command execution
|
||||
- Multi-channel messaging (Telegram, WhatsApp, Feishu)
|
||||
- Multi-channel messaging (Telegram, etc.)
|
||||
- Skill-based extensibility
|
||||
- Memory and context management
|
||||
|
||||
|
|
@ -313,10 +307,8 @@ Ultra-lightweight personal AI assistant written in Go, inspired by nanobot.
|
|||
## Goals
|
||||
|
||||
- Provide a fast, lightweight AI assistant
|
||||
- Support offline-first operation where possible
|
||||
- Enable easy customization and extension
|
||||
- Maintain high quality responses
|
||||
- Run efficiently on constrained hardware
|
||||
|
||||
## License
|
||||
MIT License - Free and open source
|
||||
|
|
@ -324,10 +316,6 @@ MIT License - Free and open source
|
|||
## Repository
|
||||
https://github.com/sipeed/picoclaw
|
||||
|
||||
## Contact
|
||||
Issues: https://github.com/sipeed/picoclaw/issues
|
||||
Discussions: https://github.com/sipeed/picoclaw/discussions
|
||||
|
||||
---
|
||||
|
||||
"Every bit helps, every bit matters."
|
||||
|
|
@ -378,14 +366,6 @@ This file stores important information that should persist across sessions.
|
|||
fmt.Println(" Created skills/")
|
||||
}
|
||||
}
|
||||
|
||||
for filename, content := range templates {
|
||||
filePath := filepath.Join(workspace, filename)
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
os.WriteFile(filePath, []byte(content), 0644)
|
||||
fmt.Printf(" Created %s\n", filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func migrateCmd() {
|
||||
|
|
@ -496,7 +476,6 @@ func agentCmd() {
|
|||
msgBus := bus.NewMessageBus()
|
||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Print agent startup info (only for interactive mode)
|
||||
startupInfo := agentLoop.GetStartupInfo()
|
||||
logger.InfoCF("agent", "Agent initialized",
|
||||
map[string]interface{}{
|
||||
|
|
@ -606,7 +585,6 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
|||
}
|
||||
|
||||
func gatewayCmd() {
|
||||
// Check for --debug flag
|
||||
args := os.Args[2:]
|
||||
for _, arg := range args {
|
||||
if arg == "--debug" || arg == "-d" {
|
||||
|
|
@ -631,7 +609,6 @@ func gatewayCmd() {
|
|||
msgBus := bus.NewMessageBus()
|
||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Print agent startup info
|
||||
fmt.Println("\n📦 Agent Status:")
|
||||
startupInfo := agentLoop.GetStartupInfo()
|
||||
toolsInfo := startupInfo["tools"].(map[string]interface{})
|
||||
|
|
@ -641,7 +618,6 @@ func gatewayCmd() {
|
|||
skillsInfo["available"],
|
||||
skillsInfo["total"])
|
||||
|
||||
// Log to file as well
|
||||
logger.InfoCF("agent", "Agent initialized",
|
||||
map[string]interface{}{
|
||||
"tools_count": toolsInfo["count"],
|
||||
|
|
@ -649,7 +625,6 @@ func gatewayCmd() {
|
|||
"skills_available": skillsInfo["available"],
|
||||
})
|
||||
|
||||
// Setup cron tool and service
|
||||
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath())
|
||||
|
||||
heartbeatService := heartbeat.NewHeartbeatService(
|
||||
|
|
@ -665,33 +640,6 @@ func gatewayCmd() {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
var transcriber *voice.GroqTranscriber
|
||||
if cfg.Providers.Groq.APIKey != "" {
|
||||
transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey)
|
||||
logger.InfoC("voice", "Groq voice transcription enabled")
|
||||
}
|
||||
|
||||
if transcriber != nil {
|
||||
if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
|
||||
if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
|
||||
tc.SetTranscriber(transcriber)
|
||||
logger.InfoC("voice", "Groq transcription attached to Telegram channel")
|
||||
}
|
||||
}
|
||||
if discordChannel, ok := channelManager.GetChannel("discord"); ok {
|
||||
if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
|
||||
dc.SetTranscriber(transcriber)
|
||||
logger.InfoC("voice", "Groq transcription attached to Discord channel")
|
||||
}
|
||||
}
|
||||
if slackChannel, ok := channelManager.GetChannel("slack"); ok {
|
||||
if sc, ok := slackChannel.(*channels.SlackChannel); ok {
|
||||
sc.SetTranscriber(transcriber)
|
||||
logger.InfoC("voice", "Groq transcription attached to Slack channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enabledChannels := channelManager.GetEnabledChannels()
|
||||
if len(enabledChannels) > 0 {
|
||||
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
||||
|
|
@ -762,12 +710,6 @@ func statusCmd() {
|
|||
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
|
||||
|
||||
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
|
||||
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 {
|
||||
|
|
@ -776,273 +718,29 @@ func statusCmd() {
|
|||
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 {
|
||||
fmt.Println("vLLM/Local: not set")
|
||||
}
|
||||
|
||||
store, _ := auth.LoadStore()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(" picoclaw auth login --provider openai")
|
||||
fmt.Println(" picoclaw auth login --provider openai --device-code")
|
||||
fmt.Println(" picoclaw auth login --provider anthropic")
|
||||
fmt.Println(" picoclaw auth logout --provider openai")
|
||||
fmt.Println(" picoclaw 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: picoclaw 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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getConfigPath() string {
|
||||
// Support --config flag
|
||||
for i, arg := range os.Args {
|
||||
if (arg == "--config" || arg == "-c") && i+1 < len(os.Args) {
|
||||
return os.Args[i+1]
|
||||
}
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".picoclaw", "config.json")
|
||||
}
|
||||
|
||||
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string) *cron.CronService {
|
||||
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
||||
|
||||
// Create cron service
|
||||
cronService := cron.NewCronService(cronStorePath, nil)
|
||||
|
||||
// Create and register CronTool
|
||||
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus)
|
||||
agentLoop.RegisterTool(cronTool)
|
||||
|
||||
// Set the onJob handler
|
||||
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
||||
result := cronTool.ExecuteJob(context.Background(), job)
|
||||
return result, nil
|
||||
})
|
||||
|
||||
return cronService
|
||||
}
|
||||
|
||||
|
|
@ -1055,18 +753,13 @@ func cronCmd() {
|
|||
cronHelp()
|
||||
return
|
||||
}
|
||||
|
||||
subcommand := os.Args[2]
|
||||
|
||||
// Load config to get workspace path
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
|
||||
|
||||
switch subcommand {
|
||||
case "list":
|
||||
cronListCmd(cronStorePath)
|
||||
|
|
@ -1108,13 +801,11 @@ func cronHelp() {
|
|||
|
||||
func cronListCmd(storePath string) {
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
jobs := cs.ListJobs(true) // Show all jobs, including disabled
|
||||
|
||||
jobs := cs.ListJobs(true)
|
||||
if len(jobs) == 0 {
|
||||
fmt.Println("No scheduled jobs.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("\nScheduled Jobs:")
|
||||
fmt.Println("----------------")
|
||||
for _, job := range jobs {
|
||||
|
|
@ -1126,18 +817,15 @@ func cronListCmd(storePath string) {
|
|||
} else {
|
||||
schedule = "one-time"
|
||||
}
|
||||
|
||||
nextRun := "scheduled"
|
||||
if job.State.NextRunAtMS != nil {
|
||||
nextTime := time.UnixMilli(*job.State.NextRunAtMS)
|
||||
nextRun = nextTime.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
status := "enabled"
|
||||
if !job.Enabled {
|
||||
status = "disabled"
|
||||
}
|
||||
|
||||
fmt.Printf(" %s (%s)\n", job.Name, job.ID)
|
||||
fmt.Printf(" Schedule: %s\n", schedule)
|
||||
fmt.Printf(" Status: %s\n", status)
|
||||
|
|
@ -1153,7 +841,6 @@ func cronAddCmd(storePath string) {
|
|||
deliver := false
|
||||
channel := ""
|
||||
to := ""
|
||||
|
||||
args := os.Args[3:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
|
|
@ -1193,22 +880,18 @@ func cronAddCmd(storePath string) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
fmt.Println("Error: --name is required")
|
||||
return
|
||||
}
|
||||
|
||||
if message == "" {
|
||||
fmt.Println("Error: --message is required")
|
||||
return
|
||||
}
|
||||
|
||||
if everySec == nil && cronExpr == "" {
|
||||
fmt.Println("Error: Either --every or --cron must be specified")
|
||||
return
|
||||
}
|
||||
|
||||
var schedule cron.CronSchedule
|
||||
if everySec != nil {
|
||||
everyMS := *everySec * 1000
|
||||
|
|
@ -1222,14 +905,12 @@ func cronAddCmd(storePath string) {
|
|||
Expr: cronExpr,
|
||||
}
|
||||
}
|
||||
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
|
||||
if err != nil {
|
||||
fmt.Printf("Error adding job: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
|
||||
}
|
||||
|
||||
|
|
@ -1247,11 +928,9 @@ func cronEnableCmd(storePath string, disable bool) {
|
|||
fmt.Println("Usage: picoclaw cron enable/disable <job_id>")
|
||||
return
|
||||
}
|
||||
|
||||
jobID := os.Args[3]
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
enabled := !disable
|
||||
|
||||
job := cs.EnableJob(jobID, enabled)
|
||||
if job != nil {
|
||||
status := "enabled"
|
||||
|
|
@ -1269,23 +948,18 @@ func skillsCmd() {
|
|||
skillsHelp()
|
||||
return
|
||||
}
|
||||
|
||||
subcommand := os.Args[2]
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
workspace := cfg.WorkspacePath()
|
||||
installer := skills.NewSkillInstaller(workspace)
|
||||
// 获取全局配置目录和内置 skills 目录
|
||||
globalDir := filepath.Dir(getConfigPath())
|
||||
globalDir := filepath.Dir(filepath.Dir(getConfigPath()))
|
||||
globalSkillsDir := filepath.Join(globalDir, "skills")
|
||||
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
|
||||
skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
|
||||
|
||||
switch subcommand {
|
||||
case "list":
|
||||
skillsListCmd(skillsLoader)
|
||||
|
|
@ -1331,12 +1005,10 @@ func skillsHelp() {
|
|||
|
||||
func skillsListCmd(loader *skills.SkillsLoader) {
|
||||
allSkills := loader.ListSkills()
|
||||
|
||||
if len(allSkills) == 0 {
|
||||
fmt.Println("No skills installed.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("\nInstalled Skills:")
|
||||
fmt.Println("------------------")
|
||||
for _, skill := range allSkills {
|
||||
|
|
@ -1350,153 +1022,67 @@ func skillsListCmd(loader *skills.SkillsLoader) {
|
|||
func skillsInstallCmd(installer *skills.SkillInstaller) {
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: picoclaw skills install <github-repo>")
|
||||
fmt.Println("Example: picoclaw skills install sipeed/picoclaw-skills/weather")
|
||||
return
|
||||
}
|
||||
|
||||
repo := os.Args[3]
|
||||
fmt.Printf("Installing skill from %s...\n", repo)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
|
||||
fmt.Printf("✗ Failed to install skill: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Skill '%s' installed successfully!\n", filepath.Base(repo))
|
||||
}
|
||||
|
||||
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
||||
fmt.Printf("Removing skill '%s'...\n", skillName)
|
||||
|
||||
if err := installer.Uninstall(skillName); err != nil {
|
||||
fmt.Printf("✗ Failed to remove skill: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
|
||||
}
|
||||
|
||||
func skillsInstallBuiltinCmd(workspace string) {
|
||||
builtinSkillsDir := "./picoclaw/skills"
|
||||
workspaceSkillsDir := filepath.Join(workspace, "skills")
|
||||
|
||||
fmt.Printf("Copying builtin skills to workspace...\n")
|
||||
|
||||
skillsToInstall := []string{
|
||||
"weather",
|
||||
"news",
|
||||
"stock",
|
||||
"calculator",
|
||||
}
|
||||
|
||||
skillsToInstall := []string{"weather", "news", "stock", "calculator"}
|
||||
for _, skillName := range skillsToInstall {
|
||||
builtinPath := filepath.Join(builtinSkillsDir, skillName)
|
||||
workspacePath := filepath.Join(workspaceSkillsDir, skillName)
|
||||
|
||||
if _, err := os.Stat(builtinPath); err != nil {
|
||||
fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err)
|
||||
fmt.Printf("⊘ Builtin skill '%s' not found\n", skillName)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(workspacePath, 0755); err != nil {
|
||||
fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := copyDirectory(builtinPath, workspacePath); err != nil {
|
||||
fmt.Printf("✗ Failed to copy %s: %v\n", skillName, err)
|
||||
}
|
||||
os.MkdirAll(workspacePath, 0755)
|
||||
copyDirectory(builtinPath, workspacePath)
|
||||
}
|
||||
|
||||
fmt.Println("\n✓ All builtin skills installed!")
|
||||
fmt.Println("Now you can use them in your workspace.")
|
||||
}
|
||||
|
||||
func skillsListBuiltinCmd() {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
return
|
||||
}
|
||||
builtinSkillsDir := filepath.Join(filepath.Dir(cfg.WorkspacePath()), "picoclaw", "skills")
|
||||
|
||||
fmt.Println("\nAvailable Builtin Skills:")
|
||||
fmt.Println("-----------------------")
|
||||
|
||||
entries, err := os.ReadDir(builtinSkillsDir)
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading builtin skills: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("No builtin skills available.")
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
skillName := entry.Name()
|
||||
skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md")
|
||||
|
||||
description := "No description"
|
||||
if _, err := os.Stat(skillFile); err == nil {
|
||||
data, err := os.ReadFile(skillFile)
|
||||
if err == nil {
|
||||
content := string(data)
|
||||
if idx := strings.Index(content, "\n"); idx > 0 {
|
||||
firstLine := content[:idx]
|
||||
if strings.Contains(firstLine, "description:") {
|
||||
descLine := strings.Index(content[idx:], "\n")
|
||||
if descLine > 0 {
|
||||
description = strings.TrimSpace(content[idx+descLine : idx+descLine])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
status := "✓"
|
||||
fmt.Printf(" %s %s\n", status, entry.Name())
|
||||
if description != "" {
|
||||
fmt.Printf(" %s\n", description)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println("\nAvailable Builtin Skills: weather, news, stock, calculator")
|
||||
}
|
||||
|
||||
func skillsSearchCmd(installer *skills.SkillInstaller) {
|
||||
fmt.Println("Searching for available skills...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
availableSkills, err := installer.ListAvailableSkills(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(availableSkills) == 0 {
|
||||
fmt.Println("No skills available.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills))
|
||||
fmt.Println("--------------------")
|
||||
for _, skill := range availableSkills {
|
||||
fmt.Printf(" 📦 %s\n", skill.Name)
|
||||
fmt.Printf(" %s\n", skill.Description)
|
||||
fmt.Printf(" Repo: %s\n", skill.Repository)
|
||||
if skill.Author != "" {
|
||||
fmt.Printf(" Author: %s\n", skill.Author)
|
||||
}
|
||||
if len(skill.Tags) > 0 {
|
||||
fmt.Printf(" Tags: %v\n", skill.Tags)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf(" 📦 %s\n %s\n", skill.Name, skill.Description)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1506,8 +1092,5 @@ func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
|
|||
fmt.Printf("✗ Skill '%s' not found\n", skillName)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n📦 Skill: %s\n", skillName)
|
||||
fmt.Println("----------------------")
|
||||
fmt.Println(content)
|
||||
fmt.Printf("\n📦 Skill: %s\n----------------------\n%s\n", skillName, content)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,358 +0,0 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OAuthProviderConfig struct {
|
||||
Issuer string
|
||||
ClientID string
|
||||
Scopes string
|
||||
Port int
|
||||
}
|
||||
|
||||
func OpenAIOAuthConfig() OAuthProviderConfig {
|
||||
return OAuthProviderConfig{
|
||||
Issuer: "https://auth.openai.com",
|
||||
ClientID: "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
Scopes: "openid profile email offline_access",
|
||||
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)
|
||||
}()
|
||||
|
||||
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("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
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
var deviceResp struct {
|
||||
DeviceAuthID string `json:"device_auth_id"`
|
||||
UserCode string `json:"user_code"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &deviceResp); 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))
|
||||
}
|
||||
|
||||
return parseTokenResponse(body, cred.Provider)
|
||||
}
|
||||
|
||||
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"},
|
||||
"state": {state},
|
||||
}
|
||||
return cfg.Issuer + "/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.AccessToken); accountID != "" {
|
||||
cred.AccountID = accountID
|
||||
}
|
||||
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
func extractAccountID(accessToken string) string {
|
||||
parts := strings.Split(accessToken, ".")
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
|
||||
payload := parts[1]
|
||||
switch len(payload) % 4 {
|
||||
case 2:
|
||||
payload += "=="
|
||||
case 3:
|
||||
payload += "="
|
||||
}
|
||||
|
||||
decoded, err := base64URLDecode(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok {
|
||||
if accountID, ok := authClaim["chatgpt_account_id"].(string); ok {
|
||||
return accountID
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
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,199 +0,0 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildAuthorizeURL(t *testing.T) {
|
||||
cfg := OAuthProviderConfig{
|
||||
Issuer: "https://auth.example.com",
|
||||
ClientID: "test-client-id",
|
||||
Scopes: "openid profile",
|
||||
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/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")
|
||||
}
|
||||
}
|
||||
|
||||
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 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 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 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ".picoclaw", "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, ".picoclaw", "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
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ package channels
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
|
|
@ -11,19 +10,16 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
"github.com/sipeed/picoclaw/pkg/voice"
|
||||
)
|
||||
|
||||
const (
|
||||
transcriptionTimeout = 30 * time.Second
|
||||
sendTimeout = 10 * time.Second
|
||||
sendTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
type DiscordChannel struct {
|
||||
*BaseChannel
|
||||
session *discordgo.Session
|
||||
config config.DiscordConfig
|
||||
transcriber *voice.GroqTranscriber
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
|
|
@ -39,15 +35,10 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
|
|||
BaseChannel: base,
|
||||
session: session,
|
||||
config: cfg,
|
||||
transcriber: nil,
|
||||
ctx: context.Background(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
||||
c.transcriber = transcriber
|
||||
}
|
||||
|
||||
func (c *DiscordChannel) getContext() context.Context {
|
||||
if c.ctx == nil {
|
||||
return context.Background()
|
||||
|
|
@ -102,7 +93,6 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
|||
|
||||
message := msg.Content
|
||||
|
||||
// 使用传入的 ctx 进行超时控制
|
||||
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||
defer cancel()
|
||||
|
||||
|
|
@ -123,7 +113,6 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
|||
}
|
||||
}
|
||||
|
||||
// appendContent 安全地追加内容到现有文本
|
||||
func appendContent(content, suffix string) string {
|
||||
if content == "" {
|
||||
return suffix
|
||||
|
|
@ -140,7 +129,6 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
|||
return
|
||||
}
|
||||
|
||||
// 检查白名单,避免为被拒绝的用户下载附件和转录
|
||||
if !c.IsAllowed(m.Author.ID) {
|
||||
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
|
||||
"user_id": m.Author.ID,
|
||||
|
|
@ -156,62 +144,10 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
|||
|
||||
content := m.Content
|
||||
mediaPaths := make([]string, 0, len(m.Attachments))
|
||||
localFiles := make([]string, 0, len(m.Attachments))
|
||||
|
||||
// 确保临时文件在函数返回时被清理
|
||||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
logger.DebugCF("discord", "Failed to cleanup temp file", map[string]any{
|
||||
"file": file,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for _, attachment := range m.Attachments {
|
||||
isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)
|
||||
|
||||
if isAudio {
|
||||
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
|
||||
if localPath != "" {
|
||||
localFiles = append(localFiles, localPath)
|
||||
|
||||
transcribedText := ""
|
||||
if c.transcriber != nil && c.transcriber.IsAvailable() {
|
||||
ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout)
|
||||
result, err := c.transcriber.Transcribe(ctx, localPath)
|
||||
cancel() // 立即释放context资源,避免在for循环中泄漏
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorCF("discord", "Voice transcription failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
transcribedText = fmt.Sprintf("[audio: %s (transcription failed)]", attachment.Filename)
|
||||
} else {
|
||||
transcribedText = fmt.Sprintf("[audio transcription: %s]", result.Text)
|
||||
logger.DebugCF("discord", "Audio transcribed successfully", map[string]any{
|
||||
"text": result.Text,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
transcribedText = fmt.Sprintf("[audio: %s]", attachment.Filename)
|
||||
}
|
||||
|
||||
content = appendContent(content, transcribedText)
|
||||
} else {
|
||||
logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{
|
||||
"url": attachment.URL,
|
||||
"filename": attachment.Filename,
|
||||
})
|
||||
mediaPaths = append(mediaPaths, attachment.URL)
|
||||
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
|
||||
}
|
||||
} else {
|
||||
mediaPaths = append(mediaPaths, attachment.URL)
|
||||
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
|
||||
}
|
||||
mediaPaths = append(mediaPaths, attachment.URL)
|
||||
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
|
||||
}
|
||||
|
||||
if content == "" && len(mediaPaths) == 0 {
|
||||
|
|
@ -240,9 +176,3 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
|||
|
||||
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
|
||||
}
|
||||
|
||||
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
|
||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||
LoggerPrefix: "discord",
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/slack-go/slack"
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
|
|
@ -16,7 +15,6 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
"github.com/sipeed/picoclaw/pkg/voice"
|
||||
)
|
||||
|
||||
type SlackChannel struct {
|
||||
|
|
@ -25,7 +23,6 @@ type SlackChannel struct {
|
|||
api *slack.Client
|
||||
socketClient *socketmode.Client
|
||||
botUserID string
|
||||
transcriber *voice.GroqTranscriber
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
pendingAcks sync.Map
|
||||
|
|
@ -58,10 +55,6 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (c *SlackChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
||||
c.transcriber = transcriber
|
||||
}
|
||||
|
||||
func (c *SlackChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("slack", "Starting Slack channel (Socket Mode)")
|
||||
|
||||
|
|
@ -198,7 +191,6 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
|||
return
|
||||
}
|
||||
|
||||
// 检查白名单,避免为被拒绝的用户下载附件
|
||||
if !c.IsAllowed(ev.User) {
|
||||
logger.DebugCF("slack", "Message rejected by allowlist", map[string]interface{}{
|
||||
"user_id": ev.User,
|
||||
|
|
@ -230,9 +222,8 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
|||
content = c.stripBotMention(content)
|
||||
|
||||
var mediaPaths []string
|
||||
localFiles := []string{} // 跟踪需要清理的本地文件
|
||||
localFiles := []string{}
|
||||
|
||||
// 确保临时文件在函数返回时被清理
|
||||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
|
|
@ -252,21 +243,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
|||
}
|
||||
localFiles = append(localFiles, localPath)
|
||||
mediaPaths = append(mediaPaths, localPath)
|
||||
|
||||
if utils.IsAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() {
|
||||
ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
result, err := c.transcriber.Transcribe(ctx, localPath)
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorCF("slack", "Voice transcription failed", map[string]interface{}{"error": err.Error()})
|
||||
content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name)
|
||||
} else {
|
||||
content += fmt.Sprintf("\n[voice transcription: %s]", result.Text)
|
||||
}
|
||||
} else {
|
||||
content += fmt.Sprintf("\n[file: %s]", file.Name)
|
||||
}
|
||||
content += fmt.Sprintf("\n[file: %s]", file.Name)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -376,7 +353,6 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string {
|
|||
downloadURL = file.URLPrivate
|
||||
}
|
||||
if downloadURL == "" {
|
||||
logger.ErrorCF("slack", "No download URL for file", map[string]interface{}{"file_id": file.ID})
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
"github.com/sipeed/picoclaw/pkg/voice"
|
||||
)
|
||||
|
||||
type TelegramChannel struct {
|
||||
|
|
@ -24,7 +23,6 @@ type TelegramChannel struct {
|
|||
bot *telego.Bot
|
||||
config config.TelegramConfig
|
||||
chatIDs map[string]int64
|
||||
transcriber *voice.GroqTranscriber
|
||||
placeholders sync.Map // chatID -> messageID
|
||||
stopThinking sync.Map // chatID -> thinkingCancel
|
||||
}
|
||||
|
|
@ -52,16 +50,11 @@ func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*Telegr
|
|||
bot: bot,
|
||||
config: cfg,
|
||||
chatIDs: make(map[string]int64),
|
||||
transcriber: nil,
|
||||
placeholders: sync.Map{},
|
||||
stopThinking: sync.Map{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
||||
c.transcriber = transcriber
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("telegram", "Starting Telegram bot (polling mode)...")
|
||||
|
||||
|
|
@ -132,7 +125,6 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
|||
if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil {
|
||||
return nil
|
||||
}
|
||||
// Fallback to new message if edit fails
|
||||
}
|
||||
|
||||
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
||||
|
|
@ -166,7 +158,6 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||
senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
|
||||
}
|
||||
|
||||
// 检查白名单,避免为被拒绝的用户下载附件
|
||||
if !c.IsAllowed(senderID) {
|
||||
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{
|
||||
"user_id": senderID,
|
||||
|
|
@ -179,9 +170,8 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||
|
||||
content := ""
|
||||
mediaPaths := []string{}
|
||||
localFiles := []string{} // 跟踪需要清理的本地文件
|
||||
localFiles := []string{}
|
||||
|
||||
// 确保临时文件在函数返回时被清理
|
||||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
|
|
@ -222,33 +212,10 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||
if voicePath != "" {
|
||||
localFiles = append(localFiles, voicePath)
|
||||
mediaPaths = append(mediaPaths, voicePath)
|
||||
|
||||
transcribedText := ""
|
||||
if c.transcriber != nil && c.transcriber.IsAvailable() {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := c.transcriber.Transcribe(ctx, voicePath)
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Voice transcription failed", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"path": voicePath,
|
||||
})
|
||||
transcribedText = fmt.Sprintf("[voice (transcription failed)]")
|
||||
} else {
|
||||
transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text)
|
||||
logger.InfoCF("telegram", "Voice transcribed successfully", map[string]interface{}{
|
||||
"text": result.Text,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
transcribedText = fmt.Sprintf("[voice]")
|
||||
}
|
||||
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
content += transcribedText
|
||||
content += "[voice]"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,15 +253,8 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
// Thinking indicator
|
||||
err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
|
||||
|
||||
// Stop any previous thinking animation
|
||||
chatIDStr := fmt.Sprintf("%d", chatID)
|
||||
if prevStop, ok := c.stopThinking.Load(chatIDStr); ok {
|
||||
if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil {
|
||||
|
|
@ -302,18 +262,16 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||
}
|
||||
}
|
||||
|
||||
// Create new context for thinking animation with timeout
|
||||
thinkCtx, thinkCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel})
|
||||
|
||||
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭"))
|
||||
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 🦞"))
|
||||
if err == nil {
|
||||
pID := pMsg.MessageID
|
||||
c.placeholders.Store(chatIDStr, pID)
|
||||
|
||||
go func(cid int64, mid int) {
|
||||
dots := []string{".", "..", "..."}
|
||||
emotes := []string{"💭", "🤔", "☁️"}
|
||||
i := 0
|
||||
ticker := time.NewTicker(2000 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
|
@ -323,13 +281,8 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||
return
|
||||
case <-ticker.C:
|
||||
i++
|
||||
text := fmt.Sprintf("Thinking%s %s", dots[i%len(dots)], emotes[i%len(emotes)])
|
||||
_, editErr := c.bot.EditMessageText(thinkCtx, tu.EditMessageText(tu.ID(chatID), mid, text))
|
||||
if editErr != nil {
|
||||
logger.DebugCF("telegram", "Failed to edit thinking message", map[string]interface{}{
|
||||
"error": editErr.Error(),
|
||||
})
|
||||
}
|
||||
text := fmt.Sprintf("Thinking%s 🦞", dots[i%len(dots)])
|
||||
c.bot.EditMessageText(thinkCtx, tu.EditMessageText(tu.ID(chatID), mid, text))
|
||||
}
|
||||
}
|
||||
}(chatID, pID)
|
||||
|
|
@ -349,12 +302,8 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
|
||||
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Failed to get photo file", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
return c.downloadFileWithInfo(file, ".jpg")
|
||||
}
|
||||
|
||||
|
|
@ -362,26 +311,15 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st
|
|||
if file.FilePath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
url := c.bot.FileDownloadURL(file.FilePath)
|
||||
logger.DebugCF("telegram", "File URL", map[string]interface{}{"url": url})
|
||||
|
||||
// Use FilePath as filename for better identification
|
||||
filename := file.FilePath + ext
|
||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||
LoggerPrefix: "telegram",
|
||||
})
|
||||
return utils.DownloadFile(url, file.FilePath+ext, utils.DownloadOptions{LoggerPrefix: "telegram"})
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string {
|
||||
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Failed to get file", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
return c.downloadFileWithInfo(file, ext)
|
||||
}
|
||||
|
||||
|
|
@ -395,25 +333,16 @@ func markdownToTelegramHTML(text string) string {
|
|||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
codeBlocks := extractCodeBlocks(text)
|
||||
text = codeBlocks.text
|
||||
|
||||
inlineCodes := extractInlineCodes(text)
|
||||
text = inlineCodes.text
|
||||
|
||||
text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1")
|
||||
|
||||
text = regexp.MustCompile(`^>\s*(.*)$`).ReplaceAllString(text, "$1")
|
||||
|
||||
text = escapeHTML(text)
|
||||
|
||||
text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, `<a href="$2">$1</a>`)
|
||||
|
||||
text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "<b>$1</b>")
|
||||
|
||||
text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "<b>$1</b>")
|
||||
|
||||
reItalic := regexp.MustCompile(`_([^_]+)_`)
|
||||
text = reItalic.ReplaceAllStringFunc(text, func(s string) string {
|
||||
match := reItalic.FindStringSubmatch(s)
|
||||
|
|
@ -422,21 +351,16 @@ func markdownToTelegramHTML(text string) string {
|
|||
}
|
||||
return "<i>" + match[1] + "</i>"
|
||||
})
|
||||
|
||||
text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "<s>$1</s>")
|
||||
|
||||
text = regexp.MustCompile(`^[-*]\s+`).ReplaceAllString(text, "• ")
|
||||
|
||||
for i, code := range inlineCodes.codes {
|
||||
escaped := escapeHTML(code)
|
||||
text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("<code>%s</code>", escaped))
|
||||
}
|
||||
|
||||
for i, code := range codeBlocks.codes {
|
||||
escaped := escapeHTML(code)
|
||||
text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i), fmt.Sprintf("<pre><code>%s</code></pre>", escaped))
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
|
|
@ -448,16 +372,13 @@ type codeBlockMatch struct {
|
|||
func extractCodeBlocks(text string) codeBlockMatch {
|
||||
re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
|
||||
matches := re.FindAllStringSubmatch(text, -1)
|
||||
|
||||
codes := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
codes = append(codes, match[1])
|
||||
}
|
||||
|
||||
text = re.ReplaceAllStringFunc(text, func(m string) string {
|
||||
return fmt.Sprintf("\x00CB%d\x00", len(codes)-1)
|
||||
})
|
||||
|
||||
return codeBlockMatch{text: text, codes: codes}
|
||||
}
|
||||
|
||||
|
|
@ -469,16 +390,13 @@ type inlineCodeMatch struct {
|
|||
func extractInlineCodes(text string) inlineCodeMatch {
|
||||
re := regexp.MustCompile("`([^`]+)`")
|
||||
matches := re.FindAllStringSubmatch(text, -1)
|
||||
|
||||
codes := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
codes = append(codes, match[1])
|
||||
}
|
||||
|
||||
text = re.ReplaceAllStringFunc(text, func(m string) string {
|
||||
return fmt.Sprintf("\x00IC%d\x00", len(codes)-1)
|
||||
})
|
||||
|
||||
return inlineCodeMatch{text: text, codes: codes}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,13 +98,7 @@ type SlackConfig struct {
|
|||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type ProviderConfig struct {
|
||||
|
|
@ -136,8 +130,8 @@ func DefaultConfig() *Config {
|
|||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Workspace: "~/.picoclaw/workspace",
|
||||
Provider: "",
|
||||
Model: "glm-4.7",
|
||||
Provider: "openrouter",
|
||||
Model: "openrouter",
|
||||
MaxTokens: 8192,
|
||||
Temperature: 0.7,
|
||||
MaxToolIterations: 20,
|
||||
|
|
@ -193,13 +187,7 @@ func DefaultConfig() *Config {
|
|||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
Anthropic: ProviderConfig{},
|
||||
OpenAI: ProviderConfig{},
|
||||
OpenRouter: ProviderConfig{},
|
||||
Groq: ProviderConfig{},
|
||||
Zhipu: ProviderConfig{},
|
||||
VLLM: ProviderConfig{},
|
||||
Gemini: ProviderConfig{},
|
||||
},
|
||||
Gateway: GatewayConfig{
|
||||
Host: "0.0.0.0",
|
||||
|
|
@ -264,46 +252,16 @@ func (c *Config) WorkspacePath() string {
|
|||
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
|
||||
}
|
||||
return ""
|
||||
return c.Providers.OpenRouter.APIKey
|
||||
}
|
||||
|
||||
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.OpenRouter.APIBase != "" {
|
||||
return c.Providers.OpenRouter.APIBase
|
||||
}
|
||||
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 ""
|
||||
return "https://openrouter.ai/api/v1"
|
||||
}
|
||||
|
||||
func expandHome(path string) string {
|
||||
|
|
|
|||
|
|
@ -12,13 +12,7 @@ import (
|
|||
)
|
||||
|
||||
var supportedProviders = map[string]bool{
|
||||
"anthropic": true,
|
||||
"openai": true,
|
||||
"openrouter": true,
|
||||
"groq": true,
|
||||
"zhipu": true,
|
||||
"vllm": true,
|
||||
"gemini": true,
|
||||
}
|
||||
|
||||
var supportedChannels = map[string]bool{
|
||||
|
|
@ -104,21 +98,8 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
|
|||
}
|
||||
|
||||
pc := config.ProviderConfig{APIKey: apiKey, APIBase: apiBase}
|
||||
switch name {
|
||||
case "anthropic":
|
||||
cfg.Providers.Anthropic = pc
|
||||
case "openai":
|
||||
cfg.Providers.OpenAI = pc
|
||||
case "openrouter":
|
||||
if name == "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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -227,27 +208,9 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
|
|||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -91,15 +91,15 @@ func TestLoadOpenClawConfig(t *testing.T) {
|
|||
|
||||
openclawConfig := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
"apiKey": "sk-ant-test123",
|
||||
"apiBase": "https://api.anthropic.com",
|
||||
"openrouter": map[string]interface{}{
|
||||
"apiKey": "sk-or-test123",
|
||||
"apiBase": "https://openrouter.ai/api/v1",
|
||||
},
|
||||
},
|
||||
"agents": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"maxTokens": float64(4096),
|
||||
"model": "claude-3-opus",
|
||||
"model": "meta-llama/llama-3-8b-instruct",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -121,12 +121,12 @@ func TestLoadOpenClawConfig(t *testing.T) {
|
|||
if !ok {
|
||||
t.Fatal("expected providers map")
|
||||
}
|
||||
anthropic, ok := providers["anthropic"].(map[string]interface{})
|
||||
openrouter, ok := providers["openrouter"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected anthropic map")
|
||||
t.Fatal("expected openrouter map")
|
||||
}
|
||||
if anthropic["api_key"] != "sk-ant-test123" {
|
||||
t.Errorf("api_key = %v, want sk-ant-test123", anthropic["api_key"])
|
||||
if openrouter["api_key"] != "sk-or-test123" {
|
||||
t.Errorf("api_key = %v, want sk-or-test123", openrouter["api_key"])
|
||||
}
|
||||
|
||||
agents, ok := result["agents"].(map[string]interface{})
|
||||
|
|
@ -146,16 +146,9 @@ 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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -166,15 +159,9 @@ func TestConvertConfig(t *testing.T) {
|
|||
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) {
|
||||
|
|
@ -256,7 +243,7 @@ func TestConvertConfig(t *testing.T) {
|
|||
data := map[string]interface{}{
|
||||
"agents": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"model": "claude-3-opus",
|
||||
"model": "meta-llama/llama-3-8b-instruct",
|
||||
"max_tokens": float64(4096),
|
||||
"temperature": 0.5,
|
||||
"max_tool_iterations": float64(10),
|
||||
|
|
@ -269,8 +256,8 @@ func TestConvertConfig(t *testing.T) {
|
|||
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.Model != "meta-llama/llama-3-8b-instruct" {
|
||||
t.Errorf("Model = %q, want %q", cfg.Agents.Defaults.Model, "meta-llama/llama-3-8b-instruct")
|
||||
}
|
||||
if cfg.Agents.Defaults.MaxTokens != 4096 {
|
||||
t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 4096)
|
||||
|
|
@ -293,8 +280,8 @@ func TestConvertConfig(t *testing.T) {
|
|||
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)
|
||||
if cfg.Agents.Defaults.Model != "openrouter" {
|
||||
t.Errorf("default model should be openrouter, got %q", cfg.Agents.Defaults.Model)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -303,13 +290,9 @@ 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")
|
||||
}
|
||||
|
|
@ -317,18 +300,14 @@ func TestMergeConfig(t *testing.T) {
|
|||
|
||||
t.Run("preserves existing non-empty fields", func(t *testing.T) {
|
||||
existing := config.DefaultConfig()
|
||||
existing.Providers.Anthropic.APIKey = "sk-ant-existing"
|
||||
existing.Providers.OpenRouter.APIKey = "sk-or-existing"
|
||||
|
||||
incoming := config.DefaultConfig()
|
||||
incoming.Providers.Anthropic.APIKey = "sk-ant-incoming"
|
||||
incoming.Providers.OpenAI.APIKey = "sk-oai-incoming"
|
||||
incoming.Providers.OpenRouter.APIKey = "sk-or-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)
|
||||
if result.Providers.OpenRouter.APIKey != "sk-or-existing" {
|
||||
t.Errorf("OpenRouter.APIKey should be preserved, got %q", result.Providers.OpenRouter.APIKey)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -346,21 +325,6 @@ func TestMergeConfig(t *testing.T) {
|
|||
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) {
|
||||
|
|
@ -390,106 +354,6 @@ func TestPlanWorkspaceMigration(t *testing.T) {
|
|||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -507,44 +371,6 @@ func TestFindOpenClawConfig(t *testing.T) {
|
|||
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) {
|
||||
|
|
@ -566,289 +392,3 @@ func TestRewriteWorkspacePath(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRun(t *testing.T) {
|
||||
openclawHome := t.TempDir()
|
||||
picoClawHome := 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,
|
||||
PicoClawHome: picoClawHome,
|
||||
}
|
||||
|
||||
result, err := Run(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
picoWs := filepath.Join(picoClawHome, "workspace")
|
||||
if _, err := os.Stat(filepath.Join(picoWs, "SOUL.md")); !os.IsNotExist(err) {
|
||||
t.Error("dry run should not create files")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(picoClawHome, "config.json")); !os.IsNotExist(err) {
|
||||
t.Error("dry run should not create config")
|
||||
}
|
||||
|
||||
_ = result
|
||||
}
|
||||
|
||||
func TestRunFullMigration(t *testing.T) {
|
||||
openclawHome := t.TempDir()
|
||||
picoClawHome := 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,
|
||||
PicoClawHome: picoClawHome,
|
||||
}
|
||||
|
||||
result, err := Run(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
picoWs := filepath.Join(picoClawHome, "workspace")
|
||||
|
||||
soulData, err := os.ReadFile(filepath.Join(picoWs, "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(picoWs, "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(picoWs, "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))
|
||||
}
|
||||
|
||||
picoConfig, err := config.LoadConfig(filepath.Join(picoClawHome, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("loading PicoClaw config: %v", err)
|
||||
}
|
||||
if picoConfig.Providers.Anthropic.APIKey != "sk-ant-migrate-test" {
|
||||
t.Errorf("Anthropic.APIKey = %q, want %q", picoConfig.Providers.Anthropic.APIKey, "sk-ant-migrate-test")
|
||||
}
|
||||
if picoConfig.Providers.OpenRouter.APIKey != "sk-or-migrate-test" {
|
||||
t.Errorf("OpenRouter.APIKey = %q, want %q", picoConfig.Providers.OpenRouter.APIKey, "sk-or-migrate-test")
|
||||
}
|
||||
if !picoConfig.Channels.Telegram.Enabled {
|
||||
t.Error("Telegram should be enabled")
|
||||
}
|
||||
if picoConfig.Channels.Telegram.Token != "tg-migrate-test" {
|
||||
t.Errorf("Telegram.Token = %q, want %q", picoConfig.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",
|
||||
PicoClawHome: 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()
|
||||
picoClawHome := 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,
|
||||
PicoClawHome: picoClawHome,
|
||||
}
|
||||
|
||||
result, err := Run(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
if !result.ConfigMigrated {
|
||||
t.Error("config should have been migrated")
|
||||
}
|
||||
|
||||
picoWs := filepath.Join(picoClawHome, "workspace")
|
||||
if _, err := os.Stat(filepath.Join(picoWs, "SOUL.md")); !os.IsNotExist(err) {
|
||||
t.Error("config-only should not copy workspace files")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspaceOnly(t *testing.T) {
|
||||
openclawHome := t.TempDir()
|
||||
picoClawHome := 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,
|
||||
PicoClawHome: picoClawHome,
|
||||
}
|
||||
|
||||
result, err := Run(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
if result.ConfigMigrated {
|
||||
t.Error("workspace-only should not migrate config")
|
||||
}
|
||||
|
||||
picoWs := filepath.Join(picoClawHome, "workspace")
|
||||
soulData, err := os.ReadFile(filepath.Join(picoWs, "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,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/sipeed/picoclaw/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: picoclaw 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,248 +0,0 @@
|
|||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/openai/openai-go/v3"
|
||||
"github.com/openai/openai-go/v3/option"
|
||||
"github.com/openai/openai-go/v3/responses"
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
)
|
||||
|
||||
type CodexProvider struct {
|
||||
client *openai.Client
|
||||
accountID string
|
||||
tokenSource func() (string, string, error)
|
||||
}
|
||||
|
||||
func NewCodexProvider(token, accountID string) *CodexProvider {
|
||||
opts := []option.RequestOption{
|
||||
option.WithBaseURL("https://chatgpt.com/backend-api/codex"),
|
||||
option.WithAPIKey(token),
|
||||
}
|
||||
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
|
||||
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 != "" {
|
||||
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accID))
|
||||
}
|
||||
}
|
||||
|
||||
params := buildCodexParams(messages, tools, model, options)
|
||||
|
||||
resp, err := p.client.Responses.New(ctx, params, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("codex API call: %w", err)
|
||||
}
|
||||
|
||||
return parseCodexResponse(resp), nil
|
||||
}
|
||||
|
||||
func (p *CodexProvider) GetDefaultModel() string {
|
||||
return "gpt-4o"
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
Store: openai.Opt(false),
|
||||
}
|
||||
|
||||
if instructions != "" {
|
||||
params.Instructions = openai.Opt(instructions)
|
||||
}
|
||||
|
||||
if maxTokens, ok := options["max_tokens"].(int); ok {
|
||||
params.MaxOutputTokens = openai.Opt(int64(maxTokens))
|
||||
}
|
||||
|
||||
if temp, ok := options["temperature"].(float64); ok {
|
||||
params.Temperature = openai.Opt(temp)
|
||||
}
|
||||
|
||||
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: picoclaw 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 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,264 +0,0 @@
|
|||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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,
|
||||
})
|
||||
if params.Model != "gpt-4o" {
|
||||
t.Errorf("Model = %q, want %q", params.Model, "gpt-4o")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(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_GetDefaultModel(t *testing.T) {
|
||||
p := NewCodexProvider("test-token", "")
|
||||
if got := p.GetDefaultModel(); got != "gpt-4o" {
|
||||
t.Errorf("GetDefaultModel() = %q, want %q", got, "gpt-4o")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -13,9 +13,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
|
|
@ -51,12 +49,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
|
|||
}
|
||||
|
||||
if maxTokens, ok := options["max_tokens"].(int); ok {
|
||||
lowerModel := strings.ToLower(model)
|
||||
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") {
|
||||
requestBody["max_completion_tokens"] = maxTokens
|
||||
} else {
|
||||
requestBody["max_tokens"] = maxTokens
|
||||
}
|
||||
requestBody["max_tokens"] = maxTokens
|
||||
}
|
||||
|
||||
if temperature, ok := options["temperature"].(float64); ok {
|
||||
|
|
@ -170,177 +163,12 @@ 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: picoclaw 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: picoclaw 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)
|
||||
apiKey := cfg.GetAPIKey()
|
||||
apiBase := cfg.GetAPIBase()
|
||||
|
||||
var apiKey, apiBase 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 == "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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: detect provider from model name
|
||||
if apiKey == "" && apiBase == "" {
|
||||
switch { 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.groq.com/openai/v1"
|
||||
}
|
||||
|
||||
case cfg.Providers.VLLM.APIBase != "":
|
||||
apiKey = cfg.Providers.VLLM.APIKey
|
||||
apiBase = cfg.Providers.VLLM.APIBase
|
||||
|
||||
default:
|
||||
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"
|
||||
}
|
||||
} 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)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("OpenRouter API key not configured")
|
||||
}
|
||||
|
||||
return NewHTTPProvider(apiKey, apiBase), nil
|
||||
|
|
|
|||
|
|
@ -1,159 +0,0 @@
|
|||
package voice
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
type GroqTranscriber struct {
|
||||
apiKey string
|
||||
apiBase string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type TranscriptionResponse struct {
|
||||
Text string `json:"text"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
func NewGroqTranscriber(apiKey string) *GroqTranscriber {
|
||||
logger.DebugCF("voice", "Creating Groq transcriber", map[string]interface{}{"has_api_key": apiKey != ""})
|
||||
|
||||
apiBase := "https://api.groq.com/openai/v1"
|
||||
return &GroqTranscriber{
|
||||
apiKey: apiKey,
|
||||
apiBase: apiBase,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
|
||||
logger.InfoCF("voice", "Starting transcription", map[string]interface{}{"audio_file": audioFilePath})
|
||||
|
||||
audioFile, err := os.Open(audioFilePath)
|
||||
if err != nil {
|
||||
logger.ErrorCF("voice", "Failed to open audio file", map[string]interface{}{"path": audioFilePath, "error": err})
|
||||
return nil, fmt.Errorf("failed to open audio file: %w", err)
|
||||
}
|
||||
defer audioFile.Close()
|
||||
|
||||
fileInfo, err := audioFile.Stat()
|
||||
if err != nil {
|
||||
logger.ErrorCF("voice", "Failed to get file info", map[string]interface{}{"path": audioFilePath, "error": err})
|
||||
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||
}
|
||||
|
||||
logger.DebugCF("voice", "Audio file details", map[string]interface{}{
|
||||
"size_bytes": fileInfo.Size(),
|
||||
"file_name": filepath.Base(audioFilePath),
|
||||
})
|
||||
|
||||
var requestBody bytes.Buffer
|
||||
writer := multipart.NewWriter(&requestBody)
|
||||
|
||||
part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath))
|
||||
if err != nil {
|
||||
logger.ErrorCF("voice", "Failed to create form file", map[string]interface{}{"error": err})
|
||||
return nil, fmt.Errorf("failed to create form file: %w", err)
|
||||
}
|
||||
|
||||
copied, err := io.Copy(part, audioFile)
|
||||
if err != nil {
|
||||
logger.ErrorCF("voice", "Failed to copy file content", map[string]interface{}{"error": err})
|
||||
return nil, fmt.Errorf("failed to copy file content: %w", err)
|
||||
}
|
||||
|
||||
logger.DebugCF("voice", "File copied to request", map[string]interface{}{"bytes_copied": copied})
|
||||
|
||||
if err := writer.WriteField("model", "whisper-large-v3"); err != nil {
|
||||
logger.ErrorCF("voice", "Failed to write model field", map[string]interface{}{"error": err})
|
||||
return nil, fmt.Errorf("failed to write model field: %w", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("response_format", "json"); err != nil {
|
||||
logger.ErrorCF("voice", "Failed to write response_format field", map[string]interface{}{"error": err})
|
||||
return nil, fmt.Errorf("failed to write response_format field: %w", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
logger.ErrorCF("voice", "Failed to close multipart writer", map[string]interface{}{"error": err})
|
||||
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
url := t.apiBase + "/audio/transcriptions"
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody)
|
||||
if err != nil {
|
||||
logger.ErrorCF("voice", "Failed to create request", map[string]interface{}{"error": err})
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+t.apiKey)
|
||||
|
||||
logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]interface{}{
|
||||
"url": url,
|
||||
"request_size_bytes": requestBody.Len(),
|
||||
"file_size_bytes": fileInfo.Size(),
|
||||
})
|
||||
|
||||
resp, err := t.httpClient.Do(req)
|
||||
if err != nil {
|
||||
logger.ErrorCF("voice", "Failed to send request", map[string]interface{}{"error": err})
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.ErrorCF("voice", "Failed to read response", map[string]interface{}{"error": err})
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.ErrorCF("voice", "API error", map[string]interface{}{
|
||||
"status_code": resp.StatusCode,
|
||||
"response": string(body),
|
||||
})
|
||||
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
logger.DebugCF("voice", "Received response from Groq API", map[string]interface{}{
|
||||
"status_code": resp.StatusCode,
|
||||
"response_size_bytes": len(body),
|
||||
})
|
||||
|
||||
var result TranscriptionResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
logger.ErrorCF("voice", "Failed to unmarshal response", map[string]interface{}{"error": err})
|
||||
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
logger.InfoCF("voice", "Transcription completed successfully", map[string]interface{}{
|
||||
"text_length": len(result.Text),
|
||||
"language": result.Language,
|
||||
"duration_seconds": result.Duration,
|
||||
"transcription_preview": utils.Truncate(result.Text, 50),
|
||||
})
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (t *GroqTranscriber) IsAvailable() bool {
|
||||
available := t.apiKey != ""
|
||||
logger.DebugCF("voice", "Checking transcriber availability", map[string]interface{}{"available": available})
|
||||
return available
|
||||
}
|
||||
|
|
@ -4,4 +4,10 @@ services:
|
|||
env: docker
|
||||
plan: free
|
||||
dockerfilePath: ./Dockerfile
|
||||
startCommand: ./app gateway
|
||||
envVars:
|
||||
- key: OPENROUTER_API_KEY
|
||||
value: your_openrouter_api_key_here
|
||||
- key: TELEGRAM_BOT_TOKEN
|
||||
value: your_telegram_bot_token_here
|
||||
- key: TELEGRAM_CHAT_ID
|
||||
value: your_chat_id_here
|
||||
|
|
|
|||
|
|
@ -38,8 +38,6 @@ If the user asked for a transcript but it’s huge, return a tight summary first
|
|||
## Model + keys
|
||||
|
||||
Set the API key for your chosen provider:
|
||||
- OpenAI: `OPENAI_API_KEY`
|
||||
- Anthropic: `ANTHROPIC_API_KEY`
|
||||
- xAI: `XAI_API_KEY`
|
||||
- Google: `GEMINI_API_KEY` (aliases: `GOOGLE_GENERATIVE_AI_API_KEY`, `GOOGLE_API_KEY`)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue