diff --git a/go/plugins/anthropic/anthropic.go b/go/plugins/anthropic/anthropic.go new file mode 100644 index 000000000..60e5fe5a3 --- /dev/null +++ b/go/plugins/anthropic/anthropic.go @@ -0,0 +1,145 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package anthropic + +import ( + "context" + "errors" + "fmt" + "os" + "sync" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/core" + "github.com/firebase/genkit/go/genkit" + ant "github.com/firebase/genkit/go/plugins/internal/anthropic" +) + +const ( + provider = "anthropic" + anthropicLabelPrefix = "Anthropic" +) + +type Anthropic struct { + APIKey string // If not provided, defaults to ANTHROPIC_API_KEY + aclient anthropic.Client // Anthropic client + mu sync.Mutex // Mutex to control access + initted bool // Whether the plugin has been initialized +} + +func (a *Anthropic) Name() string { + return provider +} + +func (a *Anthropic) Init(ctx context.Context, g *genkit.Genkit) (err error) { + if a == nil { + a = &Anthropic{} + } + + a.mu.Lock() + defer a.mu.Unlock() + if a.initted { + return errors.New("plugin already initialized") + } + defer func() { + if err != nil { + err = fmt.Errorf("Anthropic.Init: %w", err) + } + }() + + apiKey := a.APIKey + if apiKey == "" { + apiKey = os.Getenv("ANTHROPIC_API_KEY") + } + if apiKey == "" { + return fmt.Errorf("Anthropic requires setting ANTHROPIC_API_KEY in the environment") + } + + ac := anthropic.NewClient( + option.WithAPIKey(apiKey), + ) + a.aclient = ac + a.initted = true + + return nil +} + +func (a *Anthropic) DefineModel(g *genkit.Genkit, name string, info *ai.ModelInfo) ai.Model { + return ant.DefineModel(g, a.aclient, provider, name, *info) +} + +func (a *Anthropic) IsDefinedModel(g *genkit.Genkit, name string) bool { + return genkit.LookupModel(g, provider, name) != nil +} + +func (a *Anthropic) ListActions(ctx context.Context) []core.ActionDesc { + actions := []core.ActionDesc{} + + models, err := listModels(ctx, &a.aclient) + if err != nil { + return nil + } + + for _, name := range models { + metadata := map[string]any{ + "model": map[string]any{ + "supports": map[string]any{ + "media": true, + "multiturn": true, + "systemRole": true, + "tools": true, + "toolChoice": true, + "constrained": "no-tools", + }, + }, + "versions": []string{}, + "stage": string(ai.ModelStageStable), + } + metadata["label"] = fmt.Sprintf("%s - %s", anthropicLabelPrefix, name) + + actions = append(actions, core.ActionDesc{ + Type: core.ActionTypeModel, + Name: fmt.Sprintf("%s/%s", provider, name), + Key: fmt.Sprintf("/%s/%s/%s", core.ActionTypeModel, provider, name), + Metadata: metadata, + }) + + } + return actions +} + +func (a *Anthropic) ResolveAction(g *genkit.Genkit, atype core.ActionType, name string) error { + switch atype { + case core.ActionTypeModel: + ant.DefineModel(g, a.aclient, provider, name, ai.ModelInfo{ + Label: fmt.Sprintf("%s - %s", anthropicLabelPrefix, name), + Stage: ai.ModelStageStable, + Versions: []string{}, + Supports: &ai.ModelSupports{ + Media: true, + Multiturn: true, + SystemRole: true, + Tools: true, + ToolChoice: true, + Constrained: ai.ConstrainedSupportNoTools, + }, + }) + } + return nil +} diff --git a/go/plugins/anthropic/models.go b/go/plugins/anthropic/models.go new file mode 100644 index 000000000..2f00ababb --- /dev/null +++ b/go/plugins/anthropic/models.go @@ -0,0 +1,40 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package anthropic + +import ( + "context" + + "github.com/anthropics/anthropic-sdk-go" +) + +// listModels returns a list of model names supported by the Anthropic client +func listModels(ctx context.Context, client *anthropic.Client) ([]string, error) { + iter := client.Models.ListAutoPaging(ctx, anthropic.ModelListParams{}) + models := []string{} + + for iter.Next() { + m := iter.Current() + models = append(models, m.DisplayName) + } + + if err := iter.Err(); err != nil { + panic(err.Error()) + } + + return models, nil +} diff --git a/go/plugins/googlegenai/googlegenai.go b/go/plugins/googlegenai/googlegenai.go index f5c29183c..5ab3c25f8 100644 --- a/go/plugins/googlegenai/googlegenai.go +++ b/go/plugins/googlegenai/googlegenai.go @@ -330,7 +330,7 @@ func (ga *GoogleAI) ListActions(ctx context.Context) []core.ActionDesc { "systemRole": true, "tools": true, "toolChoice": true, - "constrained": true, + "constrained": "no-tools", }, "versions": []string{}, "stage": string(ai.ModelStageStable), @@ -394,7 +394,7 @@ func (v *VertexAI) ListActions(ctx context.Context) []core.ActionDesc { "systemRole": true, "tools": true, "toolChoice": true, - "constrained": true, + "constrained": "no-tools", }, "versions": []string{}, "stage": string(ai.ModelStageStable), diff --git a/go/plugins/internal/anthropic/anthropic.go b/go/plugins/internal/anthropic/anthropic.go new file mode 100644 index 000000000..c77a43624 --- /dev/null +++ b/go/plugins/internal/anthropic/anthropic.go @@ -0,0 +1,339 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package anthropic + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "regexp" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/internal/uri" + "github.com/invopop/jsonschema" + + "github.com/anthropics/anthropic-sdk-go" +) + +const ( + ToolNameRegex = `^[a-zA-Z0-9_-]{1,64}$` +) + +func DefineModel(g *genkit.Genkit, client anthropic.Client, provider, name string, info ai.ModelInfo) ai.Model { + label := "Anthropic" + if provider == "vertexai" { + label = "Vertex AI" + } + meta := &ai.ModelInfo{ + Label: label + "-" + name, + Supports: info.Supports, + Versions: info.Versions, + } + return genkit.DefineModel(g, provider, name, meta, func( + ctx context.Context, + input *ai.ModelRequest, + cb func(context.Context, *ai.ModelResponseChunk) error, + ) (*ai.ModelResponse, error) { + return generate(ctx, client, name, input, cb) + }) +} + +// Generate function defines how a generate request is done in Anthropic models +func generate( + ctx context.Context, + client anthropic.Client, + model string, + input *ai.ModelRequest, + cb func(context.Context, *ai.ModelResponseChunk) error, +) (*ai.ModelResponse, error) { + req, err := toAnthropicRequest(model, input) + if err != nil { + return nil, fmt.Errorf("unable to generate anthropic request: %w", err) + } + + // no streaming + if cb == nil { + msg, err := client.Messages.New(ctx, *req) + if err != nil { + return nil, err + } + + r, err := toGenkitResponse(msg) + if err != nil { + return nil, err + } + + r.Request = input + return r, nil + } else { + stream := client.Messages.NewStreaming(ctx, *req) + message := anthropic.Message{} + for stream.Next() { + event := stream.Current() + err := message.Accumulate(event) + if err != nil { + return nil, err + } + + content := []*ai.Part{} + switch event := event.AsAny().(type) { + case anthropic.ContentBlockDeltaEvent: + content = append(content, ai.NewTextPart(event.Delta.Text)) + cb(ctx, &ai.ModelResponseChunk{ + Content: content, + }) + case anthropic.MessageStopEvent: + r, err := toGenkitResponse(&message) + if err != nil { + return nil, err + } + r.Request = input + return r, nil + } + } + if stream.Err() != nil { + return nil, stream.Err() + } + } + + return nil, nil +} + +func toAnthropicRole(role ai.Role) (anthropic.MessageParamRole, error) { + switch role { + case ai.RoleUser: + return anthropic.MessageParamRoleUser, nil + case ai.RoleModel: + return anthropic.MessageParamRoleAssistant, nil + case ai.RoleTool: + return anthropic.MessageParamRoleAssistant, nil + default: + return "", fmt.Errorf("unknown role given: %q", role) + } +} + +// toAnthropicRequest translates [ai.ModelRequest] to an Anthropic request +func toAnthropicRequest(model string, i *ai.ModelRequest) (*anthropic.MessageNewParams, error) { + messages := make([]anthropic.MessageParam, 0) + + req, err := configFromRequest(i) + if err != nil { + return nil, err + } + + if req.Model == "" { + return nil, errors.New("anthropic model not provided in request") + } + // configure system prompt (if given) + sysBlocks := []anthropic.TextBlockParam{} + for _, message := range i.Messages { + if message.Role == ai.RoleSystem { + // only text is supported for system messages + sysBlocks = append(sysBlocks, anthropic.TextBlockParam{Text: message.Text()}) + } else if message.Content[len(message.Content)-1].IsToolResponse() { + // if the last message is a ToolResponse, the conversation must continue + // and the ToolResponse message must be sent as a user + // see: https://docs.anthropic.com/en/docs/build-with-claude/tool-use#handling-tool-use-and-tool-result-content-blocks + parts, err := toAnthropicParts(message.Content) + if err != nil { + return nil, err + } + messages = append(messages, anthropic.NewUserMessage(parts...)) + } else { + parts, err := toAnthropicParts(message.Content) + if err != nil { + return nil, err + } + role, err := toAnthropicRole(message.Role) + if err != nil { + return nil, err + } + messages = append(messages, anthropic.MessageParam{ + Role: role, + Content: parts, + }) + } + } + + req.System = sysBlocks + req.Messages = messages + + tools, err := toAnthropicTools(i.Tools) + if err != nil { + return nil, err + } + req.Tools = tools + + return req, nil +} + +// mapToStruct unmarshals a map[String]any to the expected type +func mapToStruct(m map[string]any, v any) error { + jsonData, err := json.Marshal(m) + if err != nil { + return err + } + return json.Unmarshal(jsonData, v) +} + +// configFromRequest converts any supported config type to [anthropic.MessageNewParams] +func configFromRequest(input *ai.ModelRequest) (*anthropic.MessageNewParams, error) { + var result anthropic.MessageNewParams + + switch config := input.Config.(type) { + case anthropic.MessageNewParams: + result = config + case *anthropic.MessageNewParams: + result = *config + case map[string]any: + if err := mapToStruct(config, &result); err != nil { + return nil, err + } + case nil: + // Empty configuration is considered valid + default: + return nil, fmt.Errorf("unexpected config type: %T", input.Config) + } + return &result, nil +} + +// toAnthropicTools translates [ai.ToolDefinition] to an anthropic.ToolParam type +func toAnthropicTools(tools []*ai.ToolDefinition) ([]anthropic.ToolUnionParam, error) { + resp := make([]anthropic.ToolUnionParam, 0) + regex := regexp.MustCompile(ToolNameRegex) + + for _, t := range tools { + if t.Name == "" { + return nil, fmt.Errorf("tool name is required") + } + if !regex.MatchString(t.Name) { + return nil, fmt.Errorf("tool name must match regex: %s", ToolNameRegex) + } + + resp = append(resp, anthropic.ToolUnionParam{ + OfTool: &anthropic.ToolParam{ + Name: t.Name, + Description: anthropic.String(t.Description), + InputSchema: toAnthropicSchema[map[string]any](), + }, + }) + } + + return resp, nil +} + +// toAnthropicSchema generates a JSON schema for the requested input type +func toAnthropicSchema[T any]() anthropic.ToolInputSchemaParam { + reflector := jsonschema.Reflector{ + AllowAdditionalProperties: true, + DoNotReference: true, + } + var v T + schema := reflector.Reflect(v) + return anthropic.ToolInputSchemaParam{ + Properties: schema.Properties, + } +} + +// toAnthropicParts translates [ai.Part] to an anthropic.ContentBlockParamUnion type +func toAnthropicParts(parts []*ai.Part) ([]anthropic.ContentBlockParamUnion, error) { + blocks := []anthropic.ContentBlockParamUnion{} + + for _, p := range parts { + switch { + case p.IsText(): + blocks = append(blocks, anthropic.NewTextBlock(p.Text)) + case p.IsMedia(): + contentType, data, _ := uri.Data(p) + blocks = append(blocks, anthropic.NewImageBlockBase64(contentType, base64.StdEncoding.EncodeToString(data))) + case p.IsData(): + contentType, data, _ := uri.Data(p) + blocks = append(blocks, anthropic.NewImageBlockBase64(contentType, base64.RawStdEncoding.EncodeToString(data))) + case p.IsToolRequest(): + toolReq := p.ToolRequest + blocks = append(blocks, anthropic.NewToolUseBlock(toolReq.Ref, toolReq.Input, toolReq.Name)) + case p.IsToolResponse(): + toolResp := p.ToolResponse + output, err := json.Marshal(toolResp.Output) + if err != nil { + return nil, fmt.Errorf("unable to parse tool response, err: %w", err) + } + blocks = append(blocks, anthropic.NewToolResultBlock(toolResp.Ref, string(output), false)) + case p.IsReasoning(): + signature := []byte{} + if p.Metadata != nil { + if sig, ok := p.Metadata["signature"].([]byte); ok { + signature = sig + } + } + blocks = append(blocks, anthropic.NewThinkingBlock(string(signature), p.Text)) + default: + return nil, errors.New("unknown part type in the request") + } + } + + return blocks, nil +} + +// toGenkitResponse translates an Anthropic Message to [ai.ModelResponse] +func toGenkitResponse(m *anthropic.Message) (*ai.ModelResponse, error) { + r := ai.ModelResponse{} + + switch m.StopReason { + case anthropic.StopReasonMaxTokens: + r.FinishReason = ai.FinishReasonLength + case anthropic.StopReasonStopSequence: + r.FinishReason = ai.FinishReasonStop + case anthropic.StopReasonEndTurn: + r.FinishReason = ai.FinishReasonStop + case anthropic.StopReasonToolUse: + r.FinishReason = ai.FinishReasonStop + default: + r.FinishReason = ai.FinishReasonUnknown + } + + msg := &ai.Message{} + msg.Role = ai.RoleModel + for _, part := range m.Content { + var p *ai.Part + switch part.AsAny().(type) { + case anthropic.ThinkingBlock: + p = ai.NewReasoningPart(part.Text, []byte(part.Signature)) + case anthropic.TextBlock: + p = ai.NewTextPart(string(part.Text)) + case anthropic.ToolUseBlock: + p = ai.NewToolRequestPart(&ai.ToolRequest{ + Ref: part.ID, + Input: part.Input, + Name: part.Name, + }) + default: + return nil, fmt.Errorf("unknown part: %#v", part) + } + msg.Content = append(msg.Content, p) + } + + r.Message = msg + r.Usage = &ai.GenerationUsage{ + InputTokens: int(m.Usage.InputTokens), + OutputTokens: int(m.Usage.OutputTokens), + } + return &r, nil +} diff --git a/go/plugins/internal/anthropic/anthropic_test.go b/go/plugins/internal/anthropic/anthropic_test.go new file mode 100644 index 000000000..368b90f46 --- /dev/null +++ b/go/plugins/internal/anthropic/anthropic_test.go @@ -0,0 +1,153 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package anthropic + +import ( + "reflect" + "strings" + "testing" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/firebase/genkit/go/ai" +) + +func TestAnthropic(t *testing.T) { + t.Run("to anthropic role", func(t *testing.T) { + r, err := toAnthropicRole(ai.RoleModel) + if err != nil { + t.Error(err) + } + if r != anthropic.MessageParamRoleAssistant { + t.Errorf("want: %q, got: %q", anthropic.MessageParamRoleAssistant, r) + } + r, err = toAnthropicRole(ai.RoleUser) + if err != nil { + t.Error(err) + } + if r != anthropic.MessageParamRoleUser { + t.Errorf("want: %q, got: %q", anthropic.MessageParamRoleUser, r) + } + r, err = toAnthropicRole(ai.RoleSystem) + if err == nil { + t.Errorf("should have failed, got: %q", r) + } + r, err = toAnthropicRole(ai.RoleTool) + if err != nil { + t.Error(err) + } + if r != anthropic.MessageParamRoleAssistant { + t.Errorf("want: %q, got: %q", anthropic.MessageParamRoleAssistant, r) + } + r, err = toAnthropicRole("unknown") + if err == nil { + t.Errorf("should have failed, got: %q", r) + } + }) +} + +func TestAnthropicConfig(t *testing.T) { + emptyConfig := anthropic.MessageNewParams{} + expectedConfig := anthropic.MessageNewParams{ + Temperature: anthropic.Float(1.0), + TopK: anthropic.Int(1), + } + + tests := []struct { + name string + inputReq *ai.ModelRequest + expectedConfig *anthropic.MessageNewParams + expectedErr string + }{ + { + name: "Input is anthropic.MessageNewParams struct", + inputReq: &ai.ModelRequest{ + Config: anthropic.MessageNewParams{ + Temperature: anthropic.Float(1.0), + TopK: anthropic.Int(1), + }, + }, + expectedConfig: &expectedConfig, + expectedErr: "", + }, + { + name: "Input is *anthropic.MessageNewParams struct", + inputReq: &ai.ModelRequest{ + Config: &anthropic.MessageNewParams{ + Temperature: anthropic.Float(1.0), + TopK: anthropic.Int(1), + }, + }, + expectedConfig: &expectedConfig, + expectedErr: "", + }, + { + name: "Input is map[string]any", + inputReq: &ai.ModelRequest{ + Config: map[string]any{ + "temperature": 1.0, + "top_k": 1, + }, + }, + expectedConfig: &expectedConfig, + expectedErr: "", + }, + { + name: "Input is map[string]any (empty)", + inputReq: &ai.ModelRequest{ + Config: map[string]any{}, + }, + expectedConfig: &emptyConfig, + expectedErr: "", + }, + { + name: "Input is nil", + inputReq: &ai.ModelRequest{ + Config: nil, + }, + expectedConfig: &emptyConfig, + expectedErr: "", + }, + { + name: "Input is an unexpected type", + inputReq: &ai.ModelRequest{ + Config: 123, + }, + expectedConfig: &emptyConfig, + expectedErr: "unexpected config type: int", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotConfig, err := configFromRequest(tt.inputReq) + if tt.expectedErr != "" { + if err == nil { + t.Errorf("expecting error containing %q, got nil", tt.expectedErr) + } else if !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("expecting error to contain %q, but got: %q", tt.expectedErr, err.Error()) + } + return + } + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + if !reflect.DeepEqual(gotConfig, tt.expectedConfig) { + t.Errorf("configFromRequest() got config = %+v, want %+v", gotConfig, tt.expectedConfig) + } + }) + } +} diff --git a/go/plugins/vertexai/modelgarden/models.go b/go/plugins/internal/anthropic/models.go similarity index 73% rename from go/plugins/vertexai/modelgarden/models.go rename to go/plugins/internal/anthropic/models.go index 7c136fc30..cbab42096 100644 --- a/go/plugins/vertexai/modelgarden/models.go +++ b/go/plugins/internal/anthropic/models.go @@ -14,54 +14,51 @@ // // SPDX-License-Identifier: Apache-2.0 -package modelgarden +package anthropic import ( "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/plugins/internal" ) -const provider = "vertexai" - -// supported anthropic models -var anthropicModels = map[string]ai.ModelInfo{ +var AnthropicModels = map[string]ai.ModelInfo{ "claude-3-5-sonnet-v2": { - Label: "Vertex AI Model Garden - Claude 3.5 Sonnet", + Label: "Claude 3.5 Sonnet", Supports: &internal.Multimodal, Versions: []string{"claude-3-5-sonnet-v2@20241022"}, }, "claude-3-5-sonnet": { - Label: "Vertex AI Model Garden - Claude 3.5 Sonnet", + Label: "Claude 3.5 Sonnet", Supports: &internal.Multimodal, Versions: []string{"claude-3-5-sonnet@20240620"}, }, "claude-3-sonnet": { - Label: "Vertex AI Model Garden - Claude 3 Sonnet", + Label: "Claude 3 Sonnet", Supports: &internal.Multimodal, Versions: []string{"claude-3-sonnet@20240229"}, }, "claude-3-haiku": { - Label: "Vertex AI Model Garden - Claude 3 Haiku", + Label: "Claude 3 Haiku", Supports: &internal.Multimodal, Versions: []string{"claude-3-haiku@20240307"}, }, "claude-3-opus": { - Label: "Vertex AI Model Garden - Claude 3 Opus", + Label: "Claude 3 Opus", Supports: &internal.Multimodal, Versions: []string{"claude-3-opus@20240229"}, }, "claude-3-7-sonnet": { - Label: "Vertex AI Model Garden - Claude 3.7 Sonnet", + Label: "Claude 3.7 Sonnet", Supports: &internal.Multimodal, Versions: []string{"claude-3-7-sonnet@20250219"}, }, "claude-opus-4": { - Label: "Vertex AI Model Garden - Claude Opus 4", + Label: "Claude Opus 4", Supports: &internal.Multimodal, Versions: []string{"claude-opus-4@20250514"}, }, "claude-sonnet-4": { - Label: "Vertex AI Model Garden - Claude Sonnet 4", + Label: "Claude Sonnet 4", Supports: &internal.Multimodal, Versions: []string{"claude-sonnet-4@20250514"}, }, diff --git a/go/plugins/vertexai/modelgarden/anthropic.go b/go/plugins/vertexai/modelgarden/anthropic.go index 79a0a8003..be5cffa39 100644 --- a/go/plugins/vertexai/modelgarden/anthropic.go +++ b/go/plugins/vertexai/modelgarden/anthropic.go @@ -18,26 +18,21 @@ package modelgarden import ( "context" - "encoding/base64" - "encoding/json" "errors" "fmt" "os" - "regexp" "sync" "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/genkit" - "github.com/firebase/genkit/go/plugins/internal/uri" - "github.com/invopop/jsonschema" + ant "github.com/firebase/genkit/go/plugins/internal/anthropic" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/vertex" ) const ( - MaxNumberOfTokens = 8192 - ToolNameRegex = `^[a-zA-Z0-9_-]{1,64}$` + provider = "vertexai" ) type Anthropic struct { @@ -95,8 +90,8 @@ func (a *Anthropic) Init(ctx context.Context, g *genkit.Genkit) (err error) { a.initted = true a.client = c - for name, mi := range anthropicModels { - defineAnthropicModel(g, a.client, name, mi) + for name, mi := range ant.AnthropicModels { + ant.DefineModel(g, a.client, provider, name, mi) } return nil @@ -113,323 +108,12 @@ func (a *Anthropic) DefineModel(g *genkit.Genkit, name string, info *ai.ModelInf var mi ai.ModelInfo if info == nil { var ok bool - mi, ok = anthropicModels[name] + mi, ok = ant.AnthropicModels[name] if !ok { return nil, fmt.Errorf("%s.DefineModel: called with unknown model %q and nil ModelInfo", provider, name) } } else { mi = *info } - return defineAnthropicModel(g, a.client, name, mi), nil -} - -func defineAnthropicModel(g *genkit.Genkit, client anthropic.Client, name string, info ai.ModelInfo) ai.Model { - meta := &ai.ModelInfo{ - Label: provider + "-" + name, - Supports: info.Supports, - Versions: info.Versions, - } - return genkit.DefineModel(g, provider, name, meta, func( - ctx context.Context, - input *ai.ModelRequest, - cb func(context.Context, *ai.ModelResponseChunk) error, - ) (*ai.ModelResponse, error) { - return anthropicGenerate(ctx, client, name, input, cb) - }) -} - -// generate function defines how a generate request is done in Anthropic models -func anthropicGenerate( - ctx context.Context, - client anthropic.Client, - model string, - input *ai.ModelRequest, - cb func(context.Context, *ai.ModelResponseChunk) error, -) (*ai.ModelResponse, error) { - req, err := toAnthropicRequest(model, input) - if err != nil { - return nil, fmt.Errorf("unable to generate anthropic request: %w", err) - } - - // no streaming - if cb == nil { - msg, err := client.Messages.New(ctx, *req) - if err != nil { - return nil, err - } - - r, err := anthropicToGenkitResponse(msg) - if err != nil { - return nil, err - } - - r.Request = input - return r, nil - } else { - stream := client.Messages.NewStreaming(ctx, *req) - message := anthropic.Message{} - for stream.Next() { - event := stream.Current() - err := message.Accumulate(event) - if err != nil { - return nil, err - } - - switch event := event.AsAny().(type) { - case anthropic.ContentBlockDeltaEvent: - cb(ctx, &ai.ModelResponseChunk{ - Content: []*ai.Part{ - { - Text: event.Delta.Text, - }, - }, - }) - case anthropic.MessageStopEvent: - r, err := anthropicToGenkitResponse(&message) - if err != nil { - return nil, err - } - r.Request = input - return r, nil - } - } - if stream.Err() != nil { - return nil, stream.Err() - } - } - - return nil, nil -} - -func toAnthropicRole(role ai.Role) (anthropic.MessageParamRole, error) { - switch role { - case ai.RoleUser: - return anthropic.MessageParamRoleUser, nil - case ai.RoleModel: - return anthropic.MessageParamRoleAssistant, nil - case ai.RoleTool: - return anthropic.MessageParamRoleAssistant, nil - default: - return "", fmt.Errorf("unknown role given: %q", role) - } -} - -// toAnthropicRequest translates [ai.ModelRequest] to an Anthropic request -func toAnthropicRequest(model string, i *ai.ModelRequest) (*anthropic.MessageNewParams, error) { - messages := make([]anthropic.MessageParam, 0) - - c, err := configFromRequest(i) - if err != nil { - return nil, err - } - - // minimum required data to perform a request - req := anthropic.MessageNewParams{} - req.Model = anthropic.Model(model) - req.MaxTokens = int64(MaxNumberOfTokens) - - if c.MaxOutputTokens != 0 { - req.MaxTokens = int64(c.MaxOutputTokens) - } - if c.Version != "" { - req.Model = anthropic.Model(c.Version) - } - if c.Temperature != 0 { - req.Temperature = anthropic.Float(c.Temperature) - } - if c.TopK != 0 { - req.TopK = anthropic.Int(int64(c.TopK)) - } - if c.TopP != 0 { - req.TopP = anthropic.Float(float64(c.TopP)) - } - if len(c.StopSequences) > 0 { - req.StopSequences = c.StopSequences - } - - // configure system prompt (if given) - sysBlocks := []anthropic.TextBlockParam{} - for _, message := range i.Messages { - if message.Role == ai.RoleSystem { - // only text is supported for system messages - sysBlocks = append(sysBlocks, anthropic.TextBlockParam{Text: message.Text()}) - } else if message.Content[len(message.Content)-1].IsToolResponse() { - // if the last message is a ToolResponse, the conversation must continue - // and the ToolResponse message must be sent as a user - // see: https://docs.anthropic.com/en/docs/build-with-claude/tool-use#handling-tool-use-and-tool-result-content-blocks - parts, err := toAnthropicParts(message.Content) - if err != nil { - return nil, err - } - messages = append(messages, anthropic.NewUserMessage(parts...)) - } else { - parts, err := toAnthropicParts(message.Content) - if err != nil { - return nil, err - } - role, err := toAnthropicRole(message.Role) - if err != nil { - return nil, err - } - messages = append(messages, anthropic.MessageParam{ - Role: role, - Content: parts, - }) - } - } - - req.System = sysBlocks - req.Messages = messages - - tools, err := toAnthropicTools(i.Tools) - if err != nil { - return nil, err - } - req.Tools = tools - - return &req, nil -} - -// mapToStruct unmarshals a map[String]any to the expected type -func mapToStruct(m map[string]any, v any) error { - jsonData, err := json.Marshal(m) - if err != nil { - return err - } - return json.Unmarshal(jsonData, v) -} - -// configFromRequest converts any supported config type to [ai.GenerationCommonConfig] -func configFromRequest(input *ai.ModelRequest) (*ai.GenerationCommonConfig, error) { - var result ai.GenerationCommonConfig - - switch config := input.Config.(type) { - case ai.GenerationCommonConfig: - result = config - case *ai.GenerationCommonConfig: - result = *config - case map[string]any: - if err := mapToStruct(config, &result); err != nil { - return nil, err - } - case nil: - // Empty configuration is considered valid - default: - return nil, fmt.Errorf("unexpected config type: %T", input.Config) - } - return &result, nil -} - -// toAnthropicTools translates [ai.ToolDefinition] to an anthropic.ToolParam type -func toAnthropicTools(tools []*ai.ToolDefinition) ([]anthropic.ToolUnionParam, error) { - resp := make([]anthropic.ToolUnionParam, 0) - regex := regexp.MustCompile(ToolNameRegex) - - for _, t := range tools { - if t.Name == "" { - return nil, fmt.Errorf("tool name is required") - } - if !regex.MatchString(t.Name) { - return nil, fmt.Errorf("tool name must match regex: %s", ToolNameRegex) - } - - resp = append(resp, anthropic.ToolUnionParam{ - OfTool: &anthropic.ToolParam{ - Name: t.Name, - Description: anthropic.String(t.Description), - InputSchema: toAnthropicSchema[map[string]any](), - }, - }) - } - - return resp, nil -} - -// toAnthropicSchema generates a JSON schema for the requested input type -func toAnthropicSchema[T any]() anthropic.ToolInputSchemaParam { - reflector := jsonschema.Reflector{ - AllowAdditionalProperties: true, - DoNotReference: true, - } - var v T - schema := reflector.Reflect(v) - return anthropic.ToolInputSchemaParam{ - Properties: schema.Properties, - } -} - -// toAnthropicParts translates [ai.Part] to an anthropic.ContentBlockParamUnion type -func toAnthropicParts(parts []*ai.Part) ([]anthropic.ContentBlockParamUnion, error) { - blocks := []anthropic.ContentBlockParamUnion{} - - for _, p := range parts { - switch { - case p.IsText(): - blocks = append(blocks, anthropic.NewTextBlock(p.Text)) - case p.IsMedia(): - contentType, data, _ := uri.Data(p) - blocks = append(blocks, anthropic.NewImageBlockBase64(contentType, base64.StdEncoding.EncodeToString(data))) - case p.IsData(): - contentType, data, _ := uri.Data(p) - blocks = append(blocks, anthropic.NewImageBlockBase64(contentType, base64.RawStdEncoding.EncodeToString(data))) - case p.IsToolRequest(): - toolReq := p.ToolRequest - blocks = append(blocks, anthropic.NewToolUseBlock(toolReq.Ref, toolReq.Input, toolReq.Name)) - case p.IsToolResponse(): - toolResp := p.ToolResponse - output, err := json.Marshal(toolResp.Output) - if err != nil { - return nil, fmt.Errorf("unable to parse tool response, err: %w", err) - } - blocks = append(blocks, anthropic.NewToolResultBlock(toolResp.Ref, string(output), false)) - default: - return nil, errors.New("unknown part type in the request") - } - } - - return blocks, nil -} - -// anthropicToGenkitResponse translates an Anthropic Message to [ai.ModelResponse] -func anthropicToGenkitResponse(m *anthropic.Message) (*ai.ModelResponse, error) { - r := ai.ModelResponse{} - - switch m.StopReason { - case anthropic.StopReasonMaxTokens: - r.FinishReason = ai.FinishReasonLength - case anthropic.StopReasonStopSequence: - r.FinishReason = ai.FinishReasonStop - case anthropic.StopReasonEndTurn: - r.FinishReason = ai.FinishReasonStop - case anthropic.StopReasonToolUse: - r.FinishReason = ai.FinishReasonStop - default: - r.FinishReason = ai.FinishReasonUnknown - } - - msg := &ai.Message{} - msg.Role = ai.RoleModel - for _, part := range m.Content { - var p *ai.Part - switch part.AsAny().(type) { - case anthropic.TextBlock: - p = ai.NewTextPart(string(part.Text)) - case anthropic.ToolUseBlock: - p = ai.NewToolRequestPart(&ai.ToolRequest{ - Ref: part.ID, - Input: part.Input, - Name: part.Name, - }) - default: - return nil, fmt.Errorf("unknown part: %#v", part) - } - msg.Content = append(msg.Content, p) - } - - r.Message = msg - r.Usage = &ai.GenerationUsage{ - InputTokens: int(m.Usage.InputTokens), - OutputTokens: int(m.Usage.OutputTokens), - } - return &r, nil + return ant.DefineModel(g, a.client, provider, name, mi), nil } diff --git a/go/plugins/vertexai/modelgarden/anthropic_test.go b/go/plugins/vertexai/modelgarden/anthropic_test.go deleted file mode 100644 index 8ce077453..000000000 --- a/go/plugins/vertexai/modelgarden/anthropic_test.go +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package modelgarden - -import ( - "testing" - - "github.com/anthropics/anthropic-sdk-go" - "github.com/firebase/genkit/go/ai" -) - -func TestAnthropic(t *testing.T) { - req := &ai.ModelRequest{ - Config: &ai.GenerationCommonConfig{ - MaxOutputTokens: MaxNumberOfTokens, - Temperature: 0.5, - TopK: 2, - TopP: 1, - Version: "claude-version-3", - StopSequences: []string{"tool_use"}, - }, - Messages: []*ai.Message{ - ai.NewSystemTextMessage("greet the user"), - ai.NewUserTextMessage("hello Claude"), - ai.NewModelTextMessage("hello User"), - }, - Tools: []*ai.ToolDefinition{ - { - Description: "foo description", - InputSchema: map[string]any{}, - Name: "foo-tool", - }, - }, - } - t.Run("to anthropic request", func(t *testing.T) { - ar, err := toAnthropicRequest("claude-3.7-opus", req) - if err != nil { - t.Fatal(err) - } - cfg, _ := req.Config.(*ai.GenerationCommonConfig) - if ar.MaxTokens != int64(cfg.MaxOutputTokens) { - t.Errorf("want: %d, got: %d", int64(cfg.MaxOutputTokens), ar.MaxTokens) - } - if ar.Temperature.Value != cfg.Temperature { - t.Errorf("want: %f, got: %f", cfg.Temperature, ar.Temperature.Value) - } - if ar.TopK.Value != int64(cfg.TopK) { - t.Errorf("want: %d, got: %d", int64(cfg.TopK), ar.TopK.Value) - } - if ar.TopP.Value != float64(cfg.TopP) { - t.Errorf("want: %f, got: %f", float64(cfg.TopP), ar.TopP.Value) - } - if string(ar.Model) != cfg.Version { - t.Errorf("want: %q, got: %q", cfg.Version, ar.Model) - } - if ar.Tools == nil { - t.Errorf("expecting tools, got nil") - } - if ar.Tools[0].OfTool.Name != req.Tools[0].Name { - t.Errorf("want: %q, got: %q", req.Tools[0].Name, ar.Tools[0].OfTool.Name) - } - if ar.Tools[0].OfTool.Description.Value != req.Tools[0].Description { - t.Errorf("want: %q, got: %q", req.Tools[0].Name, ar.Tools[0].OfTool.Name) - } - if ar.Tools[0].OfTool.InputSchema.Properties == nil { - t.Errorf("expecting input schema, got nil") - } - if len(ar.Messages) == 0 { - t.Errorf("expecting messages, got empty") - } - if ar.System[0].Text == "" { - t.Errorf("expecting system message, got empty") - } - if len(ar.Messages) != 2 { - t.Errorf("expecting 2 messages, got: %d", len(ar.Messages)) - } - }) - t.Run("to anthropic role", func(t *testing.T) { - r, err := toAnthropicRole(ai.RoleModel) - if err != nil { - t.Error(err) - } - if r != anthropic.MessageParamRoleAssistant { - t.Errorf("want: %q, got: %q", anthropic.MessageParamRoleAssistant, r) - } - r, err = toAnthropicRole(ai.RoleUser) - if err != nil { - t.Error(err) - } - if r != anthropic.MessageParamRoleUser { - t.Errorf("want: %q, got: %q", anthropic.MessageParamRoleUser, r) - } - r, err = toAnthropicRole(ai.RoleSystem) - if err == nil { - t.Errorf("should have failed, got: %q", r) - } - r, err = toAnthropicRole(ai.RoleTool) - if err != nil { - t.Error(err) - } - if r != anthropic.MessageParamRoleAssistant { - t.Errorf("want: %q, got: %q", anthropic.MessageParamRoleAssistant, r) - } - r, err = toAnthropicRole("unknown") - if err == nil { - t.Errorf("should have failed, got: %q", r) - } - }) -} diff --git a/go/samples/anthropic/main.go b/go/samples/anthropic/main.go new file mode 100644 index 000000000..595568a38 --- /dev/null +++ b/go/samples/anthropic/main.go @@ -0,0 +1,32 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "log" + + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/anthropic" +) + +func main() { + ctx := context.Background() + + _, err := genkit.Init(ctx, genkit.WithPlugins(&anthropic.Anthropic{})) + if err != nil { + log.Fatal(err) + } +} diff --git a/go/samples/modelgarden/main.go b/go/samples/modelgarden/main.go index 5ce005705..2c1b5fe7d 100644 --- a/go/samples/modelgarden/main.go +++ b/go/samples/modelgarden/main.go @@ -19,6 +19,7 @@ import ( "errors" "log" + "github.com/anthropics/anthropic-sdk-go" "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/genkit" "github.com/firebase/genkit/go/plugins/vertexai/modelgarden" @@ -41,8 +42,8 @@ func main() { resp, err := genkit.Generate(ctx, g, ai.WithModel(m), - ai.WithConfig(&ai.GenerationCommonConfig{ - Temperature: 1.0, + ai.WithConfig(&anthropic.MessageNewParams{ + Temperature: anthropic.Float(1.0), }), ai.WithPrompt(`Tell a short joke about %s`, input)) if err != nil {