diff --git a/.gitignore b/.gitignore index 75bdbea1f..a1f66e378 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ _bmad-output/* .DS_Store ._* .gocache/ +.pi/swarm/state/ diff --git a/internal/cmd/cursor_login.go b/internal/cmd/cursor_login.go index 731704476..9266576b0 100644 --- a/internal/cmd/cursor_login.go +++ b/internal/cmd/cursor_login.go @@ -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!") } diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go index eb1748fd1..4f8b07d88 100644 --- a/internal/runtime/executor/cursor_executor.go +++ b/internal/runtime/executor/cursor_executor.go @@ -21,6 +21,7 @@ import ( cursorproto "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/cursor/proto" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" @@ -38,6 +39,16 @@ const ( cursorHeartbeatInterval = 5 * time.Second cursorSessionTTL = 5 * time.Minute cursorCheckpointTTL = 30 * time.Minute + // cursorToolResultTimeout bounds how long processH2SessionFrames waits + // for client tool results before failing the session. This prevents + // orphaned goroutines/H2 streams when clients never send role=tool + // messages (e.g., cursor/composer-2.5 getting stuck after the first + // tool-calling reply). + cursorToolResultTimeout = 5 * time.Minute + // cursorModelsCacheTTL bounds how long a cached model list is reused + // before a new fetch is attempted. This prevents a stale model list from + // persisting across auth token refreshes or long-lived executor processes. + cursorModelsCacheTTL = 30 * time.Minute ) // CursorExecutor handles requests to the Cursor API via Connect+Protobuf protocol. @@ -296,7 +307,7 @@ func (e *CursorExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r parsed := parseOpenAIRequest(payload) ccSessId := extractClaudeCodeSessionId(req.Payload) conversationId := deriveConversationId(apiKeyFromContext(ctx), ccSessId, parsed.SystemPrompt) - params := buildRunRequestParams(parsed, conversationId) + params := buildRunRequestParams(parsed, conversationId, req.Model) requestBytes := cursorproto.EncodeRunRequest(params) framedRequest := cursorproto.FrameConnectMessage(requestBytes, 0) @@ -323,21 +334,24 @@ func (e *CursorExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r func(text string, isThinking bool) { fullText.WriteString(text) }, - nil, - nil, + nil, // onMcpExec - non-streaming doesn't need MCP exec + nil, // toolResultCh - non-streaming doesn't wait for tool results nil, // tokenUsage - non-streaming nil, // onCheckpoint - non-streaming doesn't persist + nil, // onTimeout - non-streaming doesn't wait for tool results ); streamErr != nil && fullText.Len() == 0 { return resp, classifyCursorError(fmt.Errorf("cursor: stream error: %w", streamErr)) } id := "chatcmpl-" + uuid.New().String()[:28] created := time.Now().Unix() - openaiResp := fmt.Sprintf(`{"id":"%s","object":"chat.completion","created":%d,"model":"%s","choices":[{"index":0,"message":{"role":"assistant","content":%s},"finish_reason":"stop"}],"usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}`, - id, created, parsed.Model, jsonString(fullText.String())) + openaiResp, err := helps.BuildChatCompletion(id, created, parsed.Model, fullText.String(), "stop", 0, 0, 0) + if err != nil { + return resp, classifyCursorError(fmt.Errorf("cursor: failed to build completion response: %w", err)) + } // Translate response back to source format if needed - result := []byte(openaiResp) + result := openaiResp if from.String() != "" && from.String() != "openai" { var param any result = sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), payload, result, ¶m) @@ -455,7 +469,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A saved, hasCheckpoint := e.checkpoints[checkpointKey] e.mu.Unlock() - params := buildRunRequestParams(parsed, conversationId) + params := buildRunRequestParams(parsed, conversationId, req.Model) if hasCheckpoint && saved.data != nil && saved.authID == authID { // Same auth — use checkpoint normally @@ -479,7 +493,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A e.mu.Unlock() if len(parsed.ToolResults) > 0 || len(parsed.Turns) > 0 { flattenConversationIntoUserText(parsed) - params = buildRunRequestParams(parsed, conversationId) + params = buildRunRequestParams(parsed, conversationId, req.Model) } } else if len(parsed.ToolResults) > 0 || len(parsed.Turns) > 0 { // Fallback: no checkpoint available (cold resume / proxy restart). @@ -487,7 +501,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // Cursor's turns encoding is not reliably read by the model, but userText always works. log.Debugf("cursor: no checkpoint, flattening %d turns + %d tool results into userText", len(parsed.Turns), len(parsed.ToolResults)) flattenConversationIntoUserText(parsed) - params = buildRunRequestParams(parsed, conversationId) + params = buildRunRequestParams(parsed, conversationId, req.Model) } requestBytes := cursorproto.EncodeRunRequest(params) framedRequest := cursorproto.FrameConnectMessage(requestBytes, 0) @@ -516,7 +530,13 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // Tool result channel for inline mode. processH2SessionFrames blocks on it // when mcpArgs is received, while continuing to handle KV/heartbeat. - toolResultCh := make(chan []toolResultInfo, 1) + // Only created when we have tool results to inject (resuming a session). + // For the initial request (no tool results), pass nil so processH2SessionFrames + // returns the tool call immediately without blocking. + var toolResultCh chan []toolResultInfo + if len(parsed.ToolResults) > 0 { + toolResultCh = make(chan []toolResultInfo, 1) + } // Switchable output: initially writes to `chunks`. After mcpArgs, the // onMcpExec callback closes `chunks` (ending the first HTTP response), @@ -535,14 +555,14 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } // Wrap sendChunk/sendDone to use emitToOut - sendChunkSwitchable := func(delta string, finishReason string) { - fr := "null" - if finishReason != "" { - fr = finishReason + sendChunkSwitchable := func(delta map[string]interface{}, finishReason string) { + openaiJSON, err := helps.BuildChatCompletionChunk(chatId, created, parsed.Model, delta, finishReason, nil) + if err != nil { + log.Errorf("cursor: failed to build stream chunk: %v", err) + return } - openaiJSON := fmt.Sprintf(`{"id":"%s","object":"chat.completion.chunk","created":%d,"model":"%s","choices":[{"index":0,"delta":%s,"finish_reason":%s}]}`, - chatId, created, parsed.Model, delta, fr) - sseLine := []byte("data: " + openaiJSON + "\n") + sseLine := append([]byte("data: "), openaiJSON...) + sseLine = append(sseLine, '\n') if needsTranslate { translated := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, payload, sseLine, &streamParam) @@ -550,7 +570,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A emitToOut(cliproxyexecutor.StreamChunk{Payload: bytes.Clone(t)}) } } else { - emitToOut(cliproxyexecutor.StreamChunk{Payload: []byte(openaiJSON)}) + emitToOut(cliproxyexecutor.StreamChunk{Payload: openaiJSON}) } } @@ -588,73 +608,97 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A usage := &cursorTokenUsage{} usage.setInputEstimate(len(payload)) + streamFinished := false + streamErr := processH2SessionFrames(sessionCtx, stream, params.BlobStore, params.McpTools, func(text string, isThinking bool) { if isThinking { if !thinkingActive { thinkingActive = true - sendChunkSwitchable(`{"role":"assistant","content":""}`, "") + sendChunkSwitchable(map[string]interface{}{"role": "assistant", "content": ""}, "") } - sendChunkSwitchable(fmt.Sprintf(`{"content":%s}`, jsonString(text)), "") + sendChunkSwitchable(map[string]interface{}{"content": text}, "") } else { if thinkingActive { thinkingActive = false - sendChunkSwitchable(`{"content":""}`, "") + sendChunkSwitchable(map[string]interface{}{"content": ""}, "") } - sendChunkSwitchable(fmt.Sprintf(`{"content":%s}`, jsonString(text)), "") + sendChunkSwitchable(map[string]interface{}{"content": text}, "") } }, func(exec pendingMcpExec) { if thinkingActive { thinkingActive = false - sendChunkSwitchable(`{"content":""}`, "") + sendChunkSwitchable(map[string]interface{}{"content": ""}, "") } - toolCallJSON := fmt.Sprintf(`{"tool_calls":[{"index":%d,"id":"%s","type":"function","function":{"name":"%s","arguments":%s}}]}`, - toolCallIndex, exec.ToolCallId, exec.ToolName, jsonString(exec.Args)) - toolCallIndex++ - sendChunkSwitchable(toolCallJSON, "") - sendChunkSwitchable(`{}`, `"tool_calls"`) - sendDoneSwitchable() - - // Close current output to end the current HTTP SSE response - outMu.Lock() - if currentOut != nil { - close(currentOut) - currentOut = nil + startDelta, err := helps.BuildToolCallStartDelta(toolCallIndex, exec.ToolCallId, exec.ToolName) + if err != nil { + log.Errorf("cursor: failed to build tool-call start delta: %v", err) + } else { + sendChunkSwitchable(map[string]interface{}{"tool_calls": extractFirstToolCall(startDelta)}, "") } - outMu.Unlock() + argsDelta, err := helps.BuildToolCallArgumentsDelta(toolCallIndex, exec.Args) + if err != nil { + log.Errorf("cursor: failed to build tool-call args delta: %v", err) + } else { + sendChunkSwitchable(map[string]interface{}{"tool_calls": extractFirstToolCall(argsDelta)}, "") + } + sendChunkSwitchable(map[string]interface{}{}, "tool_calls") - // Create new resume output channel, reuse the same toolResultCh - resumeOut := make(chan cliproxyexecutor.StreamChunk, 64) - log.Debugf("cursor: saving session %s for MCP tool resume (tool=%s)", sessionKey, exec.ToolName) - e.mu.Lock() - e.sessions[sessionKey] = &cursorSession{ - stream: stream, - blobStore: params.BlobStore, - mcpTools: params.McpTools, - pending: []pendingMcpExec{exec}, - cancel: sessionCancel, - createdAt: time.Now(), - authID: authID, - toolResultCh: toolResultCh, // reuse same channel across rounds - resumeOutCh: resumeOut, - switchOutput: func(ch chan cliproxyexecutor.StreamChunk) { - outMu.Lock() - currentOut = ch - // Reset translator state so the new HTTP response gets - // a fresh message_start, content_block_start, etc. - streamParam = nil - // New response needs its own message ID - chatId = "chatcmpl-" + uuid.New().String()[:28] - created = time.Now().Unix() - outMu.Unlock() - }, + // Mark the stream as finished — this is a single-round tool call. + // When toolResultCh is nil, processH2SessionFrames returns immediately + // and the outer loop should not emit a second stop chunk. + if toolResultCh == nil { + streamFinished = true } - e.mu.Unlock() - resumeOutCh = resumeOut - // processH2SessionFrames will now block on toolResultCh (inline wait loop) - // while continuing to handle KV messages + // Only end the response and save the session if we're in multi-round mode + // (toolResultCh is non-nil — we have tool results to inject from a previous round). + // When toolResultCh is nil, processH2SessionFrames returns the tool call immediately + // and this is a single-request response — the client gets the tool call inline. + if toolResultCh != nil { + sendDoneSwitchable() + + // Close current output to end the current HTTP SSE response + outMu.Lock() + if currentOut != nil { + close(currentOut) + currentOut = nil + } + outMu.Unlock() + + // Create new resume output channel, reuse the same toolResultCh + resumeOut := make(chan cliproxyexecutor.StreamChunk, 64) + log.Debugf("cursor: saving session %s for MCP tool resume (tool=%s)", sessionKey, exec.ToolName) + e.mu.Lock() + e.sessions[sessionKey] = &cursorSession{ + stream: stream, + blobStore: params.BlobStore, + mcpTools: params.McpTools, + pending: []pendingMcpExec{exec}, + cancel: sessionCancel, + createdAt: time.Now(), + authID: authID, + toolResultCh: toolResultCh, // reuse same channel across rounds + resumeOutCh: resumeOut, + switchOutput: func(ch chan cliproxyexecutor.StreamChunk) { + outMu.Lock() + currentOut = ch + // Reset translator state so the new HTTP response gets + // a fresh message_start, content_block_start, etc. + streamParam = nil + // New response needs its own message ID + chatId = "chatcmpl-" + uuid.New().String()[:28] + created = time.Now().Unix() + outMu.Unlock() + }, + } + e.mu.Unlock() + resumeOutCh = resumeOut + + // processH2SessionFrames will now block on toolResultCh (inline wait loop) + // while continuing to handle KV messages + } }, toolResultCh, usage, @@ -670,9 +714,29 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A e.mu.Unlock() log.Debugf("cursor: saved checkpoint (%d bytes) for conv=%s auth=%s", len(cpData), checkpointKey, authID) }, + func() error { + // Tool result wait timed out. Close the resume output channel + // with an error chunk so any pending HTTP request fails cleanly, + // and remove the stale session so it cannot be resumed. + e.mu.Lock() + delete(e.sessions, sessionKey) + e.mu.Unlock() + if resumeOutCh != nil { + errPayload := fmt.Sprintf(`{"error":{"message":"cursor: timed out waiting for tool results after %v","type":"timeout"}}`, cursorToolResultTimeout) + resumeOutCh <- cliproxyexecutor.StreamChunk{Payload: []byte(errPayload), Err: fmt.Errorf("cursor: tool result timeout")} + close(resumeOutCh) + resumeOutCh = nil + } + return fmt.Errorf("cursor: timed out waiting for tool results after %v", cursorToolResultTimeout) + }, ) // processH2SessionFrames returned — stream is done. + // Remove the session so it cannot be resumed after the stream ends. + e.mu.Lock() + delete(e.sessions, sessionKey) + e.mu.Unlock() + // Check if error happened before any chunks were emitted. if streamErr != nil { select { @@ -697,27 +761,44 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } if thinkingActive { - sendChunkSwitchable(`{"content":""}`, "") + sendChunkSwitchable(map[string]interface{}{"content": ""}, "") + } + if streamFinished { + // The stream already ended with a finish_reason (e.g. tool_calls). + // Do not emit a second stop chunk / [DONE]. + outMu.Lock() + if currentOut != nil { + close(currentOut) + currentOut = nil + } + outMu.Unlock() + sessionCancel() + stream.Close() + return } // Include token usage in the final stop chunk inputTok, outputTok := usage.get() - stopDelta := fmt.Sprintf(`{},"usage":{"prompt_tokens":%d,"completion_tokens":%d,"total_tokens":%d}`, - inputTok, outputTok, inputTok+outputTok) - // Build the stop chunk with usage embedded in the choices array level - fr := `"stop"` - openaiJSON := fmt.Sprintf(`{"id":"%s","object":"chat.completion.chunk","created":%d,"model":"%s","choices":[{"index":0,"delta":{},"finish_reason":%s}],"usage":{"prompt_tokens":%d,"completion_tokens":%d,"total_tokens":%d}}`, - chatId, created, parsed.Model, fr, inputTok, outputTok, inputTok+outputTok) - sseLine := []byte("data: " + openaiJSON + "\n") - if needsTranslate { - translated := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, payload, sseLine, &streamParam) - for _, t := range translated { - emitToOut(cliproxyexecutor.StreamChunk{Payload: bytes.Clone(t)}) - } + usageMap := map[string]interface{}{ + "prompt_tokens": inputTok, + "completion_tokens": outputTok, + "total_tokens": inputTok + outputTok, + } + openaiJSON, err := helps.BuildChatCompletionChunk(chatId, created, parsed.Model, map[string]interface{}{}, "stop", usageMap) + if err != nil { + log.Errorf("cursor: failed to build final stop chunk: %v", err) } else { - emitToOut(cliproxyexecutor.StreamChunk{Payload: []byte(openaiJSON)}) + sseLine := append([]byte("data: "), openaiJSON...) + sseLine = append(sseLine, '\n') + if needsTranslate { + translated := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, payload, sseLine, &streamParam) + for _, t := range translated { + emitToOut(cliproxyexecutor.StreamChunk{Payload: bytes.Clone(t)}) + } + } else { + emitToOut(cliproxyexecutor.StreamChunk{Payload: openaiJSON}) + } } sendDoneSwitchable() - _ = stopDelta // unused // Close whatever output channel is still active outMu.Lock() @@ -856,6 +937,7 @@ func processH2SessionFrames( toolResultCh <-chan []toolResultInfo, // nil for no tool result injection; non-nil to wait for results tokenUsage *cursorTokenUsage, // tracks accumulated token usage (may be nil) onCheckpoint func(data []byte), // called when server sends conversation_checkpoint_update + onTimeout func() error, // called when tool result wait times out ) error { var buf bytes.Buffer rejectReason := "Tool not available in this environment. Use the MCP tools provided instead." @@ -962,8 +1044,8 @@ func processH2SessionFrames( if toolCallId == "" { toolCallId = uuid.New().String() } - log.Debugf("cursor: received mcpArgs from server: execMsgId=%d execId=%q toolName=%s toolCallId=%s", - msg.ExecMsgId, msg.ExecId, msg.McpToolName, toolCallId) + log.Debugf("cursor: received mcpArgs from server: execMsgId=%d execId=%q toolName=%s toolCallId=%s args=%s", + msg.ExecMsgId, msg.ExecId, msg.McpToolName, toolCallId, decodedArgs) pending := pendingMcpExec{ ExecMsgId: msg.ExecMsgId, ExecId: msg.ExecId, @@ -980,16 +1062,28 @@ func processH2SessionFrames( // Inline mode: wait for tool result while handling KV/heartbeat log.Debugf("cursor: waiting for tool result on channel (inline mode)...") var toolResults []toolResultInfo + timeout := time.NewTimer(cursorToolResultTimeout) waitLoop: for { select { case <-ctx.Done(): + timeout.Stop() return ctx.Err() + case <-timeout.C: + log.Warnf("cursor: timed out waiting for tool results after %v", cursorToolResultTimeout) + if onTimeout != nil { + if err := onTimeout(); err != nil { + return err + } + } + return fmt.Errorf("cursor: timed out waiting for tool results after %v", cursorToolResultTimeout) case results, ok := <-toolResultCh: if !ok { + timeout.Stop() return nil } toolResults = results + timeout.Stop() break waitLoop case waitData, ok := <-stream.Data(): if !ok { @@ -1031,6 +1125,7 @@ func processH2SessionFrames( } } case <-stream.Done(): + timeout.Stop() return stream.Err() } } @@ -1138,6 +1233,13 @@ func parseOpenAIRequest(payload []byte) *parsedOpenAIRequest { p.Images = extractImages(msg.Get("content")) case "assistant": assistantText := extractTextContent(msg.Get("content")) + if toolCallsText := extractToolCallsText(msg.Get("tool_calls")); toolCallsText != "" { + if assistantText != "" { + assistantText += "\n" + toolCallsText + } else { + assistantText = toolCallsText + } + } if pendingUser != "" { p.Turns = append(p.Turns, cursorproto.TurnData{ UserText: pendingUser, @@ -1178,6 +1280,9 @@ func parseOpenAIRequest(payload []byte) *parsedOpenAIRequest { // (turns + tool results) into the UserText field as plain text. // This is the fallback for cold resume when no checkpoint is available. // Cursor reliably reads UserText but ignores structured turns. +// +// When tool call markers are present in the turns, they are encoded as +// <|tool_calls_begin|>...<|tool_calls_end|> blocks for Cursor to parse. func flattenConversationIntoUserText(parsed *parsedOpenAIRequest) { var buf strings.Builder @@ -1242,6 +1347,34 @@ func extractTextContent(content gjson.Result) string { return content.String() } +// extractToolCallsText returns a compact text representation of assistant +// tool_calls for inclusion in the flattened conversation history. This ensures +// that a fresh H2 stream still sees which tools the assistant invoked even +// when the assistant message has no text content. +func extractToolCallsText(toolCalls gjson.Result) string { + if !toolCalls.IsArray() { + return "" + } + var entries []string + for _, tc := range toolCalls.Array() { + if tc.Get("type").String() != "function" { + continue + } + id := tc.Get("id").String() + name := tc.Get("function.name").String() + args := tc.Get("function.arguments").String() + if name == "" { + continue + } + if id != "" { + entries = append(entries, fmt.Sprintf("[tool_call %s: %s(%s)]", id, name, args)) + } else { + entries = append(entries, fmt.Sprintf("[tool_call: %s(%s)]", name, args)) + } + } + return strings.Join(entries, "\n") +} + func extractImages(content gjson.Result) []cursorproto.ImageData { if !content.IsArray() { return nil @@ -1289,9 +1422,18 @@ func parseDataURL(url string) *cursorproto.ImageData { } } -func buildRunRequestParams(parsed *parsedOpenAIRequest, conversationId string) *cursorproto.RunRequestParams { +func buildRunRequestParams(parsed *parsedOpenAIRequest, conversationId string, modelOverride string) *cursorproto.RunRequestParams { + // modelOverride lets callers supply an upstream-resolved model name (e.g. the + // result of resolving an oauth-model-alias entry) while keeping the original + // client-facing model name in parsed.Model for response formatting. When empty, + // fall back to parsed.Model so existing call sites that don't need an override + // behave unchanged. + modelId := strings.TrimSpace(modelOverride) + if modelId == "" { + modelId = parsed.Model + } params := &cursorproto.RunRequestParams{ - ModelId: parsed.Model, + ModelId: modelId, SystemPrompt: parsed.SystemPrompt, UserText: parsed.UserText, MessageId: uuid.New().String(), @@ -1435,25 +1577,29 @@ func deriveSessionKey(clientKey string, model string, messages []gjson.Result) s return hex.EncodeToString(h[:])[:16] } -func sseChunk(id string, created int64, model string, delta string, finishReason string) cliproxyexecutor.StreamChunk { - fr := "null" - if finishReason != "" { - fr = finishReason +// extractFirstToolCall parses a JSON delta object and returns the entire +// "tool_calls" array (not a single call object). OpenAI streaming schema +// expects tool_calls at delta to be an array of tool call objects. +// The delta itself is already an array when produced by the helper builders. +func extractFirstToolCall(deltaJSON []byte) []interface{} { + var delta map[string]interface{} + if err := json.Unmarshal(deltaJSON, &delta); err != nil { + return nil } - // Note: the framework's WriteChunk adds "data: " prefix and "\n\n" suffix, - // so we only output the raw JSON here. - data := fmt.Sprintf(`{"id":"%s","object":"chat.completion.chunk","created":%d,"model":"%s","choices":[{"index":0,"delta":%s,"finish_reason":%s}]}`, - id, created, model, delta, fr) - return cliproxyexecutor.StreamChunk{ - Payload: []byte(data), + calls, ok := delta["tool_calls"].([]interface{}) + if !ok || len(calls) == 0 { + return nil } + return calls } -func jsonString(s string) string { - b, _ := json.Marshal(s) - return string(b) -} - +// parseComposerToolCalls parses <|tool_calls_begin|>...<|tool_calls_end|> markers +// from Cursor's plain-text response into structured tool call descriptors. +// Cursor encodes tool call history as these markers when responding in +// non-agent mode (e.g., a "continue without tools" fallback). They use the +// same format as composer-api's encodeCursorChatRequest. +// +// Returns nil when no tool call block is found in the text. func decodeMcpArgsToJSON(args map[string][]byte) string { if len(args) == 0 { return "{}" @@ -1479,11 +1625,110 @@ func decodeMcpArgsToJSON(args map[string][]byte) string { // --- Model Discovery --- -// FetchCursorModels retrieves available models from Cursor's API. +// modelsCacheEntry holds a cached model list with a creation timestamp. +type modelsCacheEntry struct { + models []*registry.ModelInfo + createdAt time.Time +} + +// cursorModelsCache stores the last successful model list per auth so a +// transient GetUsableModels failure does not collapse the model registry to +// the hardcoded fallback (which can drop models the user actually calls, +// e.g. composer-2.5). Keyed by auth.ID (the auth file name). +// +// Each entry has a TTL: after cursorModelsCacheTTL, stale entries are +// discarded and the next call triggers a fresh fetch. +var ( + cursorModelsCacheMu sync.RWMutex + cursorModelsCache = make(map[string]*modelsCacheEntry) +) + +// cursorModelsOrFallback returns the cached model list for authID when one +// exists and is fresh, otherwise the hardcoded fallback. The fallback is +// only ever used when no prior successful fetch has populated the cache +// for this auth, or when the cached entry has expired. +// +// The returned slice is a deep copy of the cached entries: each ModelInfo +// is cloned to prevent mutation from corrupting the cache for other +// callers or future fetches. +func cursorModelsOrFallback(authID string) []*registry.ModelInfo { + if authID != "" { + cursorModelsCacheMu.RLock() + cached, ok := cursorModelsCache[authID] + cursorModelsCacheMu.RUnlock() + if ok && cached != nil && time.Since(cached.createdAt) <= cursorModelsCacheTTL { + return cloneModelsList(cached.models) + } + } + return GetCursorFallbackModels() +} + +// cacheCursorModels records a successful model fetch for future fallback use +// with a TTL. Stale entries are lazily cleaned up: when a new fetch succeeds, +// only the matching auth's entry is refreshed; other auths' expired entries +// are evicted on their next read in cursorModelsOrFallback. +func cacheCursorModels(authID string, models []*registry.ModelInfo) { + if authID == "" || len(models) == 0 { + return + } + dense := deepCopyModels(models) + cursorModelsCacheMu.Lock() + cursorModelsCache[authID] = &modelsCacheEntry{ + models: dense, + createdAt: time.Now(), + } + cursorModelsCacheMu.Unlock() +} + +// cloneModelsList returns a deep copy of a model list for safe concurrent +// use. The returned slice and its entries are independent of the source. +func cloneModelsList(src []*registry.ModelInfo) []*registry.ModelInfo { + if len(src) == 0 { + return nil + } + dst := make([]*registry.ModelInfo, len(src)) + for i, m := range src { + dst[i] = deepCopyModel(m) + } + return dst +} + +// deepCopyModels is a convenience wrapper that clones all entries. +func deepCopyModels(src []*registry.ModelInfo) []*registry.ModelInfo { + return cloneModelsList(src) +} + +// deepCopyModel creates an independent copy of a ModelInfo, including +// its slice fields (SupportedGenerationMethods, etc.). +func deepCopyModel(src *registry.ModelInfo) *registry.ModelInfo { + if src == nil { + return nil + } + dst := *src + if src.SupportedGenerationMethods != nil { + dst.SupportedGenerationMethods = make([]string, len(src.SupportedGenerationMethods)) + copy(dst.SupportedGenerationMethods, src.SupportedGenerationMethods) + } + return &dst +} + +// FetchCursorModels retrieves available models from Cursor's API. On failure, +// returns the last successful model list cached for this auth when available; +// only falls back to GetCursorFallbackModels() when no cached list exists. +// This prevents a transient network blip from permanently shrinking the model +// registry to stale hardcoded names. +// +// A nil auth is tolerated by returning the hardcoded fallback; this avoids a +// panic in registry reconcile paths where auth may legitimately be nil. func FetchCursorModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config.Config) []*registry.ModelInfo { + if auth == nil { + return GetCursorFallbackModels() + } + authID := auth.ID + accessToken := cursorAccessToken(auth) if accessToken == "" { - return GetCursorFallbackModels() + return cursorModelsOrFallback(authID) } ctx, cancel := context.WithTimeout(ctx, 5*time.Second) @@ -1497,7 +1742,7 @@ func FetchCursorModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config cursorAPIURL+cursorModelsPath, bytes.NewReader(emptyReq)) if err != nil { log.Debugf("cursor: failed to create models request: %v", err) - return GetCursorFallbackModels() + return cursorModelsOrFallback(authID) } h2Req.Header.Set("Content-Type", "application/proto") @@ -1511,24 +1756,26 @@ func FetchCursorModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config resp, err := client.Do(h2Req) if err != nil { log.Debugf("cursor: models request failed: %v", err) - return GetCursorFallbackModels() + return cursorModelsOrFallback(authID) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { log.Debugf("cursor: models request returned status %d", resp.StatusCode) - return GetCursorFallbackModels() + return cursorModelsOrFallback(authID) } body, err := io.ReadAll(resp.Body) if err != nil { - return GetCursorFallbackModels() + return cursorModelsOrFallback(authID) } models := parseModelsResponse(body) if len(models) == 0 { - return GetCursorFallbackModels() + return cursorModelsOrFallback(authID) } + + cacheCursorModels(authID, models) return models } @@ -1655,15 +1902,23 @@ func parseModelEntry(data []byte) *registry.ModelInfo { return info } -// GetCursorFallbackModels returns hardcoded fallback models. +// GetCursorFallbackModels returns hardcoded fallback models used when no +// cached live-fetch result is available. Kept current with Cursor's actual +// catalog so a transient GetUsableModels failure does not remove models the +// user can still call (notably composer-2.5). Update when Cursor retires or +// renames any of these ids; verified live against api2.cursor.sh on 2026-06-27. func GetCursorFallbackModels() []*registry.ModelInfo { return []*registry.ModelInfo{ - {ID: "composer-2", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Composer 2", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}}, - {ID: "claude-4-sonnet", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Claude 4 Sonnet", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}}, - {ID: "claude-3.5-sonnet", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Claude 3.5 Sonnet", ContextLength: 200000, MaxCompletionTokens: 8192}, - {ID: "gpt-4o", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "GPT-4o", ContextLength: 128000, MaxCompletionTokens: 16384}, - {ID: "cursor-small", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Cursor Small", ContextLength: 200000, MaxCompletionTokens: 64000}, - {ID: "gemini-2.5-pro", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Gemini 2.5 Pro", ContextLength: 1000000, MaxCompletionTokens: 65536, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}}, + {ID: "composer-2.5", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Composer 2.5", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}}, + {ID: "composer-2.5-fast", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Composer 2.5 Fast", ContextLength: 200000, MaxCompletionTokens: 64000}, + {ID: "gpt-5.3-codex", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Codex 5.3", ContextLength: 256000, MaxCompletionTokens: 32768}, + {ID: "gpt-5.2", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "GPT-5.2", ContextLength: 256000, MaxCompletionTokens: 32768}, + {ID: "gpt-5.5-high", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "GPT-5.5 High", ContextLength: 256000, MaxCompletionTokens: 32768}, + {ID: "gpt-5.4-high", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "GPT-5.4 High", ContextLength: 256000, MaxCompletionTokens: 32768}, + {ID: "claude-opus-4-8-thinking-high", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Claude Opus 4.8 Thinking High", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}}, + {ID: "claude-opus-4-7-thinking-high", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Claude Opus 4.7 Thinking High", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}}, + {ID: "claude-4.5-sonnet", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Claude 4.5 Sonnet", ContextLength: 200000, MaxCompletionTokens: 8192}, + {ID: "gemini-3.1-pro", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Gemini 3.1 Pro", ContextLength: 1000000, MaxCompletionTokens: 65536, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}}, } } diff --git a/internal/runtime/executor/cursor_executor_buildrequest_test.go b/internal/runtime/executor/cursor_executor_buildrequest_test.go new file mode 100644 index 000000000..d1c7b3f11 --- /dev/null +++ b/internal/runtime/executor/cursor_executor_buildrequest_test.go @@ -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") + } + }) + } +} diff --git a/internal/runtime/executor/cursor_executor_test.go b/internal/runtime/executor/cursor_executor_test.go new file mode 100644 index 000000000..d497cff9c --- /dev/null +++ b/internal/runtime/executor/cursor_executor_test.go @@ -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) + } +} diff --git a/internal/runtime/executor/cursor_models_cache_test.go b/internal/runtime/executor/cursor_models_cache_test.go new file mode 100644 index 000000000..8c3f1c916 --- /dev/null +++ b/internal/runtime/executor/cursor_models_cache_test.go @@ -0,0 +1,160 @@ +package executor + +import ( + "context" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" +) + +// withCursorModelsCache swaps the package-level cache for the duration of a test +// and restores the previous state on cleanup. +func withCursorModelsCache(t *testing.T, seed map[string]*modelsCacheEntry) { + t.Helper() + cursorModelsCacheMu.Lock() + prev := cursorModelsCache + cursorModelsCache = make(map[string]*modelsCacheEntry, len(seed)) + for k, v := range seed { + cursorModelsCache[k] = v + } + cursorModelsCacheMu.Unlock() + t.Cleanup(func() { + cursorModelsCacheMu.Lock() + cursorModelsCache = prev + cursorModelsCacheMu.Unlock() + }) +} + +// TestCursorModelsOrFallback_PrefersCacheOverHardcoded ensures that when a +// previous successful fetch cached models for an auth, a subsequent fetch +// failure returns the cached models instead of the hardcoded fallback. +// This prevents the live->fallback->live churn that removes working models +// (e.g. composer-2.5) from the registry after a transient network blip. +func TestCursorModelsOrFallback_PrefersCacheOverHardcoded(t *testing.T) { + cached := []*registry.ModelInfo{ + {ID: "composer-2.5", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Composer 2.5"}, + } + withCursorModelsCache(t, map[string]*modelsCacheEntry{ + "auth-with-cache": {models: cloneModelsList(cached), createdAt: time.Now()}, + }) + + got := cursorModelsOrFallback("auth-with-cache") + if len(got) != 1 || got[0].ID != "composer-2.5" { + t.Fatalf("expected cached [composer-2.5], got %+v", got) + } + + // Unknown auth id with no cache entry must fall through to the hardcoded list. + fb := cursorModelsOrFallback("never-seen-auth") + if len(fb) == 0 { + t.Fatal("expected non-empty hardcoded fallback for unknown auth") + } +} + +// TestFetchCursorModels_NilAuthReturnsFallback guards against the auth +// pointer being nil. cursorAccessToken already nil-guards, but +// authID := auth.ID is evaluated before that helper is called, so a nil +// auth would panic. The function should degrade to the hardcoded fallback +// instead of crashing the goroutine that reconciles the model registry. +func TestFetchCursorModels_NilAuthReturnsFallback(t *testing.T) { + got := FetchCursorModels(context.Background(), nil, nil) + if got == nil { + t.Fatal("FetchCursorModels must return a non-nil slice for nil auth, got nil") + } + if len(got) == 0 { + t.Fatal("FetchCursorModels must return the hardcoded fallback for nil auth, got empty slice") + } +} + +// TestCursorModelsOrFallback_ReturnIsDefensivelyCopied guards against +// the returned cache slice aliasing the stored one. If the caller +// replaces a slice element (e.g. `got[0] = newEntry`), the cache must +// remain intact; otherwise a later failure-path fetch could return the +// caller's mutated value instead of the real cached list. +func TestCursorModelsOrFallback_ReturnIsDefensivelyCopied(t *testing.T) { + original := ®istry.ModelInfo{ID: "cached-original", Object: "model", OwnedBy: "cursor", Type: cursorAuthType} + withCursorModelsCache(t, map[string]*modelsCacheEntry{ + "auth-iso-get": {models: cloneModelsList([]*registry.ModelInfo{original}), createdAt: time.Now()}, + }) + + got := cursorModelsOrFallback("auth-iso-get") + if len(got) != 1 || got[0].ID != "cached-original" { + t.Fatalf("setup: expected [cached-original], got %+v", got) + } + + // Caller replaces slice element via the returned slice; the cache must + // still hold the original element after this. + got[0] = ®istry.ModelInfo{ID: "caller-replaced"} + + again := cursorModelsOrFallback("auth-iso-get") + if len(again) != 1 || again[0].ID != "cached-original" { + t.Fatalf("cache was corrupted by caller element replacement: got %+v", again) + } +} + +// TestCacheCursorModels_StoresDefensiveCopy guards against the cache +// aliasing the caller's slice. If the caller replaces a slice element +// after caching, the cache must remain intact. +func TestCacheCursorModels_StoresDefensiveCopy(t *testing.T) { + withCursorModelsCache(t, nil) + + models := []*registry.ModelInfo{ + {ID: "original", Object: "model", OwnedBy: "cursor", Type: cursorAuthType}, + } + cacheCursorModels("auth-iso-set", models) + + // Mutate the caller's slice after caching; the cache must still hold + // the original (uncorrupted) entry. + models[0] = ®istry.ModelInfo{ID: "caller-replaced"} + + got := cursorModelsOrFallback("auth-iso-set") + if len(got) != 1 || got[0].ID != "original" { + t.Fatalf("cache was corrupted by caller slice mutation: got %+v", got) + } +} + +// TestGetCursorFallbackModels_IsCurrent guards against the hardcoded list +// drifting to stale model ids and against it losing the models users +// actually call. Uses structural assertions rather than exact model IDs +// so it does not break when Cursor renames or retires individual models. +func TestGetCursorFallbackModels_IsCurrent(t *testing.T) { + fb := GetCursorFallbackModels() + if len(fb) == 0 { + t.Fatal("hardcoded fallback must not be empty") + } + + // All entries must be Cursor-owned (filtered by OwnedBy = "cursor" + // in the fallback list builder). + for _, m := range fb { + if m.ID == "" { + t.Errorf("fallback model has empty ID") + } + if m.OwnedBy != "cursor" { + t.Errorf( + "fallback model %q is owned by %q (expected %q); "+ + "if a non-Cursor model appears in the fallback, "+ + "the registry may collapse to an incomplete set", + m.ID, m.OwnedBy, "cursor", + ) + } + } + + // No duplicate IDs + ids := make(map[string]bool, len(fb)) + for _, m := range fb { + if ids[m.ID] { + t.Errorf("duplicate fallback model ID: %q", m.ID) + } + ids[m.ID] = true + } + + // Must contain the model the user actually calls (composer-2.5): + // this is the one model whose name is most stable and central to + // the Cursor product. + if !ids["composer-2.5"] { + t.Errorf( + "hardcoded fallback missing composer-2.5; " + + "this is the model users actually call via cursor-cli", + ) + } +} diff --git a/internal/runtime/executor/helps/openai_stream_chunk.go b/internal/runtime/executor/helps/openai_stream_chunk.go new file mode 100644 index 000000000..982a5b897 --- /dev/null +++ b/internal/runtime/executor/helps/openai_stream_chunk.go @@ -0,0 +1,108 @@ +package helps + +import ( + "encoding/json" +) + +// BuildChatCompletionChunk builds a raw OpenAI chat.completion.chunk JSON payload. +// It mirrors the json.Marshal + map pattern used by the translator packages. +// If finishReason is empty, finish_reason is emitted as null. If usage is nil, +// the usage field is omitted. +func BuildChatCompletionChunk(id string, created int64, model string, delta map[string]interface{}, finishReason string, usage map[string]interface{}) ([]byte, error) { + fr := interface{}(nil) + if finishReason != "" { + fr = finishReason + } + + if delta == nil { + delta = map[string]interface{}{} + } + + choice := map[string]interface{}{ + "index": 0, + "delta": delta, + "finish_reason": fr, + } + + chunk := map[string]interface{}{ + "id": id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": []map[string]interface{}{choice}, + } + + if usage != nil { + chunk["usage"] = usage + } + + return json.Marshal(chunk) +} + +// BuildChatCompletion builds a raw OpenAI chat.completion JSON payload for +// non-streaming responses. +func BuildChatCompletion(id string, created int64, model, content, finishReason string, promptTokens, completionTokens, totalTokens int) ([]byte, error) { + message := map[string]interface{}{ + "role": "assistant", + "content": content, + } + + choice := map[string]interface{}{ + "index": 0, + "message": message, + "finish_reason": finishReason, + } + + completion := map[string]interface{}{ + "id": id, + "object": "chat.completion", + "created": created, + "model": model, + "choices": []map[string]interface{}{choice}, + "usage": map[string]interface{}{ + "prompt_tokens": promptTokens, + "completion_tokens": completionTokens, + "total_tokens": totalTokens, + }, + } + + return json.Marshal(completion) +} + +// BuildToolCallStartDelta builds a delta map containing one tool_call with id, +// type, name, and empty arguments. +func BuildToolCallStartDelta(toolIndex int, toolCallID, toolName string) ([]byte, error) { + delta := map[string]interface{}{ + "tool_calls": []map[string]interface{}{ + { + "index": toolIndex, + "id": toolCallID, + "type": "function", + "function": map[string]interface{}{ + "name": toolName, + "arguments": "", + }, + }, + }, + } + + return json.Marshal(delta) +} + +// BuildToolCallArgumentsDelta builds a delta map containing one tool_call with +// arguments only. argumentsJSON is the raw JSON object text; json.Marshal +// encodes it as a JSON string. +func BuildToolCallArgumentsDelta(toolIndex int, argumentsJSON string) ([]byte, error) { + delta := map[string]interface{}{ + "tool_calls": []map[string]interface{}{ + { + "index": toolIndex, + "function": map[string]interface{}{ + "arguments": argumentsJSON, + }, + }, + }, + } + + return json.Marshal(delta) +} diff --git a/internal/runtime/executor/helps/openai_stream_chunk_test.go b/internal/runtime/executor/helps/openai_stream_chunk_test.go new file mode 100644 index 000000000..26dde7600 --- /dev/null +++ b/internal/runtime/executor/helps/openai_stream_chunk_test.go @@ -0,0 +1,140 @@ +package helps + +import ( + "encoding/json" + "testing" +) + +func TestBuildToolCallStartDelta(t *testing.T) { + b, err := BuildToolCallStartDelta(0, "call_abc", "get_weather") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got map[string]interface{} + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + toolCalls, ok := got["tool_calls"].([]interface{}) + if !ok || len(toolCalls) != 1 { + t.Fatalf("expected 1 tool_call, got %v", toolCalls) + } + + tc := toolCalls[0].(map[string]interface{}) + if tc["index"].(float64) != 0 { + t.Errorf("index = %v, want 0", tc["index"]) + } + if tc["id"] != "call_abc" { + t.Errorf("id = %v, want call_abc", tc["id"]) + } + if tc["type"] != "function" { + t.Errorf("type = %v, want function", tc["type"]) + } + + fn := tc["function"].(map[string]interface{}) + if fn["name"] != "get_weather" { + t.Errorf("name = %v, want get_weather", fn["name"]) + } + if fn["arguments"] != "" { + t.Errorf("arguments = %v, want empty string", fn["arguments"]) + } +} + +func TestBuildToolCallArgumentsDelta(t *testing.T) { + b, err := BuildToolCallArgumentsDelta(0, `{"city":"Paris"}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got map[string]interface{} + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + toolCalls := got["tool_calls"].([]interface{}) + fn := toolCalls[0].(map[string]interface{})["function"].(map[string]interface{}) + args := fn["arguments"].(string) + if args != `{"city":"Paris"}` { + t.Errorf("arguments = %q, want JSON-encoded object string", args) + } +} + +func TestBuildChatCompletionChunk(t *testing.T) { + b, err := BuildChatCompletionChunk("chatcmpl-test", 1, "composer-2.5", map[string]interface{}{ + "content": "hello", + }, "", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got map[string]interface{} + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if got["object"] != "chat.completion.chunk" { + t.Errorf("object = %v, want chat.completion.chunk", got["object"]) + } + if got["model"] != "composer-2.5" { + t.Errorf("model = %v, want composer-2.5", got["model"]) + } + if _, ok := got["usage"]; ok { + t.Errorf("usage field should be omitted when nil") + } + + choices := got["choices"].([]interface{}) + choice := choices[0].(map[string]interface{}) + if choice["finish_reason"] != nil { + t.Errorf("finish_reason = %v, want nil", choice["finish_reason"]) + } + + delta := choice["delta"].(map[string]interface{}) + if delta["content"] != "hello" { + t.Errorf("delta.content = %v, want hello", delta["content"]) + } +} + +func TestBuildChatCompletion(t *testing.T) { + b, err := BuildChatCompletion("chatcmpl-test", 1, "composer-2.5", "hi", "stop", 5, 1, 6) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got map[string]interface{} + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if got["object"] != "chat.completion" { + t.Errorf("object = %v, want chat.completion", got["object"]) + } + if got["model"] != "composer-2.5" { + t.Errorf("model = %v, want composer-2.5", got["model"]) + } + + usage := got["usage"].(map[string]interface{}) + if usage["prompt_tokens"].(float64) != 5 { + t.Errorf("prompt_tokens = %v, want 5", usage["prompt_tokens"]) + } + if usage["completion_tokens"].(float64) != 1 { + t.Errorf("completion_tokens = %v, want 1", usage["completion_tokens"]) + } + if usage["total_tokens"].(float64) != 6 { + t.Errorf("total_tokens = %v, want 6", usage["total_tokens"]) + } + + choices := got["choices"].([]interface{}) + choice := choices[0].(map[string]interface{}) + if choice["finish_reason"] != "stop" { + t.Errorf("finish_reason = %v, want stop", choice["finish_reason"]) + } + + message := choice["message"].(map[string]interface{}) + if message["role"] != "assistant" { + t.Errorf("role = %v, want assistant", message["role"]) + } + if message["content"] != "hi" { + t.Errorf("content = %v, want hi", message["content"]) + } +} diff --git a/sdk/auth/cursor.go b/sdk/auth/cursor.go index 792eb6063..04d074a9a 100644 --- a/sdk/auth/cursor.go +++ b/sdk/auth/cursor.go @@ -46,9 +46,14 @@ func (a CursorAuthenticator) Login(ctx context.Context, cfg *config.Config, opts return nil, fmt.Errorf("cursor: failed to generate auth params: %w", err) } - // Display the login URL - log.Info("Starting Cursor authentication...") - log.Infof("Please visit this URL to log in: %s", authParams.LoginURL) + // Display the login URL. These four prompts are user-facing CLI output + // for a one-shot login command, so they go to stdout via fmt (bypassing + // logrus) -- otherwise logging-to-file:true would hide them inside + // ~/.cli-proxy-api/logs/main.log and the user would see the program + // appear to hang (issue #122). The browser-open warning stays on logrus + // because it is a diagnostic, not a prompt. + fmt.Println("Starting Cursor authentication...") + fmt.Printf("Please visit this URL to log in: %s\n", authParams.LoginURL) // Try to open the browser automatically if !opts.NoBrowser { @@ -59,7 +64,7 @@ func (a CursorAuthenticator) Login(ctx context.Context, cfg *config.Config, opts } } - log.Info("Waiting for Cursor authorization...") + fmt.Println("Waiting for Cursor authorization...") // Poll for the auth result tokens, err := cursorauth.PollForAuth(ctx, authParams.UUID, authParams.Verifier) @@ -73,7 +78,7 @@ func (a CursorAuthenticator) Login(ctx context.Context, cfg *config.Config, opts sub := cursorauth.ParseJWTSub(tokens.AccessToken) subHash := cursorauth.SubToShortHash(sub) - log.Info("Cursor authentication successful!") + fmt.Println("Cursor authentication successful!") metadata := map[string]any{ "type": "cursor",