| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| package handlers |
|
|
| import ( |
| "github.com/libaxuan/cursor2api-go/config" |
| "github.com/libaxuan/cursor2api-go/middleware" |
| "github.com/libaxuan/cursor2api-go/models" |
| "github.com/libaxuan/cursor2api-go/services" |
| "github.com/libaxuan/cursor2api-go/utils" |
| "net/http" |
| "os" |
| "strings" |
| "time" |
|
|
| "github.com/gin-gonic/gin" |
| "github.com/sirupsen/logrus" |
| ) |
|
|
| |
| type Handler struct { |
| config *config.Config |
| cursorService *services.CursorService |
| docsContent []byte |
| responseStore *services.ResponseStore |
| } |
|
|
| |
| func NewHandler(cfg *config.Config) *Handler { |
| cursorService := services.NewCursorService(cfg) |
|
|
| |
| docsPath := "static/docs.html" |
| var docsContent []byte |
|
|
| if data, err := os.ReadFile(docsPath); err == nil { |
| docsContent = data |
| } else { |
| |
| simpleHTML := ` |
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Cursor2API - Go Version</title> |
| <style> |
| body { |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| max-width: 800px; |
| margin: 50px auto; |
| padding: 20px; |
| background-color: #f5f5f5; |
| } |
| .container { |
| background: white; |
| padding: 30px; |
| border-radius: 10px; |
| box-shadow: 0 2px 10px rgba(0,0,0,0.1); |
| } |
| h1 { |
| color: #333; |
| border-bottom: 2px solid #007bff; |
| padding-bottom: 10px; |
| } |
| .info { |
| background: #f8f9fa; |
| padding: 20px; |
| border-radius: 8px; |
| margin: 20px 0; |
| border-left: 4px solid #007bff; |
| } |
| code { |
| background: #e9ecef; |
| padding: 2px 6px; |
| border-radius: 4px; |
| font-family: 'Courier New', monospace; |
| } |
| .endpoint { |
| background: #e3f2fd; |
| padding: 10px; |
| margin: 10px 0; |
| border-radius: 5px; |
| border-left: 3px solid #2196f3; |
| } |
| .status-ok { |
| color: #28a745; |
| font-weight: bold; |
| } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <h1>🚀 Cursor2API - Go Version</h1> |
| |
| <div class="info"> |
| <p><strong>Status:</strong> <span class="status-ok">✅ Running</span></p> |
| <p><strong>Version:</strong> Go Implementation</p> |
| <p><strong>Description:</strong> OpenAI-compatible chat completions proxy for Cursor AI</p> |
| </div> |
| |
| <div class="info"> |
| <h3>📡 Available Endpoints:</h3> |
| <div class="endpoint"> |
| <strong>GET</strong> <code>/v1/models</code><br> |
| <small>List available AI models</small> |
| </div> |
| <div class="endpoint"> |
| <strong>POST</strong> <code>/v1/chat/completions</code><br> |
| <small>Create chat completion (supports streaming and tool calls)</small> |
| </div> |
| <div class="endpoint"> |
| <strong>POST</strong> <code>/v1/responses</code><br> |
| <small>Create response (Responses API compatible)</small> |
| </div> |
| <div class="endpoint"> |
| <strong>GET</strong> <code>/health</code><br> |
| <small>Health check endpoint</small> |
| </div> |
| </div> |
| |
| <div class="info"> |
| <h3>🔐 Authentication:</h3> |
| <p>Use Bearer token authentication:</p> |
| <code>Authorization: Bearer YOUR_API_KEY</code> |
| <p><small>Default API key: <code>0000</code> (change via API_KEY environment variable)</small></p> |
| </div> |
| |
| <div class="info"> |
| <h3>💻 Example Usage:</h3> |
| <pre><code>curl -X POST http://localhost:8002/v1/chat/completions \ |
| -H "Content-Type: application/json" \ |
| -H "Authorization: Bearer 0000" \ |
| -d '{ |
| "model": "claude-sonnet-4.6-thinking", |
| "messages": [ |
| {"role": "user", "content": "Plan first, then decide whether a tool is needed."} |
| ], |
| "stream": true |
| }'</code></pre> |
| </div> |
| |
| <div class="info"> |
| <p><strong>Repository:</strong> <a href="https://github.com/cursor2api/cursor2api-go">cursor2api-go</a></p> |
| <p><strong>Documentation:</strong> OpenAI API compatible</p> |
| </div> |
| </div> |
| </body> |
| </html>` |
| docsContent = []byte(simpleHTML) |
| } |
|
|
| return &Handler{ |
| config: cfg, |
| cursorService: cursorService, |
| docsContent: docsContent, |
| responseStore: services.NewResponseStore(0, 0), |
| } |
|
|
| } |
|
|
| |
| func (h *Handler) ListModels(c *gin.Context) { |
| modelNames := h.config.GetModels() |
| modelList := make([]models.Model, 0, len(modelNames)) |
|
|
| for _, modelID := range modelNames { |
| |
| modelConfig, exists := models.GetModelConfig(modelID) |
|
|
| model := models.Model{ |
| ID: modelID, |
| Object: "model", |
| Created: time.Now().Unix(), |
| OwnedBy: "cursor2api", |
| } |
|
|
| |
| if exists { |
| model.MaxTokens = modelConfig.MaxTokens |
| model.ContextWindow = modelConfig.ContextWindow |
| } |
|
|
| modelList = append(modelList, model) |
| } |
|
|
| response := models.ModelsResponse{ |
| Object: "list", |
| Data: modelList, |
| } |
|
|
| c.JSON(http.StatusOK, response) |
| } |
|
|
| |
| func (h *Handler) ChatCompletions(c *gin.Context) { |
| var request models.ChatCompletionRequest |
| if err := c.ShouldBindJSON(&request); err != nil { |
| logrus.WithError(err).Error("Failed to bind request") |
| c.JSON(http.StatusBadRequest, models.NewErrorResponse( |
| "Invalid request format", |
| "invalid_request_error", |
| "invalid_json", |
| )) |
| return |
| } |
|
|
| |
| if !h.config.IsValidModel(request.Model) { |
| c.JSON(http.StatusBadRequest, models.NewErrorResponse( |
| "Invalid model specified", |
| "invalid_request_error", |
| "model_not_found", |
| )) |
| return |
| } |
|
|
| |
| if len(request.Messages) == 0 { |
| c.JSON(http.StatusBadRequest, models.NewErrorResponse( |
| "Messages cannot be empty", |
| "invalid_request_error", |
| "missing_messages", |
| )) |
| return |
| } |
|
|
| |
| request.MaxTokens = models.ValidateMaxTokens(request.Model, request.MaxTokens) |
|
|
| |
| |
| if request.Stream { |
| chatGenerator, err := h.cursorService.ChatCompletion(c.Request.Context(), &request) |
| if err != nil { |
| logrus.WithError(err).Error("Failed to create chat completion") |
| middleware.HandleError(c, err) |
| return |
| } |
| utils.SafeStreamWrapper(utils.StreamChatCompletion, c, chatGenerator, request.Model) |
| } else { |
| resp, err := h.cursorService.ChatCompletionNonStream(c.Request.Context(), &request) |
| if err != nil { |
| logrus.WithError(err).Error("Failed to create non-stream chat completion") |
| middleware.HandleError(c, err) |
| return |
| } |
| c.JSON(http.StatusOK, resp) |
| } |
| } |
|
|
| |
| func (h *Handler) Responses(c *gin.Context) { |
| var request models.ResponseRequest |
| if err := c.ShouldBindJSON(&request); err != nil { |
| logrus.WithError(err).Error("Failed to bind response request") |
| c.JSON(http.StatusBadRequest, models.NewErrorResponse( |
| "Invalid request format", |
| "invalid_request_error", |
| "invalid_json", |
| )) |
| return |
| } |
|
|
| |
| if !h.config.IsValidModel(request.Model) { |
| c.JSON(http.StatusBadRequest, models.NewErrorResponse( |
| "Invalid model specified", |
| "invalid_request_error", |
| "model_not_found", |
| )) |
| return |
| } |
|
|
| var previousMessages []models.Message |
| previousID := "" |
| if request.PreviousResponseID != nil { |
| previousID = strings.TrimSpace(*request.PreviousResponseID) |
| } |
| if previousID != "" { |
| stored, ok := h.responseStore.Get(previousID) |
| if !ok { |
| c.JSON(http.StatusBadRequest, models.NewErrorResponse( |
| "previous_response_id not found; include prior output items in input instead", |
| "invalid_request_error", |
| "previous_response_id", |
| )) |
| return |
| } |
| previousMessages = stored |
| } |
|
|
| chatRequest, adapter, err := services.BuildChatCompletionRequestFromResponse(&request) |
| if err != nil { |
| c.JSON(http.StatusBadRequest, models.NewErrorResponse( |
| err.Error(), |
| "invalid_request_error", |
| "invalid_input", |
| )) |
| return |
| } |
|
|
| chatRequest.MaxTokens = models.ValidateMaxTokens(chatRequest.Model, chatRequest.MaxTokens) |
| if len(previousMessages) > 0 { |
| chatRequest.Messages = mergeStoredMessages(chatRequest.Messages, previousMessages) |
| } |
|
|
| if chatRequest.Stream { |
| chatGenerator, err := h.cursorService.ChatCompletion(c.Request.Context(), chatRequest) |
| if err != nil { |
| logrus.WithError(err).Error("Failed to create response stream") |
| middleware.HandleError(c, err) |
| return |
| } |
| responseID := utils.GenerateResponseID() |
| accumulator := newStreamAccumulator() |
| wrapped := teeChatGenerator(c.Request.Context(), chatGenerator, accumulator) |
| utils.SafeStreamWrapper(func(ctx *gin.Context, gen <-chan interface{}, model string) { |
| services.StreamResponse(ctx, gen, &request, adapter, responseID) |
| }, c, wrapped, chatRequest.Model) |
| if h.responseStore != nil && accumulator.okToStore() { |
| assistant := accumulator.message() |
| conversation := buildConversationForStore(chatRequest.Messages, assistant) |
| h.responseStore.Put(responseID, conversation) |
| } |
| return |
| } |
|
|
| resp, err := h.cursorService.ChatCompletionNonStream(c.Request.Context(), chatRequest) |
| if err != nil { |
| logrus.WithError(err).Error("Failed to create non-stream response") |
| middleware.HandleError(c, err) |
| return |
| } |
|
|
| if len(resp.Choices) == 0 { |
| c.JSON(http.StatusInternalServerError, models.NewErrorResponse( |
| "Empty response from upstream", |
| "internal_error", |
| "empty_response", |
| )) |
| return |
| } |
| output, outputText := services.BuildResponseOutputFromMessage(resp.Choices[0].Message, adapter) |
| usage := services.BuildResponseUsage(resp.Usage) |
| response := services.BuildCompletedResponse(&request, output, outputText, usage) |
| if h.responseStore != nil { |
| conversation := buildConversationForStore(chatRequest.Messages, resp.Choices[0].Message) |
| h.responseStore.Put(response.ID, conversation) |
| } |
| c.JSON(http.StatusOK, response) |
| } |
|
|
| |
| func (h *Handler) ServeDocs(c *gin.Context) { |
| c.Data(http.StatusOK, "text/html; charset=utf-8", h.docsContent) |
| } |
|
|
| |
| func (h *Handler) Health(c *gin.Context) { |
| c.JSON(http.StatusOK, gin.H{ |
| "status": "ok", |
| "timestamp": time.Now().Unix(), |
| "version": "go-1.0.0", |
| }) |
| } |
|
|