diff --git a/components/model/agenticclaude/consts.go b/components/model/agenticclaude/consts.go index 9c367ae53..95cebfe7b 100644 --- a/components/model/agenticclaude/consts.go +++ b/components/model/agenticclaude/consts.go @@ -87,3 +87,9 @@ const ( ) const implType = "AgenticClaude" + +// KeyOfCacheCreationInputTokens is the Extra map key for Anthropic +// cache_creation_input_tokens. It uses the same key as the legacy claude +// adapter so downstream consumers (e.g. Langfuse cost tracking) do not need +// to distinguish between the two adapters. +const KeyOfCacheCreationInputTokens = "_eino_claude_cache_creation_input_tokens" diff --git a/components/model/agenticclaude/convertor.go b/components/model/agenticclaude/convertor.go index 8cffc87eb..bea29ce07 100644 --- a/components/model/agenticclaude/convertor.go +++ b/components/model/agenticclaude/convertor.go @@ -574,11 +574,17 @@ func toAgenticMessage(resp *anthropic.Message) (*schema.AgenticMessage, error) { blocks = append(blocks, contentBlock) } - return &schema.AgenticMessage{ + msg := &schema.AgenticMessage{ Role: schema.AgenticRoleTypeAssistant, ContentBlocks: blocks, ResponseMeta: toAgenticResponseMeta(resp), - }, nil + } + if resp.Usage.CacheCreationInputTokens > 0 { + msg.Extra = map[string]any{ + KeyOfCacheCreationInputTokens: int(resp.Usage.CacheCreationInputTokens), + } + } + return msg, nil } func toAgenticContentBlock(block any) (*schema.ContentBlock, error) { diff --git a/components/model/agenticclaude/event_convertor.go b/components/model/agenticclaude/event_convertor.go index b301d4fde..9806c12b2 100644 --- a/components/model/agenticclaude/event_convertor.go +++ b/components/model/agenticclaude/event_convertor.go @@ -42,10 +42,16 @@ func (c *streamConverter) toMessageStreamingChunk(event anthropic.MessageStreamE case anthropic.MessageStartEvent: return toAgenticMessage(&e.Message) case anthropic.MessageDeltaEvent: - return &schema.AgenticMessage{ + msg := &schema.AgenticMessage{ Role: schema.AgenticRoleTypeAssistant, ResponseMeta: toDeltaResponseMeta(e), - }, nil + } + if e.Usage.CacheCreationInputTokens > 0 { + msg.Extra = map[string]any{ + KeyOfCacheCreationInputTokens: int(e.Usage.CacheCreationInputTokens), + } + } + return msg, nil case anthropic.ContentBlockStartEvent: contentBlock, err := c.toStreamingStartBlock(e.Index, e.ContentBlock.AsAny()) if err != nil { diff --git a/components/model/agenticclaude/go.mod b/components/model/agenticclaude/go.mod index 88c5f4345..06d0c44de 100644 --- a/components/model/agenticclaude/go.mod +++ b/components/model/agenticclaude/go.mod @@ -11,6 +11,7 @@ require ( github.com/eino-contrib/jsonschema v1.0.3 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/wk8/go-ordered-map/v2 v2.1.8 + golang.org/x/oauth2 v0.30.0 ) require ( @@ -73,7 +74,6 @@ require ( golang.org/x/crypto v0.40.0 // indirect golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect golang.org/x/net v0.41.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.27.0 // indirect diff --git a/components/model/agenticclaude/go.sum b/components/model/agenticclaude/go.sum index 3230bd92b..3541477f9 100644 --- a/components/model/agenticclaude/go.sum +++ b/components/model/agenticclaude/go.sum @@ -7,8 +7,6 @@ cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJ cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= -github.com/anthropics/anthropic-sdk-go v1.42.0 h1:Zv882/dnrE4OHnwhMAsi9lwVVXRF8GtR3ofiBResYUw= -github.com/anthropics/anthropic-sdk-go v1.42.0/go.mod h1:r4eaLX9tBolUrXLOrLj7eU8tmeBtoobCkM0kBsivBaY= github.com/anthropics/anthropic-sdk-go v1.56.0 h1:idVU14wOZ06D0GBNEvuhn927xXmBVEquo0469iDwLsc= github.com/anthropics/anthropic-sdk-go v1.56.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI= github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= @@ -125,8 +123,6 @@ github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= -github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= @@ -181,8 +177,6 @@ github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGB github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= -github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20260508151727-1282bb917829 h1:zGlGD0Zfk2HaIo4EnUVBRhnXQ+cnGQz5X2PdBcplOyw= -github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20260508151727-1282bb917829/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo= github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI= github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -197,9 +191,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= diff --git a/components/model/agenticclaude/model.go b/components/model/agenticclaude/model.go index 7998baeb2..99ba938b9 100644 --- a/components/model/agenticclaude/model.go +++ b/components/model/agenticclaude/model.go @@ -33,6 +33,7 @@ import ( "github.com/anthropics/anthropic-sdk-go/vertex" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" + "golang.org/x/oauth2/google" "github.com/cloudwego/eino/callbacks" "github.com/cloudwego/eino/components" @@ -142,6 +143,19 @@ type GoogleVertexAIConfig struct { // from CLOUD_ML_REGION environment variable. // See: https://claude.ai/docs/en/google-vertex-ai Region string + + // Credentials provides explicit Google credentials for Vertex AI authentication. + // When set, vertex.WithCredentials is used instead of the ambient ADC + // (vertex.WithGoogleAuth). This avoids relying on GOOGLE_APPLICATION_CREDENTIALS + // environment variable, which is process-global and unsafe under concurrent + // or hot-reload scenarios. + // + // Example: + // creds, _ := google.CredentialsFromJSON(ctx, jsonKey, "https://www.googleapis.com/auth/cloud-platform") + // cfg.ByGoogleVertexAI = &GoogleVertexAIConfig{Credentials: creds, ...} + // + // Optional. When nil, falls back to ambient ADC via WithGoogleAuth. + Credentials *google.Credentials } type BedrockConfig struct { @@ -256,6 +270,9 @@ func newClient(ctx context.Context, cfg *Config) (anthropic.Client, error) { return anthropic.Client{}, errors.New("ByGoogleVertexAI is set but no region provided; set Region or CLOUD_ML_REGION") } + if cfg.ByGoogleVertexAI.Credentials != nil { + return anthropic.NewClient(vertex.WithCredentials(ctx, region, projectID, cfg.ByGoogleVertexAI.Credentials)), nil + } return anthropic.NewClient(vertex.WithGoogleAuth(ctx, region, projectID)), nil case cfg.ByBedrock != nil: diff --git a/components/model/agenticgemini/conv.go b/components/model/agenticgemini/conv.go index f52b29abc..b05fefa9b 100644 --- a/components/model/agenticgemini/conv.go +++ b/components/model/agenticgemini/conv.go @@ -335,9 +335,11 @@ func convUserInputFile(file *schema.UserInputFile) (*genai.Part, error) { func convFunctionToolCall(call *schema.FunctionToolCall) (*genai.Part, error) { args := make(map[string]any) - err := sonic.UnmarshalString(call.Arguments, &args) - if err != nil { - return nil, fmt.Errorf("unmarshal function tool call arguments to map[string]any fail: %w", err) + if call.Arguments != "" { + err := sonic.UnmarshalString(call.Arguments, &args) + if err != nil { + return nil, fmt.Errorf("unmarshal function tool call arguments to map[string]any fail: %w", err) + } } return genai.NewPartFromFunctionCall(call.Name, args), nil @@ -353,7 +355,11 @@ func convFunctionToolResult(result *schema.FunctionToolResult) (*genai.Part, err if err != nil { response["output"] = text } - return genai.NewPartFromFunctionResponse(result.Name, response), nil + name := result.Name + if name == "" { + name = result.CallID + } + return genai.NewPartFromFunctionResponse(name, response), nil } func functionToolResultContentToText(content []*schema.FunctionToolResultContentBlock) (string, error) { @@ -650,7 +656,6 @@ func populateStreamingMeta(curBlocks []*schema.ContentBlock, curIndex int, lastT return curIndex, lastType } if len(lastType) > 0 && curBlocks[0].Type != lastType { - // a new part, index++ curIndex++ } @@ -664,9 +669,30 @@ func populateStreamingMeta(curBlocks []*schema.ContentBlock, curIndex int, lastT } curBlocks[i].StreamingMeta = &schema.StreamingMeta{Index: curIndex} + // Discrete block types (tool calls/results) are self-contained — each one is a + // complete unit, not a partial chunk to be concatenated. Advance curIndex so the + // next chunk starts at a new index, preventing ConcatAgenticMessages from trying + // to merge different tool calls that happen to share the same streaming index. + if isDiscreteBlockType(curBlocks[i].Type) { + curIndex++ + } + return curIndex, curBlocks[len(curBlocks)-1].Type } +func isDiscreteBlockType(t schema.ContentBlockType) bool { + switch t { + case schema.ContentBlockTypeFunctionToolCall, + schema.ContentBlockTypeFunctionToolResult, + schema.ContentBlockTypeServerToolCall, + schema.ContentBlockTypeServerToolResult, + schema.ContentBlockTypeMCPToolCall, + schema.ContentBlockTypeMCPToolResult: + return true + } + return false +} + func toGeminiTools(tools []*schema.ToolInfo) ([]*genai.FunctionDeclaration, error) { gTools := make([]*genai.FunctionDeclaration, len(tools)) for i, tool := range tools { diff --git a/components/model/agenticgemini/conv_test.go b/components/model/agenticgemini/conv_test.go index d22afaec4..be77c209e 100644 --- a/components/model/agenticgemini/conv_test.go +++ b/components/model/agenticgemini/conv_test.go @@ -1259,3 +1259,94 @@ func TestConvAgenticMessages_MergeToolResults(t *testing.T) { assert.Len(t, contents, 2) }) } + +func TestConvFunctionToolResult_EmptyName(t *testing.T) { + t.Run("empty name falls back to CallID", func(t *testing.T) { + part, err := convFunctionToolResult(&schema.FunctionToolResult{ + CallID: "call-fallback-1", + Content: []*schema.FunctionToolResultContentBlock{ + {Type: schema.FunctionToolResultContentBlockTypeText, Text: &schema.UserInputText{Text: `{"status":"ok"}`}}, + }, + }) + assert.NoError(t, err) + assert.NotNil(t, part) + assert.NotNil(t, part.FunctionResponse) + assert.Equal(t, "call-fallback-1", part.FunctionResponse.Name) + assert.Equal(t, "ok", part.FunctionResponse.Response["status"]) + }) + + t.Run("explicit name is preserved", func(t *testing.T) { + part, err := convFunctionToolResult(&schema.FunctionToolResult{ + CallID: "call-1", + Name: "read_file", + Content: []*schema.FunctionToolResultContentBlock{ + {Type: schema.FunctionToolResultContentBlockTypeText, Text: &schema.UserInputText{Text: "file contents"}}, + }, + }) + assert.NoError(t, err) + assert.NotNil(t, part) + assert.Equal(t, "read_file", part.FunctionResponse.Name) + assert.Equal(t, "file contents", part.FunctionResponse.Response["output"]) + }) +} + +func TestConvFunctionToolCall_EmptyArguments(t *testing.T) { + t.Run("empty string arguments treated as empty object", func(t *testing.T) { + part, err := convFunctionToolCall(&schema.FunctionToolCall{ + Name: "task_complete", + Arguments: "", + }) + assert.NoError(t, err) + assert.NotNil(t, part) + assert.Equal(t, "task_complete", part.FunctionCall.Name) + assert.Empty(t, part.FunctionCall.Args) + }) + + t.Run("valid JSON arguments", func(t *testing.T) { + part, err := convFunctionToolCall(&schema.FunctionToolCall{ + Name: "read_file", + Arguments: `{"path":"src/App.tsx"}`, + }) + assert.NoError(t, err) + assert.NotNil(t, part) + assert.Equal(t, "read_file", part.FunctionCall.Name) + assert.Equal(t, "src/App.tsx", part.FunctionCall.Args["path"]) + }) + + t.Run("empty object arguments", func(t *testing.T) { + part, err := convFunctionToolCall(&schema.FunctionToolCall{ + Name: "task_complete", + Arguments: "{}", + }) + assert.NoError(t, err) + assert.NotNil(t, part) + assert.Empty(t, part.FunctionCall.Args) + }) + + t.Run("invalid JSON arguments returns error", func(t *testing.T) { + _, err := convFunctionToolCall(&schema.FunctionToolCall{ + Name: "write_file", + Arguments: `{"path":"broken`, + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unmarshal function tool call arguments") + }) + + t.Run("empty args in message conversion roundtrip", func(t *testing.T) { + msg := &schema.AgenticMessage{ + Role: schema.AgenticRoleTypeAssistant, + ContentBlocks: []*schema.ContentBlock{ + schema.NewContentBlock(&schema.FunctionToolCall{ + CallID: "call-1", + Name: "task_complete", + Arguments: "", + }), + }, + } + content, err := convAgenticMessage(msg) + assert.NoError(t, err) + assert.NotNil(t, content) + assert.Len(t, content.Parts, 1) + assert.Equal(t, "task_complete", content.Parts[0].FunctionCall.Name) + }) +}