feat(auth): update related functionality

This commit is contained in:
Baller 2026-03-02 23:28:34 -05:00
parent 647d1ee420
commit 853e023605
9 changed files with 293 additions and 42 deletions

17
.env.example Normal file
View file

@ -0,0 +1,17 @@
# ── 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
# CLAUDE_CODE_OAUTH=xxx
# ── Chat Channel ──────────────────────────
# TELEGRAM_BOT_TOKEN=123456:ABC...
# DISCORD_BOT_TOKEN=xxx
# ── Web Search (optional) ────────────────
# BRAVE_SEARCH_API_KEY=BSA...
# ── Timezone ──────────────────────────────
TZ=Asia/Tokyo

View file

@ -17,13 +17,14 @@ import (
) )
const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity" const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
const defaultAnthropicModel = "claude-sonnet-4.6"
func authLoginCmd(provider string, useDeviceCode bool, setupToken bool) error { func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {
switch provider { switch provider {
case "openai": case "openai":
return authLoginOpenAI(useDeviceCode) return authLoginOpenAI(useDeviceCode)
case "anthropic": case "anthropic":
return authLoginAnthropic(setupToken) return authLoginAnthropic(useOauth)
case "google-antigravity", "antigravity": case "google-antigravity", "antigravity":
return authLoginGoogleAntigravity() return authLoginGoogleAntigravity()
default: default:
@ -164,17 +165,18 @@ func authLoginGoogleAntigravity() error {
return nil return nil
} }
func authLoginAnthropic(setupToken bool) error { func authLoginAnthropic(useOauth bool) error {
if setupToken { if useOauth {
return authLoginAnthropicSetupToken() return authLoginAnthropicSetupToken()
} }
fmt.Println("Anthropic login method:") fmt.Println("Anthropic login method:")
fmt.Println(" 1) Setup token (from `claude setup-token`) (Recommended)") fmt.Println(" 1) Setup token (from `claude setup-token`) (Recommended)")
fmt.Println(" 2) API key (from console.anthropic.com)") fmt.Println(" 2) API key (from console.anthropic.com)")
fmt.Print("Choose [1]: ")
scanner := bufio.NewScanner(os.Stdin) scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Print("Choose [1]: ")
choice := "1" choice := "1"
if scanner.Scan() { if scanner.Scan() {
text := strings.TrimSpace(scanner.Text()) text := strings.TrimSpace(scanner.Text())
@ -189,7 +191,8 @@ func authLoginAnthropic(setupToken bool) error {
case "2": case "2":
return authLoginPasteToken("anthropic") return authLoginPasteToken("anthropic")
default: default:
return fmt.Errorf("invalid choice: %s", choice) fmt.Printf("Invalid choice: %s. Please enter 1 or 2.\n", choice)
}
} }
} }
@ -217,13 +220,15 @@ func authLoginAnthropicSetupToken() error {
} }
if !found { if !found {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
ModelName: "claude-sonnet-4.6", ModelName: defaultAnthropicModel,
Model: "anthropic/claude-sonnet-4.6", Model: "anthropic/" + defaultAnthropicModel,
AuthMethod: "oauth", AuthMethod: "oauth",
}) })
// Only set default model if user has no default configured yet
if appCfg.Agents.Defaults.GetModelName() == "" {
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
}
} }
appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil { if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
return fmt.Errorf("could not update config: %w", err) return fmt.Errorf("could not update config: %w", err)
@ -231,7 +236,6 @@ func authLoginAnthropicSetupToken() error {
} }
fmt.Println("Setup token saved for Anthropic!") fmt.Println("Setup token saved for Anthropic!")
fmt.Println("Default model set to: claude-sonnet-4.6")
return nil return nil
} }
@ -290,13 +294,12 @@ func authLoginPasteToken(provider string) error {
} }
if !found { if !found {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
ModelName: "claude-sonnet-4.6", ModelName: defaultAnthropicModel,
Model: "anthropic/claude-sonnet-4.6", Model: "anthropic/" + defaultAnthropicModel,
AuthMethod: "token", AuthMethod: "token",
}) })
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
} }
// Update default model
appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
case "openai": case "openai":
appCfg.Providers.OpenAI.AuthMethod = "token" appCfg.Providers.OpenAI.AuthMethod = "token"
// Update ModelList // Update ModelList

View file

@ -6,7 +6,7 @@ func newLoginCommand() *cobra.Command {
var ( var (
provider string provider string
useDeviceCode bool useDeviceCode bool
setupToken bool useOauth bool
) )
cmd := &cobra.Command{ cmd := &cobra.Command{
@ -14,13 +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, setupToken) return authLoginCmd(provider, useDeviceCode, useOauth)
}, },
} }
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.Flags().BoolVar(&useOauth, "setup-token", false, "Use setup-token flow for Anthropic (from `claude setup-token`)")
_ = cmd.MarkFlagRequired("provider") _ = cmd.MarkFlagRequired("provider")
return cmd return cmd

