Skip to content

Commit fe139d1

Browse files
authored
feat(go/plugin/anthropic): add Anthropic plugin (#3109)
1 parent cb2716c commit fe139d1

File tree

15 files changed

+1696
-525
lines changed

15 files changed

+1696
-525
lines changed

go/ai/generate.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -732,11 +732,11 @@ func handleToolRequests(ctx context.Context, r api.Registry, req *ModelRequest,
732732
// Text returns the contents of the first candidate in a
733733
// [ModelResponse] as a string. It returns an empty string if there
734734
// are no candidates or if the candidate has no message.
735-
func (gr *ModelResponse) Text() string {
736-
if gr.Message == nil {
735+
func (mr *ModelResponse) Text() string {
736+
if mr.Message == nil {
737737
return ""
738738
}
739-
return gr.Message.Text()
739+
return mr.Message.Text()
740740
}
741741

742742
// History returns messages from the request combined with the response message
@@ -834,6 +834,19 @@ func (c *ModelResponseChunk) Text() string {
834834
return sb.String()
835835
}
836836

837+
func (c *ModelResponseChunk) Reasoning() string {
838+
if len(c.Content) == 0 {
839+
return ""
840+
}
841+
var sb strings.Builder
842+
for _, p := range c.Content {
843+
if p.IsReasoning() {
844+
sb.WriteString(p.Text)
845+
}
846+
}
847+
return sb.String()
848+
}
849+
837850
// Text returns the contents of a [Message] as a string. It
838851
// returns an empty string if the message has no content.
839852
// If you want to get reasoning from the message, use Reasoning() instead.

go/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ require (
1717
firebase.google.com/go/v4 v4.15.2
1818
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0
1919
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.27.0
20-
github.com/anthropics/anthropic-sdk-go v1.9.1
20+
github.com/anthropics/anthropic-sdk-go v1.19.0
2121
github.com/blues/jsonata-go v1.5.4
2222
github.com/goccy/go-yaml v1.17.1
2323
github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254

go/go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID
5454
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
5555
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
5656
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
57-
github.com/anthropics/anthropic-sdk-go v1.9.1 h1:raRhZKmayVSVZtLpLDd6IsMXvxLeeSU03/2IBTerWlg=
58-
github.com/anthropics/anthropic-sdk-go v1.9.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
57+
github.com/anthropics/anthropic-sdk-go v1.19.0 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE=
58+
github.com/anthropics/anthropic-sdk-go v1.19.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
5959
github.com/apache/arrow/go/v15 v15.0.2 h1:60IliRbiyTWCWjERBCkO1W4Qun9svcYoZrSLcyOsMLE=
6060
github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA=
6161
github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=

go/plugins/anthropic/anthropic.go

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// SPDX-License-Identifier: Apache-2.0
16+
17+
package anthropic
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"log/slog"
23+
"os"
24+
"reflect"
25+
"sync"
26+
27+
"github.com/anthropics/anthropic-sdk-go"
28+
"github.com/anthropics/anthropic-sdk-go/option"
29+
"github.com/firebase/genkit/go/ai"
30+
"github.com/firebase/genkit/go/core/api"
31+
"github.com/firebase/genkit/go/genkit"
32+
"github.com/firebase/genkit/go/internal/base"
33+
ant "github.com/firebase/genkit/go/plugins/internal/anthropic"
34+
"github.com/invopop/jsonschema"
35+
)
36+
37+
const (
38+
provider = "anthropic"
39+
anthropicLabelPrefix = "Anthropic"
40+
)
41+
42+
// Anthropic is a Genkit plugin for interacting with the Anthropic services
43+
type Anthropic struct {
44+
APIKey string // If not provided, defaults to ANTHROPIC_API_KEY
45+
BaseURL string // Optional. If not provided, defaults to ANTHROPIC_BASE_URL
46+
47+
aclient anthropic.Client // Anthropic client
48+
mu sync.Mutex // Mutex to control access
49+
initted bool // Whether the plugin has been initialized
50+
}
51+
52+
// Name returns the name of the plugin
53+
func (a *Anthropic) Name() string {
54+
return provider
55+
}
56+
57+
// Init initializes the Anthropic plugin and all known models
58+
func (a *Anthropic) Init(ctx context.Context) []api.Action {
59+
if a == nil {
60+
a = &Anthropic{}
61+
}
62+
63+
a.mu.Lock()
64+
defer a.mu.Unlock()
65+
if a.initted {
66+
panic("plugin already initialized")
67+
}
68+
69+
apiKey := a.APIKey
70+
if apiKey == "" {
71+
apiKey = os.Getenv("ANTHROPIC_API_KEY")
72+
}
73+
if apiKey == "" {
74+
panic("Anthropic requires setting ANTHROPIC_API_KEY in the environment")
75+
}
76+
77+
opts := []option.RequestOption{option.WithAPIKey(apiKey)}
78+
79+
baseURL := a.BaseURL
80+
if baseURL == "" {
81+
baseURL = os.Getenv("ANTHROPIC_BASE_URL")
82+
}
83+
if baseURL != "" {
84+
opts = append(opts, option.WithBaseURL(baseURL))
85+
}
86+
87+
ac := anthropic.NewClient(opts...)
88+
a.aclient = ac
89+
a.initted = true
90+
91+
return []api.Action{}
92+
}
93+
94+
// DefineModel defines an unknown model with the given name.
95+
// The second argument describes the capability of the model.
96+
// Use [IsDefinedModel] to determine if a model is already defined.
97+
// After [Init] is called, only the known models are defined.
98+
func (a *Anthropic) DefineModel(g *genkit.Genkit, name string, opts *ai.ModelOptions) (ai.Model, error) {
99+
return ant.DefineModel(a.aclient, provider, name, *opts), nil
100+
}
101+
102+
// ListActions lists all the actions supported by the Anthropic plugin
103+
func (a *Anthropic) ListActions(ctx context.Context) []api.ActionDesc {
104+
actions := []api.ActionDesc{}
105+
106+
models, err := listModels(ctx, &a.aclient)
107+
if err != nil {
108+
slog.Error("unable to list anthropic models from Anthropic API", "error", err)
109+
return nil
110+
}
111+
112+
for _, name := range models {
113+
model := newModel(a.aclient, name, defaultClaudeOpts)
114+
if actionDef, ok := model.(api.Action); ok {
115+
actions = append(actions, actionDef.Desc())
116+
}
117+
}
118+
119+
return actions
120+
}
121+
122+
// Model returns a previously registered model
123+
func Model(g *genkit.Genkit, name string) ai.Model {
124+
return genkit.LookupModel(g, api.NewName(provider, name))
125+
}
126+
127+
// IsDefinedModel returns whether a model is already defined
128+
func IsDefinedModel(g *genkit.Genkit, name string) bool {
129+
return genkit.LookupModel(g, api.NewName(provider, name)) != nil
130+
}
131+
132+
// ResolveAction resolves an action with the given name
133+
func (a *Anthropic) ResolveAction(atype api.ActionType, id string) api.Action {
134+
switch atype {
135+
case api.ActionTypeModel:
136+
return newModel(a.aclient, id, ai.ModelOptions{
137+
Label: fmt.Sprintf("%s - %s", anthropicLabelPrefix, id),
138+
Stage: ai.ModelStageStable,
139+
Versions: []string{},
140+
Supports: defaultClaudeOpts.Supports,
141+
}).(api.Action)
142+
}
143+
return nil
144+
}
145+
146+
// newModel creates a model wihout registering it
147+
func newModel(client anthropic.Client, name string, opts ai.ModelOptions) ai.Model {
148+
config := &anthropic.MessageNewParams{}
149+
150+
meta := &ai.ModelOptions{
151+
Label: opts.Label,
152+
Supports: opts.Supports,
153+
Versions: opts.Versions,
154+
ConfigSchema: configToMap(config),
155+
Stage: opts.Stage,
156+
}
157+
158+
fn := func(
159+
ctx context.Context,
160+
input *ai.ModelRequest,
161+
cb func(context.Context, *ai.ModelResponseChunk) error,
162+
) (*ai.ModelResponse, error) {
163+
return ant.Generate(ctx, client, name, input, cb)
164+
}
165+
166+
return ai.NewModel(api.NewName(provider, name), meta, fn)
167+
}
168+
169+
// configToMap converts a config struct to a map[string]any.
170+
func configToMap(config any) map[string]any {
171+
r := jsonschema.Reflector{
172+
DoNotReference: false, // Prevent $ref usage
173+
AllowAdditionalProperties: false,
174+
RequiredFromJSONSchemaTags: true,
175+
}
176+
// The anthropic SDK uses a number of wrapper types for float, int, etc.
177+
// By default, jsonschema will treat these as objects, but we want to
178+
// treat them as their underlying primitive types.
179+
r.Mapper = func(r reflect.Type) *jsonschema.Schema {
180+
if r.Name() == "Opt[float64]" {
181+
return &jsonschema.Schema{
182+
Type: "number",
183+
}
184+
}
185+
if r.Name() == "Opt[int64]" {
186+
return &jsonschema.Schema{
187+
Type: "integer",
188+
}
189+
}
190+
if r.Name() == "Opt[string]" {
191+
return &jsonschema.Schema{
192+
Type: "string",
193+
}
194+
}
195+
if r.Name() == "Opt[bool]" {
196+
return &jsonschema.Schema{
197+
Type: "boolean",
198+
}
199+
}
200+
return nil
201+
}
202+
schema := r.Reflect(config)
203+
result := base.SchemaAsMap(schema)
204+
return result
205+
}

0 commit comments

Comments
 (0)