diff --git a/.env.example b/.env.example deleted file mode 100644 index 06d43070c..000000000 --- a/.env.example +++ /dev/null @@ -1,20 +0,0 @@ -# ── LLM Provider ────────────────────────── -# Uncomment and set the API key for your provider -# OPENROUTER_API_KEY=sk-or-v1-xxx -# ZHIPU_API_KEY=xxx -# ANTHROPIC_API_KEY=sk-ant-xxx -# OPENAI_API_KEY=sk-xxx -# GEMINI_API_KEY=xxx -# CEREBRAS_API_KEY=xxx - -# ── Chat Channel ────────────────────────── -# TELEGRAM_BOT_TOKEN=123456:ABC... -# DISCORD_BOT_TOKEN=xxx -# LINE_CHANNEL_SECRET=xxx -# LINE_CHANNEL_ACCESS_TOKEN=xxx - -# ── Web Search (optional) ──────────────── -# BRAVE_SEARCH_API_KEY=BSA... - -# ── Timezone ────────────────────────────── -TZ=Asia/Tokyo diff --git a/cmd/picoclaw/internal/auth/helpers.go b/cmd/picoclaw/internal/auth/helpers.go index 633ce8740..f2236ce3d 100644 --- a/cmd/picoclaw/internal/auth/helpers.go +++ b/cmd/picoclaw/internal/auth/helpers.go @@ -1,6 +1,7 @@ package auth import ( + "bufio" "encoding/json" "fmt" "io" @@ -17,12 +18,12 @@ import ( const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity" -func authLoginCmd(provider string, useDeviceCode bool) error { +func authLoginCmd(provider string, useDeviceCode bool, setupToken bool) error { switch provider { case "openai": return authLoginOpenAI(useDeviceCode) case "anthropic": - return authLoginPasteToken(provider) + return authLoginAnthropic(setupToken) case "google-antigravity", "antigravity": return authLoginGoogleAntigravity() default: @@ -163,6 +164,78 @@ func authLoginGoogleAntigravity() error { return nil } +func authLoginAnthropic(setupToken bool) error { + if setupToken { + return authLoginAnthropicSetupToken() + } + + fmt.Println("Anthropic login method:") + fmt.Println(" 1) Setup token (from `claude setup-token`) (Recommended)") + fmt.Println(" 2) API key (from console.anthropic.com)") + fmt.Print("Choose [1]: ") + + scanner := bufio.NewScanner(os.Stdin) + choice := "1" + if scanner.Scan() { + text := strings.TrimSpace(scanner.Text()) + if text != "" { + choice = text + } + } + + switch choice { + case "1": + return authLoginAnthropicSetupToken() + case "2": + return authLoginPasteToken("anthropic") + default: + return fmt.Errorf("invalid choice: %s", choice) + } +} + +func authLoginAnthropicSetupToken() error { + cred, err := auth.LoginSetupToken(os.Stdin) + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + + if err = auth.SetCredential("anthropic", cred); err != nil { + return fmt.Errorf("failed to save credentials: %w", err) + } + + appCfg, err := internal.LoadConfig() + if err == nil { + appCfg.Providers.Anthropic.AuthMethod = "oauth" + + found := false + for i := range appCfg.ModelList { + if isAnthropicModel(appCfg.ModelList[i].Model) { + appCfg.ModelList[i].AuthMethod = "oauth" + found = true + break + } + } + if !found { + appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ + ModelName: "claude-sonnet-4.6", + Model: "anthropic/claude-sonnet-4.6", + AuthMethod: "oauth", + }) + } + + appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6" + + if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil { + return fmt.Errorf("could not update config: %w", err) + } + } + + fmt.Println("Setup token saved for Anthropic!") + fmt.Println("Default model set to: claude-sonnet-4.6") + + return nil +} + func fetchGoogleUserEmail(accessToken string) (string, error) { req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil) if err != nil { @@ -360,6 +433,16 @@ func authStatusCmd() error { if !cred.ExpiresAt.IsZero() { fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04")) } + + if provider == "anthropic" && cred.AuthMethod == "oauth" { + usage, err := auth.FetchAnthropicUsage(cred.AccessToken) + if err != nil { + fmt.Printf(" Usage: unavailable (%v)\n", err) + } else { + fmt.Printf(" Usage (5h): %.1f%%\n", usage.FiveHourUtilization*100) + fmt.Printf(" Usage (7d): %.1f%%\n", usage.SevenDayUtilization*100) + } + } } return nil diff --git a/cmd/picoclaw/internal/auth/login.go b/cmd/picoclaw/internal/auth/login.go index 9a6d28d2f..1cc238896 100644 --- a/cmd/picoclaw/internal/auth/login.go +++ b/cmd/picoclaw/internal/auth/login.go @@ -6,6 +6,7 @@ func newLoginCommand() *cobra.Command { var ( provider string useDeviceCode bool + setupToken bool ) cmd := &cobra.Command{ @@ -13,12 +14,13 @@ func newLoginCommand() *cobra.Command { Short: "Login via OAuth or paste token", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return authLoginCmd(provider, useDeviceCode) + return authLoginCmd(provider, useDeviceCode, setupToken) }, } cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)") cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)") + cmd.Flags().BoolVar(&setupToken, "setup-token", false, "Use setup-token flow for Anthropic (from `claude setup-token`)") _ = cmd.MarkFlagRequired("provider") return cmd diff --git a/pkg/auth/token.go b/pkg/auth/token.go index a5a13ff03..3280f629b 100644 --- a/pkg/auth/token.go +++ b/pkg/auth/token.go @@ -31,6 +31,38 @@ func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) { }, nil } +func LoginSetupToken(r io.Reader) (*AuthCredential, error) { + fmt.Println("Paste your setup token from `claude setup-token`:") + 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") + } + + if !strings.HasPrefix(token, "sk-ant-oat01-") { + return nil, fmt.Errorf("invalid setup token: expected prefix sk-ant-oat01-") + } + + if len(token) < 80 { + return nil, fmt.Errorf("invalid setup token: too short (expected at least 80 characters)") + } + + return &AuthCredential{ + AccessToken: token, + Provider: "anthropic", + AuthMethod: "oauth", + }, nil +} + func providerDisplayName(provider string) string { switch provider { case "anthropic": diff --git a/pkg/auth/usage.go b/pkg/auth/usage.go new file mode 100644 index 000000000..ded3d9723 --- /dev/null +++ b/pkg/auth/usage.go @@ -0,0 +1,58 @@ +package auth + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type AnthropicUsage struct { + FiveHourUtilization float64 + SevenDayUtilization float64 +} + +func FetchAnthropicUsage(token string) (*AnthropicUsage, error) { + req, err := http.NewRequest("GET", "https://api.anthropic.com/api/oauth/usage", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("anthropic-beta", "oauth-2025-04-20") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == http.StatusForbidden { + return nil, fmt.Errorf("insufficient scope: usage endpoint requires oauth scope") + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("usage request failed (%d): %s", resp.StatusCode, string(body)) + } + + var result struct { + FiveHour struct { + Utilization float64 `json:"utilization"` + } `json:"five_hour"` + SevenDay struct { + Utilization float64 `json:"utilization"` + } `json:"seven_day"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parsing usage response: %w", err) + } + + return &AnthropicUsage{ + FiveHourUtilization: result.FiveHour.Utilization, + SevenDayUtilization: result.SevenDay.Utilization, + }, nil +} diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index 9162174c9..c68a34219 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -77,7 +77,10 @@ func (p *Provider) Chat( if err != nil { return nil, fmt.Errorf("refreshing token: %w", err) } - opts = append(opts, option.WithAuthToken(tok)) + opts = append(opts, + option.WithAuthToken(tok), + option.WithHeader("anthropic-beta", "oauth-2025-04-20"), + ) } params, err := buildParams(messages, tools, model, options) @@ -85,6 +88,11 @@ func (p *Provider) Chat( return nil, err } + // OAuth/setup-tokens require streaming; API keys use non-streaming. + if p.tokenSource != nil { + return p.chatStreaming(ctx, params, opts) + } + resp, err := p.client.Messages.New(ctx, params, opts...) if err != nil { return nil, fmt.Errorf("claude API call: %w", err) @@ -93,6 +101,28 @@ func (p *Provider) Chat( return parseResponse(resp), nil } +func (p *Provider) chatStreaming( + ctx context.Context, + params anthropic.MessageNewParams, + opts []option.RequestOption, +) (*LLMResponse, error) { + stream := p.client.Messages.NewStreaming(ctx, params, opts...) + defer stream.Close() + + var msg anthropic.Message + for stream.Next() { + event := stream.Current() + if err := msg.Accumulate(event); err != nil { + return nil, fmt.Errorf("claude streaming accumulate: %w", err) + } + } + if err := stream.Err(); err != nil { + return nil, fmt.Errorf("claude API call: %w", err) + } + + return parseResponse(&msg), nil +} + func (p *Provider) GetDefaultModel() string { return "claude-sonnet-4.6" } @@ -164,8 +194,12 @@ func buildParams( maxTokens = int64(mt) } + // Normalize model ID: Anthropic API uses hyphens (claude-sonnet-4-6), + // but config may use dots (claude-sonnet-4.6). + apiModel := strings.ReplaceAll(model, ".", "-") + params := anthropic.MessageNewParams{ - Model: anthropic.Model(model), + Model: anthropic.Model(apiModel), Messages: anthropicMessages, MaxTokens: maxTokens, } diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go index 3d21c1d0b..d10fde81c 100644 --- a/pkg/providers/anthropic/provider_test.go +++ b/pkg/providers/anthropic/provider_test.go @@ -21,8 +21,8 @@ func TestBuildParams_BasicMessage(t *testing.T) { if err != nil { t.Fatalf("buildParams() error: %v", err) } - if string(params.Model) != "claude-sonnet-4.6" { - t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4.6") + if string(params.Model) != "claude-sonnet-4-6" { + t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-6") } if params.MaxTokens != 1024 { t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)