Files
bifrost/transports/bifrost-http/integrations/cursor.go
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

1068 lines
37 KiB
Go

package integrations
import (
"bytes"
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
bifrost "github.com/maximhq/bifrost/core"
"github.com/maximhq/bifrost/core/providers/openai"
"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/transports/bifrost-http/lib"
"github.com/valyala/fasthttp"
)
// Chat completion chunk types for Cursor responses
// These lightweight structs produce clean chat completion JSON without the
// extra_fields that BifrostChatResponse would include.
type cursorChatChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []cursorChatChoice `json:"choices"`
Usage *cursorUsage `json:"usage,omitempty"`
}
type cursorChatChoice struct {
Index int `json:"index"`
Delta cursorChatDelta `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
type cursorChatDelta struct {
Role *string `json:"role,omitempty"`
Content *string `json:"content,omitempty"`
Reasoning *string `json:"reasoning,omitempty"`
ToolCalls []cursorToolCallDelta `json:"tool_calls,omitempty"`
}
type cursorToolCallDelta struct {
Index int `json:"index"`
ID *string `json:"id,omitempty"`
Type *string `json:"type,omitempty"`
Function *cursorToolCallFnDelta `json:"function,omitempty"`
}
type cursorToolCallFnDelta struct {
Name *string `json:"name,omitempty"`
Arguments *string `json:"arguments,omitempty"`
}
type cursorUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// Non-streaming chat completion types
type cursorChatCompletion struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []cursorChatCompletionChoice `json:"choices"`
Usage *cursorUsage `json:"usage,omitempty"`
}
type cursorChatCompletionChoice struct {
Index int `json:"index"`
Message cursorChatCompletionMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
type cursorChatCompletionMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []cursorToolCall `json:"tool_calls,omitempty"`
}
type cursorToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function cursorToolCallFn `json:"function"`
}
type cursorToolCallFn struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
// Converter helpers
// cursorChunkID builds a deterministic chunk ID from the response extra fields.
func cursorChunkID(extras *schemas.BifrostResponseExtraFields) string {
return "chatcmpl-bifrost-" + strconv.Itoa(extras.ChunkIndex)
}
// cursorModel returns the best model name available from extra fields.
func cursorModel(extras *schemas.BifrostResponseExtraFields) string {
if extras.ResolvedModelUsed != "" {
return extras.ResolvedModelUsed
}
return extras.OriginalModelRequested
}
// convertResponsesStreamToChatChunk maps a Responses API stream event to a
// chat completion chunk. Returns ("", nil, nil) for events that should be skipped.
func convertResponsesStreamToChatChunk(resp *schemas.BifrostResponsesStreamResponse) (string, interface{}, error) {
switch resp.Type {
case schemas.ResponsesStreamResponseTypeOutputItemAdded:
if resp.Item == nil {
return "", nil, nil
}
// Function call item → send first tool call chunk with id, type, and name
// NOTE: This must be checked before the role branch because function_call
// items can also carry role:"assistant", which would cause an early return
// and prevent the tool-call id/type/name chunk from being emitted.
if resp.Item.Type != nil && *resp.Item.Type == schemas.ResponsesMessageTypeFunctionCall &&
resp.Item.ResponsesToolMessage != nil {
fnType := "function"
toolCallIndex := 0
if resp.OutputIndex != nil {
toolCallIndex = *resp.OutputIndex
}
tc := cursorToolCallDelta{
Index: toolCallIndex,
ID: resp.Item.CallID,
Type: &fnType,
Function: &cursorToolCallFnDelta{Name: resp.Item.Name},
}
return "", &cursorChatChunk{
ID: cursorChunkID(&resp.ExtraFields),
Object: "chat.completion.chunk",
Created: time.Now().Unix(),
Model: cursorModel(&resp.ExtraFields),
Choices: []cursorChatChoice{{
Index: 0,
Delta: cursorChatDelta{ToolCalls: []cursorToolCallDelta{tc}},
}},
}, nil
}
// Assistant text output item → send role delta
if resp.Item.Role != nil && *resp.Item.Role == schemas.ResponsesInputMessageRoleAssistant {
role := "assistant"
return "", &cursorChatChunk{
ID: cursorChunkID(&resp.ExtraFields),
Object: "chat.completion.chunk",
Created: time.Now().Unix(),
Model: cursorModel(&resp.ExtraFields),
Choices: []cursorChatChoice{{
Index: 0,
Delta: cursorChatDelta{Role: &role},
}},
}, nil
}
return "", nil, nil
case schemas.ResponsesStreamResponseTypeFunctionCallArgumentsDelta:
if resp.Delta == nil {
return "", nil, nil
}
toolCallIndex := 0
if resp.OutputIndex != nil {
toolCallIndex = *resp.OutputIndex
}
tc := cursorToolCallDelta{
Index: toolCallIndex,
Function: &cursorToolCallFnDelta{Arguments: resp.Delta},
}
return "", &cursorChatChunk{
ID: cursorChunkID(&resp.ExtraFields),
Object: "chat.completion.chunk",
Created: time.Now().Unix(),
Model: cursorModel(&resp.ExtraFields),
Choices: []cursorChatChoice{{
Index: 0,
Delta: cursorChatDelta{ToolCalls: []cursorToolCallDelta{tc}},
}},
}, nil
case schemas.ResponsesStreamResponseTypeOutputTextDelta:
if resp.Delta == nil {
return "", nil, nil
}
return "", &cursorChatChunk{
ID: cursorChunkID(&resp.ExtraFields),
Object: "chat.completion.chunk",
Created: time.Now().Unix(),
Model: cursorModel(&resp.ExtraFields),
Choices: []cursorChatChoice{{
Index: 0,
Delta: cursorChatDelta{Content: resp.Delta},
}},
}, nil
case schemas.ResponsesStreamResponseTypeReasoningSummaryTextDelta:
if resp.Delta == nil {
return "", nil, nil
}
return "", &cursorChatChunk{
ID: cursorChunkID(&resp.ExtraFields),
Object: "chat.completion.chunk",
Created: time.Now().Unix(),
Model: cursorModel(&resp.ExtraFields),
Choices: []cursorChatChoice{{
Index: 0,
Delta: cursorChatDelta{Reasoning: resp.Delta},
}},
}, nil
case schemas.ResponsesStreamResponseTypeRefusalDelta:
// Map refusal to content so Cursor can display it
if resp.Refusal == nil {
return "", nil, nil
}
return "", &cursorChatChunk{
ID: cursorChunkID(&resp.ExtraFields),
Object: "chat.completion.chunk",
Created: time.Now().Unix(),
Model: cursorModel(&resp.ExtraFields),
Choices: []cursorChatChoice{{
Index: 0,
Delta: cursorChatDelta{Content: resp.Refusal},
}},
}, nil
case schemas.ResponsesStreamResponseTypeCompleted:
finishReason := "stop"
// If the response contains function call items, use "tool_calls" finish reason
if resp.Response != nil {
for _, item := range resp.Response.Output {
if item.Type != nil && *item.Type == schemas.ResponsesMessageTypeFunctionCall {
finishReason = "tool_calls"
break
}
}
}
chunk := &cursorChatChunk{
ID: cursorChunkID(&resp.ExtraFields),
Object: "chat.completion.chunk",
Created: time.Now().Unix(),
Model: cursorModel(&resp.ExtraFields),
Choices: []cursorChatChoice{{
Index: 0,
Delta: cursorChatDelta{},
FinishReason: &finishReason,
}},
}
// Include usage from the completed response if available
if resp.Response != nil && resp.Response.Usage != nil {
chunk.Usage = &cursorUsage{
PromptTokens: resp.Response.Usage.InputTokens,
CompletionTokens: resp.Response.Usage.OutputTokens,
TotalTokens: resp.Response.Usage.TotalTokens,
}
// Use response ID if available
if resp.Response.ID != nil {
chunk.ID = "chatcmpl-" + *resp.Response.ID
}
}
return "", chunk, nil
case schemas.ResponsesStreamResponseTypeFailed:
finishReason := "stop"
return "", &cursorChatChunk{
ID: cursorChunkID(&resp.ExtraFields),
Object: "chat.completion.chunk",
Created: time.Now().Unix(),
Model: cursorModel(&resp.ExtraFields),
Choices: []cursorChatChoice{{
Index: 0,
Delta: cursorChatDelta{},
FinishReason: &finishReason,
}},
}, nil
default:
// Skip all other Responses API events (created, in_progress, content part events, etc.)
return "", nil, nil
}
}
// convertResponsesResponseToChatCompletion converts a non-streaming Responses API
// response to a chat completion response object.
func convertResponsesResponseToChatCompletion(resp *schemas.BifrostResponsesResponse) *cursorChatCompletion {
// Extract text content and tool calls from output messages
var sb strings.Builder
var toolCalls []cursorToolCall
finishReason := "stop"
for _, msg := range resp.Output {
// Function call items
if msg.Type != nil && *msg.Type == schemas.ResponsesMessageTypeFunctionCall && msg.ResponsesToolMessage != nil {
callID := ""
if msg.CallID != nil {
callID = *msg.CallID
}
name := ""
if msg.Name != nil {
name = *msg.Name
}
args := ""
if msg.Arguments != nil {
args = *msg.Arguments
}
toolCalls = append(toolCalls, cursorToolCall{
ID: callID,
Type: "function",
Function: cursorToolCallFn{
Name: name,
Arguments: args,
},
})
finishReason = "tool_calls"
continue
}
// Text content
if msg.Content == nil {
continue
}
for _, block := range msg.Content.ContentBlocks {
if block.Type == schemas.ResponsesOutputMessageContentTypeText && block.Text != nil {
sb.WriteString(*block.Text)
}
}
}
content := sb.String()
id := "chatcmpl-bifrost"
if resp.ID != nil {
id = "chatcmpl-" + *resp.ID
}
message := cursorChatCompletionMessage{
Role: "assistant",
Content: content,
}
if len(toolCalls) > 0 {
message.ToolCalls = toolCalls
}
result := &cursorChatCompletion{
ID: id,
Object: "chat.completion",
Created: int64(resp.CreatedAt),
Model: resp.Model,
Choices: []cursorChatCompletionChoice{{
Index: 0,
Message: message,
FinishReason: finishReason,
}},
}
if resp.Usage != nil {
result.Usage = &cursorUsage{
PromptTokens: resp.Usage.InputTokens,
CompletionTokens: resp.Usage.OutputTokens,
TotalTokens: resp.Usage.TotalTokens,
}
}
return result
}
// Cursor raw tool type
// cursorRawTool represents the tool format Cursor actually sends:
//
// {"name":"Shell","description":"...","input_schema":{"type":"object",...}}
//
// This is neither Responses API format (which requires a "type" field) nor
// Chat Completions format (which wraps in {"type":"function","function":{...}}).
type cursorRawTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema *schemas.ToolFunctionParameters `json:"input_schema,omitempty"`
}
// Cursor request parsing
// cursorRequestParser handles Cursor's hybrid request format where input uses
// Responses API format but tools may use chat completions format
// ({"type":"function","function":{"name":"...","parameters":{...}}}).
//
// This is used as a RequestParser on the route config so it is explicitly called
// by the router, avoiding reliance on sonic's UnmarshalJSON dispatch through interface{}.
func cursorRequestParser(ctx *fasthttp.RequestCtx, req interface{}) error {
cursorReq, ok := req.(*openai.OpenAIResponsesRequest)
if !ok {
return errors.New("invalid request type for cursor parser")
}
data := ctx.Request.Body()
if len(data) == 0 {
return nil
}
// Happy path: standard Responses API format (or no tools at all)
if err := sonic.Unmarshal(data, cursorReq); err == nil {
// If input is empty, Cursor may have sent "messages" (chat completions key)
// instead of "input" (Responses API key). Convert messages → input.
if len(cursorReq.Input.OpenAIResponsesRequestInputArray) == 0 && cursorReq.Input.OpenAIResponsesRequestInputStr == nil {
cursorConvertMessagesToInput(data, cursorReq)
}
cursorConvertAnthropicToolBlocks(data, cursorReq)
cursorMergeToolResultsFromMessages(data, cursorReq)
normalizeInputContentBlocks(cursorReq)
return nil
}
// Fallback: tools may be in Cursor's flat format (no "type" field, "input_schema"
// instead of "parameters") which causes ResponsesTool.UnmarshalJSON to fail.
// Parse all fields except tools using a tools-free struct to avoid triggering
// ResponsesTool.UnmarshalJSON.
type responsesParamsNoTools struct {
schemas.ResponsesParameters
Tools json.RawMessage `json:"tools,omitempty"` // shadow to absorb without parsing
}
var base struct {
Model string `json:"model"`
Input openai.OpenAIResponsesRequestInput `json:"input"`
Stream *bool `json:"stream,omitempty"`
Fallbacks []string `json:"fallbacks,omitempty"`
responsesParamsNoTools
}
if err := sonic.Unmarshal(data, &base); err != nil {
return err
}
cursorReq.Model = base.Model
cursorReq.Input = base.Input
cursorReq.Stream = base.Stream
cursorReq.Fallbacks = base.Fallbacks
cursorReq.ResponsesParameters = base.ResponsesParameters
// If input is empty, Cursor may have sent "messages" instead of "input"
if len(cursorReq.Input.OpenAIResponsesRequestInputArray) == 0 && cursorReq.Input.OpenAIResponsesRequestInputStr == nil {
cursorConvertMessagesToInput(data, cursorReq)
}
cursorConvertAnthropicToolBlocks(data, cursorReq)
cursorMergeToolResultsFromMessages(data, cursorReq)
normalizeInputContentBlocks(cursorReq)
// Parse tools from Cursor's flat format:
// {"name":"Shell","description":"...","input_schema":{"type":"object","properties":{...},"required":[...]}}
// This differs from both Responses API format (has "type" field) and Chat Completions format
// (wraps in {"type":"function","function":{...}}). Cursor uses "input_schema" instead of "parameters".
var toolsWrapper struct {
Tools []cursorRawTool `json:"tools"`
}
if err := sonic.Unmarshal(data, &toolsWrapper); err != nil {
return err
}
for i := range toolsWrapper.Tools {
t := &toolsWrapper.Tools[i]
name := t.Name
desc := t.Description
cursorReq.ResponsesParameters.Tools = append(cursorReq.ResponsesParameters.Tools, schemas.ResponsesTool{
Type: schemas.ResponsesToolTypeFunction,
Name: &name,
Description: &desc,
ResponsesToolFunction: &schemas.ResponsesToolFunction{
Parameters: t.InputSchema,
},
})
}
return nil
}
// cursorConvertAnthropicToolBlocks handles Cursor's Anthropic-style tool_use and tool_result
// content blocks. Cursor can send these inside Responses API messages, but the standard
// ResponsesMessageContentBlock struct doesn't have fields for tool_use_id or tool_result content,
// so the data is lost during parsing. This function re-parses the raw JSON to extract tool blocks
// and converts them to proper Responses API messages (function_call / function_call_output).
func cursorConvertAnthropicToolBlocks(data []byte, cursorReq *openai.OpenAIResponsesRequest) {
// Quick check: only process if there are tool_use or tool_result blocks in the raw JSON
if !bytes.Contains(data, []byte("\"tool_use\"")) && !bytes.Contains(data, []byte("\"tool_result\"")) {
return
}
// Re-parse from raw JSON to access tool block fields
// Try both "input" (Responses API) and "messages" (chat completions) keys
type rawMessage struct {
Type *string `json:"type,omitempty"`
Role *string `json:"role,omitempty"`
ID *string `json:"id,omitempty"`
Status *string `json:"status,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
}
var rawInput struct {
Input []rawMessage `json:"input"`
Messages []rawMessage `json:"messages"`
}
if err := sonic.Unmarshal(data, &rawInput); err != nil {
return
}
// Use whichever array has content - prefer input, fallback to messages
rawMessages := rawInput.Input
if len(rawMessages) == 0 && len(rawInput.Messages) > 0 {
rawMessages = rawInput.Messages
}
if len(rawMessages) == 0 {
return
}
// Save the pre-converted input so we can reuse rich messages (with image blocks,
// multi-part content, etc.) instead of falling back to createBasicMessage which
// only preserves plain-string content.
preConvertedInput := cursorReq.Input.OpenAIResponsesRequestInputArray
type anthropicContentBlock struct {
// Anthropic-style fields
Type string `json:"type"`
Text *string `json:"text,omitempty"`
ID *string `json:"id,omitempty"`
Name *string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
ToolUseID *string `json:"tool_use_id,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
// OpenAI-style fields (Cursor may use these instead of Anthropic-style)
ToolCallID *string `json:"tool_call_id,omitempty"`
Function *struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function,omitempty"`
}
// Helper to create a basic message from raw data when no tool blocks present
createBasicMessage := func(rawMsg rawMessage) schemas.ResponsesMessage {
msgType := schemas.ResponsesMessageTypeMessage
var role schemas.ResponsesMessageRoleType
if rawMsg.Role != nil {
switch *rawMsg.Role {
case "assistant":
role = schemas.ResponsesInputMessageRoleAssistant
case "system":
role = schemas.ResponsesInputMessageRoleSystem
default:
role = schemas.ResponsesInputMessageRoleUser
}
} else {
role = schemas.ResponsesInputMessageRoleUser
}
msg := schemas.ResponsesMessage{
Type: &msgType,
Role: &role,
}
// Try to set content
if rawMsg.Content != nil && len(rawMsg.Content) > 0 {
// Try as string first
var contentStr string
if err := sonic.Unmarshal(rawMsg.Content, &contentStr); err == nil {
msg.Content = &schemas.ResponsesMessageContent{
ContentStr: &contentStr,
}
}
}
return msg
}
var newInput []schemas.ResponsesMessage
for i, rawMsg := range rawMessages {
messageStart := len(newInput)
if rawMsg.Content == nil || len(rawMsg.Content) == 0 {
// Keep original message as-is if available, otherwise create basic message
if i < len(preConvertedInput) {
newInput = append(newInput, preConvertedInput[i])
} else {
newInput = append(newInput, createBasicMessage(rawMsg))
}
continue
}
// Try to parse content as array of blocks
var blocks []anthropicContentBlock
if err := sonic.Unmarshal(rawMsg.Content, &blocks); err != nil {
// Content is a string or unparseable — keep original or create basic
if i < len(preConvertedInput) {
newInput = append(newInput, preConvertedInput[i])
} else {
newInput = append(newInput, createBasicMessage(rawMsg))
}
continue
}
hasToolBlocks := false
for _, b := range blocks {
if b.Type == "tool_use" || b.Type == "tool_result" {
hasToolBlocks = true
break
}
}
if !hasToolBlocks {
// No Anthropic tool blocks — keep original message or create basic
if i < len(preConvertedInput) {
newInput = append(newInput, preConvertedInput[i])
} else {
newInput = append(newInput, createBasicMessage(rawMsg))
}
continue
}
// Split message into regular content blocks and tool blocks
var regularBlocks []schemas.ResponsesMessageContentBlock
nextRegularIdx := 0
for _, b := range blocks {
switch b.Type {
case "tool_use":
// Convert to function_call message
// Support both Anthropic-style (id, name, input) and OpenAI-style (tool_call_id, function.name, function.arguments)
callID := b.ID
if callID == nil {
callID = b.ToolCallID
}
toolName := b.Name
var arguments *string
if b.Function != nil {
// OpenAI-style: function.name and function.arguments
if toolName == nil {
fnName := b.Function.Name
toolName = &fnName
}
if b.Function.Arguments != "" {
arguments = &b.Function.Arguments
}
}
if arguments == nil && len(b.Input) > 0 && string(b.Input) != "null" {
argStr := string(b.Input)
arguments = &argStr
}
fcType := schemas.ResponsesMessageTypeFunctionCall
newInput = append(newInput, schemas.ResponsesMessage{
ID: callID,
Type: &fcType,
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Status: schemas.Ptr("completed"),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: callID,
Name: toolName,
Arguments: arguments,
},
})
case "tool_result":
// Convert to function_call_output message
// Support both Anthropic-style (tool_use_id) and OpenAI-style (tool_call_id)
resultCallID := b.ToolUseID
if resultCallID == nil {
resultCallID = b.ToolCallID
}
fcoType := schemas.ResponsesMessageTypeFunctionCallOutput
msg := schemas.ResponsesMessage{
Type: &fcoType,
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: resultCallID,
},
}
// Extract output content
if len(b.Content) > 0 && string(b.Content) != "null" {
// Try as string first
var contentStr string
if err := sonic.Unmarshal(b.Content, &contentStr); err == nil {
msg.ResponsesToolMessage.Output = &schemas.ResponsesToolMessageOutputStruct{
ResponsesToolCallOutputStr: &contentStr,
}
} else {
// Try as array of content blocks
var contentBlocks []struct {
Type string `json:"type"`
Text *string `json:"text,omitempty"`
}
if err := sonic.Unmarshal(b.Content, &contentBlocks); err == nil {
var text strings.Builder
for _, cb := range contentBlocks {
if cb.Text != nil {
text.WriteString(*cb.Text)
}
}
if text.Len() > 0 {
s := text.String()
msg.ResponsesToolMessage.Output = &schemas.ResponsesToolMessageOutputStruct{
ResponsesToolCallOutputStr: &s,
}
}
}
}
}
newInput = append(newInput, msg)
default:
// Regular content block (text, image, etc.) — collect for the original message
matched := false
if i < len(preConvertedInput) {
origMsg := preConvertedInput[i]
if origMsg.Content != nil {
for nextRegularIdx < len(origMsg.Content.ContentBlocks) {
origBlock := origMsg.Content.ContentBlocks[nextRegularIdx]
nextRegularIdx++
if string(origBlock.Type) == b.Type {
regularBlocks = append(regularBlocks, origBlock)
matched = true
break
}
}
}
}
// Fallback: create a text block if we have text
if !matched && b.Text != nil {
blockType := schemas.ResponsesInputMessageContentBlockTypeText
if rawMsg.Role != nil && *rawMsg.Role == string(schemas.ResponsesInputMessageRoleAssistant) {
blockType = schemas.ResponsesOutputMessageContentTypeText
}
regularBlocks = append(regularBlocks, schemas.ResponsesMessageContentBlock{
Type: blockType,
Text: b.Text,
})
}
}
}
// If there were regular content blocks alongside tool blocks, add the original message with just those
if len(regularBlocks) > 0 {
var role *schemas.ResponsesMessageRoleType
if rawMsg.Role != nil {
r := schemas.ResponsesMessageRoleType(*rawMsg.Role)
role = &r
}
msg := schemas.ResponsesMessage{
Role: role,
Content: &schemas.ResponsesMessageContent{
ContentBlocks: regularBlocks,
},
}
if rawMsg.Type != nil {
mt := schemas.ResponsesMessageType(*rawMsg.Type)
msg.Type = &mt
}
// Insert the regular content message before the tool messages
// Find the position where we started adding tool messages
insertPos := len(newInput)
for j := len(newInput) - 1; j >= messageStart; j-- {
if newInput[j].Type != nil && (*newInput[j].Type == schemas.ResponsesMessageTypeFunctionCall || *newInput[j].Type == schemas.ResponsesMessageTypeFunctionCallOutput) {
insertPos = j
} else {
break
}
}
newInput = append(newInput[:insertPos], append([]schemas.ResponsesMessage{msg}, newInput[insertPos:]...)...)
}
}
cursorReq.Input = openai.OpenAIResponsesRequestInput{
OpenAIResponsesRequestInputArray: newInput,
}
}
// normalizeInputContentBlocks ensures all input messages have ContentBlocks instead of
// ContentStr. Some providers (e.g. Anthropic) require content as an array of content blocks,
// but Cursor may send content as a plain string. This must run after all parsing paths.
func normalizeInputContentBlocks(req *openai.OpenAIResponsesRequest) {
for i := range req.Input.OpenAIResponsesRequestInputArray {
msg := &req.Input.OpenAIResponsesRequestInputArray[i]
if msg.Content != nil && msg.Content.ContentStr != nil {
text := msg.Content.ContentStr
blockType := schemas.ResponsesInputMessageContentBlockTypeText
if msg.Role != nil && *msg.Role == schemas.ResponsesInputMessageRoleAssistant {
blockType = schemas.ResponsesOutputMessageContentTypeText
}
msg.Content = &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{{
Type: blockType,
Text: text,
}},
}
}
}
}
// isEffectivelyEmptyContent checks whether a message's content would produce an empty
// content array after provider-level conversion. Cursor can send content blocks with
// unrecognized types (e.g., "tool_result") that downstream converters filter out,
// resulting in empty content. This uses a whitelist of known-good content types.
func isEffectivelyEmptyContent(content *schemas.ResponsesMessageContent) bool {
if content == nil {
return true
}
if content.ContentStr != nil && strings.TrimSpace(*content.ContentStr) != "" {
return false
}
if len(content.ContentBlocks) == 0 {
return true
}
for _, block := range content.ContentBlocks {
switch block.Type {
case schemas.ResponsesInputMessageContentBlockTypeText, schemas.ResponsesOutputMessageContentTypeText:
if block.Text != nil && strings.TrimSpace(*block.Text) != "" {
return false
}
case schemas.ResponsesInputMessageContentBlockTypeImage:
if block.ResponsesInputMessageContentBlockImage != nil {
return false
}
case schemas.ResponsesInputMessageContentBlockTypeFile:
if block.ResponsesInputMessageContentBlockFile != nil {
return false
}
case schemas.ResponsesInputMessageContentBlockTypeAudio:
if block.Audio != nil {
return false
}
case schemas.ResponsesOutputMessageContentTypeCompaction:
if block.ResponsesOutputMessageContentCompaction != nil {
return false
}
// All other types (tool_result, unknown types, etc.) are considered
// effectively empty since downstream converters will filter them out.
}
}
return true
}
// normalizeBifrostInputContentBlocks ensures all input messages in a BifrostResponsesRequest
// have ContentBlocks instead of ContentStr. This is a defense-in-depth normalization that runs
// AFTER ToBifrostResponsesRequest, which can re-introduce ContentStr when the input is a string.
func normalizeBifrostInputContentBlocks(req *schemas.BifrostResponsesRequest) {
if req == nil {
return
}
for i := range req.Input {
msg := &req.Input[i]
// Normalize message content: ContentStr → ContentBlocks
if msg.Content != nil && msg.Content.ContentStr != nil {
text := msg.Content.ContentStr
blockType := schemas.ResponsesInputMessageContentBlockTypeText
if msg.Role != nil && *msg.Role == schemas.ResponsesInputMessageRoleAssistant {
blockType = schemas.ResponsesOutputMessageContentTypeText
}
msg.Content = &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{{
Type: blockType,
Text: text,
}},
}
}
// Cursor can send user messages with nil, empty, or effectively empty content
// (e.g., text blocks with nil Text pointers that downstream providers filter out).
// Anthropic requires user messages to have non-empty, non-whitespace content.
// Backfill with a placeholder to prevent 400 errors.
if msg.Role != nil && *msg.Role == schemas.ResponsesInputMessageRoleUser &&
isEffectivelyEmptyContent(msg.Content) {
placeholder := "..."
msg.Content = &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{{
Type: schemas.ResponsesInputMessageContentBlockTypeText,
Text: &placeholder,
}},
}
}
// Normalize tool output: ResponsesToolCallOutputStr → ResponsesFunctionToolCallOutputBlocks
if msg.ResponsesToolMessage != nil && msg.ResponsesToolMessage.Output != nil &&
msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != nil &&
msg.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks == nil {
text := msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr
msg.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks = []schemas.ResponsesMessageContentBlock{{
Type: schemas.ResponsesInputMessageContentBlockTypeText,
Text: text,
}}
msg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr = nil
}
}
}
// cursorMergeToolResultsFromMessages checks whether the parsed input is missing
// function_call_output messages and, if so, looks for tool results in the "messages"
// key (chat completions format with role:"tool"). When Cursor sends both "input" and
// "messages", the input array may contain the conversation but omit tool results,
// while the messages array has the complete conversation including role:"tool" entries.
// In that case we replace input with the fully converted messages.
func cursorMergeToolResultsFromMessages(data []byte, cursorReq *openai.OpenAIResponsesRequest) {
// If we already have function_call_output messages, tool results are present
for _, msg := range cursorReq.Input.OpenAIResponsesRequestInputArray {
if msg.Type != nil && *msg.Type == schemas.ResponsesMessageTypeFunctionCallOutput {
return
}
}
// Quick check: does the raw body contain role:"tool" in messages?
type msgProbeWithContent struct {
Messages []struct {
Role *string `json:"role"`
Content json.RawMessage `json:"content"`
} `json:"messages"`
}
var probe msgProbeWithContent
if err := sonic.Unmarshal(data, &probe); err != nil || len(probe.Messages) == 0 {
return
}
hasToolMessages := false
for _, m := range probe.Messages {
if m.Role != nil && *m.Role == "tool" {
hasToolMessages = true
break
}
// Cursor sends tool results as user messages with Anthropic-style tool_result content blocks
if m.Role != nil && *m.Role == "user" && bytes.Contains(m.Content, []byte("\"tool_result\"")) {
hasToolMessages = true
break
}
}
if !hasToolMessages {
return
}
// Tool results exist in messages but not in input — use messages path instead.
// This replaces input entirely with the properly converted messages array,
// which includes function_call and function_call_output messages via ToResponsesMessages().
cursorConvertMessagesToInput(data, cursorReq)
// Re-run Anthropic tool-block conversion on the newly replaced input.
// ToResponsesMessages() doesn't handle Anthropic-style tool_result/tool_use content
// blocks inside user messages, so we need cursorConvertAnthropicToolBlocks to
// extract and convert them to proper function_call/function_call_output messages.
cursorConvertAnthropicToolBlocks(data, cursorReq)
}
// cursorConvertMessagesToInput handles Cursor's use of "messages" (chat completions key)
// instead of "input" (Responses API key). It parses chat completions messages and converts
// them to Responses API input format using ChatMessage.ToResponsesMessages().
func cursorConvertMessagesToInput(data []byte, cursorReq *openai.OpenAIResponsesRequest) {
var messagesWrapper struct {
Messages []schemas.ChatMessage `json:"messages"`
}
if err := sonic.Unmarshal(data, &messagesWrapper); err != nil || len(messagesWrapper.Messages) == 0 {
return
}
var allInput []schemas.ResponsesMessage
for i := range messagesWrapper.Messages {
allInput = append(allInput, messagesWrapper.Messages[i].ToResponsesMessages()...)
}
// Normalize ContentStr → ContentBlocks for all messages.
// ToResponsesMessages() produces ContentStr (string) for user/system/developer messages,
// but some providers (e.g. Anthropic) require content as an array of content blocks.
for i := range allInput {
if allInput[i].Content != nil && allInput[i].Content.ContentStr != nil {
text := allInput[i].Content.ContentStr
blockType := schemas.ResponsesInputMessageContentBlockTypeText
if allInput[i].Role != nil && *allInput[i].Role == schemas.ResponsesInputMessageRoleAssistant {
blockType = schemas.ResponsesOutputMessageContentTypeText
}
allInput[i].Content = &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{{
Type: blockType,
Text: text,
}},
}
}
}
cursorReq.Input = openai.OpenAIResponsesRequestInput{
OpenAIResponsesRequestInputArray: allInput,
}
}
// CursorRouter holds route registrations for Cursor IDE endpoints.
// Cursor sends hybrid payloads using the OpenAI Responses API format
// (input field with input_text content blocks) to chat completions endpoints.
// This router routes /cursor/v1/chat/completions through the Responses API pipeline
// while converting responses back to chat completions format that Cursor expects.
type CursorRouter struct {
*GenericRouter
}
// CreateCursorChatCompletionsRouteConfigs creates route configs for Cursor's chat completions endpoint.
// It parses requests as OpenAI Responses API format since Cursor's payload is valid Responses API format,
// but converts responses back to chat completions format (choices/delta/content).
func CreateCursorChatCompletionsRouteConfigs(pathPrefix string, handlerStore lib.HandlerStore) []RouteConfig {
routes := []RouteConfig{}
for _, path := range []string{
"/v1/chat/completions",
"/chat/completions",
} {
routes = append(routes, RouteConfig{
Type: RouteConfigTypeOpenAI,
Path: pathPrefix + path,
Method: "POST",
GetHTTPRequestType: func(ctx *fasthttp.RequestCtx) schemas.RequestType {
return schemas.ResponsesRequest
},
GetRequestTypeInstance: func(ctx context.Context) interface{} {
return &openai.OpenAIResponsesRequest{}
},
RequestParser: cursorRequestParser,
RequestConverter: func(ctx *schemas.BifrostContext, req interface{}) (*schemas.BifrostRequest, error) {
if openaiReq, ok := req.(*openai.OpenAIResponsesRequest); ok {
bifrostReq := openaiReq.ToBifrostResponsesRequest(ctx)
if bifrostReq == nil {
return nil, errors.New("bifrost responses request conversion returned nil")
}
normalizeBifrostInputContentBlocks(bifrostReq)
return &schemas.BifrostRequest{
ResponsesRequest: bifrostReq,
}, nil
}
return nil, errors.New("invalid request type")
},
ResponsesResponseConverter: func(ctx *schemas.BifrostContext, resp *schemas.BifrostResponsesResponse) (interface{}, error) {
return convertResponsesResponseToChatCompletion(resp), nil
},
ErrorConverter: func(ctx *schemas.BifrostContext, err *schemas.BifrostError) interface{} {
return err
},
StreamConfig: &StreamConfig{
ResponsesStreamResponseConverter: func(ctx *schemas.BifrostContext, resp *schemas.BifrostResponsesStreamResponse) (string, interface{}, error) {
return convertResponsesStreamToChatChunk(resp)
},
ErrorConverter: func(ctx *schemas.BifrostContext, err *schemas.BifrostError) interface{} {
return err
},
},
PreCallback: func(ctx *fasthttp.RequestCtx, bifrostCtx *schemas.BifrostContext, req interface{}) error {
// Set the user agent to cursor for tool manager duplicate checks
bifrostCtx.SetValue(schemas.BifrostContextKeyUserAgent, schemas.Cursor.String())
return nil
},
})
}
return routes
}
// NewCursorRouter creates a new CursorRouter with the given bifrost client.
func NewCursorRouter(client *bifrost.Bifrost, handlerStore lib.HandlerStore, logger schemas.Logger) *CursorRouter {
routes := []RouteConfig{}
// Custom Responses-based chat completions handler for Cursor's hybrid payloads
routes = append(routes, CreateCursorChatCompletionsRouteConfigs("/cursor", handlerStore)...)
// Add OpenAI list models route for /cursor/v1/models
routes = append(routes, CreateOpenAIListModelsRouteConfigs("/cursor", handlerStore)...)
// Add Anthropic routes for /cursor/anthropic/...
routes = append(routes, CreateAnthropicRouteConfigs("/cursor", logger)...)
// Add Anthropic count tokens route
routes = append(routes, CreateAnthropicCountTokensRouteConfigs("/cursor", handlerStore)...)
// Add GenAI routes for /cursor/genai/...
routes = append(routes, CreateGenAIRouteConfigs("/cursor")...)
// Add Bedrock routes for /cursor/bedrock/...
routes = append(routes, CreateBedrockRouteConfigs("/cursor", handlerStore)...)
// Add Cohere routes for /cursor/cohere/...
routes = append(routes, CreateCohereRouteConfigs("/cursor")...)
return &CursorRouter{
GenericRouter: NewGenericRouter(client, handlerStore, routes, nil, logger),
}
}