feat(providers): add SDK-based providers for subscription OAuth login
Add ClaudeProvider (anthropic-sdk-go) and CodexProvider (openai-go) that use the correct subscription endpoints and API formats: - CodexProvider: chatgpt.com/backend-api/codex/responses (Responses API) with OAuth Bearer auth and Chatgpt-Account-Id header - ClaudeProvider: api.anthropic.com/v1/messages (Messages API) with Authorization: Bearer token auth Update CreateProvider() routing to use new SDK-based providers when auth_method is "oauth" or "token", removing the stopgap that sent subscription tokens to pay-per-token endpoints. Closes #18 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
210
pkg/providers/claude_provider_test.go
Normal file
210
pkg/providers/claude_provider_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user