package http import ( "encoding/json" "fmt" "log/slog" "net/http" "time" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/agent" "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/sessions" "github.com/nextlevelbuilder/goclaw/internal/store" ) // ResponsesHandler handles POST /v1/responses (OpenResponses protocol). type ResponsesHandler struct { agents *agent.Router sessions store.SessionStore token string } // NewResponsesHandler creates a handler for the responses endpoint. func NewResponsesHandler(agents *agent.Router, sess store.SessionStore, token string) *ResponsesHandler { return &ResponsesHandler{ agents: agents, sessions: sess, token: token, } } type responsesRequest struct { Model string `json:"model"` Messages []chatMessage `json:"messages"` Stream bool `json:"stream"` MaxTokens int `json:"max_tokens,omitempty"` } func (h *ResponsesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // Auth + RBAC check (gateway token or API key, operator required for POST) auth := resolveAuth(r, h.token) if !auth.Authenticated { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } if !permissions.HasMinRole(auth.Role, permissions.RoleOperator) { http.Error(w, `{"error":"permission denied: insufficient role"}`, http.StatusForbidden) return } // Limit request body size to prevent DoS const maxRequestBodySize = 1 << 20 // 1MB r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) var req responsesRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, fmt.Sprintf(`{"error":"invalid JSON: %s"}`, err), http.StatusBadRequest) return } if len(req.Messages) == 0 { http.Error(w, `{"error":"messages is required"}`, http.StatusBadRequest) return } agentID := extractAgentID(r, req.Model) userID := extractUserID(r) loop, err := h.agents.Get(agentID) if err != nil { http.Error(w, fmt.Sprintf(`{"error":"agent not found: %s"}`, agentID), http.StatusNotFound) return } var lastMessage string for i := len(req.Messages) - 1; i >= 0; i-- { if req.Messages[i].Role == "user" { lastMessage = req.Messages[i].Content break } } if lastMessage == "" { http.Error(w, `{"error":"no user message found"}`, http.StatusBadRequest) return } // Inject user_id into context for downstream stores/tools ctx := r.Context() if userID != "" { ctx = store.WithUserID(ctx, userID) } runID := uuid.NewString() responseID := "resp-" + runID[:8] sessionSuffix := "responses-" + runID[:8] if userID != "" { sessionSuffix = "responses-" + userID + "-" + runID[:8] } sessionKey := sessions.SessionKey(agentID, sessionSuffix) slog.Info("responses request", "agent", agentID, "stream", req.Stream, "user", userID) if req.Stream { h.handleStream(w, r.WithContext(ctx), loop, runID, responseID, sessionKey, lastMessage, userID) } else { h.handleNonStream(w, r.WithContext(ctx), loop, runID, responseID, sessionKey, lastMessage, userID) } } func (h *ResponsesHandler) handleNonStream(w http.ResponseWriter, r *http.Request, loop agent.Agent, runID, responseID, sessionKey, message, userID string) { result, err := loop.Run(r.Context(), agent.RunRequest{ SessionKey: sessionKey, Message: message, Channel: "http", ChatID: "api", RunID: runID, UserID: userID, Stream: false, }) if err != nil { http.Error(w, fmt.Sprintf(`{"error":"agent error: %s"}`, err), http.StatusInternalServerError) return } resp := map[string]any{ "id": responseID, "status": "completed", "output": []map[string]any{{ "type": "message", "role": "assistant", "content": []map[string]string{{"type": "text", "text": result.Content}}, }}, } if result.Usage != nil { resp["usage"] = map[string]int{ "prompt_tokens": result.Usage.PromptTokens, "completion_tokens": result.Usage.CompletionTokens, "total_tokens": result.Usage.TotalTokens, } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } func (h *ResponsesHandler) handleStream(w http.ResponseWriter, r *http.Request, loop agent.Agent, runID, responseID, sessionKey, message, userID string) { flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming not supported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.WriteHeader(http.StatusOK) // response.started writeResponseEvent(w, flusher, map[string]any{ "type": "response.started", "response": map[string]any{ "id": responseID, "status": "in_progress", "created_at": time.Now().Unix(), }, }) result, err := loop.Run(r.Context(), agent.RunRequest{ SessionKey: sessionKey, Message: message, Channel: "http", ChatID: "api", RunID: runID, UserID: userID, Stream: true, }) if err != nil { // response.done with error writeResponseEvent(w, flusher, map[string]any{ "type": "response.done", "response": map[string]any{ "id": responseID, "status": "failed", "error": err.Error(), }, }) return } // response.delta writeResponseEvent(w, flusher, map[string]any{ "type": "response.delta", "delta": map[string]any{ "type": "content", "content": result.Content, }, }) // response.done doneResp := map[string]any{ "id": responseID, "status": "completed", } if result.Usage != nil { doneResp["usage"] = map[string]int{ "prompt_tokens": result.Usage.PromptTokens, "completion_tokens": result.Usage.CompletionTokens, "total_tokens": result.Usage.TotalTokens, } } writeResponseEvent(w, flusher, map[string]any{ "type": "response.done", "response": doneResp, }) } func writeResponseEvent(w http.ResponseWriter, flusher http.Flusher, data any) { jsonData, _ := json.Marshal(data) fmt.Fprintf(w, "data: %s\n\n", jsonData) flusher.Flush() }