From d4508f262a5ea44fc2eb2791d8d4596f7d4c4b03 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Thu, 30 Jul 2026 13:17:28 -0700 Subject: [PATCH 1/4] refactor(go/plugins/anthropic): take the typed config in the Claude model Migrates the Anthropic plugin and the Vertex AI Model Garden Claude models to the typed-config constructors: the model function receives an anthropic.MessageNewParams the framework has already validated and deserialized, so the hand-rolled configFromRequest type switch is gone. Both plugins built their own model action with the same options and the same generate closure, so they now share one constructor. It replaces ant.DefineModel, which registered nothing despite its name, returns the concrete *ai.ModelAction so the callers stop asserting their way back to api.Action, and honors the caller's label instead of overwriting it with a provider-prefixed model ID. The config schema is reflected once at package level rather than per model, which ListActions builds once per discovered model. --- go/plugins/anthropic/anthropic.go | 50 +++------- go/plugins/internal/anthropic/anthropic.go | 99 ++++++++++---------- go/plugins/vertexai/modelgarden/anthropic.go | 5 +- 3 files changed, 61 insertions(+), 93 deletions(-) diff --git a/go/plugins/anthropic/anthropic.go b/go/plugins/anthropic/anthropic.go index b68225c024..042cd65434 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" @@ -100,22 +99,19 @@ func (a *Anthropic) Init(ctx context.Context) []api.Action { // 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 + 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 +128,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 +162,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 +186,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/internal/anthropic/anthropic.go b/go/plugins/internal/anthropic/anthropic.go index 1585425273..5a8a443d2a 100644 --- a/go/plugins/internal/anthropic/anthropic.go +++ b/go/plugins/internal/anthropic/anthropic.go @@ -45,6 +45,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 +91,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 +171,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 +275,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 @@ -355,29 +375,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/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 } From 026affd8f717b3c473a94d93c2f528cbd336f0a0 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Thu, 30 Jul 2026 13:17:34 -0700 Subject: [PATCH 2/4] test(go/plugins/anthropic): pin the config contract and the model labels The config schema a model advertises is now enforced on every request, so what it accepts has to match what anthropic.MessageNewParams can hold: every config form the action boundary accepts is checked to deserialize into the SDK type, including the wrapper types the SDK uses for optional primitives and the thinking union. The forms it rejects (unknown fields, camelCase spellings of snake_case wire names, mistyped values) are pinned too, since the model function never sees them. Labels move with the constructor: curated ones are honored as given and the rest are derived from the provider and the model name. --- go/plugins/anthropic/anthropic_test.go | 66 +++++- .../internal/anthropic/anthropic_test.go | 216 ++++++++++-------- 2 files changed, 187 insertions(+), 95 deletions(-) diff --git a/go/plugins/anthropic/anthropic_test.go b/go/plugins/anthropic/anthropic_test.go index ab25b913a7..4c9e2e9460 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,64 @@ 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) + } + }) + } +} + +// 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_test.go b/go/plugins/internal/anthropic/anthropic_test.go index 860a0aa0e0..aafebdbbed 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,27 +711,31 @@ 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) } if len(got.Tools) != 2 { t.Errorf("got %d tools, want the config tool plus the genkit tool", len(got.Tools)) } + if len(config.Tools) != 1 { + t.Errorf("the caller's config grew to %d tools", len(config.Tools)) + } }) // 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 +744,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 +758,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 +773,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 +796,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 +812,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) } From 55a6c6969dc813554b52ddab6de2c6539e9744a7 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Thu, 30 Jul 2026 13:27:55 -0700 Subject: [PATCH 3/4] fix(go/plugins/anthropic): resolve capabilities for a nil DefineModel opts DefineModel dereferenced its ModelOptions pointer unguarded, so a nil panicked. A nil now takes the capabilities the plugin already resolves by name, curated for a known model and the Claude defaults for the rest, rather than an empty ModelOptions that would advertise a model supporting neither tools nor multiturn nor a system role. --- go/plugins/anthropic/anthropic.go | 8 +++++++- go/plugins/anthropic/anthropic_test.go | 27 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/go/plugins/anthropic/anthropic.go b/go/plugins/anthropic/anthropic.go index 042cd65434..db190e0f30 100644 --- a/go/plugins/anthropic/anthropic.go +++ b/go/plugins/anthropic/anthropic.go @@ -95,10 +95,16 @@ 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) { + if opts == nil { + resolved := modelOptions(name) + opts = &resolved + } return newModel(a.aclient, name, name, *opts), nil } diff --git a/go/plugins/anthropic/anthropic_test.go b/go/plugins/anthropic/anthropic_test.go index 4c9e2e9460..4c4caf95a5 100644 --- a/go/plugins/anthropic/anthropic_test.go +++ b/go/plugins/anthropic/anthropic_test.go @@ -134,6 +134,33 @@ func TestNewModelDescriptor(t *testing.T) { } } +// 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. From 0b313a6d19fa386c9a570d1d02e6394279adf5e5 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Fri, 31 Jul 2026 15:17:02 -0700 Subject: [PATCH 4/4] fix(go/plugins/anthropic): stop the tool append from writing the caller's array The request's config is a shallow copy, so its Tools slice header still points at the caller's backing array. Appending the request's tools in place writes into that array's spare capacity, so a config hoisted into a package-level var or a ModelRef, which every request made with it shares, has concurrent requests writing the same slot and reading each other's tools. Clipping before the append forces an allocation. The test that claimed to pin this only compared lengths, which an in-place append leaves untouched while overwriting the slots past them. It now checks that the result does not share the caller's array and that the caller's spare capacity is still empty; it fails on the old append. --- go/plugins/internal/anthropic/anthropic.go | 10 ++++- .../internal/anthropic/anthropic_test.go | 39 ++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/go/plugins/internal/anthropic/anthropic.go b/go/plugins/internal/anthropic/anthropic.go index 5a8a443d2a..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" @@ -341,7 +342,14 @@ func toAnthropicRequest(provider string, i *ai.ModelRequest, config anthropic.Me // 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 diff --git a/go/plugins/internal/anthropic/anthropic_test.go b/go/plugins/internal/anthropic/anthropic_test.go index aafebdbbed..0e21a450e4 100644 --- a/go/plugins/internal/anthropic/anthropic_test.go +++ b/go/plugins/internal/anthropic/anthropic_test.go @@ -724,8 +724,43 @@ func TestToAnthropicRequestPreservesConfig(t *testing.T) { if len(got.Tools) != 2 { t.Errorf("got %d tools, want the config tool plus the genkit tool", len(got.Tools)) } - if len(config.Tools) != 1 { - t.Errorf("the caller's config grew to %d tools", len(config.Tools)) + }) + + // 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) + } } })