mirror of
https://github.com/langgenius/dify.git
synced 2026-08-24 12:32:54 -04:00
355 lines
10 KiB
Go
355 lines
10 KiB
Go
package agentcli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/textproto"
|
|
"os"
|
|
"time"
|
|
)
|
|
|
|
// HTTPClient wraps HTTP interactions with the Agent Stub server.
|
|
type HTTPClient struct {
|
|
baseURL string
|
|
authJWE string
|
|
client *http.Client
|
|
openUploadFile func(string) (io.ReadCloser, error)
|
|
doUploadRequest func(*http.Request) (*http.Response, error)
|
|
}
|
|
|
|
var errUploadRequestAborted = errors.New("upload request aborted")
|
|
|
|
const (
|
|
agentStubAuthorizationExpiredCode = "agent_stub_authorization_expired"
|
|
defaultUploadRequestTimeout = 180 * time.Second
|
|
)
|
|
|
|
type agentStubHTTPError struct {
|
|
statusCode int
|
|
code string
|
|
message string
|
|
}
|
|
|
|
func (e *agentStubHTTPError) Error() string {
|
|
if e.code == agentStubAuthorizationExpiredCode {
|
|
return fmt.Sprintf(
|
|
"HTTP %d: Agent Stub authorization expired after 5 minutes; the authorization in this process will not refresh automatically; start a new shell tool call and retry the command",
|
|
e.statusCode,
|
|
)
|
|
}
|
|
return fmt.Sprintf("HTTP %d: %s", e.statusCode, e.message)
|
|
}
|
|
|
|
// NewHTTPClient creates a new HTTP client for the Agent Stub API.
|
|
func NewHTTPClient(env *Environment) *HTTPClient {
|
|
return &HTTPClient{
|
|
baseURL: env.URL,
|
|
authJWE: env.AuthJWE,
|
|
client: &http.Client{Timeout: 30 * time.Second},
|
|
openUploadFile: openUploadSource,
|
|
doUploadRequest: doUploadRequest,
|
|
}
|
|
}
|
|
|
|
// NewHTTPClientWithTimeout creates a client with a custom timeout.
|
|
func NewHTTPClientWithTimeout(env *Environment, timeout time.Duration) *HTTPClient {
|
|
return &HTTPClient{
|
|
baseURL: env.URL,
|
|
authJWE: env.AuthJWE,
|
|
client: &http.Client{Timeout: timeout},
|
|
openUploadFile: openUploadSource,
|
|
doUploadRequest: doUploadRequest,
|
|
}
|
|
}
|
|
|
|
func openUploadSource(path string) (io.ReadCloser, error) {
|
|
return os.Open(path)
|
|
}
|
|
|
|
func doUploadRequest(req *http.Request) (*http.Response, error) {
|
|
return (&http.Client{Timeout: defaultUploadRequestTimeout}).Do(req)
|
|
}
|
|
|
|
// postJSON sends a POST request with JSON body and returns the response body.
|
|
func (c *HTTPClient) postJSON(path string, payload any) ([]byte, int, error) {
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", c.baseURL+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+c.authJWE)
|
|
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, resp.StatusCode, fmt.Errorf("read response: %w", err)
|
|
}
|
|
return respBody, resp.StatusCode, nil
|
|
}
|
|
|
|
// getJSON sends a GET request and returns the response body.
|
|
func (c *HTTPClient) getJSON(path string, params map[string]string) ([]byte, int, error) {
|
|
req, err := http.NewRequest("GET", c.baseURL+path, nil)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.authJWE)
|
|
|
|
if len(params) > 0 {
|
|
q := req.URL.Query()
|
|
for k, v := range params {
|
|
q.Set(k, v)
|
|
}
|
|
req.URL.RawQuery = q.Encode()
|
|
}
|
|
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, resp.StatusCode, fmt.Errorf("read response: %w", err)
|
|
}
|
|
return respBody, resp.StatusCode, nil
|
|
}
|
|
|
|
// patchJSON sends a PATCH request with JSON body.
|
|
func (c *HTTPClient) patchJSON(path string, payload any) ([]byte, int, error) {
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("PATCH", c.baseURL+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+c.authJWE)
|
|
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, resp.StatusCode, fmt.Errorf("read response: %w", err)
|
|
}
|
|
return respBody, resp.StatusCode, nil
|
|
}
|
|
|
|
// putJSON sends a PUT request with JSON body.
|
|
func (c *HTTPClient) putJSON(path string, payload any) ([]byte, int, error) {
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("PUT", c.baseURL+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+c.authJWE)
|
|
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, resp.StatusCode, fmt.Errorf("read response: %w", err)
|
|
}
|
|
return respBody, resp.StatusCode, nil
|
|
}
|
|
|
|
// uploadFile uploads a file to a signed URL using multipart form.
|
|
func (c *HTTPClient) uploadFile(uploadURL string, filePath string, filename string, mimetype string) ([]byte, error) {
|
|
file, err := c.openUploadFile(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open file: %w", err)
|
|
}
|
|
pipeReader, pipeWriter := io.Pipe()
|
|
|
|
req, err := http.NewRequest("POST", uploadURL, pipeReader)
|
|
if err != nil {
|
|
_ = pipeReader.Close()
|
|
_ = pipeWriter.Close()
|
|
_ = file.Close()
|
|
return nil, errors.New("create upload request: invalid signed upload URL")
|
|
}
|
|
multipartWriter := multipart.NewWriter(pipeWriter)
|
|
req.Header.Set("Content-Type", multipartWriter.FormDataContentType())
|
|
|
|
writerDone := make(chan error, 1)
|
|
go func() {
|
|
writeErr := writeMultipartFile(multipartWriter, file, filename, mimetype)
|
|
if closeErr := file.Close(); writeErr == nil && closeErr != nil {
|
|
writeErr = fmt.Errorf("close file: %w", closeErr)
|
|
}
|
|
if writeErr != nil {
|
|
_ = pipeWriter.CloseWithError(writeErr)
|
|
} else {
|
|
writeErr = pipeWriter.Close()
|
|
}
|
|
writerDone <- writeErr
|
|
}()
|
|
|
|
resp, requestErr := c.doUploadRequest(req)
|
|
_ = pipeReader.CloseWithError(errUploadRequestAborted)
|
|
|
|
const maxUploadResponseBytes = 1024 * 1024
|
|
var respBody []byte
|
|
var responseErr error
|
|
if resp != nil {
|
|
defer func() { _ = resp.Body.Close() }()
|
|
respBody, responseErr = io.ReadAll(io.LimitReader(resp.Body, maxUploadResponseBytes+1))
|
|
}
|
|
|
|
writerErr := <-writerDone
|
|
if resp != nil && (resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices) {
|
|
if len(respBody) > maxUploadResponseBytes {
|
|
respBody = respBody[:maxUploadResponseBytes]
|
|
}
|
|
return nil, fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
if requestErr != nil {
|
|
if writerErr != nil && !isUploadWriterAbort(writerErr) {
|
|
return nil, writerErr
|
|
}
|
|
if errors.Is(requestErr, context.DeadlineExceeded) || os.IsTimeout(requestErr) {
|
|
return nil, errors.New("upload request timed out")
|
|
}
|
|
return nil, errors.New("upload request failed")
|
|
}
|
|
if writerErr != nil && !isUploadWriterAbort(writerErr) {
|
|
return nil, writerErr
|
|
}
|
|
if isUploadWriterAbort(writerErr) {
|
|
return nil, errors.New("upload request completed before multipart body was fully written")
|
|
}
|
|
if responseErr != nil {
|
|
return nil, fmt.Errorf("read upload response: %w", responseErr)
|
|
}
|
|
if len(respBody) > maxUploadResponseBytes {
|
|
return nil, fmt.Errorf("upload response exceeds %d bytes", maxUploadResponseBytes)
|
|
}
|
|
return respBody, nil
|
|
}
|
|
|
|
func isUploadWriterAbort(err error) bool {
|
|
return errors.Is(err, errUploadRequestAborted) || errors.Is(err, io.ErrClosedPipe)
|
|
}
|
|
|
|
func writeMultipartFile(
|
|
writer *multipart.Writer,
|
|
file io.Reader,
|
|
filename string,
|
|
mimetype string,
|
|
) (resultErr error) {
|
|
defer func() {
|
|
if closeErr := writer.Close(); resultErr == nil && closeErr != nil {
|
|
resultErr = fmt.Errorf("close multipart writer: %w", closeErr)
|
|
}
|
|
}()
|
|
|
|
h := make(textproto.MIMEHeader)
|
|
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename))
|
|
h.Set("Content-Type", mimetype)
|
|
part, err := writer.CreatePart(h)
|
|
if err != nil {
|
|
return fmt.Errorf("create form file: %w", err)
|
|
}
|
|
if _, err := io.Copy(part, file); err != nil {
|
|
return fmt.Errorf("copy file content: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// downloadFromURL downloads bytes from a signed URL.
|
|
func (c *HTTPClient) downloadFromURL(downloadURL string) ([]byte, error) {
|
|
dlClient := &http.Client{Timeout: 120 * time.Second}
|
|
resp, err := dlClient.Get(downloadURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("download request failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("download failed with status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// checkHTTPError returns a formatted error if status >= 400.
|
|
func checkHTTPError(body []byte, statusCode int, operation string) error {
|
|
if statusCode < 400 {
|
|
return nil
|
|
}
|
|
var detail struct {
|
|
Detail any `json:"detail"`
|
|
}
|
|
if json.Unmarshal(body, &detail) == nil && detail.Detail != nil {
|
|
return fmt.Errorf("agent stub %s failed (HTTP %d): %v", operation, statusCode, detail.Detail)
|
|
}
|
|
return fmt.Errorf("agent stub %s failed (HTTP %d): %s", operation, statusCode, string(body))
|
|
}
|
|
|
|
// checkAgentStubHTTPError decodes structured errors for Agent-visible
|
|
// connect, file, and config commands. Drive retains its legacy error contract.
|
|
func checkAgentStubHTTPError(body []byte, statusCode int) error {
|
|
if statusCode < 400 {
|
|
return nil
|
|
}
|
|
|
|
message := string(body)
|
|
code := ""
|
|
var response struct {
|
|
Detail json.RawMessage `json:"detail"`
|
|
}
|
|
if json.Unmarshal(body, &response) == nil && len(response.Detail) > 0 {
|
|
var detailMessage string
|
|
if json.Unmarshal(response.Detail, &detailMessage) == nil {
|
|
message = detailMessage
|
|
} else {
|
|
var detail struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
if json.Unmarshal(response.Detail, &detail) == nil {
|
|
code = detail.Code
|
|
if detail.Message != "" {
|
|
message = detail.Message
|
|
} else {
|
|
message = string(response.Detail)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return &agentStubHTTPError{statusCode: statusCode, code: code, message: message}
|
|
}
|