mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(auth): add Anthropic OAuth setup-token login (#926)
* 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> * feat(auth): update related functionality * refactor(auth): organize constants and improve header casing in requests fo CI * fix(auth): fix golint again * fix(auth): handle nil arguments in tool calls for buildParams function --------- Co-authored-by: Baller <sharonms3377@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"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 {
|
||||
FiveHourUtilization float64
|
||||
SevenDayUtilization float64
|
||||
}
|
||||
|
||||
func FetchAnthropicUsage(token string) (*AnthropicUsage, error) {
|
||||
req, err := http.NewRequest("GET", anthropicUsageURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Anthropic-Version", anthropicAPIVersion)
|
||||
req.Header.Set("Anthropic-Beta", anthropicBetaHeader)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading usage response: %w", err)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,35 @@ 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 !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":
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,10 @@ type (
|
||||
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||
)
|
||||
|
||||
const defaultBaseURL = "https://api.anthropic.com"
|
||||
const (
|
||||
defaultBaseURL = "https://api.anthropic.com"
|
||||
anthropicBetaHeader = "oauth-2025-04-20"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
client *anthropic.Client
|
||||
@@ -80,7 +83,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", anthropicBetaHeader),
|
||||
)
|
||||
}
|
||||
|
||||
params, err := buildParams(messages, tools, model, options)
|
||||
@@ -88,6 +94,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)
|
||||
@@ -96,6 +107,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"
|
||||
}
|
||||
@@ -147,7 +180,16 @@ func buildParams(
|
||||
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
|
||||
args := tc.Arguments
|
||||
if args == nil && tc.Function != nil && tc.Function.Arguments != "" {
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, args, tc.Name))
|
||||
}
|
||||
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
|
||||
} else {
|
||||
@@ -167,8 +209,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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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 {
|
||||
c := anthropic.NewClient(
|
||||
anthropicoption.WithAuthToken(token),
|
||||
|
||||
Reference in New Issue
Block a user