feat(auth): add Anthropic OAuth setup-token login flow
Add support for Anthropic's OAuth-based setup tokens (sk-ant-oat01-*) as an alternative to API keys. This includes: - New `--setup-token` flag on `auth login` command - Interactive login menu for Anthropic (setup token vs API key) - Setup token validation and credential storage with oauth auth method - Usage endpoint integration to show 5h/7d utilization in `auth status` - Streaming support for OAuth tokens (required by Anthropic API) - Model ID normalization (dots to hyphens) for API compatibility - Remove .env.example (secrets should not be templated) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9c9524f934
commit
647d1ee420
7 changed files with 216 additions and 27 deletions
20
.env.example
20
.env.example
|
|
@ -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
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -17,12 +18,12 @@ import (
|
||||||
|
|
||||||
const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
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 {
|
switch provider {
|
||||||
case "openai":
|
case "openai":
|
||||||
return authLoginOpenAI(useDeviceCode)
|
return authLoginOpenAI(useDeviceCode)
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
return authLoginPasteToken(provider)
|
return authLoginAnthropic(setupToken)
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
return authLoginGoogleAntigravity()
|
return authLoginGoogleAntigravity()
|
||||||
default:
|
default:
|
||||||
|
|
@ -163,6 +164,78 @@ func authLoginGoogleAntigravity() error {
|
||||||
return nil
|
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) {
|
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -360,6 +433,16 @@ func authStatusCmd() error {
|
||||||
if !cred.ExpiresAt.IsZero() {
|
if !cred.ExpiresAt.IsZero() {
|
||||||
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
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
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ func newLoginCommand() *cobra.Command {
|
||||||
var (
|
var (
|
||||||
provider string
|
provider string
|
||||||
useDeviceCode bool
|
useDeviceCode bool
|
||||||
|
setupToken bool
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|
@ -13,12 +14,13 @@ func newLoginCommand() *cobra.Command {
|
||||||
Short: "Login via OAuth or paste token",
|
Short: "Login via OAuth or paste token",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
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().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(&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")
|
_ = cmd.MarkFlagRequired("provider")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,38 @@ func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) {
|
||||||
}, nil
|
}, 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 {
|
func providerDisplayName(provider string) string {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
|
|
|
||||||
58
pkg/auth/usage.go
Normal file
58
pkg/auth/usage.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -77,7 +77,10 @@ func (p *Provider) Chat(
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("refreshing token: %w", err)
|
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)
|
params, err := buildParams(messages, tools, model, options)
|
||||||
|
|
@ -85,6 +88,11 @@ func (p *Provider) Chat(
|
||||||
return nil, err
|
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...)
|
resp, err := p.client.Messages.New(ctx, params, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("claude API call: %w", err)
|
return nil, fmt.Errorf("claude API call: %w", err)
|
||||||
|
|
@ -93,6 +101,28 @@ func (p *Provider) Chat(
|
||||||
return parseResponse(resp), nil
|
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 {
|
func (p *Provider) GetDefaultModel() string {
|
||||||
return "claude-sonnet-4.6"
|
return "claude-sonnet-4.6"
|
||||||
}
|
}
|
||||||
|
|
@ -164,8 +194,12 @@ func buildParams(
|
||||||
maxTokens = int64(mt)
|
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{
|
params := anthropic.MessageNewParams{
|
||||||
Model: anthropic.Model(model),
|
Model: anthropic.Model(apiModel),
|
||||||
Messages: anthropicMessages,
|
Messages: anthropicMessages,
|
||||||
MaxTokens: maxTokens,
|
MaxTokens: maxTokens,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ func TestBuildParams_BasicMessage(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildParams() error: %v", err)
|
t.Fatalf("buildParams() error: %v", err)
|
||||||
}
|
}
|
||||||
if string(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")
|
t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-6")
|
||||||
}
|
}
|
||||||
if params.MaxTokens != 1024 {
|
if params.MaxTokens != 1024 {
|
||||||
t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
|
t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue