Skip to content

feat: add Cursor IDE provider with cursor-agent integration#451

Closed
pnewell wants to merge 1 commit intorouter-for-me:mainfrom
pnewell:add-cursor-provider
Closed

feat: add Cursor IDE provider with cursor-agent integration#451
pnewell wants to merge 1 commit intorouter-for-me:mainfrom
pnewell:add-cursor-provider

Conversation

@pnewell
Copy link

@pnewell pnewell commented Mar 20, 2026

Adds comprehensive support for Cursor IDE as a new AI provider via cursor-agent subprocess execution. Includes an OAuth authentication flow using cursor-agent login, bidirectional translation between OpenAI Chat Completions and Cursor's NDJSON streaming format, and streaming/non-streaming response handling. Thinking support is implemented via reasoning_effort (low/medium/high), and tool call parsing maps Cursor's verbose tool names back to canonical names for OpenAI compatibility. Dynamic model discovery is implemented with a fallback to 14 hardcoded models covering Claude, GPT, Gemini, Grok, and Kimi variants.

🤖 Generated with Claude Code

Adds Cursor as a new AI provider using cursor-agent as a subprocess executor.
Includes OAuth auth flow, OpenAI↔Cursor NDJSON translation, streaming/non-streaming
completions, thinking support (reasoning_effort), tool call parsing, and dynamic
model discovery with 14 fallback models.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@gemini-code-assist
Copy link

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly expands the platform's AI provider capabilities by integrating Cursor IDE. It establishes a robust framework for authenticating with Cursor, translating requests and responses to ensure compatibility with existing OpenAI-based workflows, and managing Cursor's unique features like thinking support and tool execution. This integration allows users to seamlessly leverage Cursor's advanced AI models and agentic capabilities within the system.

Highlights

  • Cursor IDE Provider Integration: Added comprehensive support for Cursor IDE as a new AI provider, enabling interaction via the cursor-agent subprocess.
  • OAuth Authentication Flow: Implemented an OAuth authentication flow for Cursor using cursor-agent login, including handling token storage and metadata.
  • Bidirectional Translation: Introduced bidirectional translation between OpenAI Chat Completions format and Cursor's NDJSON streaming format for requests and responses.
  • Streaming and Non-Streaming Support: Provided full support for both streaming and non-streaming responses from the Cursor agent.
  • Thinking Support and Tool Call Parsing: Integrated thinking support via reasoning_effort (low/medium/high) and implemented parsing to map verbose Cursor tool names to canonical OpenAI names.
  • Dynamic Model Discovery with Fallback: Enabled dynamic model discovery through cursor-agent and included a fallback mechanism with 14 hardcoded models covering Claude, GPT, Gemini, Grok, and Kimi variants.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds comprehensive support for Cursor IDE as a new AI provider. The integration is well-implemented, leveraging cursor-agent for authentication and chat completions. The changes include a new OAuth flow, translation layers for OpenAI compatibility, and dynamic model discovery. My review focuses on a critical bug in the authentication flow, some dead code that can be removed, and a redundant implementation in the translation layer. Overall, this is a great addition, and with a few fixes, it will be ready to merge.

Comment on lines +70 to +115
// Read stdout in background to extract login URL
stdoutCh := make(chan string, 1)
go func() {
buf := make([]byte, 8192)
var output strings.Builder
for {
n, readErr := stdout.Read(buf)
if n > 0 {
output.Write(buf[:n])
}
if readErr != nil {
break
}
}
stdoutCh <- output.String()
}()

// Read stderr in background
go func() {
buf := make([]byte, 4096)
for {
_, readErr := stderr.Read(buf)
if readErr != nil {
break
}
}
}()

