Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,4 @@ _bmad-output/*
.DS_Store
._*
.gocache/
.pi/swarm/state/
5 changes: 4 additions & 1 deletion internal/cmd/cursor_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,8 @@ func DoCursorLogin(cfg *config.Config, options *LoginOptions) {
if record != nil && record.Label != "" {
log.Infof("Authenticated as %s", record.Label)
}
log.Info("Cursor authentication successful!")
// The SDK already prints "Cursor authentication successful!" to stdout;
// this log line would double the output for users with default log levels.
// Downgraded to Debug so it appears only in verbose/trace mode.
log.Debug("Cursor authentication successful!")
}
481 changes: 368 additions & 113 deletions internal/runtime/executor/cursor_executor.go

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions internal/runtime/executor/cursor_executor_buildrequest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package executor

import "testing"

// TestBuildRunRequestParams_ModelOverride verifies that buildRunRequestParams
// honors the modelOverride parameter when supplied (so the upstream Cursor Run
// call receives the resolved model name from an oauth-model-alias entry),
// and falls back to parsed.Model when the override is empty or whitespace.
func TestBuildRunRequestParams_ModelOverride(t *testing.T) {
tests := []struct {
name string
parsedModel string
override string
wantModelId string
}{
{
name: "override wins over parsed.Model",
parsedModel: "cursor/composer-2.5",
override: "composer-2.5",
wantModelId: "composer-2.5",
},
{
name: "empty override falls back to parsed.Model",
parsedModel: "composer-2.5",
override: "",
wantModelId: "composer-2.5",
},
{
name: "whitespace override falls back to parsed.Model",
parsedModel: "composer-2.5",
override: " \t ",
wantModelId: "composer-2.5",
},
{
name: "override wins even when parsed.Model is empty",
parsedModel: "",
override: "composer-2.5",
wantModelId: "composer-2.5",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
parsed := &parsedOpenAIRequest{Model: tc.parsedModel}
params := buildRunRequestParams(parsed, "conv-123", tc.override)
if params == nil {
t.Fatal("buildRunRequestParams returned nil")
}
if params.ModelId != tc.wantModelId {
t.Errorf("ModelId = %q, want %q", params.ModelId, tc.wantModelId)
}
if params.ConversationId != "conv-123" {
t.Errorf("ConversationId = %q, want %q", params.ConversationId, "conv-123")
}
})
}
}
272 changes: 272 additions & 0 deletions internal/runtime/executor/cursor_executor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
package executor

import (
"encoding/json"
"strings"
"testing"

"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
"github.com/tidwall/gjson"
)

func TestParseOpenAIRequest_AssistantToolCalls(t *testing.T) {
tests := []struct {
name string
payload string
wantToolCallInTurns bool
wantUserText string
wantToolResults int
}{
{
name: "assistant message with tool_calls and tool results preserves tool calls in turn",
payload: `{
"model": "cursor/composer-2.5",
"messages": [
{"role": "user", "content": "call a tool"},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}
}
]
},
{"role": "tool", "tool_call_id": "call_abc123", "content": "sunny"}
]
}`,
wantToolCallInTurns: true,
wantUserText: "",
wantToolResults: 1,
},
{
name: "assistant message with content and tool_calls retains both when tool results present",
payload: `{
"model": "cursor/composer-2.5",
"messages": [
{"role": "user", "content": "call a tool"},
{
"role": "assistant",
"content": "I'll check the weather.",
"tool_calls": [
{
"id": "call_def456",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\":\"London\"}"}
}
]
},
{"role": "tool", "tool_call_id": "call_def456", "content": "rainy"}
]
}`,
wantToolCallInTurns: true,
wantUserText: "",
wantToolResults: 1,
},
{
name: "plain assistant message without tool_calls behaves as before",
payload: `{
"model": "cursor/composer-2.5",
"messages": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "Hi there."}
]
}`,
wantToolCallInTurns: false,
wantUserText: "hello",
wantToolResults: 0,
},
{
name: "malformed tool_calls entries are skipped",
payload: `{
"model": "cursor/composer-2.5",
"messages": [
{"role": "user", "content": "call a tool"},
{
"role": "assistant",
"content": null,
"tool_calls": [
{"type": "function"}
]
},
{"role": "tool", "tool_call_id": "", "content": "result"}
]
}`,
wantToolCallInTurns: false,
wantUserText: "",
wantToolResults: 1,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
parsed := parseOpenAIRequest([]byte(tc.payload))
if parsed == nil {
t.Fatal("parseOpenAIRequest returned nil")
}

foundToolCall := false
for _, turn := range parsed.Turns {
if strings.Contains(turn.AssistantText, "[tool_call") {
foundToolCall = true
}
}

if tc.wantToolCallInTurns && !foundToolCall {
t.Errorf("expected assistant turn to contain a tool_call marker, got turns=%+v", parsed.Turns)
}
if !tc.wantToolCallInTurns && foundToolCall {
t.Errorf("did not expect assistant turn to contain a tool_call marker, got turns=%+v", parsed.Turns)
}
if parsed.UserText != tc.wantUserText {
t.Errorf("UserText = %q, want %q", parsed.UserText, tc.wantUserText)
}
if len(parsed.ToolResults) != tc.wantToolResults {
t.Errorf("len(ToolResults) = %d, want %d", len(parsed.ToolResults), tc.wantToolResults)
}
})
}
}

func TestExtractToolCallsText(t *testing.T) {
tests := []struct {
name string
payload string
expected string
}{
{
name: "single function call",
payload: `[{"id":"call_abc123","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Paris\"}"}}]`,
expected: "[tool_call call_abc123: get_weather({\"city\":\"Paris\"})]",
},
{
name: "multiple function calls",
payload: `[{"id":"call_1","type":"function","function":{"name":"a","arguments":"{}"}},{"id":"call_2","type":"function","function":{"name":"b","arguments":"{\"x\":1}"}}]`,
expected: "[tool_call call_1: a({})]\n[tool_call call_2: b({\"x\":1})]",
},
{
name: "missing id falls back",
payload: `[{"type":"function","function":{"name":"no_id","arguments":"{}"}}]`,
expected: "[tool_call: no_id({})]",
},
{
name: "non-function type is skipped",
payload: `[{"id":"call_x","type":"retrieval","function":{"name":"retrieve","arguments":"{}"}}]`,
expected: "",
},
{
name: "malformed tool_calls returns empty",
payload: `{}`,
expected: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := extractToolCallsText(gjson.Parse(tc.payload))
if got != tc.expected {
t.Errorf("extractToolCallsText() = %q, want %q", got, tc.expected)
}
})
}
}

func TestDecodeMcpArgsToJSON(t *testing.T) {
tests := []struct {
name string
args map[string][]byte
want string
}{
{
name: "empty args",
args: map[string][]byte{},
want: "{}",
},
{
name: "simple string arg",
args: map[string][]byte{"city": []byte(`"Paris"`)},
want: `{"city":"Paris"}`,
},
{
name: "nested object arg",
args: map[string][]byte{"query": []byte(`{"city":"Paris","country":"FR"}`)},
want: `{"query":{"city":"Paris","country":"FR"}}`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := decodeMcpArgsToJSON(tc.args)
if got != tc.want {
t.Errorf("decodeMcpArgsToJSON() = %q, want %q", got, tc.want)
}
})
}
}

func TestExecuteNonStreamingResponseFormat(t *testing.T) {
payload, err := helps.BuildChatCompletion("chatcmpl-test", 1234567890, "cursor/test-model", "hello world", "stop", 1, 2, 3)
if err != nil {
t.Fatalf("BuildChatCompletion failed: %v", err)
}

var resp struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []struct {
Index int `json:"index"`
Message struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal(payload, &resp); err != nil {
t.Fatalf("failed to unmarshal completion response: %v", err)
}

if resp.ID != "chatcmpl-test" {
t.Errorf("id = %q, want chatcmpl-test", resp.ID)
}
if resp.Object != "chat.completion" {
t.Errorf("object = %q, want chat.completion", resp.Object)
}
if resp.Created != 1234567890 {
t.Errorf("created = %d, want 1234567890", resp.Created)
}
if resp.Model != "cursor/test-model" {
t.Errorf("model = %q, want cursor/test-model", resp.Model)
}
if len(resp.Choices) != 1 {
t.Fatalf("len(choices) = %d, want 1", len(resp.Choices))
}
if resp.Choices[0].Index != 0 {
t.Errorf("choices[0].index = %d, want 0", resp.Choices[0].Index)
}
if resp.Choices[0].Message.Role != "assistant" {
t.Errorf("choices[0].message.role = %q, want assistant", resp.Choices[0].Message.Role)
}
if resp.Choices[0].Message.Content != "hello world" {
t.Errorf("choices[0].message.content = %q, want hello world", resp.Choices[0].Message.Content)
}
if resp.Choices[0].FinishReason != "stop" {
t.Errorf("choices[0].finish_reason = %q, want stop", resp.Choices[0].FinishReason)
}
if resp.Usage.PromptTokens != 1 {
t.Errorf("usage.prompt_tokens = %d, want 1", resp.Usage.PromptTokens)
}
if resp.Usage.CompletionTokens != 2 {
t.Errorf("usage.completion_tokens = %d, want 2", resp.Usage.CompletionTokens)
}
if resp.Usage.TotalTokens != 3 {
t.Errorf("usage.total_tokens = %d, want 3", resp.Usage.TotalTokens)
}
}
Loading
Loading