chore: commit workspace updates
This commit is contained in:
229
backend/internal/handler/agent.go
Normal file
229
backend/internal/handler/agent.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"infinite-canvas/backend/internal/service"
|
||||
)
|
||||
|
||||
// Agent orchestration is durable; the browser stream never drives execution.
|
||||
func RegisterAgentRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.GET("/agent/capabilities", func(c *gin.Context) {
|
||||
if _, err := currentUser(c, svc); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"version": 2, "permissionModes": []string{"read_only", "request_approval", "auto"}, "contextScopes": []string{"canvas"}, "skills": true, "writeTools": true, "billing": "fixed_request", "maxHistoryPairs": 8, "maxSteps": 8, "tools": []string{"canvas_list_node_types", "canvas_get_state", "skill_read_file", "task_get", "model_list", "canvas_apply_ops", "generate_media"}})
|
||||
})
|
||||
create := func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "tasks:"+user.ID, policy.Request.TaskCreatePerMinute, time.Minute) {
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 128<<10)
|
||||
var req service.CloudAgentRequest
|
||||
decoder := json.NewDecoder(c.Request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
fail(c, http.StatusBadRequest, errors.New("请求必须只包含一个 JSON 对象"))
|
||||
return
|
||||
}
|
||||
run, err := svc.CreateCloudAgentRun(user.ID, req, c.Param("id"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"run": run})
|
||||
}
|
||||
r.POST("/agent/runs", create)
|
||||
// Each additional message starts a new immutable turn and returns its ID.
|
||||
r.POST("/agent/runs/:id/messages", create)
|
||||
r.GET("/agent/runs/:id", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
run, err := svc.CloudAgentRun(user.ID, c.Param("id"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"run": run})
|
||||
})
|
||||
r.POST("/agent/runs/:id/cancel", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if err = svc.CancelCloudAgent(c.Request.Context(), user.ID, c.Param("id")); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"accepted": true})
|
||||
})
|
||||
r.POST("/agent/runs/:id/undo", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4096)
|
||||
var req struct {
|
||||
StepID string `json:"stepId"`
|
||||
ExpectedSnapshotHash string `json:"expectedSnapshotHash"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
decoder := json.NewDecoder(c.Request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if req.StepID != "" && (!utf8.ValidString(req.StepID) || strings.TrimSpace(req.StepID) != req.StepID || utf8.RuneCountInString(req.StepID) > 160) {
|
||||
fail(c, http.StatusBadRequest, errors.New("stepId 无效"))
|
||||
return
|
||||
}
|
||||
if req.Reason != "" && (!utf8.ValidString(req.Reason) || utf8.RuneCountInString(req.Reason) > 2000 || strings.ContainsAny(req.Reason, "\x00\r\n")) {
|
||||
fail(c, http.StatusBadRequest, errors.New("reason 无效"))
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
fail(c, http.StatusBadRequest, errors.New("请求必须只包含一个 JSON 对象"))
|
||||
return
|
||||
}
|
||||
result, err := svc.UndoCloudAgentCanvas(user.ID, c.Param("id"), req.StepID, req.ExpectedSnapshotHash, req.Reason)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, result)
|
||||
})
|
||||
r.POST("/agent/runs/:id/approvals/:approvalId/decision", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if _, err = svc.CloudAgentRun(user.ID, c.Param("id")); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4096)
|
||||
var req struct {
|
||||
Decision string `json:"decision"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
decoder := json.NewDecoder(c.Request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if req.Decision != "approve" && req.Decision != "reject" {
|
||||
fail(c, http.StatusBadRequest, errors.New("decision 无效"))
|
||||
return
|
||||
}
|
||||
if req.Reason != "" && (!utf8.ValidString(req.Reason) || utf8.RuneCountInString(req.Reason) > 2000 || strings.ContainsAny(req.Reason, "\x00\r\n")) {
|
||||
fail(c, http.StatusBadRequest, errors.New("reason 无效"))
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
fail(c, http.StatusBadRequest, errors.New("请求必须只包含一个 JSON 对象"))
|
||||
return
|
||||
}
|
||||
if err := svc.DecideCloudAgentApproval(user.ID, c.Param("id"), c.Param("approvalId"), req.Decision, req.Reason); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"accepted": true})
|
||||
})
|
||||
r.GET("/agent/runs/:id/events", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
run, err := svc.CloudAgentRun(user.ID, c.Param("id"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
after, err := taskTextEventCursor(c)
|
||||
if err != nil {
|
||||
fail(c, 400, err)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
revision := run.Revision
|
||||
lastWrite := time.Now()
|
||||
for {
|
||||
if err != nil {
|
||||
c.SSEvent("error", gin.H{"message": "Agent 状态读取失败"})
|
||||
c.Writer.Flush()
|
||||
return
|
||||
}
|
||||
if run != nil {
|
||||
for _, event := range run.Events {
|
||||
if int64(event.Seq) > after {
|
||||
writeAgentSSE(c, "agent_event", int64(event.Seq), event)
|
||||
after = int64(event.Seq)
|
||||
}
|
||||
}
|
||||
// Snapshots are state observations, not replayable Agent events; they
|
||||
// intentionally have no SSE id and therefore never advance the cursor.
|
||||
snapshot := *run
|
||||
snapshot.Events = nil // Events were sent once above, never in every snapshot too.
|
||||
writeAgentSSE(c, "run_snapshot", 0, &snapshot)
|
||||
revision, lastWrite = run.Revision, time.Now()
|
||||
if !run.CleanupPending && (run.Status == "completed" || run.Status == "failed" || run.Status == "cancelled") {
|
||||
return
|
||||
}
|
||||
} else if time.Since(lastWrite) >= 15*time.Second {
|
||||
_, _ = fmt.Fprint(c.Writer, ": heartbeat\n\n")
|
||||
c.Writer.Flush()
|
||||
lastWrite = time.Now()
|
||||
}
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
run, err = svc.CloudAgentRunIfChanged(user.ID, c.Param("id"), revision)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func writeAgentSSE(c *gin.Context, event string, id int64, value any) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if id > 0 {
|
||||
_, _ = fmt.Fprintf(c.Writer, "id: %d\n", id)
|
||||
}
|
||||
_, _ = fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
|
||||
c.Writer.Flush()
|
||||
}
|
||||
@@ -14,6 +14,7 @@ func RegisterCanvasAPI(api *gin.RouterGroup, svc *service.Service) {
|
||||
RegisterAuthRoutes(api, svc)
|
||||
RegisterAppearanceRoutes(api, svc)
|
||||
RegisterFeatureAvailabilityRoutes(api, svc)
|
||||
RegisterAgentRoutes(api, svc)
|
||||
RegisterResponseInterceptionRoutes(api, svc)
|
||||
RegisterAdminRoutes(api, svc)
|
||||
RegisterAdminAnalyticsRoutes(api, svc)
|
||||
@@ -32,9 +33,7 @@ func RegisterCanvasAPI(api *gin.RouterGroup, svc *service.Service) {
|
||||
RegisterSystemProxyRoutes(api, svc)
|
||||
RegisterCustomRelayRoutes(api, svc)
|
||||
RegisterTaskRoutes(api, svc)
|
||||
RegisterComfyBridgeRoutes(api, svc)
|
||||
RegisterRunningHubRoutes(api, svc)
|
||||
RegisterSessionRoutes(api, svc)
|
||||
RegisterSkillRoutes(api, svc)
|
||||
RegisterUserDataRoutes(api, svc)
|
||||
RegisterChunkedUploadRoutes(api, svc)
|
||||
|
||||
@@ -871,7 +871,7 @@ func shortSystemProxyPath(rawPath string) (string, string, bool) {
|
||||
// as a channel request when a business route returns 404.
|
||||
func isReservedAPIPathPrefix(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "admin", "ai", "announcements", "assets", "auth", "canvas-projects", "channels", "diagnostics", "features", "files", "model-catalog", "models", "oauth", "plugins", "projects", "public", "resources", "sessions", "settings", "skills", "style-profiles", "tasks", "timeline", "user-data", "voice-profiles", "wallet":
|
||||
case "admin", "agent", "ai", "announcements", "assets", "auth", "canvas-projects", "channels", "diagnostics", "features", "files", "model-catalog", "models", "oauth", "plugins", "projects", "public", "resources", "sessions", "settings", "skills", "style-profiles", "tasks", "timeline", "user-data", "voice-profiles", "wallet":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -949,7 +949,7 @@ func proxySystemRequestPath(c *gin.Context, svc *service.Service, user *model.Us
|
||||
if encodedQuery := query.Encode(); encodedQuery != "" {
|
||||
target += "?" + encodedQuery
|
||||
}
|
||||
validatedTarget, err := svc.ValidateChannelOutboundURL(target, channel.AllowLocalChannel, false)
|
||||
validatedTarget, err := svc.ValidateChannelOutboundURL(target)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
@@ -1010,7 +1010,7 @@ func proxySystemRequestPath(c *gin.Context, svc *service.Service, user *model.Us
|
||||
status := model.ApiCallStatusSucceeded
|
||||
statusCode := 0
|
||||
errorText := ""
|
||||
resp, err := svc.OutboundHTTPClientForChannel(35*time.Minute, validatedTarget, channel.AllowLocalChannel).Do(upstreamReq)
|
||||
resp, err := svc.OutboundHTTPClientForChannel(35*time.Minute, validatedTarget).Do(upstreamReq)
|
||||
if err != nil {
|
||||
status = model.ApiCallStatusFailed
|
||||
errorText = err.Error()
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Bridge 结果通常包含 base64 媒体;保持和 service 层相同的硬上限,避免请求体无限增长。
|
||||
const maxComfyBridgeRequestBytes int64 = 64 << 20
|
||||
|
||||
// Bridge 心跳会携带本机工作流字段和拓扑,允许与 service 层一致的 4MB 上限。
|
||||
const maxComfyBridgeCapabilitiesBytes int64 = 4 << 20
|
||||
|
||||
type comfyBridgeResultRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Result map[string]any `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterComfyBridgeRoutes 同时注册用户管理接口和 Bridge 专用轮询接口。
|
||||
// 两套接口使用不同认证方式:用户接口依赖登录 Cookie,Bridge 接口只接受专用 Header Token。
|
||||
func RegisterComfyBridgeRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.POST("/comfy-bridges", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if err := svc.RequireWorkflowPluginForUser(user.ID, "comfyui-bridge-image"); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
|
||||
var req service.CreateComfyBridgeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.CreateComfyBridge(user.ID, req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, result)
|
||||
})
|
||||
r.GET("/comfy-bridges", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if err := svc.RequireWorkflowPluginForUser(user.ID, "comfyui-bridge-image"); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
bridges, err := svc.ComfyBridges(user.ID)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
ok(c, bridges)
|
||||
})
|
||||
revoke := func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if err := svc.RevokeComfyBridge(user.ID, c.Param("id")); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"revoked": true})
|
||||
}
|
||||
r.DELETE("/comfy-bridges/:id", revoke)
|
||||
r.POST("/comfy-bridges/:id/revoke", revoke)
|
||||
|
||||
r.GET("/comfy-bridge/poll", func(c *gin.Context) {
|
||||
bridge, err := authenticateComfyBridgeRequest(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
wait, err := comfyBridgeWait(c.Query("wait"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
request, err := svc.PollComfyBridgeRequest(c.Request.Context(), bridge, wait)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"request": request})
|
||||
})
|
||||
r.POST("/comfy-bridge/result", func(c *gin.Context) {
|
||||
bridge, err := authenticateComfyBridgeRequest(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if c.Request.ContentLength > maxComfyBridgeRequestBytes {
|
||||
fail(c, http.StatusRequestEntityTooLarge, errors.New("Bridge 结果请求体超过 64MB"))
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxComfyBridgeRequestBytes)
|
||||
var req comfyBridgeResultRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
fail(c, http.StatusRequestEntityTooLarge, errors.New("Bridge 结果请求体超过 64MB"))
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
requestID := strings.TrimSpace(req.RequestID)
|
||||
if requestID == "" {
|
||||
requestID = strings.TrimSpace(req.ID)
|
||||
}
|
||||
err = svc.CompleteComfyBridgeRequest(bridge.ID, service.ComfyBridgeCompletion{
|
||||
RequestID: requestID,
|
||||
Status: req.Status,
|
||||
Result: req.Result,
|
||||
Error: req.Error,
|
||||
})
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"accepted": true})
|
||||
})
|
||||
r.POST("/comfy-bridge/heartbeat", func(c *gin.Context) {
|
||||
bridge, err := authenticateComfyBridgeRequest(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Capabilities map[string]any `json:"capabilities,omitempty"`
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxComfyBridgeCapabilitiesBytes)
|
||||
if c.Request.ContentLength != 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := svc.TouchComfyBridgeHeartbeat(bridge.ID, req.Capabilities); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"ok": true})
|
||||
})
|
||||
}
|
||||
|
||||
func authenticateComfyBridgeRequest(c *gin.Context, svc *service.Service) (*model.ComfyBridge, error) {
|
||||
token := strings.TrimSpace(c.GetHeader("X-Canvas-Comfy-Bridge-Token"))
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(c.GetHeader("X-Canvas-Bridge-Token"))
|
||||
}
|
||||
if token == "" {
|
||||
authorization := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if len(authorization) >= 7 && strings.EqualFold(authorization[:7], "Bearer ") {
|
||||
token = strings.TrimSpace(authorization[7:])
|
||||
}
|
||||
}
|
||||
return svc.AuthenticateComfyBridge(token)
|
||||
}
|
||||
|
||||
func comfyBridgeWait(raw string) (time.Duration, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return 20 * time.Second, nil
|
||||
}
|
||||
seconds, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || seconds < 0 || seconds > 60 {
|
||||
return 0, errors.New("wait 必须是 0 到 60 之间的整数秒")
|
||||
}
|
||||
return time.Duration(seconds) * time.Second, nil
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
const maxCustomRelayErrorResponseBytes int64 = 64 << 10
|
||||
|
||||
var customRelayClient = service.CustomRelayHTTPClientForChannel
|
||||
var customRelayClient = service.CustomRelayHTTPClient
|
||||
|
||||
func RegisterCustomRelayRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.Any("/ai/custom", func(c *gin.Context) {
|
||||
@@ -46,21 +46,16 @@ func RegisterCustomRelayRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
proxyCustomRelayRequestWithService(c, policy.Request, svc.DesktopLocalChannelsEnabled(), svc)
|
||||
proxyCustomRelayRequestWithService(c, policy.Request, svc)
|
||||
})
|
||||
}
|
||||
|
||||
func proxyCustomRelayRequest(c *gin.Context, policy service.RuntimeRequestPolicy) {
|
||||
proxyCustomRelayRequestWithCapabilities(c, policy, false)
|
||||
proxyCustomRelayRequestWithService(c, policy, nil)
|
||||
}
|
||||
|
||||
func proxyCustomRelayRequestWithCapabilities(c *gin.Context, policy service.RuntimeRequestPolicy, desktopLocalChannelsEnabled bool) {
|
||||
proxyCustomRelayRequestWithService(c, policy, desktopLocalChannelsEnabled, nil)
|
||||
}
|
||||
|
||||
func proxyCustomRelayRequestWithService(c *gin.Context, policy service.RuntimeRequestPolicy, desktopLocalChannelsEnabled bool, svc *service.Service) {
|
||||
requestedAllowLocal := strings.TrimSpace(c.GetHeader(service.LocalChannelRequestHeader)) == "1"
|
||||
target, err := service.ValidateCustomRelayChannelURL(c.GetHeader("X-Canvas-Upstream-URL"), c.GetHeader(service.LocalChannelBaseURLHeader), requestedAllowLocal, desktopLocalChannelsEnabled)
|
||||
func proxyCustomRelayRequestWithService(c *gin.Context, policy service.RuntimeRequestPolicy, svc *service.Service) {
|
||||
target, err := service.ValidateCustomRelayURL(c.GetHeader("X-Canvas-Upstream-URL"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
@@ -127,7 +122,7 @@ func proxyCustomRelayRequestWithService(c *gin.Context, policy service.RuntimeRe
|
||||
upstreamReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
resp, err := customRelayClient(time.Duration(policy.CustomRelayTimeoutMinutes)*time.Minute, target, requestedAllowLocal, desktopLocalChannelsEnabled).Do(upstreamReq)
|
||||
resp, err := customRelayClient(time.Duration(policy.CustomRelayTimeoutMinutes) * time.Minute).Do(upstreamReq)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadGateway, errors.New(userFacingRelayError(svc, errors.New("自定义渠道上游连接失败"))))
|
||||
return
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -48,7 +47,7 @@ func TestCustomRelayForwardsOpenAIRequestWithoutBrowserHeaders(t *testing.T) {
|
||||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/models")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
markDesktopLocalRelayTestRequest(request, upstream.URL)
|
||||
prepareRelayTestRequest(t, request)
|
||||
request.Header.Set(service.CustomRelayHeadersHeader, base64.StdEncoding.EncodeToString([]byte(`[{"name":"User-Agent","value":"Custom Relay Agent"},{"name":"X-Gateway-Tenant","value":"tenant-a"}]`)))
|
||||
request.Header.Set("Cookie", "browser=session")
|
||||
request.Header.Set("Origin", "https://canvas.example.com")
|
||||
@@ -56,7 +55,7 @@ func TestCustomRelayForwardsOpenAIRequestWithoutBrowserHeaders(t *testing.T) {
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
|
||||
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
@@ -82,12 +81,12 @@ func TestCustomRelayReturnsVideoContent(t *testing.T) {
|
||||
request.Header.Set("Authorization", "Bearer test-key")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/videos/task-1/content")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
markDesktopLocalRelayTestRequest(request, upstream.URL)
|
||||
prepareRelayTestRequest(t, request)
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
|
||||
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
|
||||
if response.Code != http.StatusOK || response.Header().Get("Content-Type") != "video/mp4" || response.Body.String() != "video-content" {
|
||||
t.Fatalf("status = %d, content-type = %q, body = %q", response.Code, response.Header().Get("Content-Type"), response.Body.String())
|
||||
}
|
||||
@@ -115,12 +114,12 @@ func TestCustomRelayConvertsGeminiAuthentication(t *testing.T) {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1beta/models/gemini-test:generateContent")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "gemini")
|
||||
markDesktopLocalRelayTestRequest(request, upstream.URL)
|
||||
prepareRelayTestRequest(t, request)
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
|
||||
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
@@ -154,12 +153,12 @@ func TestCustomRelayConvertsClaudeAuthentication(t *testing.T) {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/messages")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "claude")
|
||||
markDesktopLocalRelayTestRequest(request, upstream.URL)
|
||||
prepareRelayTestRequest(t, request)
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
|
||||
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "claude relay works") {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
@@ -183,7 +182,7 @@ func TestCustomRelayStreamsBeforeUpstreamCompletes(t *testing.T) {
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
context, _ := gin.CreateTestContext(w)
|
||||
context.Request = r
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
|
||||
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
|
||||
}))
|
||||
defer proxy.Close()
|
||||
request, _ := http.NewRequest(http.MethodPost, proxy.URL, strings.NewReader(`{"model":"test"}`))
|
||||
@@ -192,7 +191,7 @@ func TestCustomRelayStreamsBeforeUpstreamCompletes(t *testing.T) {
|
||||
request.Header.Set("Accept", "text/event-stream")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/responses")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
markDesktopLocalRelayTestRequest(request, upstream.URL)
|
||||
prepareRelayTestRequest(t, request)
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
@@ -228,7 +227,7 @@ func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
|
||||
connected := false
|
||||
previous := customRelayClient
|
||||
customRelayClient = func(time.Duration, *url.URL, bool, bool) *http.Client {
|
||||
customRelayClient = func(time.Duration) *http.Client {
|
||||
connected = true
|
||||
return http.DefaultClient
|
||||
}
|
||||
@@ -240,12 +239,12 @@ func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", "https://127.0.0.1/v1/responses")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
markDesktopLocalRelayTestRequest(request, "https://127.0.0.1")
|
||||
prepareRelayTestRequest(t, request)
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
|
||||
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
|
||||
if response.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
@@ -254,134 +253,19 @@ func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLocalCustomRelayRequiresServerCapabilityAcrossSupportedPaths(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
requests := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
if strings.HasSuffix(r.URL.Path, "/content") {
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
_, _ = w.Write([]byte("video-content"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
contentType string
|
||||
}{
|
||||
{name: "models", method: http.MethodGet, path: "/v1/models"},
|
||||
{name: "text", method: http.MethodPost, path: "/v1/responses", contentType: "application/json"},
|
||||
{name: "image", method: http.MethodPost, path: "/v1/images/generations", contentType: "application/json"},
|
||||
{name: "video submit", method: http.MethodPost, path: "/v1/videos", contentType: "application/json"},
|
||||
{name: "audio", method: http.MethodPost, path: "/v1/audio/speech", contentType: "application/json"},
|
||||
{name: "video poll", method: http.MethodGet, path: "/v1/videos/task-1"},
|
||||
{name: "video download", method: http.MethodGet, path: "/v1/videos/task-1/content"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body := io.Reader(nil)
|
||||
if test.method == http.MethodPost {
|
||||
body = strings.NewReader(`{}`)
|
||||
}
|
||||
request := httptest.NewRequest(test.method, "/api/ai/custom", body)
|
||||
request.Header.Set("Authorization", "Bearer test-key")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+test.path)
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
request.Header.Set(service.LocalChannelRequestHeader, "1")
|
||||
request.Header.Set(service.LocalChannelBaseURLHeader, upstream.URL)
|
||||
if test.contentType != "" {
|
||||
request.Header.Set("Content-Type", test.contentType)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
before := requests
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), false)
|
||||
if response.Code == http.StatusOK || requests != before {
|
||||
t.Fatalf("capability=false status=%d requests=%d->%d", response.Code, before, requests)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(test.method, "/api/ai/custom", bodyForRelayMethod(test.method))
|
||||
request.Header.Set("Authorization", "Bearer test-key")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+test.path)
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
request.Header.Set(service.LocalChannelRequestHeader, "1")
|
||||
request.Header.Set(service.LocalChannelBaseURLHeader, upstream.URL)
|
||||
if test.contentType != "" {
|
||||
request.Header.Set("Content-Type", test.contentType)
|
||||
}
|
||||
response = httptest.NewRecorder()
|
||||
context, _ = gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
|
||||
if response.Code != http.StatusOK || requests != before+1 {
|
||||
t.Fatalf("capability=true status=%d body=%s requests=%d->%d", response.Code, response.Body.String(), before, requests)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLocalCustomRelayDoesNotFollowRedirects(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
reachedTarget := false
|
||||
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
reachedTarget = true
|
||||
}))
|
||||
defer target.Close()
|
||||
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Location", target.URL+"/v1/models")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer source.Close()
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/ai/custom", nil)
|
||||
request.Header.Set("Authorization", "Bearer test-key")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", source.URL+"/v1/models")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
request.Header.Set(service.LocalChannelRequestHeader, "1")
|
||||
request.Header.Set(service.LocalChannelBaseURLHeader, source.URL)
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
|
||||
if response.Code == http.StatusOK {
|
||||
t.Fatalf("redirect response status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if reachedTarget {
|
||||
t.Fatal("desktop local relay redirect target must not be reached")
|
||||
}
|
||||
}
|
||||
|
||||
func bodyForRelayMethod(method string) io.Reader {
|
||||
if method == http.MethodPost {
|
||||
return strings.NewReader(`{}`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markDesktopLocalRelayTestRequest(request *http.Request, baseURL string) {
|
||||
request.Header.Set(service.LocalChannelRequestHeader, "1")
|
||||
request.Header.Set(service.LocalChannelBaseURLHeader, baseURL)
|
||||
}
|
||||
|
||||
func useCustomRelayTestClient(t *testing.T, client *http.Client) {
|
||||
t.Helper()
|
||||
previous := customRelayClient
|
||||
customRelayClient = func(time.Duration, *url.URL, bool, bool) *http.Client { return client }
|
||||
customRelayClient = func(time.Duration) *http.Client { return client }
|
||||
t.Cleanup(func() { customRelayClient = previous })
|
||||
}
|
||||
|
||||
func prepareRelayTestRequest(t *testing.T, request *http.Request) {
|
||||
t.Helper()
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
|
||||
_ = request
|
||||
}
|
||||
|
||||
func defaultCustomRelayTestPolicy() service.RuntimeRequestPolicy {
|
||||
return service.RuntimeRequestPolicy{
|
||||
CustomRelayRequestMB: 32, CustomRelayResponseMB: 32, CustomRelayTimeoutMinutes: 10,
|
||||
|
||||
@@ -21,7 +21,158 @@ tags:
|
||||
- name: resources
|
||||
- name: tasks
|
||||
- name: assets
|
||||
- name: agent
|
||||
paths:
|
||||
/agent/capabilities:
|
||||
get:
|
||||
tags: [agent]
|
||||
summary: 读取云端 Agent 能力
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data 包含 version、permissionModes、contextScopes、skills、writeTools、billing、maxHistoryPairs
|
||||
/agent/runs:
|
||||
post:
|
||||
tags: [agent]
|
||||
summary: 创建一轮云端 Agent 任务
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CloudAgentRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data.run,包含新一轮 id、canvasId、status、permissionMode、model 及时间
|
||||
"400":
|
||||
description: 参数、能力、模型或预算不符合要求,未创建任务
|
||||
"409":
|
||||
description: 幂等键已用于不同请求
|
||||
/agent/runs/{id}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/AgentRunID"
|
||||
get:
|
||||
tags: [agent]
|
||||
summary: 读取当前用户的 Agent 轮次
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data.run
|
||||
"404":
|
||||
description: 轮次不存在或不属于当前用户
|
||||
/agent/runs/{id}/messages:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/AgentRunID"
|
||||
post:
|
||||
tags: [agent]
|
||||
summary: 从服务端父轮历史创建新轮次
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CloudAgentRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data.run(新的 ID)
|
||||
"409":
|
||||
description: 父轮仍在执行或幂等键冲突
|
||||
/agent/runs/{id}/cancel:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/AgentRunID"
|
||||
post:
|
||||
tags: [agent]
|
||||
summary: 取消当前用户的 Agent 轮次
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data.accepted
|
||||
/agent/runs/{id}/undo:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/AgentRunID"
|
||||
post:
|
||||
tags: [agent]
|
||||
summary: 撤销当前 Agent 运行产生的最新画布变更
|
||||
description: 仅撤销仍是最新快照、且未提交生成任务的画布变更;不会取消任务或退款。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [expectedSnapshotHash]
|
||||
properties:
|
||||
stepId:
|
||||
type: string
|
||||
maxLength: 160
|
||||
expectedSnapshotHash:
|
||||
type: string
|
||||
minLength: 64
|
||||
maxLength: 64
|
||||
pattern: '^[0-9a-fA-F]{64}$'
|
||||
reason:
|
||||
type: string
|
||||
maxLength: 2000
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data.accepted 与当前快照哈希
|
||||
"400":
|
||||
description: 请求无效、快照不可撤销或已提交任务不能撤销
|
||||
"409":
|
||||
description: 画布已发生后续变化、操作已处理或 stepId 不匹配
|
||||
/agent/runs/{id}/approvals/{approvalId}/decision:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/AgentRunID"
|
||||
- name: approvalId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
post:
|
||||
tags: [agent]
|
||||
summary: 提交 Agent 工具审批决定
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [decision]
|
||||
properties:
|
||||
decision:
|
||||
type: string
|
||||
enum: [approve, reject]
|
||||
reason:
|
||||
type: string
|
||||
maxLength: 2000
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data.accepted
|
||||
"409":
|
||||
description: 审批不存在、已过期或决定冲突
|
||||
/agent/runs/{id}/events:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/AgentRunID"
|
||||
get:
|
||||
tags: [agent]
|
||||
summary: Agent 运行事件回放与持续订阅
|
||||
parameters:
|
||||
- name: after
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
- name: Last-Event-ID
|
||||
in: header
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
responses:
|
||||
"200":
|
||||
description: 首次及 revision 变化时发送紧凑 run_snapshot;无变化时 heartbeat;agent_event.seq 为递增游标,cleanupPending 期间持续发送
|
||||
content:
|
||||
text/event-stream:
|
||||
schema:
|
||||
type: string
|
||||
/openapi.yaml:
|
||||
get:
|
||||
tags: [meta]
|
||||
@@ -150,12 +301,74 @@ paths:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
components:
|
||||
parameters:
|
||||
AgentRunID:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
securitySchemes:
|
||||
sessionCookie:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: open_ai_canvas_session
|
||||
schemas:
|
||||
CloudAgentRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [canvasId, prompt, permissionMode, contextScope, budget, idempotencyKey]
|
||||
properties:
|
||||
canvasId:
|
||||
type: string
|
||||
maxLength: 80
|
||||
prompt:
|
||||
type: string
|
||||
description: 非空,最多 16000 字节
|
||||
logicalModelId:
|
||||
type: string
|
||||
model:
|
||||
type: string
|
||||
channelId:
|
||||
type: string
|
||||
channelModelKey:
|
||||
type: string
|
||||
permissionMode:
|
||||
type: string
|
||||
enum: [read_only, request_approval, auto]
|
||||
skillIds:
|
||||
type: array
|
||||
maxItems: 8
|
||||
items:
|
||||
type: string
|
||||
contextScope:
|
||||
type: array
|
||||
maxItems: 1
|
||||
items:
|
||||
type: string
|
||||
enum: [canvas]
|
||||
budget:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [maxCredits]
|
||||
properties:
|
||||
maxCredits:
|
||||
type: number
|
||||
minimum: 0
|
||||
exclusiveMinimum: true
|
||||
maximum: 1000000
|
||||
maxGenerationTasks:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 8
|
||||
maxVideoSeconds:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 120
|
||||
idempotencyKey:
|
||||
type: string
|
||||
minLength: 8
|
||||
maxLength: 128
|
||||
Envelope:
|
||||
type: object
|
||||
required: [code, msg]
|
||||
|
||||
@@ -264,18 +264,27 @@ func RegisterTaskRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
|
||||
func taskTextEventCursor(c *gin.Context) (int64, error) {
|
||||
raw := c.Query("after")
|
||||
if raw == "" {
|
||||
raw = c.GetHeader("Last-Event-ID")
|
||||
queryRaw, headerRaw := c.Query("after"), c.GetHeader("Last-Event-ID")
|
||||
var cursor int64
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{name: "after", raw: queryRaw},
|
||||
{name: "Last-Event-ID", raw: headerRaw},
|
||||
} {
|
||||
if item.raw == "" {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseInt(item.raw, 10, 64)
|
||||
if err != nil || value < 0 {
|
||||
return 0, errors.New("after 或 Last-Event-ID 必须是非负整数")
|
||||
}
|
||||
if value > cursor {
|
||||
cursor = value
|
||||
}
|
||||
}
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
after, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || after < 0 {
|
||||
return 0, errors.New("after 或 Last-Event-ID 必须是非负整数")
|
||||
}
|
||||
return after, nil
|
||||
return cursor, nil
|
||||
}
|
||||
|
||||
func streamTaskTextEvents(c *gin.Context, svc *service.Service, userID string, taskID string, after int64, replay *service.TextReplayResult) {
|
||||
@@ -340,84 +349,3 @@ func writeTaskTextSSE(c *gin.Context, event string, id int64, value any) {
|
||||
_, _ = fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
func RegisterSessionRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
createSession := func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "sessions:"+user.ID, policy.Request.SessionCreatePerMinute, time.Minute) {
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20)
|
||||
var req service.CreateSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
req.TraceID = TraceID(c)
|
||||
req.RequestID = RequestID(c)
|
||||
detail, err := svc.CreateSession(user.ID, req)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
ok(c, detail)
|
||||
}
|
||||
querySession := func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
detail, err := svc.SessionDetail(user.ID, c.Param("id"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
ok(c, detail)
|
||||
}
|
||||
uploadFile := func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "session-files:"+user.ID, policy.Request.SessionFilePerMinute, time.Minute) {
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, (policy.Resource.SessionUploadMB<<20)+(1<<20))
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
item, err := svc.StoreUpload(user.ID, c.PostForm("sessionId"), file)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, item)
|
||||
}
|
||||
downloadResults := func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
detail, err := svc.SessionDetail(user.ID, c.Param("id"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
ok(c, detail.Results)
|
||||
}
|
||||
r.POST("/sessions", createSession)
|
||||
r.GET("/sessions/:id", querySession)
|
||||
r.POST("/files", uploadFile)
|
||||
r.GET("/sessions/:id/results", downloadResults)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ func TestShortSystemProxyPath(t *testing.T) {
|
||||
}{
|
||||
{path: "/api/channel-1/chat/completions", channel: "channel-1", provider: "/chat/completions", ok: true},
|
||||
{path: "/api/channel-1/v1/models", channel: "channel-1", provider: "/v1/models", ok: true},
|
||||
{path: "/api/agent/runs", ok: false},
|
||||
{path: "/api/tasks/123", ok: false},
|
||||
{path: "/api/diagnostics/export", ok: false},
|
||||
{path: "/api/channel-1", ok: false},
|
||||
|
||||
Reference in New Issue
Block a user