diff --git a/components/model/agenticopenai/go.mod b/components/model/agenticopenai/go.mod index 6ec1cad47..8bd221ee9 100644 --- a/components/model/agenticopenai/go.mod +++ b/components/model/agenticopenai/go.mod @@ -1,6 +1,6 @@ module github.com/cloudwego/eino-ext/components/model/agenticopenai -go 1.22 +go 1.25.0 require ( github.com/bytedance/mockey v1.4.6 @@ -9,16 +9,16 @@ require ( github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260527084435-846f52bd97c6 github.com/eino-contrib/jsonschema v1.0.3 github.com/go-viper/mapstructure/v2 v2.5.0 - github.com/openai/openai-go/v3 v3.35.0 + github.com/openai/openai-go/v3 v3.56.0 github.com/smartystreets/goconvey v1.8.1 github.com/stretchr/testify v1.11.1 github.com/wk8/go-ordered-map/v2 v2.1.8 - golang.org/x/sync v0.10.0 + golang.org/x/sync v0.22.0 ) require ( - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect @@ -44,7 +44,7 @@ require ( github.com/sirupsen/logrus v1.9.3 // indirect github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect github.com/smarty/assertions v1.15.0 // indirect - github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect @@ -52,8 +52,8 @@ require ( github.com/yargevad/filepathx v1.0.0 // indirect golang.org/x/arch v0.11.0 // indirect golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/components/model/agenticopenai/go.sum b/components/model/agenticopenai/go.sum index f70db8fc9..7c4320ef2 100644 --- a/components/model/agenticopenai/go.sum +++ b/components/model/agenticopenai/go.sum @@ -1,11 +1,11 @@ -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= -github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= -github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.0 h1:4gRPBpN1f6xt88yi4WR26m7XaD9OlWtVT6bWPdGUIok= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.0/go.mod h1:G7QVLxw1j1JVyrO1MA95S8m8HStaaleDZYTcfGgjB2o= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= @@ -46,8 +46,8 @@ github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclK github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -96,8 +96,8 @@ github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTf github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/openai/openai-go/v3 v3.35.0 h1:109x3epXMSE423KW2euR506GGFezcEt0s87MoWejpH0= -github.com/openai/openai-go/v3 v3.35.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/openai/openai-go/v3 v3.56.0 h1:Xb+gsS7Gsb5dx+Uat9BHTWhifAOvQjU+pQNWT/HXeWQ= +github.com/openai/openai-go/v3 v3.56.0/go.mod h1:ufI1+K+t0ijRB3gk8eztiw1crcDpsBuxRQL4sbLIrts= github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -134,8 +134,8 @@ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf 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= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= @@ -156,26 +156,26 @@ go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4= golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= -golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/components/model/agenticopenai/register.go b/components/model/agenticopenai/register.go index cefe1d872..c3cf172b3 100644 --- a/components/model/agenticopenai/register.go +++ b/components/model/agenticopenai/register.go @@ -27,11 +27,13 @@ func init() { schema.RegisterName[blockExtraItemID]("_eino_ext_openai_block_extra_item_id") schema.RegisterName[blockExtraItemStatus]("_eino_ext_openai_block_extra_item_status") + schema.RegisterName[blockExtraMCPErrorJSON]("_eino_ext_openai_block_extra_mcp_error_json") schema.RegisterName[*ServerToolCallArguments]("_eino_ext_openai_server_tool_call_arguments") schema.RegisterName[*ServerToolResult]("_eino_ext_openai_server_tool_result") compose.RegisterStreamChunkConcatFunc(concatFirstNonZero[blockExtraItemID]) compose.RegisterStreamChunkConcatFunc(concatLast[blockExtraItemStatus]) + compose.RegisterStreamChunkConcatFunc(concatLast[blockExtraMCPErrorJSON]) compose.RegisterStreamChunkConcatFunc(concatServerToolCallArguments) compose.RegisterStreamChunkConcatFunc(concatServerToolResult) } diff --git a/components/model/agenticopenai/responses_convertor.go b/components/model/agenticopenai/responses_convertor.go index 52cc2297e..5d86aae90 100644 --- a/components/model/agenticopenai/responses_convertor.go +++ b/components/model/agenticopenai/responses_convertor.go @@ -699,7 +699,7 @@ func functionToolResultToInputItem(block *schema.FunctionToolResult) (item respo item = responses.ResponseInputItemUnionParam{ OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{ - CallID: block.CallID, + CallID: param.NewOpt(block.CallID), Output: output, }, } @@ -1469,9 +1469,9 @@ func mcpToolResultToInputItem(block *schema.ContentBlock) (item responses.Respon id, _ := getItemID(block) status, _ := GetItemStatus(block) - var errorMsg string - if content.Error != nil { - errorMsg = content.Error.Message + errorParam, err := mcpErrorToInputParam(block) + if err != nil { + return item, err } item = responses.ResponseInputItemUnionParam{ @@ -1479,7 +1479,7 @@ func mcpToolResultToInputItem(block *schema.ContentBlock) (item responses.Respon ID: id, ServerLabel: content.ServerLabel, Name: content.Name, - Error: newOpenaiStrOpt(errorMsg), + Error: errorParam, Output: newOpenaiStrOpt(content.Content), Status: status, }, @@ -2169,19 +2169,19 @@ func mcpCallToContentBlocks(item responses.ResponseOutputItemMcpCall) (blocks [] }) setItemID(callBlock, item.ID) + callError, rawError, err := mcpErrorFromResponse(item) + if err != nil { + return nil, err + } resultBlock := schema.NewContentBlock(&schema.MCPToolResult{ ServerLabel: item.ServerLabel, Name: item.Name, Content: item.Output, - Error: func() *schema.MCPToolCallError { - if item.Error == "" { - return nil - } - return &schema.MCPToolCallError{ - Message: item.Error, - } - }(), + Error: callError, }) + if rawError != "" { + setBlockExtraValue(resultBlock, mcpErrorJSONKey, blockExtraMCPErrorJSON(rawError)) + } setItemID(resultBlock, item.ID) blocks = []*schema.ContentBlock{callBlock, resultBlock} diff --git a/components/model/agenticopenai/responses_convertor_test.go b/components/model/agenticopenai/responses_convertor_test.go index 4bb40ab13..b2766ac6b 100644 --- a/components/model/agenticopenai/responses_convertor_test.go +++ b/components/model/agenticopenai/responses_convertor_test.go @@ -440,7 +440,8 @@ func TestFunctionToolResultToInputItem(t *testing.T) { }}) assert.NoError(t, err) assert.NotNil(t, item.OfFunctionCallOutput) - assert.Equal(t, "c", item.OfFunctionCallOutput.CallID) + assert.True(t, item.OfFunctionCallOutput.CallID.Valid()) + assert.Equal(t, "c", item.OfFunctionCallOutput.CallID.Value) if assert.Len(t, item.OfFunctionCallOutput.Output.OfResponseFunctionCallOutputItemArray, 1) { assert.Equal(t, "r", item.OfFunctionCallOutput.Output.OfResponseFunctionCallOutputItemArray[0].OfInputText.Text) } @@ -677,7 +678,7 @@ func TestMcpToolResultToInputItem(t *testing.T) { assert.NotNil(t, item.OfMcpCall) assert.True(t, item.OfMcpCall.Output.Valid()) assert.Equal(t, "out", item.OfMcpCall.Output.Value) - assert.False(t, item.OfMcpCall.Error.Valid()) + assert.True(t, param.IsOmitted(item.OfMcpCall.Error)) }) } diff --git a/components/model/agenticopenai/responses_heartbeat_test.go b/components/model/agenticopenai/responses_heartbeat_test.go new file mode 100644 index 000000000..14b54ed48 --- /dev/null +++ b/components/model/agenticopenai/responses_heartbeat_test.go @@ -0,0 +1,95 @@ +/* + * Copyright 2026 CloudWeGo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package agenticopenai + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/cloudwego/eino/schema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Exercise the real SDK decoder and Responses converter together. Mocking +// Stream.Next would hide comment-only blocks being decoded as empty JSON. +// Keep this regression when upgrading openai-go: the adapter must inherit +// the SDK's no-data dispatch fix (openai/openai-go#621). +func TestResponsesStreamIgnoresNoDataBlocks(t *testing.T) { + for name, keepAlive := range map[string]string{ + "comment": ": keep-alive\n\n", + "retry": "retry: 3000\n\n", + "crlf": ": keep-alive\r\n\r\n", + "repeated": strings.Repeat(": keep-alive\n\n", 1000), + } { + t.Run(name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, keepAlive) + w.(http.Flusher).Flush() + _, _ = fmt.Fprint(w, "event: response.created\ndata: ", `{"type":"response.created","response":{"id":"resp_test","status":"in_progress"}}`, "\n\n") + _, _ = fmt.Fprint(w, "event: response.output_item.added\ndata: ", `{"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_test","role":"assistant","status":"in_progress","content":[]}}`, "\n\n") + _, _ = fmt.Fprint(w, "event: response.content_part.added\ndata: ", `{"type":"response.content_part.added","item_id":"msg_test","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}}`, "\n\n") + _, _ = io.WriteString(w, keepAlive) + _, _ = fmt.Fprint(w, "event: response.output_text.delta\ndata: ", `{"type":"response.output_text.delta","item_id":"msg_test","output_index":0,"content_index":0,"delta":"ok"}`, "\n\n") + _, _ = io.WriteString(w, keepAlive) + _, _ = fmt.Fprint(w, "event: response.completed\ndata: ", `{"type":"response.completed","response":{"id":"resp_test","status":"completed","usage":{"input_tokens":20,"input_tokens_details":{"cached_tokens":5},"output_tokens":1,"total_tokens":21}}}`, "\n\n") + })) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + m, err := NewResponsesModel(ctx, &ResponsesConfig{APIKey: "test", BaseURL: server.URL, Model: "test", HTTPClient: server.Client()}) + require.NoError(t, err) + stream, err := m.Stream(ctx, []*schema.AgenticMessage{schema.UserAgenticMessage("test")}) + require.NoError(t, err) + defer stream.Close() + var chunks []*schema.AgenticMessage + for { + chunk, err := stream.Recv() + if err == io.EOF { + break + } + require.NoError(t, err) + if chunk != nil { + chunks = append(chunks, chunk) + } + } + msg, err := schema.ConcatAgenticMessages(chunks) + require.NoError(t, err) + require.NotNil(t, msg) + var text strings.Builder + for _, block := range msg.ContentBlocks { + if block.AssistantGenText != nil { + text.WriteString(block.AssistantGenText.Text) + } + } + assert.Equal(t, "ok", text.String()) + require.NotNil(t, msg.ResponseMeta) + require.NotNil(t, msg.ResponseMeta.TokenUsage) + assert.Equal(t, 5, msg.ResponseMeta.TokenUsage.PromptTokenDetails.CachedTokens) + require.NotNil(t, msg.ResponseMeta.OpenAIExtension) + assert.Equal(t, "resp_test", msg.ResponseMeta.OpenAIExtension.ID) + assert.Equal(t, "completed", string(msg.ResponseMeta.OpenAIExtension.Status)) + }) + } +} diff --git a/components/model/agenticopenai/responses_mcp_error.go b/components/model/agenticopenai/responses_mcp_error.go new file mode 100644 index 000000000..58369aeee --- /dev/null +++ b/components/model/agenticopenai/responses_mcp_error.go @@ -0,0 +1,109 @@ +/* + * Copyright 2026 CloudWeGo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package agenticopenai + +import ( + "encoding/json" + "fmt" + + "github.com/cloudwego/eino/schema" + "github.com/openai/openai-go/v3/packages/param" + "github.com/openai/openai-go/v3/responses" +) + +type blockExtraMCPErrorJSON string + +const mcpErrorJSONKey = "openai-mcp-error-json" + +// Eino's MCPToolCallError has only Code/Message, while the SDK now models +// protocol, HTTP and tool-execution errors (the last carries arbitrary content). +// Preserve the wire union in block extras for lossless history replay, alongside +// a useful projection for callers. Do not infer the union type from its code: +// an HTTP status and an MCP protocol code belong to different namespaces. +func mcpErrorFromResponse(item responses.ResponseOutputItemMcpCall) (*schema.MCPToolCallError, string, error) { + value := item.Error + // The outer field preserves legacy string values that the structured + // union decoder cannot populate, as well as explicit null. + raw := item.JSON.Error.Raw() + if raw == "" { + raw = value.RawJSON() + } + if raw == "null" { + return nil, "", nil + } + if raw == "" { + if value.Type == "" && value.Message == "" && value.Code == 0 && value.Content == nil { + return nil, "", nil + } + encoded, err := json.Marshal(value) + if err != nil { + return nil, "", fmt.Errorf("encode MCP error: %w", err) + } + raw = string(encoded) + } + var legacy string + if json.Unmarshal([]byte(raw), &legacy) == nil { + if legacy == "" { + return nil, "", nil + } + return &schema.MCPToolCallError{Message: legacy}, raw, nil + } + out := &schema.MCPToolCallError{Message: value.Message} + switch value.Type { + case "mcp_protocol_error", "http_error": + out.Code = ptrOf(value.Code) + case "mcp_tool_execution_error": + encoded, err := json.Marshal(value.Content) + if err != nil { + return nil, "", fmt.Errorf("encode MCP tool execution error content: %w", err) + } + out.Message = string(encoded) + default: + // Unknown future variants remain errors and retain their full payload. + if out.Message == "" { + out.Message = raw + } + } + return out, raw, nil +} + +func mcpErrorToInputParam(block *schema.ContentBlock) (responses.McpToolCallErrorUnionParam, error) { + var empty responses.McpToolCallErrorUnionParam + value := block.MCPToolResult.Error + if value == nil { + return empty, nil + } + raw, ok := getBlockExtraValue[blockExtraMCPErrorJSON](block, mcpErrorJSONKey) + if !ok { + // JSON-persisted extras decode private string aliases as plain strings. + text, _ := getBlockExtraValue[string](block, mcpErrorJSONKey) + raw = blockExtraMCPErrorJSON(text) + } + if raw != "" { + if !json.Valid([]byte(raw)) { + return empty, fmt.Errorf("invalid MCP error replay metadata") + } + return param.Override[responses.McpToolCallErrorUnionParam](json.RawMessage(raw)), nil + } + // Hand-built or older Eino history lacks a provider variant. Preserve the + // legacy string wire shape rather than inventing an HTTP/protocol error. + // A caller that needs structured errors should replay original blocks. + if value.Message == "" { + return empty, nil + } + return param.Override[responses.McpToolCallErrorUnionParam](value.Message), nil +} diff --git a/components/model/agenticopenai/responses_mcp_error_test.go b/components/model/agenticopenai/responses_mcp_error_test.go new file mode 100644 index 000000000..1fab5b462 --- /dev/null +++ b/components/model/agenticopenai/responses_mcp_error_test.go @@ -0,0 +1,128 @@ +/* + * Copyright 2026 CloudWeGo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package agenticopenai + +import ( + "encoding/json" + "testing" + + "github.com/cloudwego/eino/schema" + "github.com/cloudwego/eino/schema/openai" + "github.com/openai/openai-go/v3/packages/param" + "github.com/openai/openai-go/v3/responses" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMCPErrorUnionHistoryReplay(t *testing.T) { + for _, tc := range []struct { + name, raw, message string + code *int64 + }{ + {"protocol", `{"type":"mcp_protocol_error","code":-32603,"message":"protocol failure"}`, "protocol failure", ptrOf(int64(-32603))}, + {"zero_code", `{"type":"mcp_protocol_error","code":0,"message":"failure"}`, "failure", ptrOf(int64(0))}, + {"http", `{"type":"http_error","code":503,"message":"unavailable"}`, "unavailable", ptrOf(int64(503))}, + {"execution", `{"type":"mcp_tool_execution_error","content":[{"type":"text","text":"failed"}]}`, `[{"text":"failed","type":"text"}]`, nil}, + {"future", `{"type":"future_error","detail":{"x":1}}`, `{"type":"future_error","detail":{"x":1}}`, nil}, + {"legacy", `"legacy error"`, "legacy error", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + var item responses.ResponseOutputItemMcpCall + require.NoError(t, json.Unmarshal([]byte(`{"id":"m1","type":"mcp_call","name":"lookup","server_label":"server","arguments":"{}","status":"failed","error":`+tc.raw+`}`), &item)) + blocks, err := mcpCallToContentBlocks(item) + require.NoError(t, err) + require.Len(t, blocks, 2) + detail := blocks[1].MCPToolResult.Error + require.NotNil(t, detail) + assert.Equal(t, tc.message, detail.Message) + assert.Equal(t, tc.code, detail.Code) + // Check both original blocks and JSON-persisted history, whose Extra + // values lose private Go alias types when decoded through any. + for _, persisted := range []bool{false, true} { + msg := &schema.AgenticMessage{Role: schema.AgenticRoleTypeAssistant, ContentBlocks: blocks, ResponseMeta: &schema.AgenticResponseMeta{OpenAIExtension: &openai.ResponseMetaExtension{ID: "r1"}}} + if persisted { + raw, err := json.Marshal(msg) + require.NoError(t, err) + msg = &schema.AgenticMessage{} + require.NoError(t, json.Unmarshal(raw, msg)) + } + items, err := toAssistantRoleInputItems(msg) + require.NoError(t, err) + require.Len(t, items, 1) + raw, err := json.Marshal(items[0]) + require.NoError(t, err) + var wire map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &wire)) + assert.JSONEq(t, tc.raw, string(wire["error"])) + } + }) + } +} + +func TestMCPErrorAbsentAndLegacyHistory(t *testing.T) { + for _, raw := range []string{`{}`, `{"error":null}`} { + var item responses.ResponseOutputItemMcpCall + require.NoError(t, json.Unmarshal([]byte(raw), &item)) + blocks, err := mcpCallToContentBlocks(item) + require.NoError(t, err) + assert.Nil(t, blocks[1].MCPToolResult.Error) + input, err := mcpToolResultToInputItem(blocks[1]) + require.NoError(t, err) + assert.True(t, param.IsOmitted(input.OfMcpCall.Error)) + } + b := schema.NewContentBlock(&schema.MCPToolResult{Name: "lookup", ServerLabel: "s", Error: &schema.MCPToolCallError{Message: "legacy"}}) + input, err := mcpToolResultToInputItem(b) + require.NoError(t, err) + raw, err := json.Marshal(input) + require.NoError(t, err) + var wire map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &wire)) + assert.JSONEq(t, `"legacy"`, string(wire["error"])) +} + +func TestFunctionResultCallIDWire(t *testing.T) { + item, err := functionToolResultToInputItem(&schema.FunctionToolResult{CallID: "call_1", Content: []*schema.FunctionToolResultContentBlock{{Type: schema.FunctionToolResultContentBlockTypeText, Text: &schema.UserInputText{Text: "ok"}}}}) + require.NoError(t, err) + raw, err := json.Marshal(item) + require.NoError(t, err) + var wire map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &wire)) + assert.JSONEq(t, `"call_1"`, string(wire["call_id"])) +} + +func TestMCPErrorReplayAfterStreamingConcat(t *testing.T) { + var item responses.ResponseOutputItemMcpCall + rawError := `{"type":"http_error","code":503,"message":"unavailable"}` + require.NoError(t, json.Unmarshal([]byte(`{"id":"m1","name":"lookup","server_label":"s","error":`+rawError+`}`), &item)) + blocks, err := mcpCallToContentBlocks(item) + require.NoError(t, err) + result := blocks[1] + result.StreamingMeta = &schema.StreamingMeta{Index: 1} + // A provider may repeat terminal metadata; the concat registration must + // retain one valid JSON payload rather than concatenate the two strings. + msg, err := schema.ConcatAgenticMessages([]*schema.AgenticMessage{ + {Role: schema.AgenticRoleTypeAssistant, ContentBlocks: []*schema.ContentBlock{result}}, + {Role: schema.AgenticRoleTypeAssistant, ContentBlocks: []*schema.ContentBlock{result}}, + }) + require.NoError(t, err) + require.Len(t, msg.ContentBlocks, 1) + input, err := mcpToolResultToInputItem(msg.ContentBlocks[0]) + require.NoError(t, err) + raw, err := json.Marshal(input.OfMcpCall.Error) + require.NoError(t, err) + assert.JSONEq(t, rawError, string(raw)) +}