Files
magpie/internal/gateway/cursor.go
T
yetone b56bfb5a94 gateway: an upstream that answers 200 with a web page, with nothing at all, or with JSON that isn't, has failed as a 502 does, so a group asks its next member (#1012, cth123456: Codex on group/medium, agentrouter/deepseek-v4-flash now and then answered 200 text/html; the log said status=200 done=true in=0 out=0 served=null, codex/gpt-6-luna was never asked, and Codex failed with "stream disconnected before completion: stream closed before response.completed") — the candidate loop judged a try by its status alone, and holdWriter passes a 200 that is neither text/event-stream nor application/json straight to the agent, so the page went to Codex as the reply. notAnAPIReply now reads every 2xx (but 204) an upstream gives, in forwardOnce (every relayed and translated request, every API, plugins included) and in the built-in clients' own requests (the ChatGPT backend's /responses, Kiro, Zed, Qoder, Devin, Cursor, Command Code): a text/html reply, a body that begins as a web page whatever its Content-Type, an empty body, or one sent as JSON that doesn't begin with { or [ is turned into the 502 it stands for, with a JSON error saying what came back ("answered 200 OK with a web page (text/html), not an API reply: “Just a moment...”", the firewall hint for a block page). It then fails over, rests a minute and is logged as any 502 before the reply begins does; a provider with nobody after it hands the agent that 502 rather than the page. Only the first bytes are peeked, which a stream waits for anyway; a read that fails is left to the reader, a compressed body and anything else as it came. TestNotAnAPIReplyFailsOver (Chat, Responses and Messages, streamed and not, a member answering a Cloudflare page as text/html, as JSON and as SSE, an empty body as JSON and as SSE, and plain text as JSON; the next member must answer and the log name why) fails without the change in all 36 cases (the page relayed to the agent, or b never asked, or no reason logged) and passes; TestNotAnAPIReplyAlone (a 502 with the reason, logged 502) and TestCodexBackendWebPage (the ChatGPT backend's page reached Codex as a 200) fail without it and pass; TestAPIReplyUntouched keeps JSON after whitespace, SSE with and without a Content-Type, gzip, XML, a 204 and a 404 as they came. -race -count=10 on them; go test -tags nogui ./internal/gateway ./internal/provider, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. Not tried with the reporter's relay (agentrouter) itself; the page is a Cloudflare challenge's, as the issue has only its Content-Type. docs/subsystems/gateway-routing.md says it.
2026-10-06 18:26:31 +08:00

1180 lines
37 KiB
Go

package gateway
// PLUGIN-SERVED (see AGENTS.md): Cursor ("cursor") is a deprecated built-in
// subscription served by its plugin, @magpie-community/opencode-cursor-auth,
// once moved onto it (provider.Moved; the default for a new sign-in). A
// moved one's sign-ins, models, requests and usage are all the plugin's,
// never this code's (only the move, in migrate*.go, still reads its
// accounts). A fix here alone doesn't reach those users; fix the plugin
// (github.com/magpie-community/plugins, packages/cursor) and raise the
// mover's min in internal/provider/migrate_side.go.
// A Cursor subscription is served through the API cursor-agent talks to —
// AgentService/Run, a Connect stream both ways over HTTP/2 — with the CLI's
// sign-in, the way a Devin one is (devin.go).
//
// Cursor keeps a conversation on its client: each message of the prompt is
// an AI SDK message in JSON, a blob named by its sha256, which the server
// asks the client for as it reads them. So each request goes whole, as such
// a conversation, the caller's system prompt at its head, and the server
// adds nothing of its own; the Run only resumes it. The caller's tools are
// MCP tools, which the model calls through Cursor's CallDynamicTool: the
// server hands each call to the client to run, and once it has said how
// many a step made, the stream is closed and the calls go to the caller,
// whose results come back in the next request's conversation. Nothing else
// the server asks the client to run — a shell, a file read — is run.
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"math"
"net/http"
"net/url"
"os"
"runtime"
"slices"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/provider"
)
// cursorToken and cursorVersion are the sign-in and the CLI version the
// API is told of, cursorAPI the API that says where a team's agent API is,
// cursorAgent the one used when it can't say, and cursorVariants the ids a
// model magpie offers stands for, cursorBaseOf the other way round; vars
// so tests can stand in for them.
var (
cursorToken = provider.CursorToken
cursorVersion = provider.CursorClientVersion
cursorAPI = "https://api2.cursor.sh"
cursorAgent = "https://agentn.global.api5.cursor.sh"
cursorVariants = provider.CursorVariants
cursorBaseOf = provider.CursorBase
)
// cursorEndpoint is the agent API last picked, for the token (by its
// hash) it was picked for.
var cursorEndpoint struct {
sync.Mutex
key string
url string
at time.Time
listed bool // the server config named it; else it is cursorAgent
}
// cursorAgentURL is the agent API to run on with this token. As
// cursor-agent does, it is what ServerConfigService/GetServerConfig names
// in its agentUrlConfig — a team may be served in one region only, and
// the global API turns it away — asked once a token, and again when fresh
// is set; the global one while it can't be had.
func (s *Server) cursorAgentURL(ctx context.Context, tok string, fresh bool) string {
h := sha256.Sum256([]byte(tok))
key := hex.EncodeToString(h[:])
c := &cursorEndpoint
c.Lock()
defer c.Unlock()
if !fresh && c.key == key && (c.listed || time.Since(c.at) < time.Minute) {
return c.url
}
u, err := s.cursorServerAgent(ctx, tok)
if err != nil {
if ctx.Err() != nil { // the caller gone: nothing learned
return cursorAgent
}
u = cursorAgent
}
c.key, c.url, c.at, c.listed = key, u, time.Now(), err == nil
return u
}
// cursorServerAgent is the agent API Cursor's server config names. The CLI
// takes agentUrl in privacy mode, which the Run always asks for, and
// agentnUrl otherwise.
func (s *Server) cursorServerAgent(ctx context.Context, tok string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
hr, err := http.NewRequestWithContext(ctx, http.MethodPost, cursorAPI+"/aiserver.v1.ServerConfigService/GetServerConfig", strings.NewReader("{}"))
if err != nil {
return "", err
}
hr.Header.Set("Content-Type", "application/json")
hr.Header.Set("Connect-Protocol-Version", "1")
hr.Header.Set("Authorization", "Bearer "+tok)
hr.Header.Set("x-cursor-client-version", cursorVersion())
hr.Header.Set("x-cursor-client-type", "cli")
hr.Header.Set("x-ghost-mode", "true")
res, err := s.client.Do(hr)
if err != nil {
return "", err
}
defer res.Body.Close()
b, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20))
if res.StatusCode/100 != 2 {
return "", errors.New(res.Status)
}
var cfg struct {
AgentURLConfig struct {
AgentURL string `json:"agentUrl"`
AgentnURL string `json:"agentnUrl"`
} `json:"agentUrlConfig"`
}
if err := json.Unmarshal(b, &cfg); err != nil {
return "", err
}
for _, raw := range []string{cfg.AgentURLConfig.AgentURL, cfg.AgentURLConfig.AgentnURL} {
if u, err := url.Parse(raw); err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" {
return strings.TrimRight(raw, "/"), nil
}
}
return "", errors.New("no agent URL in the server config")
}
// cursorRegional tells an error of the region a team may be served in:
// "This region is not yet available for your team".
func cursorRegional(msg string) bool {
return strings.Contains(strings.ToLower(msg), "region")
}
// cursorModelID is Cursor's id for a model magpie offers, at the effort
// asked for: the family's variant at it; else, for an effort between the
// ones it has, the one Cursor picks by default (gpt-5.2 is low, high and
// one without an effort); else the nearest. Fast, when asked, is from the
// family's fast one. An id of Cursor's own at an effort (grok-4.7-low, one
// an agent was set to before) is at the effort asked for, and fast, where
// Cursor has that id; else it goes as it is.
func cursorModelID(model, effort string, fast bool) string {
if base, at, ok := cursorBaseOf(model); ok {
if effort == "" {
effort = at
}
if fast && !strings.HasSuffix(base, "-fast") {
if _, ok := cursorVariants(base + "-fast"); ok {
base += "-fast"
}
}
if vs, ok := cursorVariants(base); ok && vs[effort] != "" && effort != "" {
return vs[effort]
}
return model
}
if fast && !strings.HasSuffix(model, "-fast") {
if _, ok := cursorVariants(model + "-fast"); ok {
model += "-fast"
}
}
vs, ok := cursorVariants(model)
if !ok {
return model
}
if effort == "" {
return vs[""]
}
if id := vs[effort]; id != "" {
return id
}
var levels []string
unnamed := true // the default has no effort of its own
for _, l := range effortRank {
if id := vs[l]; id != "" {
levels = append(levels, l)
unnamed = unnamed && id != vs[""]
}
}
if len(levels) == 0 {
return vs[""]
}
at := slices.Index(effortRank, effort)
if unnamed && at > slices.Index(effortRank, levels[0]) && at < slices.Index(effortRank, levels[len(levels)-1]) {
return vs[""]
}
return vs[fitEffort(effort, levels)]
}
// cursorCall is how the model calls an MCP tool.
const cursorCall = "CallDynamicTool"
// serveCursor answers a request through Cursor's API.
func (s *Server) serveCursor(w http.ResponseWriter, r *http.Request, from provider.Protocol, model string, body []byte, usage *Usage) (int, string) {
req, err := parse(from, body)
if err != nil {
return writeError(w, from, 400, err.Error()), err.Error()
}
req.Model = model
if conv := cursorConversation(r.Header, req.CacheKey, body); conv != "" {
r = r.WithContext(context.WithValue(r.Context(), cursorConvKey{}, conv))
}
ask := s.askCursor(model)
if req.WebSearch && !searching(r.Context()) {
if canSearch() {
return s.searchReply(w, r, from, "Cursor", req, usage, ask)
}
}
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
events, status, msg := ask(ctx, req)
if events == nil {
return writeError(w, from, status, msg), msg
}
return relay(w, r, from, "Cursor", req, events, usage, cancel, func(string, string, bool) {})
}
// askCursor is a round for Cursor's API.
func (s *Server) askCursor(model string) round {
return func(ctx context.Context, req *Request) (<-chan Event, int, string) {
tok, err := cursorToken()
if err != nil {
return nil, 401, "Cursor: " + err.Error()
}
id := cursorModelID(model, req.Effort, req.Fast)
if id == "auto" { // Cursor's pick, which its API calls default
id = "default"
}
base := s.cursorAgentURL(ctx, tok, false)
_, maxMode := cursorMaxOnly.Load(id)
events, status, msg := s.cursorRun(ctx, req, model, id, tok, base, maxMode)
if events == nil && cursorRegional(msg) {
// the team moved, or the config was kept from before: once more
// with what the config says now
if fresh := s.cursorAgentURL(ctx, tok, true); fresh != base {
base = fresh
events, status, msg = s.cursorRun(ctx, req, model, id, tok, base, maxMode)
}
}
if events == nil && !maxMode && cursorMaxRequired(msg) {
// a model Cursor serves only in Max Mode: in Max Mode, as
// cursor-agent turns it on for such a model, and so from now
// on. An account Max Mode isn't open to gets Cursor's answer.
cursorMaxOnly.Store(id, true)
events, status, msg = s.cursorRun(ctx, req, model, id, tok, base, true)
}
return events, status, msg
}
}
// cursorMaxOnly are the ids Cursor said it serves in Max Mode only.
var cursorMaxOnly sync.Map
// cursorMaxRequired is Cursor's refusal of a model asked for without Max
// Mode (cursor-agent's MAX_MODE_REQUIRED): "Max Mode Required: The model
// "gpt-5.6-luna-low" requires Max Mode to be enabled. …" (ARNO on Discord).
func cursorMaxRequired(msg string) bool {
low := strings.ToLower(msg)
return strings.Contains(low, "max mode required") || strings.Contains(low, "max_mode_required") || strings.Contains(low, "requires max mode")
}
// cursorRun is one Run of the request on the agent API at base, in Max
// Mode when maxMode is set.
func (s *Server) cursorRun(ctx context.Context, req *Request, model, id, tok, base string, maxMode bool) (<-chan Event, int, string) {
tools := bridgeTools(req)
msgs := cursorMessages(req, tools)
conv, _ := ctx.Value(cursorConvKey{}).(string)
if conv == "" { // a new one: the Run's usage event is found by it
conv = cursorUUID()
}
since := time.Now()
run, blobs := buildCursorRun(msgs, cursorLastUser(req), tools, id, conv, maxMode)
// the Run ends with the turn, or once the calls are made
rctx, cancel := context.WithCancel(ctx)
pr, pw := io.Pipe()
st := &cursorStream{pw: pw, blobs: blobs}
hr, err := http.NewRequestWithContext(rctx, http.MethodPost, base+"/agent.v1.AgentService/Run", pr)
if err != nil {
cancel()
return nil, 500, "Cursor: " + err.Error()
}
hr.Header.Set("Content-Type", "application/connect+proto")
hr.Header.Set("Connect-Protocol-Version", "1")
hr.Header.Set("Authorization", "Bearer "+tok)
hr.Header.Set("x-cursor-client-version", cursorVersion())
hr.Header.Set("x-cursor-client-type", "cli") // else Cursor adds a prompt of its own
hr.Header.Set("x-ghost-mode", "true") // privacy mode: nothing kept for training
hr.Header.Set("x-request-id", cursorUUID())
// none of Cursor's own tools, only the caller's
hr.Header.Set("x-cursor-agent-allowed-tools", "mcp_tool_call,get_mcp_tools_tool_call")
go st.send(run)
go st.heartbeat(rctx)
res, err := s.client.Do(hr)
if err != nil {
cancel()
pw.Close()
return nil, 502, "Cursor: " + err.Error()
}
res = notAnAPIReply(res, "") // a web page served 200 (#1012)
if res.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
res.Body.Close()
cancel()
pw.Close()
status, msg := cursorFailure(res.StatusCode, b)
return nil, status, "Cursor: " + msg
}
raw := make(chan Event, 16)
st.done = func() {
res.Body.Close()
pw.Close()
}
st.step = func(ctx context.Context) (Usage, bool) {
return s.cursorStepUsage(ctx, tok, conv, since)
}
go func() {
defer res.Body.Close()
defer pw.Close()
defer cancel()
st.decode(rctx, bufio.NewReaderSize(res.Body, 64<<10), raw, tools)
}()
// an error comes before anything of the answer: out of quota, a
// model the plan doesn't have — answered with its own status
first, ok := <-raw
if !ok {
return nil, 502, "Cursor: an empty reply"
}
if first.Kind == KError {
cancel()
status := st.status
if status == 0 {
status = 502
}
return nil, status, "Cursor: " + first.Text
}
out := make(chan Event, 16)
go func() {
defer close(out)
send := func(ev Event) bool {
select {
case out <- ev:
return true
case <-ctx.Done():
return false
}
}
if !send(Event{Kind: KStart, MsgID: "msg_" + randomToken()[:24], Model: model}) || !send(first) {
return
}
for ev := range raw {
// a guess only when Cursor counted nothing: a prompt read whole
// from the cache leaves no uncached rest
if u := ev.Usage; ev.Kind == KUsage && u.Input == 0 && u.CacheRead == 0 && u.CacheWrite == 0 {
ev.Usage.Input = estimate(req)
}
if !send(ev) {
return
}
}
}()
return out, 0, ""
}
// cursorFailure is the status and message for Cursor's Connect error,
// whose details say it best: {"code", "message", "details": [{"debug":
// {"error", "details": {"title", "detail"}}}]}, or the same under "error"
// at a stream's end.
func cursorFailure(status int, body []byte) (int, string) {
type failure struct {
Code string `json:"code"`
Message string `json:"message"`
Details []struct {
Debug struct {
Error string `json:"error"`
Details struct {
Title string `json:"title"`
Detail string `json:"detail"`
} `json:"details"`
} `json:"debug"`
} `json:"details"`
}
var e struct {
failure
Error *failure `json:"error"`
}
if json.Unmarshal(body, &e) != nil {
return cursorStatus(status, "", strings.TrimSpace(string(body)))
}
f := e.failure
if e.Error != nil {
f = *e.Error
}
msg := f.Message
for _, d := range f.Details {
if t := strings.TrimSpace(d.Debug.Details.Title + ": " + d.Debug.Details.Detail); t != ":" {
msg = strings.Trim(t, ": ")
}
}
if msg == "" || msg == "Error" {
msg = f.Code
}
if f.Code == "" && status/100 == 2 { // a stream that ended well
return status, msg
}
return cursorStatus(status, f.Code, msg)
}
// cursorStatus is the status and words for Cursor's error of this code and
// message. A region the team isn't served in says so, not to sign in: a
// sign-in doesn't change it.
func cursorStatus(status int, code, msg string) (int, string) {
if msg == "" {
msg = http.StatusText(status)
}
lower := strings.ToLower(msg)
switch {
case cursorRegional(msg):
return 403, msg + " — Cursor serves your team only in some regions and turned this request away; signing in again won't change that"
case code == "permission_denied":
return 403, msg
case code == "unauthenticated" || status == 401 || strings.Contains(lower, "expired"):
return 401, msg + " — sign in to Cursor again in magpie"
case code == "resource_exhausted" || strings.Contains(lower, "quota") || strings.Contains(lower, "rate limit") || strings.Contains(lower, "usage limit"):
return 429, "usage limit reached: " + msg
case strings.Contains(lower, "too long") || strings.Contains(lower, "context length") || strings.Contains(lower, "too many tokens"):
return 400, "input is too long for the model's context: " + msg
case code == "invalid_argument":
return 400, msg
case code == "unavailable":
return 503, msg
case code != "":
return 502, msg
}
return status, msg
}
// ---- the conversation ----------------------------------------------------
// cursorMessages is the conversation as AI SDK messages, in JSON: the
// system prompt with the caller's tools listed, then each turn. A tool
// result answers a call of the reply before it; a call left unanswered is
// answered for, as the API wants every call to have its result.
func cursorMessages(r *Request, tools []bridgeTool) [][]byte {
var out [][]byte
add := func(m map[string]any) {
b, _ := json.Marshal(m)
out = append(out, b)
}
if sys := r.System + cursorCatalog(tools); sys != "" {
add(map[string]any{"role": "system", "content": sys})
}
var pending []string // calls the last reply made, not yet answered
answer := func(results []any) {
for _, id := range pending {
results = append(results, cursorResult(id, devinNoResult, true))
}
pending = nil
if len(results) > 0 {
add(map[string]any{"role": "tool", "content": results})
}
}
for _, m := range r.Messages {
if m.Role == "assistant" {
answer(nil)
var content []any
for _, p := range m.Parts {
switch p.Kind {
case Text:
if p.Text != "" {
content = append(content, map[string]any{"type": "text", "text": p.Text})
}
case ToolCall:
var args any = map[string]any{}
if json.Valid(p.Args) {
args = json.RawMessage(p.Args)
}
content = append(content, map[string]any{"type": "tool-call", "toolCallId": cursorCallID(p.ID), "toolName": cursorCall,
"args": map[string]any{"namespace": "magpie", "toolName": p.Name, "arguments": args}})
pending = append(pending, p.ID)
}
}
if len(content) > 0 {
add(map[string]any{"role": "assistant", "content": content})
}
continue
}
var results, content []any
for _, p := range m.Parts {
switch p.Kind {
case ToolResult:
if i := indexOf(pending, p.CallID); i >= 0 {
pending = append(pending[:i], pending[i+1:]...)
results = append(results, cursorResult(p.CallID, p.Text, p.IsError))
}
case Text:
if p.Text != "" {
content = append(content, map[string]any{"type": "text", "text": p.Text})
}
case Image:
if b, err := base64.StdEncoding.DecodeString(p.Data); err == nil && p.Data != "" {
content = append(content, map[string]any{"type": "image", "mimeType": p.MediaType,
"image": map[string]any{"__type": "Uint8Array", "hex": hex.EncodeToString(b)}})
} else {
content = append(content, map[string]any{"type": "text", "text": attachmentText(p)})
}
case File:
content = append(content, map[string]any{"type": "text", "text": attachmentText(p)})
}
}
answer(results)
if len(content) > 0 {
add(map[string]any{"role": "user", "content": content})
}
}
answer(nil)
return out
}
func indexOf(ids []string, id string) int {
for i, x := range ids {
if x == id {
return i
}
}
return -1
}
// cursorResult is a tool's result, as the model is shown it.
func cursorResult(id, text string, isError bool) map[string]any {
var result any = text
if json.Valid([]byte(text)) {
result = json.RawMessage(text)
}
r := map[string]any{"type": "tool-result", "toolCallId": cursorCallID(id), "toolName": cursorCall, "result": result,
"experimental_content": []any{map[string]any{"type": "text", "text": text}}}
if isError {
r["isError"] = true
}
return r
}
// cursorCatalog lists the caller's tools for the model. It sees MCP tools
// only through Cursor's GetDynamicTools and CallDynamicTool, and Cursor
// names them in a prompt of its own, which this conversation goes without:
// without the list the model says it has no such tool.
func cursorCatalog(tools []bridgeTool) string {
if len(tools) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n\n<dynamic_tool_catalog>\nThe tools below are available in the MCP namespace \"magpie\". Call one with `" + cursorCall + "` (namespace \"magpie\", toolName, arguments). Their schemas are given here, so there is no need to call `GetDynamicTools` first.\n")
for _, t := range tools {
b.WriteString("<tool name=\"" + t.Name + "\">\n" + t.Description + "\ninput schema: " + string(t.InputSchema) + "\n</tool>\n")
}
b.WriteString("</dynamic_tool_catalog>")
return b.String()
}
// cursorLastUser is the user's last words, which the turn is named by.
func cursorLastUser(r *Request) string {
for i := len(r.Messages) - 1; i >= 0; i-- {
if r.Messages[i].Role != "user" {
continue
}
for _, p := range r.Messages[i].Parts {
if p.Kind == Text && p.Text != "" {
return p.Text
}
}
}
return "."
}
// cursorCallID is a call's id as Cursor gave it, before the line in it
// was sent to the caller as __.
func cursorCallID(id string) string {
if strings.HasPrefix(id, "call_") {
return strings.Replace(id, "__fc_", "\nfc_", 1)
}
return id
}
// ---- the request ---------------------------------------------------------
func cursorBlobID(b []byte) []byte {
h := sha256.Sum256(b)
return h[:]
}
func cursorUUID() string {
b, _ := hex.DecodeString(randomToken()[:32])
return uuidOf(b)
}
// uuidOf is 16 bytes as a version 4 UUID, the shape of every id Cursor's
// own client sends.
func uuidOf(b []byte) string {
b = slices.Clone(b[:16])
b[6] = b[6]&0x0f | 0x40
b[8] = b[8]&0x3f | 0x80
h := hex.EncodeToString(b)
return h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
}
// cursorConvKey holds, in a request's context, the conversation_id its
// Runs go with.
type cursorConvKey struct{}
// cursorConversation is the AgentRunRequest's conversation_id for a
// conversation: the same on every request of it, so that Cursor's backend
// keeps sending it to the machine that has its prompt cached — the
// x-grok-conv-id grokSigned sends xAI for Grok, which caches by machine
// (#498). Cursor's own client keeps one conversationId per agent session,
// so it is made from what names the session — the client's
// prompt_cache_key (Codex's thread id), else the agent's own session
// header (Claude Code's, OpenCode's) — together with the conversation's
// first user message, which every later request repeats: subagents
// running at once under one session (Claude Code's Task agents share its
// session id) are separate conversations to Cursor, as they are to its
// own client, and never one conversation sent twice at the same time.
// With nothing naming the session it is "", and each Run gets a new id
// as before: a first message alone ("hi") would put strangers'
// conversations under one id.
func cursorConversation(in http.Header, cacheKey string, body []byte) string {
key := cacheKey
if key == "" {
key = nativeSessionOf(in)
}
if key == "" {
return ""
}
sum := sha256.Sum256([]byte("cursor conversation\x00" + key + "\x00" + conversationID(http.Header{}, body)))
return uuidOf(sum[:])
}
// buildCursorRun is the Run's first message, an AgentClientMessage with its
// run_request, and the blobs it names. The conversation state is the
// messages and one turn, which the server wants there to sample at all.
// conv is the conversation_id (cursorConversation), a new one when "".
// maxMode asks for the model in Max Mode, as cursor-agent does for a model
// Cursor serves only that way: ModelDetails' max_mode (7) and
// RequestedModel's (2).
func buildCursorRun(msgs [][]byte, lastUser string, tools []bridgeTool, model, conv string, maxMode bool) ([]byte, map[string][]byte) {
if conv == "" {
conv = cursorUUID()
}
blobs := map[string][]byte{}
put := func(b []byte) []byte {
id := cursorBlobID(b)
blobs[string(id)] = b
return id
}
var state pb
for _, m := range msgs {
state = state.bytes(1, put(m))
}
mid := cursorUUID()
user := pb{}.str(1, lastUser).str(2, mid).varint(4, 1) // mode: agent
turn := pb{}.bytes(1, pb{}.bytes(1, put(user)).str(10, mid))
state = state.bytes(8, put(turn)).varint(10, 1).str(22, "cli")
var defs []pb
for _, t := range tools {
defs = append(defs, cursorToolDef(t))
}
env := pb{}.str(1, runtime.GOOS).str(2, os.TempDir()).str(10, "UTC")
rc := pb{}.bytes(4, env)
var mcp pb
for _, d := range defs {
rc = rc.bytes(7, d)
mcp = mcp.bytes(1, d)
}
action := pb{}.bytes(2, pb{}.bytes(2, rc)) // resume_action
details, requested := pb{}.str(1, model).str(3, model).str(4, model), pb{}.str(1, model)
if maxMode {
details, requested = details.varint(7, 1), requested.varint(2, 1)
}
rr := pb{}.bytes(1, state).bytes(2, action).
bytes(3, details).
bytes(4, mcp).str(5, conv).
bytes(9, requested).
varint(19, 1) // inline images
return pb{}.bytes(1, rr), blobs
}
// cursorUsage is a TurnEndedUpdate as magpie counts usage. Its
// input_tokens is the whole prompt, what was read from the cache and
// written to it included, as Cursor's own client has it (it takes both out
// to get the prompt's uncached rest); Usage.Input is that rest, as
// Anthropic's count and every other provider here has it, prompt() adding
// the cache back (#498). Its reasoning_tokens is kept as Reasoning.
func cursorUsage(uf []pbField) Usage {
in, cr, cw := int(pbNum(uf, 1)), int(pbNum(uf, 3)), int(pbNum(uf, 4))
return Usage{Input: max(in-cr-cw, 0), Output: int(pbNum(uf, 2)), CacheRead: cr, CacheWrite: cw, Reasoning: int(pbNum(uf, 5))}
}
// A step that calls the caller's tools has no TurnEndedUpdate: Cursor ends
// the turn, and tells what it used, only once the client has run the tools
// and sent their results in the same Run, and the caller runs them after
// the Run is closed (#676). Before that it sends nothing more than the
// conversation's checkpoint and heartbeats, and a cancelled Run ends with
// an error and no usage either. The dashboard's usage events count every
// Run, by its conversation_id, the uncached input apart from what was read
// from the cache and written to it, as TurnEndedUpdate does; one shows
// about 2.5 s after the Run is closed. cursorStepWait is how long a step's
// is looked for: first after that long, then every so often until the
// end, after which it is guessed as before.
var cursorStepWait = struct{ first, every, until time.Duration }{1500 * time.Millisecond, 500 * time.Millisecond, 6 * time.Second}
// cursorClaimed are the usage events already counted, by conversation and
// time, so that two steps of one conversation never count the same one.
var cursorClaimed = struct {
sync.Mutex
at map[string]time.Time
}{at: map[string]time.Time{}}
// cursorStepUsage is the usage of the Run of conversation conv started at
// since, as the dashboard's usage events count it; false when none shows
// in time.
func (s *Server) cursorStepUsage(ctx context.Context, tok, conv string, since time.Time) (Usage, bool) {
end := time.Now().Add(cursorStepWait.until)
cursorClaimed.Lock()
for k, at := range cursorClaimed.at {
if time.Since(at) > 10*time.Minute {
delete(cursorClaimed.at, k)
}
}
cursorClaimed.Unlock()
wait := func(d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return true
case <-ctx.Done():
return false
}
}
if !wait(cursorStepWait.first) {
return Usage{}, false
}
for {
u, ok, stop := s.cursorUsageEvent(ctx, tok, conv, since)
if ok {
return u, true
}
if stop || time.Now().Add(cursorStepWait.every).After(end) || !wait(cursorStepWait.every) {
return Usage{}, false
}
}
}
// cursorUsageEvent is one look for the Run's usage event: the earliest of
// conv's not counted yet since the Run began; stop when the account can't
// read them.
func (s *Server) cursorUsageEvent(ctx context.Context, tok, conv string, since time.Time) (u Usage, ok, stop bool) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
q, _ := json.Marshal(map[string]any{
"teamId": 0, "page": 1, "pageSize": 50,
"startDate": strconv.FormatInt(since.Add(-time.Minute).UnixMilli(), 10),
"endDate": strconv.FormatInt(time.Now().Add(time.Minute).UnixMilli(), 10),
})
hr, err := http.NewRequestWithContext(ctx, http.MethodPost, cursorAPI+"/aiserver.v1.DashboardService/GetFilteredUsageEvents", bytes.NewReader(q))
if err != nil {
return Usage{}, false, true
}
hr.Header.Set("Content-Type", "application/json")
hr.Header.Set("Connect-Protocol-Version", "1")
hr.Header.Set("Authorization", "Bearer "+tok)
hr.Header.Set("x-cursor-client-version", cursorVersion())
hr.Header.Set("x-cursor-client-type", "cli")
hr.Header.Set("x-ghost-mode", "true")
res, err := s.client.Do(hr)
if err != nil {
return Usage{}, false, false
}
defer res.Body.Close()
if res.StatusCode/100 == 4 && res.StatusCode != http.StatusTooManyRequests {
return Usage{}, false, true // not this account's to read
}
var out struct {
Events []struct {
Timestamp string `json:"timestamp"`
ConversationID string `json:"conversationId"`
TokenUsage *struct {
Input float64 `json:"inputTokens"`
Output float64 `json:"outputTokens"`
CacheRead float64 `json:"cacheReadTokens"`
CacheWrite float64 `json:"cacheWriteTokens"`
} `json:"tokenUsage"`
} `json:"usageEventsDisplay"`
}
if res.StatusCode/100 != 2 || json.NewDecoder(io.LimitReader(res.Body, 8<<20)).Decode(&out) != nil {
return Usage{}, false, false
}
cursorClaimed.Lock()
defer cursorClaimed.Unlock()
best, bestAt := -1, int64(0)
for i, e := range out.Events {
at, err := strconv.ParseInt(e.Timestamp, 10, 64)
if err != nil || e.ConversationID != conv || e.TokenUsage == nil || at < since.UnixMilli()-1000 {
continue
}
if _, taken := cursorClaimed.at[conv+"\x00"+e.Timestamp]; taken {
continue
}
if best < 0 || at < bestAt {
best, bestAt = i, at
}
}
if best < 0 {
return Usage{}, false, false
}
e := out.Events[best]
cursorClaimed.at[conv+"\x00"+e.Timestamp] = time.Now()
t := e.TokenUsage
return Usage{Input: int(t.Input), Output: int(t.Output), CacheRead: int(t.CacheRead), CacheWrite: int(t.CacheWrite)}, true, false
}
// cursorToolDef is an McpToolDefinition.
func cursorToolDef(t bridgeTool) pb {
d := pb{}.str(1, t.Name)
if t.Description != "" {
d = d.str(2, t.Description)
}
var schema any
if json.Unmarshal(t.InputSchema, &schema) == nil {
d = d.bytes(3, pbValue(schema))
}
return d.str(4, "magpie").str(5, t.Name).str(6, string(t.InputSchema))
}
// pbValue is a google.protobuf.Value.
func pbValue(v any) pb {
switch x := v.(type) {
case nil:
return pb{}.varint(1, 0)
case float64:
return pb{}.double(2, x)
case string:
return pb{}.str(3, x)
case bool:
n := uint64(0)
if x {
n = 1
}
return pb{}.varint(4, n)
case map[string]any:
keys := make([]string, 0, len(x))
for k := range x {
keys = append(keys, k)
}
sort.Strings(keys)
var st pb
for _, k := range keys {
st = st.bytes(1, pb{}.str(1, k).bytes(2, pbValue(x[k])))
}
return pb{}.bytes(5, st)
case []any:
var l pb
for _, e := range x {
l = l.bytes(1, pbValue(e))
}
return pb{}.bytes(6, l)
}
return pb{}.varint(1, 0)
}
// pbAny reads a google.protobuf.Value.
func pbAny(b []byte) any {
for _, f := range pbFields(b) {
switch f.num {
case 1:
return nil
case 2:
return math.Float64frombits(f.n)
case 3:
return string(f.data)
case 4:
return f.n != 0
case 5:
m := map[string]any{}
for _, e := range pbFields(f.data) {
if e.num != 1 {
continue
}
var k string
var v any
for _, kv := range pbFields(e.data) {
switch kv.num {
case 1:
k = string(kv.data)
case 2:
v = pbAny(kv.data)
}
}
m[k] = v
}
return m
case 6:
l := []any{}
for _, e := range pbFields(f.data) {
if e.num == 1 {
l = append(l, pbAny(e.data))
}
}
return l
}
}
return nil
}
// ---- the stream ----------------------------------------------------------
// cursorStream is one Run: what the client sends, and the blobs the server
// may ask for.
type cursorStream struct {
mu sync.Mutex
pw *io.PipeWriter
blobs map[string][]byte
status int // the status an error at the head is answered with
// done closes the Run; step is the usage of a step that called tools,
// which Cursor never tells the Run (cursorStepUsage)
done func()
step func(context.Context) (Usage, bool)
}
func (st *cursorStream) send(msg []byte) {
st.mu.Lock()
defer st.mu.Unlock()
st.pw.Write(connectFrame(msg))
}
// heartbeat tells the server the client is still there, as the CLI does.
func (st *cursorStream) heartbeat(ctx context.Context) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
st.send(pb{}.bytes(7, nil))
}
}
}
// closeExec ends the server's request id, as every answer to one is.
func (st *cursorStream) closeExec(id uint64) {
st.send(pb{}.bytes(5, pb{}.bytes(1, pb{}.varint(1, id))))
}
// decode follows the server's messages into events until the turn ends, or
// the model has made its tool calls.
func (st *cursorStream) decode(ctx context.Context, br *bufio.Reader, out chan<- Event, tools []bridgeTool) {
defer close(out)
send := func(ev Event) bool {
select {
case out <- ev:
return true
case <-ctx.Done():
return false
}
}
fail := func(status int, msg string) {
st.status = status
send(Event{Kind: KError, Text: msg})
}
var calls, listed, said int
var usage Usage
ended := false // Cursor said what the turn used
var finish func()
// settle is finish once a step that called tools has its usage: the
// dashboard's count, the Run closed first, when the turn didn't end
settle := func() {
if calls > 0 && !ended && st.step != nil {
if st.done != nil {
st.done()
}
if u, ok := st.step(ctx); ok {
usage = u
}
}
finish()
}
finish = func() {
if usage.Output == 0 {
usage.Output = (said + 3) / 4
}
send(Event{Kind: KUsage, Usage: usage})
stop := "stop"
if calls > 0 {
stop = "tool"
}
send(Event{Kind: KStop, Stop: stop})
}
for {
f, err := readConnectFrame(br)
if err != nil {
if ctx.Err() == nil {
if errors.Is(err, io.EOF) && calls > 0 {
settle()
return
}
fail(502, "the reply broke off: "+err.Error())
}
return
}
if f.end {
if status, msg := cursorFailure(200, f.data); status/100 != 2 {
fail(status, msg)
} else if said == 0 && calls == 0 {
fail(502, "an empty reply")
} else {
settle()
}
return
}
for _, m := range pbFields(f.data) {
switch m.num {
case 1: // interaction_update
for _, u := range pbFields(m.data) {
uf := pbFields(u.data)
switch u.num {
case 1: // text
if t := pbStr(uf, 1); t != "" {
said += len(t)
if !send(Event{Kind: KText, Text: t}) {
return
}
}
case 4: // thinking
if t := pbStr(uf, 1); t != "" {
said += len(t)
if !send(Event{Kind: KThink, Text: t}) {
return
}
}
case 14: // turn ended, with what it used
usage, ended = cursorUsage(uf), true
if calls == 0 {
finish()
return
}
case 27: // how many tool calls the step makes, sent before them
if listed = int(pbNum(uf, 1)); listed > 0 && calls >= listed {
settle()
return
}
}
}
case 2: // exec_server_message: the server asks the client to run something
if !st.exec(m.data, tools, func(id, name string, args []byte) bool {
calls++
said += len(args)
return send(Event{Kind: KToolStart, ID: id, Name: name}) && send(Event{Kind: KToolArgs, Text: string(args)})
}) {
return
}
if listed > 0 && calls >= listed { // every call made: the caller runs them
settle()
return
}
case 4: // kv_server_message: a blob wanted, or one to keep
kv := pbFields(m.data)
id := pbNum(kv, 1)
for _, k := range kv {
switch k.num {
case 2:
res := pb{}
if b, ok := st.blobs[pbStr(pbFields(k.data), 1)]; ok {
res = res.bytes(1, b)
} else {
res = res.bytes(2, pb{}.str(1, "blob not found"))
}
st.send(pb{}.bytes(3, pb{}.varint(1, id).bytes(2, res)))
case 3: // the server's own record of the turn, not needed
st.send(pb{}.bytes(3, pb{}.varint(1, id).bytes(3, nil)))
}
}
}
}
}
}
// exec answers what the server asks the client to run: a call of the
// caller's tools goes to call, the list of them is given, and anything
// else is refused. It is false once the caller has gone.
func (st *cursorStream) exec(msg []byte, tools []bridgeTool, call func(id, name string, args []byte) bool) bool {
es := pbFields(msg)
id := pbNum(es, 1)
execID := pbStr(es, 15)
answer := func(num int, result pb) {
st.send(pb{}.bytes(2, pb{}.varint(1, id).str(15, execID).bytes(num, result)))
st.closeExec(id)
}
for _, e := range es {
switch e.num {
case 11: // mcp_args: a call of the caller's tools
a := pbFields(e.data)
args := map[string]any{}
for _, kv := range a {
if kv.num != 2 {
continue
}
ef := pbFields(kv.data)
var v any
for _, x := range ef {
if x.num == 2 {
v = pbAny(x.data)
}
}
args[pbStr(ef, 1)] = v
}
js, _ := json.Marshal(args)
name := pbStr(a, 5)
if name == "" {
name = strings.TrimPrefix(pbStr(a, 1), "magpie-")
}
// an OpenAI model's id is its call's and its item's, a line apart,
// which no caller would take as an id: the line goes as __
callID := strings.ReplaceAll(pbStr(a, 3), "\n", "__")
if callID == "" {
callID = "call_" + randomToken()[:24]
}
return call(callID, name, js)
case 36: // mcp_state_exec_args: the tools there are
srv := pb{}.str(1, "magpie").str(2, "magpie").str(7, "connected")
for _, t := range tools {
srv = srv.bytes(5, cursorToolDef(t))
}
answer(36, pb{}.bytes(1, pb{}.bytes(1, srv)))
return true
case 10: // request_context_args
env := pb{}.str(1, runtime.GOOS).str(2, os.TempDir()).str(10, "UTC")
answer(10, pb{}.bytes(1, pb{}.bytes(1, pb{}.bytes(4, env))))
return true
}
}
// a shell, a file read or an edit of Cursor's own: never run
st.send(pb{}.bytes(5, pb{}.bytes(2, pb{}.varint(1, id).str(2, "not available"))))
st.closeExec(id)
return true
}
// pbStr and pbNum are a field of a message read, "" or 0 when it has none.
func pbStr(fs []pbField, num int) string {
for _, f := range fs {
if f.num == num {
return string(f.data)
}
}
return ""
}
func pbNum(fs []pbField, num int) uint64 {
for _, f := range fs {
if f.num == num {
return f.n
}
}
return 0
}