diff --git a/go/plugins/anthropic/anthropic.go b/go/plugins/anthropic/anthropic.go index b68225c024..db190e0f30 100644 --- a/go/plugins/anthropic/anthropic.go +++ b/go/plugins/anthropic/anthropic.go @@ -18,7 +18,6 @@ package anthropic import ( "context" - "fmt" "log/slog" "os" "regexp" @@ -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 } // 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 } @@ -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 @@ -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 } @@ -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 { diff --git a/go/plugins/anthropic/anthropic_test.go b/go/plugins/anthropic/anthropic_test.go index ab25b913a7..4c4caf95a5 100644 --- a/go/plugins/anthropic/anthropic_test.go +++ b/go/plugins/anthropic/anthropic_test.go @@ -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 @@ -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) @@ -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") } } diff --git a/go/plugins/internal/anthropic/anthropic.go b/go/plugins/internal/anthropic/anthropic.go index 1585425273..f0563e78b9 100644 --- a/go/plugins/internal/anthropic/anthropic.go +++ b/go/plugins/internal/anthropic/anthropic.go @@ -24,6 +24,7 @@ import ( "fmt" "reflect" "regexp" + "slices" "strings" "github.com/firebase/genkit/go/ai" @@ -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 @@ -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, @@ -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) } @@ -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 @@ -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 @@ -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 { diff --git a/go/plugins/internal/anthropic/anthropic_test.go b/go/plugins/internal/anthropic/anthropic_test.go index 860a0aa0e0..0e21a450e4 100644 --- a/go/plugins/internal/anthropic/anthropic_test.go +++ b/go/plugins/internal/anthropic/anthropic_test.go @@ -24,6 +24,7 @@ import ( "github.com/anthropics/anthropic-sdk-go" "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/internal/base" "github.com/google/go-cmp/cmp" ) @@ -62,79 +63,114 @@ func TestAnthropic(t *testing.T) { type modelRequestTestCase struct { name string req *ai.ModelRequest + config anthropic.MessageNewParams expected *anthropic.MessageNewParams expectedErr string } -func TestAnthropicConfig(t *testing.T) { - emptyConfig := anthropic.MessageNewParams{} - expectedConfig := anthropic.MessageNewParams{ +// validateConfig runs a config through the same check the action boundary +// performs: the config schema the model advertises is enforced on every call. +func validateConfig(t *testing.T, inputSchema map[string]any, config any) error { + t.Helper() + return base.ValidateValue(&ai.ModelRequest{ + Messages: []*ai.Message{ai.NewUserMessage(ai.NewTextPart("hello"))}, + Config: config, + }, inputSchema) +} + +// TestModelConfig pins the contract between the schema a model advertises and +// the SDK type the framework deserializes into: every config form the action +// boundary accepts must convert to [anthropic.MessageNewParams], and the +// wrapper types the SDK uses for optional primitives must survive the trip. +// The schema is enforced on every request, so a form that the two disagree on +// is either a request rejected for no reason or a value silently dropped. +func TestModelConfig(t *testing.T) { + desc := NewModel(anthropic.Client{}, "anthropic", "claude-opus-4-5", "", ai.ModelOptions{}).Desc() + + sampled := anthropic.MessageNewParams{ Temperature: anthropic.Float(1.0), TopK: anthropic.Int(1), } - tests := []modelRequestTestCase{ - { - name: "Input is anthropic.MessageNewParams struct", - req: &ai.ModelRequest{ - Config: anthropic.MessageNewParams{ - Temperature: anthropic.Float(1.0), - TopK: anthropic.Int(1), - }, - }, - expected: &expectedConfig, - }, - { - name: "Input is *anthropic.MessageNewParams struct", - req: &ai.ModelRequest{ - Config: &anthropic.MessageNewParams{ - Temperature: anthropic.Float(1.0), - TopK: anthropic.Int(1), - }, - }, - expected: &expectedConfig, - }, - { - name: "Input is map[string]any", - req: &ai.ModelRequest{ - Config: map[string]any{ - "temperature": 1.0, - "top_k": 1, - }, - }, - expected: &expectedConfig, - }, - { - name: "Input is map[string]any (empty)", - req: &ai.ModelRequest{ - Config: map[string]any{}, - }, - expected: &emptyConfig, - }, - { - name: "Input is nil", - req: &ai.ModelRequest{ - Config: nil, - }, - expected: &emptyConfig, - }, - { - name: "Input is an unexpected type", - req: &ai.ModelRequest{ - Config: 123, - }, - expectedErr: "unexpected config type: int", - }, + accepted := []struct { + name string + config any + want anthropic.MessageNewParams + }{ + {"struct config", sampled, sampled}, + {"pointer config", &sampled, sampled}, + {"map config", map[string]any{"temperature": 1.0, "top_k": 1}, sampled}, + {"empty map config", map[string]any{}, anthropic.MessageNewParams{}}, + {"nil config", nil, anthropic.MessageNewParams{}}, + // A typed nil marshals to JSON null, which the config slot tolerates. + {"typed nil config", (*anthropic.MessageNewParams)(nil), anthropic.MessageNewParams{}}, + {"thinking union", map[string]any{"thinking": map[string]any{"type": "enabled", "budget_tokens": 1024}}, anthropic.MessageNewParams{ + Thinking: anthropic.ThinkingConfigParamOfEnabled(1024), + }}, + } + for _, tt := range accepted { + t.Run("accepts "+tt.name, func(t *testing.T) { + if err := validateConfig(t, desc.InputSchema, tt.config); err != nil { + t.Fatalf("config rejected at the action boundary: %v", err) + } + got, err := base.ConvertToExact[anthropic.MessageNewParams](tt.config) + if err != nil { + t.Fatalf("config accepted by the schema but not deserializable: %v", err) + } + // Compared as JSON: the SDK's param structs carry the raw + // message they were deserialized from, so two values that send + // the same request are not deeply equal. + if diff := cmp.Diff(wireJSON(t, tt.want), wireJSON(t, got)); diff != "" { + t.Errorf("deserialized config mismatch (-want +got):\n%s", diff) + } + }) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := configFromRequest(tt.req) - if checkError(t, err, tt.expectedErr) { - return + rejected := []struct { + name string + config any + }{ + {"unknown field", map[string]any{"nope": 1}}, + // The SDK's wire names are snake_case; camelCase would deserialize to + // nothing at all. + {"camelCase field name", map[string]any{"maxTokens": 10}}, + {"mistyped value", map[string]any{"temperature": "hot"}}, + } + for _, tt := range rejected { + t.Run("rejects "+tt.name, func(t *testing.T) { + if err := validateConfig(t, desc.InputSchema, tt.config); err == nil { + t.Error("expected the action boundary to reject this config") } - if !reflect.DeepEqual(tt.expected, got) { - t.Errorf("configFromRequest() got = %+v, want %+v", got, tt.expected) + }) + } + + // Another provider's config never reaches the model function. + if _, err := base.ConvertToExact[anthropic.MessageNewParams](123); err == nil { + t.Error("expected an int config to be rejected as a type mismatch") + } +} + +// TestModelLabel covers the label a model advertises: the caller's when set, +// otherwise one derived from the provider and the model name. +func TestModelLabel(t *testing.T) { + tests := []struct { + provider string + name string + opts ai.ModelOptions + want string + }{ + {"anthropic", "claude-opus-4-5", ai.ModelOptions{Label: "Anthropic - Claude Opus 4.5"}, "Anthropic - Claude Opus 4.5"}, + {"anthropic", "claude-something-new", ai.ModelOptions{}, "Anthropic - claude-something-new"}, + {"vertexai", "claude-opus-4-5", ai.ModelOptions{Label: "Claude Opus 4.5"}, "Claude Opus 4.5"}, + {"vertexai", "claude-something-new", ai.ModelOptions{}, "Vertex AI - claude-something-new"}, + } + + for _, tt := range tests { + t.Run(tt.provider+"/"+tt.name, func(t *testing.T) { + desc := NewModel(anthropic.Client{}, tt.provider, tt.name, "", tt.opts).Desc() + got := desc.Metadata["model"].(map[string]any)["label"] + if got != tt.want { + t.Errorf("label = %v, want %q", got, tt.want) } }) } @@ -436,10 +472,8 @@ func TestToAnthropicRequest(t *testing.T) { Content: []*ai.Part{ai.NewTextPart("hello")}, }, }, - Config: map[string]any{ - "max_tokens": 10, - }, }, + config: anthropic.MessageNewParams{MaxTokens: 10}, expected: &anthropic.MessageNewParams{ MaxTokens: 10, System: []anthropic.TextBlockParam{}, @@ -461,10 +495,8 @@ func TestToAnthropicRequest(t *testing.T) { Content: []*ai.Part{ai.NewTextPart("hello")}, }, }, - Config: map[string]any{ - "max_tokens": 10, - }, }, + config: anthropic.MessageNewParams{MaxTokens: 10}, expected: &anthropic.MessageNewParams{ MaxTokens: 10, System: []anthropic.TextBlockParam{ @@ -498,7 +530,7 @@ func TestToAnthropicRequest(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := toAnthropicRequest("anthropic", tt.req) + got, err := toAnthropicRequest("anthropic", tt.req, tt.config) if checkError(t, err, tt.expectedErr) { return } @@ -531,9 +563,6 @@ func TestToAnthropicRequest_StructuredOutput(t *testing.T) { Content: []*ai.Part{ai.NewTextPart("hello")}, }, }, - Config: map[string]any{ - "max_tokens": 100, - }, Output: &ai.ModelOutputConfig{ Format: "json", Schema: schema, @@ -541,7 +570,7 @@ func TestToAnthropicRequest_StructuredOutput(t *testing.T) { }, } - got, err := toAnthropicRequest("anthropic", req) + got, err := toAnthropicRequest("anthropic", req, anthropic.MessageNewParams{MaxTokens: 100}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -565,10 +594,10 @@ func TestToAnthropicRequest_StructuredOutput(t *testing.T) { } } -// userRequest builds a minimal request carrying a single user message. -func userRequest(config any) *ai.ModelRequest { +// userRequest builds a minimal request carrying a single user message. The +// config travels beside the request now, so it is not part of it. +func userRequest() *ai.ModelRequest { return &ai.ModelRequest{ - Config: config, Messages: []*ai.Message{ai.NewUserMessage(ai.NewTextPart("hi"))}, } } @@ -664,15 +693,17 @@ func TestToAnthropicPartsReasoningSignature(t *testing.T) { func TestToAnthropicRequestPreservesConfig(t *testing.T) { // Server-side tools can only be expressed through the config, so the - // genkit tool list must merge with them rather than replace them. + // genkit tool list must merge with them rather than replace them. The same + // config value is reused across both calls, which also pins that a request + // amends its own copy rather than the caller's. t.Run("config tools survive", func(t *testing.T) { - config := &anthropic.MessageNewParams{ + config := anthropic.MessageNewParams{ MaxTokens: 100, Tools: []anthropic.ToolUnionParam{ {OfWebSearchTool20250305: &anthropic.WebSearchTool20250305Param{}}, }, } - got, err := toAnthropicRequest("anthropic", userRequest(config)) + got, err := toAnthropicRequest("anthropic", userRequest(), config) if err != nil { t.Fatalf("toAnthropicRequest: %v", err) } @@ -680,13 +711,13 @@ func TestToAnthropicRequestPreservesConfig(t *testing.T) { t.Fatalf("got %d tools, want the config tool preserved", len(got.Tools)) } - req := userRequest(config) + req := userRequest() req.Tools = []*ai.ToolDefinition{{ Name: "my_tool", Description: "d", InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}, }} - got, err = toAnthropicRequest("anthropic", req) + got, err = toAnthropicRequest("anthropic", req, config) if err != nil { t.Fatalf("toAnthropicRequest: %v", err) } @@ -695,12 +726,51 @@ func TestToAnthropicRequestPreservesConfig(t *testing.T) { } }) + // The request's config is a shallow copy, so its Tools header still points + // at the caller's backing array. Appending in place would write the genkit + // tools into that array's spare capacity, which concurrent requests over a + // hoisted config race over. Length alone does not catch it: an in-place + // append leaves the caller's length untouched while overwriting the slots + // past it. + t.Run("appending tools leaves the caller's array alone", func(t *testing.T) { + configTools := make([]anthropic.ToolUnionParam, 1, 4) + configTools[0] = anthropic.ToolUnionParam{OfWebSearchTool20250305: &anthropic.WebSearchTool20250305Param{}} + config := anthropic.MessageNewParams{MaxTokens: 100, Tools: configTools} + + req := userRequest() + req.Tools = []*ai.ToolDefinition{{ + Name: "my_tool", + Description: "d", + InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}, + }} + + got, err := toAnthropicRequest("anthropic", req, config) + if err != nil { + t.Fatalf("toAnthropicRequest: %v", err) + } + if len(got.Tools) != 2 { + t.Fatalf("got %d tools, want the config tool plus the genkit tool", len(got.Tools)) + } + if &got.Tools[0] == &configTools[0] { + t.Error("the request's tools share the caller's backing array") + } + for i, tool := range configTools[:cap(configTools)] { + if i == 0 { + continue + } + if tool.OfTool != nil { + t.Errorf("the caller's spare capacity was written at index %d", i) + } + } + }) + // Assigning a fresh OutputConfig would drop a config-provided effort. t.Run("output config effort survives structured output", func(t *testing.T) { - req := userRequest(&anthropic.MessageNewParams{ + config := anthropic.MessageNewParams{ MaxTokens: 100, OutputConfig: anthropic.OutputConfigParam{Effort: anthropic.OutputConfigEffort("high")}, - }) + } + req := userRequest() req.Output = &ai.ModelOutputConfig{ Format: "json", Constrained: true, @@ -709,7 +779,7 @@ func TestToAnthropicRequestPreservesConfig(t *testing.T) { "properties": map[string]any{"a": map[string]any{"type": "string"}}, }, } - got, err := toAnthropicRequest("anthropic", req) + got, err := toAnthropicRequest("anthropic", req, config) if err != nil { t.Fatalf("toAnthropicRequest: %v", err) } @@ -723,10 +793,10 @@ func TestToAnthropicRequestPreservesConfig(t *testing.T) { }) t.Run("config tool choice survives when unset", func(t *testing.T) { - got, err := toAnthropicRequest("anthropic", userRequest(&anthropic.MessageNewParams{ + got, err := toAnthropicRequest("anthropic", userRequest(), anthropic.MessageNewParams{ MaxTokens: 100, ToolChoice: anthropic.ToolChoiceUnionParam{OfTool: &anthropic.ToolChoiceToolParam{Name: "pinned"}}, - })) + }) if err != nil { t.Fatalf("toAnthropicRequest: %v", err) } @@ -738,7 +808,7 @@ func TestToAnthropicRequestPreservesConfig(t *testing.T) { // Anthropic rejects an empty content array, and an empty tools array // conflicts with a config-provided tool_choice. t.Run("no empty arrays on the wire", func(t *testing.T) { - got, err := toAnthropicRequest("anthropic", userRequest(nil)) + got, err := toAnthropicRequest("anthropic", userRequest(), anthropic.MessageNewParams{}) if err != nil { t.Fatalf("toAnthropicRequest: %v", err) } @@ -761,9 +831,9 @@ func TestToAnthropicRequestToolChoice(t *testing.T) { for _, tt := range tests { t.Run(string(tt.choice), func(t *testing.T) { - req := userRequest(nil) + req := userRequest() req.ToolChoice = tt.choice - got, err := toAnthropicRequest("anthropic", req) + got, err := toAnthropicRequest("anthropic", req, anthropic.MessageNewParams{}) if err != nil { t.Fatalf("toAnthropicRequest: %v", err) } @@ -777,12 +847,11 @@ func TestToAnthropicRequestToolChoice(t *testing.T) { // A message whose content is empty must be skipped rather than indexed into. func TestToAnthropicRequestSkipsEmptyMessages(t *testing.T) { got, err := toAnthropicRequest("anthropic", &ai.ModelRequest{ - Config: &anthropic.MessageNewParams{MaxTokens: 100}, Messages: []*ai.Message{ {Role: ai.RoleModel, Content: nil}, ai.NewUserMessage(ai.NewTextPart("hi")), }, - }) + }, anthropic.MessageNewParams{MaxTokens: 100}) if err != nil { t.Fatalf("toAnthropicRequest: %v", err) } diff --git a/go/plugins/vertexai/modelgarden/anthropic.go b/go/plugins/vertexai/modelgarden/anthropic.go index 09daab59af..6de8cfbfbe 100644 --- a/go/plugins/vertexai/modelgarden/anthropic.go +++ b/go/plugins/vertexai/modelgarden/anthropic.go @@ -72,8 +72,7 @@ func (a *Anthropic) Init(ctx context.Context) []api.Action { // Models must be defined manually var actions []api.Action for name, opts := range AnthropicModels { - model := ant.DefineModel(a.client, provider, name, opts) - actions = append(actions, model.(api.Action)) + actions = append(actions, ant.NewModel(a.client, provider, name, name, opts)) } return actions @@ -95,5 +94,5 @@ func (a *Anthropic) DefineModel(name string, opts *ai.ModelOptions) (ai.Model, e if opts == nil { return nil, fmt.Errorf("DefineModel called with nil ai.ModelOptions") } - return ant.DefineModel(a.client, provider, name, *opts), nil + return ant.NewModel(a.client, provider, name, name, *opts), nil }