-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Refactor providers by protocol family (discussion #122) #213
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a6e885b
762565b
362c49a
e3c246a
c4cbb5f
2276bd1
8a3be99
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,248 @@ | ||
| package anthropicprovider | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "log" | ||
| "strings" | ||
|
|
||
| "github.com/anthropics/anthropic-sdk-go" | ||
| "github.com/anthropics/anthropic-sdk-go/option" | ||
| "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" | ||
| ) | ||
|
|
||
| type ToolCall = protocoltypes.ToolCall | ||
| type FunctionCall = protocoltypes.FunctionCall | ||
| type LLMResponse = protocoltypes.LLMResponse | ||
| type UsageInfo = protocoltypes.UsageInfo | ||
| type Message = protocoltypes.Message | ||
| type ToolDefinition = protocoltypes.ToolDefinition | ||
| type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition | ||
|
|
||
| const defaultBaseURL = "https://api.anthropic.com" | ||
|
|
||
| type Provider struct { | ||
| client *anthropic.Client | ||
| tokenSource func() (string, error) | ||
| baseURL string | ||
| } | ||
|
|
||
| func NewProvider(token string) *Provider { | ||
| return NewProviderWithBaseURL(token, "") | ||
| } | ||
|
|
||
| func NewProviderWithBaseURL(token, apiBase string) *Provider { | ||
| baseURL := normalizeBaseURL(apiBase) | ||
| client := anthropic.NewClient( | ||
| option.WithAuthToken(token), | ||
| option.WithBaseURL(baseURL), | ||
| ) | ||
| return &Provider{ | ||
| client: &client, | ||
| baseURL: baseURL, | ||
| } | ||
| } | ||
|
|
||
| func NewProviderWithClient(client *anthropic.Client) *Provider { | ||
| return &Provider{ | ||
| client: client, | ||
| baseURL: defaultBaseURL, | ||
| } | ||
| } | ||
|
|
||
| func NewProviderWithTokenSource(token string, tokenSource func() (string, error)) *Provider { | ||
| return NewProviderWithTokenSourceAndBaseURL(token, tokenSource, "") | ||
| } | ||
|
|
||
| func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *Provider { | ||
| p := NewProviderWithBaseURL(token, apiBase) | ||
| p.tokenSource = tokenSource | ||
| return p | ||
| } | ||
|
|
||
| func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { | ||
| var opts []option.RequestOption | ||
| if p.tokenSource != nil { | ||
| tok, err := p.tokenSource() | ||
| if err != nil { | ||
| return nil, fmt.Errorf("refreshing token: %w", err) | ||
| } | ||
| opts = append(opts, option.WithAuthToken(tok)) | ||
| } | ||
|
|
||
| params, err := buildParams(messages, tools, model, options) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| resp, err := p.client.Messages.New(ctx, params, opts...) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("claude API call: %w", err) | ||
| } | ||
|
|
||
| return parseResponse(resp), nil | ||
| } | ||
|
|
||
| func (p *Provider) GetDefaultModel() string { | ||
| return "claude-sonnet-4-5-20250929" | ||
| } | ||
|
|
||
| func (p *Provider) BaseURL() string { | ||
| return p.baseURL | ||
| } | ||
|
|
||
| func buildParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) { | ||
| var system []anthropic.TextBlockParam | ||
| var anthropicMessages []anthropic.MessageParam | ||
|
|
||
| for _, msg := range messages { | ||
| switch msg.Role { | ||
| case "system": | ||
| system = append(system, anthropic.TextBlockParam{Text: msg.Content}) | ||
| case "user": | ||
| if msg.ToolCallID != "" { | ||
| anthropicMessages = append(anthropicMessages, | ||
| anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)), | ||
| ) | ||
| } else { | ||
| anthropicMessages = append(anthropicMessages, | ||
| anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)), | ||
| ) | ||
| } | ||
| case "assistant": | ||
| if len(msg.ToolCalls) > 0 { | ||
| var blocks []anthropic.ContentBlockParamUnion | ||
| if msg.Content != "" { | ||
| blocks = append(blocks, anthropic.NewTextBlock(msg.Content)) | ||
| } | ||
| for _, tc := range msg.ToolCalls { | ||
| blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name)) | ||
| } | ||
| anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...)) | ||
| } else { | ||
| anthropicMessages = append(anthropicMessages, | ||
| anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)), | ||
| ) | ||
| } | ||
| case "tool": | ||
| anthropicMessages = append(anthropicMessages, | ||
| anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)), | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| maxTokens := int64(4096) | ||
| if mt, ok := options["max_tokens"].(int); ok { | ||
| maxTokens = int64(mt) | ||
| } | ||
|
|
||
| params := anthropic.MessageNewParams{ | ||
| Model: anthropic.Model(model), | ||
| Messages: anthropicMessages, | ||
| MaxTokens: maxTokens, | ||
| } | ||
|
|
||
| if len(system) > 0 { | ||
| params.System = system | ||
| } | ||
|
|
||
| if temp, ok := options["temperature"].(float64); ok { | ||
| params.Temperature = anthropic.Float(temp) | ||
| } | ||
|
|
||
| if len(tools) > 0 { | ||
| params.Tools = translateTools(tools) | ||
| } | ||
|
|
||
| return params, nil | ||
| } | ||
|
|
||
| func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam { | ||
| result := make([]anthropic.ToolUnionParam, 0, len(tools)) | ||
| for _, t := range tools { | ||
| tool := anthropic.ToolParam{ | ||
| Name: t.Function.Name, | ||
| InputSchema: anthropic.ToolInputSchemaParam{ | ||
| Properties: t.Function.Parameters["properties"], | ||
| }, | ||
| } | ||
| if desc := t.Function.Description; desc != "" { | ||
| tool.Description = anthropic.String(desc) | ||
| } | ||
| if req, ok := t.Function.Parameters["required"].([]interface{}); ok { | ||
| required := make([]string, 0, len(req)) | ||
| for _, r := range req { | ||
| if s, ok := r.(string); ok { | ||
| required = append(required, s) | ||
| } | ||
| } | ||
| tool.InputSchema.Required = required | ||
| } | ||
| result = append(result, anthropic.ToolUnionParam{OfTool: &tool}) | ||
| } | ||
| return result | ||
| } | ||
|
|
||
| func parseResponse(resp *anthropic.Message) *LLMResponse { | ||
| var content string | ||
| var toolCalls []ToolCall | ||
|
|
||
| for _, block := range resp.Content { | ||
| switch block.Type { | ||
| case "text": | ||
| tb := block.AsText() | ||
| content += tb.Text | ||
| case "tool_use": | ||
| tu := block.AsToolUse() | ||
| var args map[string]interface{} | ||
| if err := json.Unmarshal(tu.Input, &args); err != nil { | ||
| log.Printf("anthropic: failed to decode tool call input for %q: %v", tu.Name, err) | ||
| args = map[string]interface{}{"raw": string(tu.Input)} | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [M2 — MEDIUM] @jmahotiedu — When Consider at minimum adding a |
||
| toolCalls = append(toolCalls, ToolCall{ | ||
| ID: tu.ID, | ||
| Name: tu.Name, | ||
| Arguments: args, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| finishReason := "stop" | ||
| switch resp.StopReason { | ||
| case anthropic.StopReasonToolUse: | ||
| finishReason = "tool_calls" | ||
| case anthropic.StopReasonMaxTokens: | ||
| finishReason = "length" | ||
| case anthropic.StopReasonEndTurn: | ||
| finishReason = "stop" | ||
| } | ||
|
|
||
| return &LLMResponse{ | ||
| Content: content, | ||
| ToolCalls: toolCalls, | ||
| FinishReason: finishReason, | ||
| Usage: &UsageInfo{ | ||
| PromptTokens: int(resp.Usage.InputTokens), | ||
| CompletionTokens: int(resp.Usage.OutputTokens), | ||
| TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens), | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| func normalizeBaseURL(apiBase string) string { | ||
| base := strings.TrimSpace(apiBase) | ||
| if base == "" { | ||
| return defaultBaseURL | ||
| } | ||
|
|
||
| base = strings.TrimRight(base, "/") | ||
| if strings.HasSuffix(base, "/v1") { | ||
| base = strings.TrimSuffix(base, "/v1") | ||
| } | ||
| if base == "" { | ||
| return defaultBaseURL | ||
| } | ||
|
|
||
| return base | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[POSITIVE] Good resolution of C2 — the
NewProviderWithTokenSourceAndBaseURLconstructor cleanly chains throughNewProviderWithBaseURL, which callsnormalizeBaseURLto handle the/v1config/SDK mismatch.The
BaseURL()getter is also a nice addition — makes the flow testable end-to-end as proven byTestCreateProviderReturnsClaudeProviderForAnthropicOAuth.