View file

@ -8,19 +8,30 @@ import (
"time" "time"
) )
const (
anthropicBetaHeader = "oauth-2025-04-20"
anthropicAPIVersion = "2023-06-01"
)
// anthropicUsageURL is the endpoint for fetching OAuth usage stats.
// It is a var (not const) to allow overriding in tests.
var anthropicUsageURL = "https://api.anthropic.com/api/oauth/usage"
func setAnthropicUsageURL(url string) { anthropicUsageURL = url }
type AnthropicUsage struct { type AnthropicUsage struct {
FiveHourUtilization float64 FiveHourUtilization float64
SevenDayUtilization float64 SevenDayUtilization float64
} }
func FetchAnthropicUsage(token string) (*AnthropicUsage, error) { func FetchAnthropicUsage(token string) (*AnthropicUsage, error) {
req, err := http.NewRequest("GET", "https://api.anthropic.com/api/oauth/usage", nil) req, err := http.NewRequest("GET", anthropicUsageURL, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("anthropic-version", "2023-06-01") req.Header.Set("anthropic-version", anthropicAPIVersion)
req.Header.Set("anthropic-beta", "oauth-2025-04-20") req.Header.Set("anthropic-beta", anthropicBetaHeader)
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)
@ -29,13 +40,15 @@ func FetchAnthropicUsage(token string) (*AnthropicUsage, error) {
} }
defer resp.Body.Close() defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil {
if resp.StatusCode == http.StatusForbidden { return nil, fmt.Errorf("reading usage response: %w", err)
return nil, fmt.Errorf("insufficient scope: usage endpoint requires oauth scope")
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusForbidden {
return nil, fmt.Errorf("insufficient scope: usage endpoint requires oauth scope")
}
return nil, fmt.Errorf("usage request failed (%d): %s", resp.StatusCode, string(body)) return nil, fmt.Errorf("usage request failed (%d): %s", resp.StatusCode, string(body))
} }

View file

@ -0,0 +1,98 @@
package auth
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestFetchAnthropicUsage_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Errorf("Authorization = %q, want %q", got, "Bearer test-token")
}
if got := r.Header.Get("anthropic-beta"); got != anthropicBetaHeader {
t.Errorf("anthropic-beta = %q, want %q", got, anthropicBetaHeader)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"five_hour":{"utilization":0.42},"seven_day":{"utilization":0.85}}`))
}))
defer srv.Close()
// Temporarily override the URL by using the test server
origURL := anthropicUsageURL
defer func() { setAnthropicUsageURL(origURL) }()
setAnthropicUsageURL(srv.URL)
usage, err := FetchAnthropicUsage("test-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if usage.FiveHourUtilization != 0.42 {
t.Errorf("FiveHourUtilization = %v, want 0.42", usage.FiveHourUtilization)
}
if usage.SevenDayUtilization != 0.85 {
t.Errorf("SevenDayUtilization = %v, want 0.85", usage.SevenDayUtilization)
}
}
func TestFetchAnthropicUsage_Forbidden(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":"forbidden"}`))
}))
defer srv.Close()
origURL := anthropicUsageURL
defer func() { setAnthropicUsageURL(origURL) }()
setAnthropicUsageURL(srv.URL)
_, err := FetchAnthropicUsage("test-token")
if err == nil {
t.Fatal("expected error for 403, got nil")
}
if !strings.Contains(err.Error(), "insufficient scope") {
t.Errorf("expected 'insufficient scope' error, got %q", err.Error())
}
}
func TestFetchAnthropicUsage_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`internal error`))
}))
defer srv.Close()
origURL := anthropicUsageURL
defer func() { setAnthropicUsageURL(origURL) }()
setAnthropicUsageURL(srv.URL)
_, err := FetchAnthropicUsage("test-token")
if err == nil {
t.Fatal("expected error for 500, got nil")
}
if !strings.Contains(err.Error(), "500") {
t.Errorf("expected error containing '500', got %q", err.Error())
}
}
func TestFetchAnthropicUsage_MalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`not json`))
}))
defer srv.Close()
origURL := anthropicUsageURL
defer func() { setAnthropicUsageURL(origURL) }()
setAnthropicUsageURL(srv.URL)
_, err := FetchAnthropicUsage("test-token")
if err == nil {
t.Fatal("expected error for malformed JSON, got nil")
}
if !strings.Contains(err.Error(), "parsing usage response") {
t.Errorf("expected 'parsing usage response' error, got %q", err.Error())
}
}