// Try to extract URL with polling
var loginURL string
extractStart := time.Now()
for loginURL == "" && time.Since(extractStart) < urlExtractTimeout {
select {
case rawOutput := <-stdoutCh:
loginURL = extractLoginURL(rawOutput)
if loginURL == "" {
// Put it back for later
go func() { stdoutCh <- rawOutput }()
}
case <-time.After(100 * time.Millisecond):
// Keep waiting
case <-ctx.Done():
_ = cmd.Process.Kill()
return nil, ctx.Err()
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The current implementation for reading stdout from the cursor-agent process and extracting the login URL has a critical bug that will lead to a deadlock. The goroutine on lines 72-85 reads from the stdout pipe in a loop until it encounters an error (like io.EOF), which typically happens only when the process exits. However, the cursor-agent login process prints the URL and then waits for the user to authenticate in the browser before exiting. This creates a deadlock: the Go code waits for the process to exit to get the URL, and the process waits for the user to use the URL to exit.

The URL extraction loop on lines 98-115 is also problematic, as it uses go func() { stdoutCh <- rawOutput }() to put data back onto the channel, which is an inefficient busy-wait pattern.

I suggest refactoring this section to process the stdout stream as it arrives and extract the URL without waiting for the process to terminate.

    // Read stdout in background to extract the login URL.
    urlCh := make(chan string, 1)
    go func() {
        defer close(urlCh)
        buf := make([]byte, 4096)
        var output strings.Builder
        for {
            n, err := stdout.Read(buf)
            if n > 0 {
                output.Write(buf[:n])
                if url := extractLoginURL(output.String()); url != "" {
                    urlCh <- url
                    // Once the URL is found, drain the rest of stdout to prevent the process from blocking.
                    _, _ = io.Copy(io.Discard, stdout)
                    return
                }
            }
            if err != nil {
                return // Exit goroutine on read error or EOF.
            }
        }
    }()

    // Read and discard stderr in the background to prevent the process from blocking.
    go func() {
        _, _ = io.Copy(io.Discard, stderr)
    }()

    // Wait for the login URL with a timeout.
    var loginURL string
    select {
    case url, ok := <-urlCh:
        if ok {
            loginURL = url
        }
    case <-time.After(urlExtractTimeout):
        log.Warn("cursor: timed out waiting for login URL from cursor-agent")
    case <-ctx.Done():
        _ = cmd.Process.Kill()
        return nil, ctx.Err()
    }

Comment on lines +1 to +23
// Package cursor provides HTML templates for Cursor OAuth status pages.
package cursor

// SuccessHTML is displayed after successful Cursor authentication.
const SuccessHTML = `<!DOCTYPE html>
<html>
<head><title>Cursor Auth</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>Cursor Authentication Successful</h1>
<p>You can close this window and return to the terminal.</p>
</body>
</html>`

// FailureHTML is displayed when Cursor authentication fails.
const FailureHTML = `<!DOCTYPE html>
<html>
<head><title>Cursor Auth</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>Cursor Authentication Failed</h1>
<p>%s</p>
<p>Please try again.</p>
</body>
</html>`

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This file defines HTML templates for a web-based OAuth flow. However, the Cursor authentication implemented in this pull request uses the command-line cursor-agent and does not involve a web callback to this application. As such, these templates appear to be unused. This file can likely be removed to avoid dead code in the repository.

Comment on lines +23 to +35
func (s *CursorTokenStorage) SaveTokenToFile(authFilePath string) error {
if s == nil {
return fmt.Errorf("cursor token storage is nil")
}
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
return fmt.Errorf("cursor: create directory: %w", err)
}
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("cursor: marshal token: %w", err)
}
return os.WriteFile(authFilePath, data, 0600)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The SaveTokenToFile method on CursorTokenStorage appears to be unused. The application's generic token store (sdk/auth/token_store.go) saves authentication records by marshaling the coreauth.Auth struct, which includes the Storage field (CursorTokenStorage in this case). It does not call this specific SaveTokenToFile method. This method can be removed to eliminate dead code.

Comment on lines +674 to +684
// estimateCursorUsage provides rough token count estimates from character counts.
// Uses a ~4 chars per token heuristic since cursor-agent doesn't report token counts.
func estimateCursorUsage(inputChars, outputChars int) usage.Detail {
inputTokens := int64(inputChars / 4)
outputTokens := int64(outputChars / 4)
return usage.Detail{
InputTokens: inputTokens,
OutputTokens: outputTokens,
TotalTokens: inputTokens + outputTokens,
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The usage estimation of 4 characters per token is a reasonable heuristic. However, to improve accuracy, you could consider using a proper tokenizer library like tiktoken for a more precise estimation, especially since many of the models available through Cursor are from OpenAI. This would provide more accurate usage tracking.

Comment on lines +1 to +257
// Package openai provides response translation from Cursor NDJSON to OpenAI SSE format.
package openai

import (
"context"
"encoding/json"
"time"

"github.com/google/uuid"
cursorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/cursor/common"
"github.com/tidwall/gjson"
)

// CursorStreamState tracks the state of streaming response conversion.
type CursorStreamState struct {
ChunkIndex int
ToolCallIndex int
HasSentFirstChunk bool
Model string
ResponseID string
Created int64
}

// NewCursorStreamState creates a new stream state for tracking.
func NewCursorStreamState(model string) *CursorStreamState {
return &CursorStreamState{
Model: model,
ResponseID: "chatcmpl-" + uuid.New().String()[:24],
Created: time.Now().Unix(),
}
}

// ConvertCursorStreamToOpenAI converts a Cursor NDJSON line to OpenAI SSE format chunks.
func ConvertCursorStreamToOpenAI(_ context.Context, model string, _, _, rawJSON []byte, param *any) []string {
if len(rawJSON) == 0 {
return nil
}

// Initialize state on first call
if *param == nil {
*param = NewCursorStreamState(model)
}
state := (*param).(*CursorStreamState)

line := rawJSON
eventType := gjson.GetBytes(line, "type").String()

var results []string

switch eventType {
case "assistant":
contentArr := gjson.GetBytes(line, "message.content")
if !contentArr.IsArray() {
return nil
}
for _, item := range contentArr.Array() {
itemType := item.Get("type").String()
switch itemType {
case "text":
text := item.Get("text").String()
if text == "" {
continue
}
chunk := buildTextDelta(state, text)
results = append(results, chunk)
case "thinking":
thinkText := item.Get("thinking").String()
if thinkText == "" {
continue
}
chunk := buildReasoningDelta(state, thinkText)
results = append(results, chunk)
}
}

case "thinking":
subtype := gjson.GetBytes(line, "subtype").String()
if subtype == "delta" {
text := gjson.GetBytes(line, "text").String()
if text != "" {
chunk := buildReasoningDelta(state, text)
results = append(results, chunk)
}
}
// "completed" subtype: no-op

case "tool_call":
toolCallObj := gjson.GetBytes(line, "tool_call")
if !toolCallObj.IsObject() {
return nil
}

callID := gjson.GetBytes(line, "call_id").String()
if callID == "" {
callID = "call_" + uuid.New().String()[:8]
}

toolCallObj.ForEach(func(key, value gjson.Result) bool {
rawName := key.String()
toolName := cursorcommon.ResolveToolName(rawName)

// Extract arguments
var argsJSON string
args := value.Get("args")
if args.Exists() && args.IsObject() {
argsJSON = args.Raw
} else if value.IsObject() {
argsJSON = value.Raw
}

// Emit tool call start
startChunk := buildToolCallStart(state, callID, toolName)
results = append(results, startChunk)

// Emit tool call arguments
if argsJSON != "" {
argsChunk := buildToolCallArgsDelta(state, argsJSON)
results = append(results, argsChunk)
}

state.ToolCallIndex++
return false // only process first key
})

case "result":
finishReason := "stop"
chunk := buildFinish(state, finishReason)
results = append(results, chunk)

// system, user events: skip
}

return results
}

// ConvertCursorNonStreamToOpenAI converts accumulated Cursor responses to a non-streaming OpenAI response.
func ConvertCursorNonStreamToOpenAI(_ context.Context, model string, _, _, rawJSON []byte, _ *any) string {
// For non-streaming, rawJSON is the full accumulated response
// Build a standard chat.completion response
content := string(rawJSON)

response := map[string]interface{}{
"id": "chatcmpl-" + uuid.New().String()[:24],
"object": "chat.completion",
"created": time.Now().Unix(),
"model": model,
"choices": []map[string]interface{}{
{
"index": 0,
"message": map[string]interface{}{
"role": "assistant",
"content": content,
},
"finish_reason": "stop",
},
},
"usage": map[string]interface{}{
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
},
}

result, _ := json.Marshal(response)
return string(result)
}

// --- SSE chunk builders ---

func buildTextDelta(state *CursorStreamState, text string) string {
delta := map[string]interface{}{"content": text}
if !state.HasSentFirstChunk {
delta["role"] = "assistant"
state.HasSentFirstChunk = true
}
chunk := buildBaseChunk(state, delta, nil)
result, _ := json.Marshal(chunk)
state.ChunkIndex++
return string(result)
}

func buildReasoningDelta(state *CursorStreamState, text string) string {
delta := map[string]interface{}{"reasoning_content": text}
if !state.HasSentFirstChunk {
delta["role"] = "assistant"
state.HasSentFirstChunk = true
}
chunk := buildBaseChunk(state, delta, nil)
result, _ := json.Marshal(chunk)
state.ChunkIndex++
return string(result)
}

func buildToolCallStart(state *CursorStreamState, callID, name string) string {
tc := map[string]interface{}{
"index": state.ToolCallIndex,
"id": callID,
"type": "function",
"function": map[string]interface{}{
"name": name,
"arguments": "",
},
}
delta := map[string]interface{}{
"tool_calls": []map[string]interface{}{tc},
}
if !state.HasSentFirstChunk {
delta["role"] = "assistant"
state.HasSentFirstChunk = true
}
chunk := buildBaseChunk(state, delta, nil)
result, _ := json.Marshal(chunk)
state.ChunkIndex++
return string(result)
}

func buildToolCallArgsDelta(state *CursorStreamState, args string) string {
tc := map[string]interface{}{
"index": state.ToolCallIndex,
"function": map[string]interface{}{
"arguments": args,
},
}
delta := map[string]interface{}{
"tool_calls": []map[string]interface{}{tc},
}
chunk := buildBaseChunk(state, delta, nil)
result, _ := json.Marshal(chunk)
state.ChunkIndex++
return string(result)
}

func buildFinish(state *CursorStreamState, finishReason string) string {
chunk := buildBaseChunk(state, map[string]interface{}{}, &finishReason)
result, _ := json.Marshal(chunk)
state.ChunkIndex++
return string(result)
}

func buildBaseChunk(state *CursorStreamState, delta map[string]interface{}, finishReason *string) map[string]interface{} {
choice := map[string]interface{}{
"index": 0,
"delta": delta,
}
if finishReason != nil {
choice["finish_reason"] = *finishReason
} else {
choice["finish_reason"] = nil
}
return map[string]interface{}{
"id": state.ResponseID,
"object": "chat.completion.chunk",
"created": state.Created,
"model": state.Model,
"choices": []map[string]interface{}{choice},
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The response translation functions in this file (ConvertCursorStreamToOpenAI and ConvertCursorNonStreamToOpenAI) appear to be redundant. The CursorExecutor in internal/runtime/executor/cursor_executor.go is already responsible for parsing the NDJSON output from cursor-agent and constructing an OpenAI-compatible response directly, for both streaming and non-streaming cases.

Since the executor handles the response translation, these functions are likely unused in the current execution flow. It would be good to verify if they are called from somewhere I might have missed; otherwise, they could be removed to avoid confusion and dead code.

@pnewell pnewell closed this Mar 20, 2026
@pnewell pnewell deleted the add-cursor-provider branch March 20, 2026 22:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant