Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 18 additions & 40 deletions go/plugins/anthropic/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ package anthropic

import (
"context"
"fmt"
"log/slog"
"os"
"regexp"
Expand Down Expand Up @@ -96,26 +95,29 @@ func (a *Anthropic) Init(ctx context.Context) []api.Action {
}

// DefineModel defines an unknown model with the given name.
// The second argument describes the capability of the model.
// The second argument describes the capability of the model; a nil opts gets
// the capabilities the plugin resolves for that name, curated for a known
// model and the Claude defaults for the rest.
// Use [IsDefinedModel] to determine if a model is already defined.
// After [Init] is called, only the known models are defined.
func (a *Anthropic) DefineModel(g *genkit.Genkit, name string, opts *ai.ModelOptions) (ai.Model, error) {
return ant.DefineModel(a.aclient, provider, name, *opts), nil
if opts == nil {
resolved := modelOptions(name)
opts = &resolved
}
return newModel(a.aclient, name, name, *opts), nil
}
Comment thread
apascal07 marked this conversation as resolved.

// modelOptions returns the ModelOptions for a Claude model name. Known models
// (see knownModels) carry curated capabilities; any other model falls back to
// defaultClaudeOpts. The returned options always carry a provider-prefixed
// label. This is the single source of model capabilities shared by ListActions
// (see knownModels) carry curated capabilities and labels; any other model
// falls back to defaultClaudeOpts, whose label newModel fills in from the
// name. This is the single source of model capabilities shared by ListActions
// and ResolveAction, mirroring the JS plugin's claudeModelReference.
func modelOptions(name string) ai.ModelOptions {
opts, ok := knownModels[baseModelName(name)]
if !ok {
opts = defaultClaudeOpts
}
if opts.Label == "" {
opts.Label = fmt.Sprintf("%s - %s", anthropicLabelPrefix, name)
}
return opts
}

Expand All @@ -132,10 +134,7 @@ func (a *Anthropic) ListActions(ctx context.Context) []api.ActionDesc {
for _, name := range models {
// When listing discovered models, the Genkit action name and the
// Anthropic API model ID are identical.
model := newModel(a.aclient, name, name, modelOptions(name))
if actionDef, ok := model.(api.Action); ok {
actions = append(actions, actionDef.Desc())
}
actions = append(actions, newModel(a.aclient, name, name, modelOptions(name)).Desc())
}

return actions
Expand Down Expand Up @@ -169,7 +168,7 @@ func (a *Anthropic) ResolveAction(atype api.ActionType, id string) api.Action {

// We register the model using the ID requested by the user, but
// use the resolved 'realID' (e.g. versioned) for actual API calls.
return newModel(a.aclient, id, realID, modelOptions(id)).(api.Action)
return newModel(a.aclient, id, realID, modelOptions(id))
}
return nil
}
Expand All @@ -193,32 +192,11 @@ func (a *Anthropic) getModels(ctx context.Context) ([]string, error) {
return models, nil
}

// newModel creates a model wihout registering it
func newModel(client anthropic.Client, name, apiModelName string, opts ai.ModelOptions) ai.Model {
config := &anthropic.MessageNewParams{}

meta := &ai.ModelOptions{
Label: opts.Label,
Supports: opts.Supports,
Versions: opts.Versions,
ConfigSchema: ant.ConfigSchema(config),
Stage: opts.Stage,
}

targetModel := name
if apiModelName != "" {
targetModel = apiModelName
}

fn := func(
ctx context.Context,
input *ai.ModelRequest,
cb func(context.Context, *ai.ModelResponseChunk) error,
) (*ai.ModelResponse, error) {
return ant.Generate(ctx, client, provider, targetModel, input, cb)
}

return ai.NewModel(api.NewName(provider, name), meta, fn)
// newModel creates a model without registering it. name is the Genkit action
// name and apiModelName is the model ID sent to the API, which differ when the
// name is an alias for a dated release.
func newModel(client anthropic.Client, name, apiModelName string, opts ai.ModelOptions) *ai.ModelAction {
return ant.NewModel(client, provider, name, apiModelName, opts)
}

func baseModelName(name string) string {
Expand Down
93 changes: 89 additions & 4 deletions go/plugins/anthropic/anthropic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import (
"slices"
"testing"

"github.com/anthropics/anthropic-sdk-go"
"github.com/firebase/genkit/go/ai"
"github.com/firebase/genkit/go/internal/base"
)

// TestModelOptionsKnownModels verifies the curated Claude models resolve through
Expand Down Expand Up @@ -81,8 +83,8 @@ func TestModelOptionsKnownVersionedModels(t *testing.T) {
}
}

// TestModelOptionsUnknownFallback verifies models not in knownModels fall back to
// defaultClaudeOpts (no JSON output) but still get a provider-prefixed label.
// TestModelOptionsUnknownFallback verifies models not in knownModels fall back
// to defaultClaudeOpts (no JSON output).
func TestModelOptionsUnknownFallback(t *testing.T) {
const name = "claude-something-unreleased"
opts := modelOptions(name)
Expand All @@ -93,8 +95,91 @@ func TestModelOptionsUnknownFallback(t *testing.T) {
if slices.Contains(opts.Supports.Output, "json") {
t.Errorf("modelOptions(%q): unknown model should use default supports without JSON output, got %v", name, opts.Supports.Output)
}
if want := anthropicLabelPrefix + " - " + name; opts.Label != want {
t.Errorf("modelOptions(%q): Label = %q, want %q", name, opts.Label, want)
}

// TestNewModelDescriptor covers what a built model advertises: a curated label
// for known models and a name-derived one for the rest, plus the config schema
// the framework validates every request against.
func TestNewModelDescriptor(t *testing.T) {
tests := []struct {
name string
wantLabel string
}{
{"claude-opus-4-5", anthropicLabelPrefix + " - Claude Opus 4.5"},
{"claude-opus-4-5-20251101", anthropicLabelPrefix + " - Claude Opus 4.5"},
{"claude-something-unreleased", anthropicLabelPrefix + " - claude-something-unreleased"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
desc := newModel(anthropic.Client{}, tt.name, tt.name, modelOptions(tt.name)).Desc()

model, ok := desc.Metadata["model"].(map[string]any)
if !ok {
t.Fatalf("model metadata missing, got %v", desc.Metadata)
}
if got := model["label"]; got != tt.wantLabel {
t.Errorf("label = %v, want %q", got, tt.wantLabel)
}

schema, ok := model["customOptions"].(map[string]any)
if !ok {
t.Fatalf("customOptions missing, got %v", model["customOptions"])
}
props, ok := schema["properties"].(map[string]any)
if !ok || props["max_tokens"] == nil {
t.Errorf("config schema is not the Anthropic message params schema, got %v", schema)
}
})
}
}

// TestDefineModelNilOptions covers the nil ModelOptions path: the model gets
// the capabilities the plugin resolves for its name rather than panicking or
// advertising a model that supports nothing.
func TestDefineModelNilOptions(t *testing.T) {
a := &Anthropic{}

m, err := a.DefineModel(nil, "claude-opus-4-5", nil)
if err != nil {
t.Fatalf("DefineModel() error = %v", err)
}

model, ok := m.(*ai.ModelAction).Desc().Metadata["model"].(map[string]any)
if !ok {
t.Fatalf("model metadata missing")
}
if want := anthropicLabelPrefix + " - Claude Opus 4.5"; model["label"] != want {
t.Errorf("label = %v, want %q", model["label"], want)
}
supports, ok := model["supports"].(map[string]any)
if !ok {
t.Fatalf("supports metadata missing")
}
if supports["tools"] != true || supports["multiturn"] != true {
t.Errorf("supports = %v, want the curated Claude capabilities", supports)
}
}

// TestModelConfigIsValidated pins that the config schema reaches the request
// input schema, so the framework rejects a config the SDK type cannot hold
// before it reaches the model function.
func TestModelConfigIsValidated(t *testing.T) {
const name = "claude-opus-4-5"
inputSchema := newModel(anthropic.Client{}, name, name, modelOptions(name)).Desc().InputSchema

req := func(config any) *ai.ModelRequest {
return &ai.ModelRequest{
Messages: []*ai.Message{ai.NewUserMessage(ai.NewTextPart("hello"))},
Config: config,
}
}

if err := base.ValidateValue(req(map[string]any{"max_tokens": 100, "temperature": 0.4}), inputSchema); err != nil {
t.Errorf("config rejected at the action boundary: %v", err)
}
if err := base.ValidateValue(req(map[string]any{"max_tokens": "lots"}), inputSchema); err == nil {
t.Error("expected a mistyped max_tokens to be rejected")
}
}

Expand Down
109 changes: 57 additions & 52 deletions go/plugins/internal/anthropic/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"fmt"
"reflect"
"regexp"
"slices"
"strings"

"github.com/firebase/genkit/go/ai"
Expand All @@ -45,6 +46,11 @@ const (
DefaultMaxOutputTokens = 4096
)

// defaultConfigSchema is the schema every Claude model advertises for its
// config. Reflecting the SDK params struct is expensive and the result is
// read-only, so it is built once and shared by every model of both plugins.
var defaultConfigSchema = reflectConfigSchema(anthropic.MessageNewParams{})

// metadataSignature extracts a reasoning signature from part metadata. It
// handles both []byte (the value [ai.NewReasoningPart] stores) and string
// (base64-encoded, after the part has been through a JSON roundtrip such as
Expand Down Expand Up @@ -86,36 +92,48 @@ func toAnthropicMediaBlock(p *ai.Part, kind string) (anthropic.ContentBlockParam
}
}

func DefineModel(client anthropic.Client, provider, name string, info ai.ModelOptions) ai.Model {
label := "Anthropic"

if provider == "vertexai" {
label = "Vertex AI"
// NewModel creates a Claude model action without registering it. name is the
// Genkit action name and apiModel is the model ID sent to the API, which
// differ when the name is an alias for a dated release; an empty apiModel
// falls back to name. opts is used as given, except that a nil ConfigSchema
// defaults to the reflected [anthropic.MessageNewParams] schema and an empty
// label is derived from the provider and the name.
//
// The framework validates the request's config against the config schema and
// deserializes it into [anthropic.MessageNewParams] before the model function
// runs, so the request arrives with the config already typed.
func NewModel(client anthropic.Client, provider, name, apiModel string, opts ai.ModelOptions) *ai.ModelAction {
if opts.ConfigSchema == nil {
opts.ConfigSchema = defaultConfigSchema
}

configSchema := info.ConfigSchema
if configSchema == nil {
configSchema = ConfigSchema(anthropic.MessageNewParams{})
if opts.Label == "" {
opts.Label = fmt.Sprintf("%s - %s", providerLabel(provider), name)
}

meta := &ai.ModelOptions{
Label: label + "-" + name,
Supports: info.Supports,
Versions: info.Versions,
ConfigSchema: configSchema,
if apiModel == "" {
apiModel = name
}

return ai.NewModel(api.NewName(provider, name), meta, func(
return ai.NewTypedModel(api.NewName(provider, name), &opts, func(
ctx context.Context,
input *ai.ModelRequest,
cb func(context.Context, *ai.ModelResponseChunk) error,
config anthropic.MessageNewParams,
cb ai.ModelStreamCallback,
) (*ai.ModelResponse, error) {
return Generate(ctx, client, provider, name, input, cb)
return Generate(ctx, client, provider, apiModel, input, config, cb)
})
}

// ConfigSchema converts a config struct to a map[string]any.
func ConfigSchema(config any) map[string]any {
// providerLabel is the display name Claude models are labeled with when the
// caller supplies no label of its own.
func providerLabel(provider string) string {
if provider == "vertexai" {
return "Vertex AI"
}
return "Anthropic"
}

// reflectConfigSchema converts a config struct to a map[string]any.
func reflectConfigSchema(config any) map[string]any {
r := jsonschema.Reflector{
DoNotReference: true, // Prevent $ref usage
AllowAdditionalProperties: false,
Expand Down Expand Up @@ -154,16 +172,19 @@ func ConfigSchema(config any) map[string]any {
return result
}

// Generate function defines how a generate request is done in Anthropic models
// Generate function defines how a generate request is done in Anthropic models.
// config is the request's config, already deserialized by the framework, and is
// the base the request is built on.
func Generate(
ctx context.Context,
client anthropic.Client,
provider string,
model string,
input *ai.ModelRequest,
config anthropic.MessageNewParams,
cb func(context.Context, *ai.ModelResponseChunk) error,
) (*ai.ModelResponse, error) {
req, err := toAnthropicRequest(provider, input)
req, err := toAnthropicRequest(provider, input, config)
if err != nil {
return nil, fmt.Errorf("unable to generate anthropic request: %w", err)
}
Expand Down Expand Up @@ -255,14 +276,14 @@ func toAnthropicRole(role ai.Role) (anthropic.MessageParamRole, error) {
}
}

// toAnthropicRequest translates [ai.ModelRequest] to an Anthropic request
func toAnthropicRequest(provider string, i *ai.ModelRequest) (*anthropic.MessageNewParams, error) {
// toAnthropicRequest folds an [ai.ModelRequest] into the config the framework
// deserialized for the request, and returns the result to send to the API.
// config is taken by value: the request's own copy is what gets amended, never
// the caller's.
func toAnthropicRequest(provider string, i *ai.ModelRequest, config anthropic.MessageNewParams) (*anthropic.MessageNewParams, error) {
messages := make([]anthropic.MessageParam, 0)

req, err := configFromRequest(i)
if err != nil {
return nil, err
}
req := &config

// max_tokens is required by the Anthropic API. Fall back to a conservative
// default that every Claude model accepts, mirroring the JS plugin's
Expand Down Expand Up @@ -321,7 +342,14 @@ func toAnthropicRequest(provider string, i *ai.ModelRequest) (*anthropic.Message
// Append rather than assign: server-side tools (web search, code execution,
// ...) can only be expressed through the config, and assigning here would
// silently drop them.
req.Tools = append(req.Tools, tools...)
//
// Clip first so the append always allocates. config is only a shallow copy,
// so its slice header still points at the caller's backing array, and a
// config hoisted into a package-level var or a ModelRef is shared by every
// request made with it. Appending in place would write into that array's
// spare capacity, which two concurrent requests then race over, and one
// request's tools would surface in another's.
req.Tools = append(slices.Clip(req.Tools), tools...)

if toolChoice, ok := toAnthropicToolChoice(i.ToolChoice); ok {
req.ToolChoice = toolChoice
Expand Down Expand Up @@ -355,29 +383,6 @@ func toAnthropicToolChoice(choice ai.ToolChoice) (anthropic.ToolChoiceUnionParam
}
}

// 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:
var err error
result, err = base.MapToStruct[anthropic.MessageNewParams](config)
if 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(provider string, tools []*ai.ToolDefinition) ([]anthropic.ToolUnionParam, error) {
if len(tools) == 0 {
Expand Down
Loading
Loading