View file

@ -44,9 +44,6 @@ func LoginSetupToken(r io.Reader) (*AuthCredential, error) {
} }
token := strings.TrimSpace(scanner.Text()) token := strings.TrimSpace(scanner.Text())
if token == "" {
return nil, fmt.Errorf("token cannot be empty")
}
if !strings.HasPrefix(token, "sk-ant-oat01-") { if !strings.HasPrefix(token, "sk-ant-oat01-") {
return nil, fmt.Errorf("invalid setup token: expected prefix sk-ant-oat01-") return nil, fmt.Errorf("invalid setup token: expected prefix sk-ant-oat01-")

61
pkg/auth/token_test.go Normal file
View file

@ -0,0 +1,61 @@
package auth
import (
"strings"
"testing"
)
func TestLoginSetupToken(t *testing.T) {
// A valid token: correct prefix + at least 80 chars
validToken := "sk-ant-oat01-" + strings.Repeat("a", 80)
tests := []struct {
name string
input string
wantErr string
}{
{"valid token", validToken, ""},
{"empty input", "", "expected prefix sk-ant-oat01-"},
{"wrong prefix", "sk-ant-api-" + strings.Repeat("a", 80), "expected prefix sk-ant-oat01-"},
{"too short", "sk-ant-oat01-short", "too short"},
{"whitespace only", " ", "expected prefix sk-ant-oat01-"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := strings.NewReader(tt.input + "\n")
cred, err := LoginSetupToken(r)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cred.AccessToken != validToken {
t.Errorf("AccessToken = %q, want %q", cred.AccessToken, validToken)
}
if cred.Provider != "anthropic" {
t.Errorf("Provider = %q, want %q", cred.Provider, "anthropic")
}
if cred.AuthMethod != "oauth" {
t.Errorf("AuthMethod = %q, want %q", cred.AuthMethod, "oauth")
}
})
}
}
func TestLoginSetupToken_EmptyReader(t *testing.T) {
r := strings.NewReader("")
_, err := LoginSetupToken(r)
if err == nil {
t.Fatal("expected error for empty reader, got nil")
}
}

View file

@ -23,7 +23,10 @@ type (
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
) )
const defaultBaseURL = "https://api.anthropic.com" const (
defaultBaseURL = "https://api.anthropic.com"
anthropicBetaHeader = "oauth-2025-04-20"
)
type Provider struct { type Provider struct {
client *anthropic.Client client *anthropic.Client
@ -79,7 +82,7 @@ func (p *Provider) Chat(
} }
opts = append(opts, opts = append(opts,
option.WithAuthToken(tok), option.WithAuthToken(tok),
option.WithHeader("anthropic-beta", "oauth-2025-04-20"), option.WithHeader("anthropic-beta", anthropicBetaHeader),
) )
} }

View file

@ -262,6 +262,65 @@ func TestProvider_ChatUsesTokenSource(t *testing.T) {
} }
} }
func TestProvider_ChatStreamingRoundTrip(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 got := r.Header.Get("Authorization"); got != "Bearer refreshed-token" {
t.Errorf("Authorization = %q, want %q", got, "Bearer refreshed-token")
}
if got := r.Header.Get("anthropic-beta"); got != anthropicBetaHeader {
t.Errorf("anthropic-beta = %q, want %q", got, anthropicBetaHeader)
}
w.Header().Set("Content-Type", "text/event-stream")
flusher, _ := w.(http.Flusher)
events := []string{
"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-sonnet-4-6\",\"stop_reason\":null,\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n",
"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n",
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n\n",
"event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n",
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
}
for _, e := range events {
w.Write([]byte(e))
if flusher != nil {
flusher.Flush()
}
}
}))
defer server.Close()
p := NewProviderWithTokenSourceAndBaseURL("stale-token", func() (string, error) {
return "refreshed-token", nil
}, server.URL)
resp, err := p.Chat(
t.Context(),
[]Message{{Role: "user", Content: "Hello"}},
nil,
"claude-sonnet-4.6",
map[string]any{},
)
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
if resp.Content != "Hello world" {
t.Errorf("Content = %q, want %q", resp.Content, "Hello world")
}
if resp.FinishReason != "stop" {
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
}
if resp.Usage.CompletionTokens != 5 {
t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens)
}
}
func createAnthropicTestClient(baseURL, token string) *anthropic.Client { func createAnthropicTestClient(baseURL, token string) *anthropic.Client {
c := anthropic.NewClient( c := anthropic.NewClient(
anthropicoption.WithAuthToken(token), anthropicoption.WithAuthToken(token),