@playwo/opencode-cursor-oauth 0.0.0-dev.c80ebcb27754 → 0.0.0-dev.f7099c3761b9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/auth.js +26 -1
- package/dist/index.js +119 -51
- package/dist/logger.d.ts +6 -0
- package/dist/logger.js +142 -0
- package/dist/models.d.ts +3 -0
- package/dist/models.js +79 -31
- package/dist/proxy.d.ts +1 -0
- package/dist/proxy.js +167 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ OpenAI-compatible proxy on demand and routes requests through Cursor's gRPC API.
|
|
|
44
44
|
## How it works
|
|
45
45
|
|
|
46
46
|
1. OAuth — browser-based login to Cursor via PKCE.
|
|
47
|
-
2. Model discovery — queries Cursor's gRPC API for all available models.
|
|
47
|
+
2. Model discovery — queries Cursor's gRPC API for all available models; if discovery fails, the plugin disables the Cursor provider for that load and shows a visible error toast instead of crashing OpenCode.
|
|
48
48
|
3. Local proxy — translates `POST /v1/chat/completions` into Cursor's
|
|
49
49
|
protobuf/Connect protocol.
|
|
50
50
|
4. Native tool routing — rejects Cursor's built-in filesystem/shell tools and
|
package/dist/auth.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { generatePKCE } from "./pkce";
|
|
2
|
+
import { errorDetails, logPluginError, logPluginWarn } from "./logger";
|
|
2
3
|
const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl";
|
|
3
4
|
const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll";
|
|
4
5
|
const CURSOR_REFRESH_URL = process.env.CURSOR_REFRESH_URL ??
|
|
@@ -40,13 +41,32 @@ export async function pollCursorAuth(uuid, verifier) {
|
|
|
40
41
|
}
|
|
41
42
|
throw new Error(`Poll failed: ${response.status}`);
|
|
42
43
|
}
|
|
43
|
-
catch {
|
|
44
|
+
catch (error) {
|
|
44
45
|
consecutiveErrors++;
|
|
45
46
|
if (consecutiveErrors >= 3) {
|
|
47
|
+
logPluginError("Cursor auth polling failed repeatedly", {
|
|
48
|
+
stage: "oauth_poll",
|
|
49
|
+
uuid,
|
|
50
|
+
attempts: attempt + 1,
|
|
51
|
+
consecutiveErrors,
|
|
52
|
+
...errorDetails(error),
|
|
53
|
+
});
|
|
46
54
|
throw new Error("Too many consecutive errors during Cursor auth polling");
|
|
47
55
|
}
|
|
56
|
+
logPluginWarn("Cursor auth polling attempt failed", {
|
|
57
|
+
stage: "oauth_poll",
|
|
58
|
+
uuid,
|
|
59
|
+
attempt: attempt + 1,
|
|
60
|
+
consecutiveErrors,
|
|
61
|
+
...errorDetails(error),
|
|
62
|
+
});
|
|
48
63
|
}
|
|
49
64
|
}
|
|
65
|
+
logPluginError("Cursor authentication polling timed out", {
|
|
66
|
+
stage: "oauth_poll",
|
|
67
|
+
uuid,
|
|
68
|
+
attempts: POLL_MAX_ATTEMPTS,
|
|
69
|
+
});
|
|
50
70
|
throw new Error("Cursor authentication polling timeout");
|
|
51
71
|
}
|
|
52
72
|
export async function refreshCursorToken(refreshToken) {
|
|
@@ -60,6 +80,11 @@ export async function refreshCursorToken(refreshToken) {
|
|
|
60
80
|
});
|
|
61
81
|
if (!response.ok) {
|
|
62
82
|
const error = await response.text();
|
|
83
|
+
logPluginError("Cursor token refresh failed", {
|
|
84
|
+
stage: "token_refresh",
|
|
85
|
+
status: response.status,
|
|
86
|
+
responseBody: error,
|
|
87
|
+
});
|
|
63
88
|
throw new Error(`Cursor token refresh failed: ${error}`);
|
|
64
89
|
}
|
|
65
90
|
const data = (await response.json());
|
package/dist/index.js
CHANGED
|
@@ -1,42 +1,27 @@
|
|
|
1
1
|
import { generateCursorAuthParams, getTokenExpiry, pollCursorAuth, refreshCursorToken, } from "./auth";
|
|
2
|
+
import { configurePluginLogger, errorDetails, logPluginError, logPluginWarn } from "./logger";
|
|
2
3
|
import { getCursorModels } from "./models";
|
|
3
|
-
import { startProxy } from "./proxy";
|
|
4
|
+
import { startProxy, stopProxy } from "./proxy";
|
|
4
5
|
const CURSOR_PROVIDER_ID = "cursor";
|
|
6
|
+
let lastModelDiscoveryError = null;
|
|
5
7
|
/**
|
|
6
8
|
* OpenCode plugin that provides Cursor authentication and model access.
|
|
7
9
|
* Register in opencode.json: { "plugin": ["opencode-cursor-oauth"] }
|
|
8
10
|
*/
|
|
9
11
|
export const CursorAuthPlugin = async (input) => {
|
|
12
|
+
configurePluginLogger(input);
|
|
10
13
|
return {
|
|
11
14
|
auth: {
|
|
12
15
|
provider: CURSOR_PROVIDER_ID,
|
|
13
16
|
async loader(getAuth, provider) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
path: { id: CURSOR_PROVIDER_ID },
|
|
23
|
-
body: {
|
|
24
|
-
type: "oauth",
|
|
25
|
-
refresh: refreshed.refresh,
|
|
26
|
-
access: refreshed.access,
|
|
27
|
-
expires: refreshed.expires,
|
|
28
|
-
},
|
|
29
|
-
});
|
|
30
|
-
accessToken = refreshed.access;
|
|
31
|
-
}
|
|
32
|
-
const models = await getCursorModels(accessToken);
|
|
33
|
-
const port = await startProxy(async () => {
|
|
34
|
-
const currentAuth = await getAuth();
|
|
35
|
-
if (currentAuth.type !== "oauth") {
|
|
36
|
-
throw new Error("Cursor auth not configured");
|
|
37
|
-
}
|
|
38
|
-
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
|
39
|
-
const refreshed = await refreshCursorToken(currentAuth.refresh);
|
|
17
|
+
try {
|
|
18
|
+
const auth = await getAuth();
|
|
19
|
+
if (!auth || auth.type !== "oauth")
|
|
20
|
+
return {};
|
|
21
|
+
// Ensure we have a valid access token, refreshing if expired
|
|
22
|
+
let accessToken = auth.access;
|
|
23
|
+
if (!accessToken || auth.expires < Date.now()) {
|
|
24
|
+
const refreshed = await refreshCursorToken(auth.refresh);
|
|
40
25
|
await input.client.auth.set({
|
|
41
26
|
path: { id: CURSOR_PROVIDER_ID },
|
|
42
27
|
body: {
|
|
@@ -46,32 +31,78 @@ export const CursorAuthPlugin = async (input) => {
|
|
|
46
31
|
expires: refreshed.expires,
|
|
47
32
|
},
|
|
48
33
|
});
|
|
49
|
-
|
|
34
|
+
accessToken = refreshed.access;
|
|
50
35
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
else if (Array.isArray(init.headers)) {
|
|
65
|
-
init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization");
|
|
66
|
-
}
|
|
67
|
-
else {
|
|
68
|
-
delete init.headers["authorization"];
|
|
69
|
-
delete init.headers["Authorization"];
|
|
70
|
-
}
|
|
36
|
+
let models;
|
|
37
|
+
models = await getCursorModels(accessToken);
|
|
38
|
+
lastModelDiscoveryError = null;
|
|
39
|
+
const port = await startProxy(async () => {
|
|
40
|
+
const currentAuth = await getAuth();
|
|
41
|
+
if (currentAuth.type !== "oauth") {
|
|
42
|
+
const authError = new Error("Cursor auth not configured");
|
|
43
|
+
logPluginError("Cursor proxy access token lookup failed", {
|
|
44
|
+
stage: "proxy_access_token",
|
|
45
|
+
...errorDetails(authError),
|
|
46
|
+
});
|
|
47
|
+
throw authError;
|
|
71
48
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
49
|
+
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
|
50
|
+
const refreshed = await refreshCursorToken(currentAuth.refresh);
|
|
51
|
+
await input.client.auth.set({
|
|
52
|
+
path: { id: CURSOR_PROVIDER_ID },
|
|
53
|
+
body: {
|
|
54
|
+
type: "oauth",
|
|
55
|
+
refresh: refreshed.refresh,
|
|
56
|
+
access: refreshed.access,
|
|
57
|
+
expires: refreshed.expires,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
return refreshed.access;
|
|
61
|
+
}
|
|
62
|
+
return currentAuth.access;
|
|
63
|
+
}, models);
|
|
64
|
+
if (provider) {
|
|
65
|
+
provider.models = buildCursorProviderModels(models, port);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
baseURL: `http://localhost:${port}/v1`,
|
|
69
|
+
apiKey: "cursor-proxy",
|
|
70
|
+
async fetch(requestInput, init) {
|
|
71
|
+
if (init?.headers) {
|
|
72
|
+
if (init.headers instanceof Headers) {
|
|
73
|
+
init.headers.delete("authorization");
|
|
74
|
+
}
|
|
75
|
+
else if (Array.isArray(init.headers)) {
|
|
76
|
+
init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization");
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
delete init.headers["authorization"];
|
|
80
|
+
delete init.headers["Authorization"];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return fetch(requestInput, init);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
const message = error instanceof Error
|
|
89
|
+
? error.message
|
|
90
|
+
: "Cursor model discovery failed.";
|
|
91
|
+
logPluginError("Cursor auth loader failed", {
|
|
92
|
+
stage: "loader",
|
|
93
|
+
providerID: CURSOR_PROVIDER_ID,
|
|
94
|
+
...errorDetails(error),
|
|
95
|
+
});
|
|
96
|
+
stopProxy();
|
|
97
|
+
if (provider) {
|
|
98
|
+
provider.models = {};
|
|
99
|
+
}
|
|
100
|
+
if (message !== lastModelDiscoveryError) {
|
|
101
|
+
lastModelDiscoveryError = message;
|
|
102
|
+
await showDiscoveryFailureToast(input, message);
|
|
103
|
+
}
|
|
104
|
+
return buildDisabledProviderConfig(message);
|
|
105
|
+
}
|
|
75
106
|
},
|
|
76
107
|
methods: [
|
|
77
108
|
{
|
|
@@ -145,6 +176,43 @@ function buildCursorProviderModels(models, port) {
|
|
|
145
176
|
},
|
|
146
177
|
]));
|
|
147
178
|
}
|
|
179
|
+
async function showDiscoveryFailureToast(input, message) {
|
|
180
|
+
try {
|
|
181
|
+
await input.client.tui.showToast({
|
|
182
|
+
body: {
|
|
183
|
+
title: "Cursor plugin disabled",
|
|
184
|
+
message,
|
|
185
|
+
variant: "error",
|
|
186
|
+
duration: 8_000,
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
logPluginWarn("Failed to display Cursor plugin toast", {
|
|
192
|
+
title: "Cursor plugin disabled",
|
|
193
|
+
message,
|
|
194
|
+
...errorDetails(error),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function buildDisabledProviderConfig(message) {
|
|
199
|
+
return {
|
|
200
|
+
baseURL: "http://127.0.0.1/cursor-disabled/v1",
|
|
201
|
+
apiKey: "cursor-disabled",
|
|
202
|
+
async fetch() {
|
|
203
|
+
return new Response(JSON.stringify({
|
|
204
|
+
error: {
|
|
205
|
+
message,
|
|
206
|
+
type: "server_error",
|
|
207
|
+
code: "cursor_model_discovery_failed",
|
|
208
|
+
},
|
|
209
|
+
}), {
|
|
210
|
+
status: 503,
|
|
211
|
+
headers: { "Content-Type": "application/json" },
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
148
216
|
// $/M token rates from cursor.com/docs/models-and-pricing
|
|
149
217
|
const MODEL_COST_TABLE = {
|
|
150
218
|
// Anthropic
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
export declare function configurePluginLogger(input: PluginInput): void;
|
|
3
|
+
export declare function errorDetails(error: unknown): Record<string, unknown>;
|
|
4
|
+
export declare function logPluginWarn(message: string, extra?: Record<string, unknown>): void;
|
|
5
|
+
export declare function logPluginError(message: string, extra?: Record<string, unknown>): void;
|
|
6
|
+
export declare function flushPluginLogs(): Promise<void>;
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const PLUGIN_LOG_SERVICE = "opencode-cursor-oauth";
|
|
2
|
+
const MAX_STRING_LENGTH = 1_500;
|
|
3
|
+
const MAX_ARRAY_LENGTH = 20;
|
|
4
|
+
const MAX_OBJECT_KEYS = 25;
|
|
5
|
+
let currentLogger;
|
|
6
|
+
let pendingLogWrites = Promise.resolve();
|
|
7
|
+
export function configurePluginLogger(input) {
|
|
8
|
+
currentLogger = {
|
|
9
|
+
client: input.client,
|
|
10
|
+
directory: input.directory,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function errorDetails(error) {
|
|
14
|
+
if (error instanceof Error) {
|
|
15
|
+
return {
|
|
16
|
+
errorName: error.name,
|
|
17
|
+
errorMessage: error.message,
|
|
18
|
+
errorStack: error.stack,
|
|
19
|
+
errorCause: serializeValue(error.cause, 1),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
errorType: typeof error,
|
|
24
|
+
errorValue: serializeValue(error, 1),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function logPluginWarn(message, extra = {}) {
|
|
28
|
+
logPlugin("warn", message, extra);
|
|
29
|
+
}
|
|
30
|
+
export function logPluginError(message, extra = {}) {
|
|
31
|
+
logPlugin("error", message, extra);
|
|
32
|
+
}
|
|
33
|
+
export function flushPluginLogs() {
|
|
34
|
+
return pendingLogWrites;
|
|
35
|
+
}
|
|
36
|
+
function logPlugin(level, message, extra) {
|
|
37
|
+
const serializedExtra = serializeValue(extra, 0);
|
|
38
|
+
writeConsoleLog(level, message, serializedExtra);
|
|
39
|
+
if (!currentLogger?.client?.app?.log) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
pendingLogWrites = pendingLogWrites
|
|
43
|
+
.catch(() => { })
|
|
44
|
+
.then(async () => {
|
|
45
|
+
try {
|
|
46
|
+
await currentLogger?.client.app.log({
|
|
47
|
+
query: { directory: currentLogger.directory },
|
|
48
|
+
body: {
|
|
49
|
+
service: PLUGIN_LOG_SERVICE,
|
|
50
|
+
level,
|
|
51
|
+
message,
|
|
52
|
+
extra: serializedExtra,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (logError) {
|
|
57
|
+
writeConsoleLog("warn", "Failed to forward plugin log to OpenCode", {
|
|
58
|
+
originalLevel: level,
|
|
59
|
+
originalMessage: message,
|
|
60
|
+
...errorDetails(logError),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function writeConsoleLog(level, message, extra) {
|
|
66
|
+
const prefix = `[${PLUGIN_LOG_SERVICE}] ${message}`;
|
|
67
|
+
const suffix = Object.keys(extra).length > 0 ? ` ${JSON.stringify(extra)}` : "";
|
|
68
|
+
if (level === "error") {
|
|
69
|
+
console.error(`${prefix}${suffix}`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
console.warn(`${prefix}${suffix}`);
|
|
73
|
+
}
|
|
74
|
+
function serializeValue(value, depth, seen = new WeakSet()) {
|
|
75
|
+
if (value === null || value === undefined)
|
|
76
|
+
return value;
|
|
77
|
+
if (typeof value === "string")
|
|
78
|
+
return truncateString(value);
|
|
79
|
+
const valueType = typeof value;
|
|
80
|
+
if (valueType === "number" || valueType === "boolean")
|
|
81
|
+
return value;
|
|
82
|
+
if (valueType === "bigint")
|
|
83
|
+
return value.toString();
|
|
84
|
+
if (valueType === "symbol")
|
|
85
|
+
return String(value);
|
|
86
|
+
if (valueType === "function")
|
|
87
|
+
return `[function ${value.name || "anonymous"}]`;
|
|
88
|
+
if (value instanceof URL)
|
|
89
|
+
return value.toString();
|
|
90
|
+
if (value instanceof Headers)
|
|
91
|
+
return Object.fromEntries(value.entries());
|
|
92
|
+
if (value instanceof Error) {
|
|
93
|
+
return {
|
|
94
|
+
name: value.name,
|
|
95
|
+
message: value.message,
|
|
96
|
+
stack: truncateString(value.stack),
|
|
97
|
+
cause: serializeValue(value.cause, depth + 1, seen),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (value instanceof Uint8Array) {
|
|
101
|
+
return serializeBinary(value);
|
|
102
|
+
}
|
|
103
|
+
if (Array.isArray(value)) {
|
|
104
|
+
if (depth >= 3)
|
|
105
|
+
return `[array(${value.length})]`;
|
|
106
|
+
return value.slice(0, MAX_ARRAY_LENGTH).map((entry) => serializeValue(entry, depth + 1, seen));
|
|
107
|
+
}
|
|
108
|
+
if (typeof value === "object") {
|
|
109
|
+
if (seen.has(value))
|
|
110
|
+
return "[circular]";
|
|
111
|
+
seen.add(value);
|
|
112
|
+
if (depth >= 3) {
|
|
113
|
+
return `[object ${value.constructor?.name || "Object"}]`;
|
|
114
|
+
}
|
|
115
|
+
const entries = Object.entries(value).slice(0, MAX_OBJECT_KEYS);
|
|
116
|
+
return Object.fromEntries(entries.map(([key, entry]) => [key, serializeValue(entry, depth + 1, seen)]));
|
|
117
|
+
}
|
|
118
|
+
return String(value);
|
|
119
|
+
}
|
|
120
|
+
function serializeBinary(value) {
|
|
121
|
+
const text = new TextDecoder().decode(value);
|
|
122
|
+
const printable = /^[\x09\x0a\x0d\x20-\x7e]*$/.test(text);
|
|
123
|
+
if (printable) {
|
|
124
|
+
return {
|
|
125
|
+
type: "uint8array",
|
|
126
|
+
length: value.length,
|
|
127
|
+
text: truncateString(text),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
type: "uint8array",
|
|
132
|
+
length: value.length,
|
|
133
|
+
base64: truncateString(Buffer.from(value).toString("base64")),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function truncateString(value) {
|
|
137
|
+
if (value === undefined)
|
|
138
|
+
return undefined;
|
|
139
|
+
if (value.length <= MAX_STRING_LENGTH)
|
|
140
|
+
return value;
|
|
141
|
+
return `${value.slice(0, MAX_STRING_LENGTH - 3)}...`;
|
|
142
|
+
}
|
package/dist/models.d.ts
CHANGED
|
@@ -5,6 +5,9 @@ export interface CursorModel {
|
|
|
5
5
|
contextWindow: number;
|
|
6
6
|
maxTokens: number;
|
|
7
7
|
}
|
|
8
|
+
export declare class CursorModelDiscoveryError extends Error {
|
|
9
|
+
constructor(message: string);
|
|
10
|
+
}
|
|
8
11
|
export declare function getCursorModels(apiKey: string): Promise<CursorModel[]>;
|
|
9
12
|
/** @internal Test-only. */
|
|
10
13
|
export declare function clearModelCache(): void;
|
package/dist/models.js
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cursor model discovery via GetUsableModels.
|
|
3
|
-
* Uses the H2 bridge for transport. Falls back to a hardcoded list
|
|
4
|
-
* when discovery fails.
|
|
5
|
-
*/
|
|
6
1
|
import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
|
|
7
2
|
import { z } from "zod";
|
|
3
|
+
import { errorDetails, logPluginError, logPluginWarn } from "./logger";
|
|
8
4
|
import { callCursorUnaryRpc } from "./proxy";
|
|
9
5
|
import { GetUsableModelsRequestSchema, GetUsableModelsResponseSchema, } from "./proto/agent_pb";
|
|
10
6
|
const GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels";
|
|
7
|
+
const MODEL_DISCOVERY_TIMEOUT_MS = 5_000;
|
|
11
8
|
const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
12
9
|
const DEFAULT_MAX_TOKENS = 64_000;
|
|
13
10
|
const CursorModelDetailsSchema = z.object({
|
|
@@ -22,24 +19,12 @@ const CursorModelDetailsSchema = z.object({
|
|
|
22
19
|
.transform((aliases) => (aliases ?? []).filter((alias) => typeof alias === "string")),
|
|
23
20
|
thinkingDetails: z.unknown().optional(),
|
|
24
21
|
});
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
{ id: "claude-4.6-sonnet-medium", name: "Claude 4.6 Sonnet", reasoning: true, contextWindow: 200_000, maxTokens: 64_000 },
|
|
32
|
-
{ id: "claude-4.5-sonnet", name: "Claude 4.5 Sonnet", reasoning: true, contextWindow: 200_000, maxTokens: 64_000 },
|
|
33
|
-
// GPT models
|
|
34
|
-
{ id: "gpt-5.4-medium", name: "GPT-5.4", reasoning: true, contextWindow: 272_000, maxTokens: 128_000 },
|
|
35
|
-
{ id: "gpt-5.2", name: "GPT-5.2", reasoning: true, contextWindow: 400_000, maxTokens: 128_000 },
|
|
36
|
-
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex", reasoning: true, contextWindow: 400_000, maxTokens: 128_000 },
|
|
37
|
-
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex", reasoning: true, contextWindow: 400_000, maxTokens: 128_000 },
|
|
38
|
-
{ id: "gpt-5.3-codex-spark-preview", name: "GPT-5.3 Codex Spark", reasoning: true, contextWindow: 128_000, maxTokens: 128_000 },
|
|
39
|
-
// Other models
|
|
40
|
-
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", reasoning: true, contextWindow: 1_000_000, maxTokens: 64_000 },
|
|
41
|
-
{ id: "grok-code-fast-1", name: "Grok Code Fast 1", reasoning: false, contextWindow: 128_000, maxTokens: 64_000 },
|
|
42
|
-
];
|
|
22
|
+
export class CursorModelDiscoveryError extends Error {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "CursorModelDiscoveryError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
43
28
|
async function fetchCursorUsableModels(apiKey) {
|
|
44
29
|
try {
|
|
45
30
|
const requestPayload = create(GetUsableModelsRequestSchema, {});
|
|
@@ -48,18 +33,51 @@ async function fetchCursorUsableModels(apiKey) {
|
|
|
48
33
|
accessToken: apiKey,
|
|
49
34
|
rpcPath: GET_USABLE_MODELS_PATH,
|
|
50
35
|
requestBody,
|
|
36
|
+
timeoutMs: MODEL_DISCOVERY_TIMEOUT_MS,
|
|
51
37
|
});
|
|
52
|
-
if (response.timedOut
|
|
53
|
-
|
|
38
|
+
if (response.timedOut) {
|
|
39
|
+
logPluginError("Cursor model discovery timed out", {
|
|
40
|
+
rpcPath: GET_USABLE_MODELS_PATH,
|
|
41
|
+
timeoutMs: MODEL_DISCOVERY_TIMEOUT_MS,
|
|
42
|
+
});
|
|
43
|
+
throw new CursorModelDiscoveryError(`Cursor model discovery timed out after ${MODEL_DISCOVERY_TIMEOUT_MS}ms.`);
|
|
44
|
+
}
|
|
45
|
+
if (response.exitCode !== 0) {
|
|
46
|
+
logPluginError("Cursor model discovery HTTP failure", {
|
|
47
|
+
rpcPath: GET_USABLE_MODELS_PATH,
|
|
48
|
+
exitCode: response.exitCode,
|
|
49
|
+
responseBody: response.body,
|
|
50
|
+
});
|
|
51
|
+
throw new CursorModelDiscoveryError(buildDiscoveryHttpError(response.exitCode, response.body));
|
|
52
|
+
}
|
|
53
|
+
if (response.body.length === 0) {
|
|
54
|
+
logPluginWarn("Cursor model discovery returned an empty response", {
|
|
55
|
+
rpcPath: GET_USABLE_MODELS_PATH,
|
|
56
|
+
});
|
|
57
|
+
throw new CursorModelDiscoveryError("Cursor model discovery returned an empty response.");
|
|
54
58
|
}
|
|
55
59
|
const decoded = decodeGetUsableModelsResponse(response.body);
|
|
56
|
-
if (!decoded)
|
|
57
|
-
|
|
60
|
+
if (!decoded) {
|
|
61
|
+
logPluginError("Cursor model discovery returned an unreadable response", {
|
|
62
|
+
rpcPath: GET_USABLE_MODELS_PATH,
|
|
63
|
+
responseBody: response.body,
|
|
64
|
+
});
|
|
65
|
+
throw new CursorModelDiscoveryError("Cursor model discovery returned an unreadable response.");
|
|
66
|
+
}
|
|
58
67
|
const models = normalizeCursorModels(decoded.models);
|
|
59
|
-
|
|
68
|
+
if (models.length === 0) {
|
|
69
|
+
throw new CursorModelDiscoveryError("Cursor model discovery returned no usable models.");
|
|
70
|
+
}
|
|
71
|
+
return models;
|
|
60
72
|
}
|
|
61
|
-
catch {
|
|
62
|
-
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error instanceof CursorModelDiscoveryError)
|
|
75
|
+
throw error;
|
|
76
|
+
logPluginError("Cursor model discovery crashed", {
|
|
77
|
+
rpcPath: GET_USABLE_MODELS_PATH,
|
|
78
|
+
...errorDetails(error),
|
|
79
|
+
});
|
|
80
|
+
throw new CursorModelDiscoveryError("Cursor model discovery failed.");
|
|
63
81
|
}
|
|
64
82
|
}
|
|
65
83
|
let cachedModels = null;
|
|
@@ -67,13 +85,43 @@ export async function getCursorModels(apiKey) {
|
|
|
67
85
|
if (cachedModels)
|
|
68
86
|
return cachedModels;
|
|
69
87
|
const discovered = await fetchCursorUsableModels(apiKey);
|
|
70
|
-
cachedModels = discovered
|
|
88
|
+
cachedModels = discovered;
|
|
71
89
|
return cachedModels;
|
|
72
90
|
}
|
|
73
91
|
/** @internal Test-only. */
|
|
74
92
|
export function clearModelCache() {
|
|
75
93
|
cachedModels = null;
|
|
76
94
|
}
|
|
95
|
+
function buildDiscoveryHttpError(exitCode, body) {
|
|
96
|
+
const detail = extractDiscoveryErrorDetail(body);
|
|
97
|
+
const protocolHint = exitCode === 464
|
|
98
|
+
? " Likely protocol mismatch: Cursor appears to expect an HTTP/2 Connect unary request."
|
|
99
|
+
: "";
|
|
100
|
+
if (!detail) {
|
|
101
|
+
return `Cursor model discovery failed with HTTP ${exitCode}.${protocolHint}`;
|
|
102
|
+
}
|
|
103
|
+
return `Cursor model discovery failed with HTTP ${exitCode}: ${detail}.${protocolHint}`;
|
|
104
|
+
}
|
|
105
|
+
function extractDiscoveryErrorDetail(body) {
|
|
106
|
+
if (body.length === 0)
|
|
107
|
+
return null;
|
|
108
|
+
const text = new TextDecoder().decode(body).trim();
|
|
109
|
+
if (!text)
|
|
110
|
+
return null;
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(text);
|
|
113
|
+
const code = typeof parsed.code === "string" ? parsed.code : undefined;
|
|
114
|
+
const message = typeof parsed.message === "string" ? parsed.message : undefined;
|
|
115
|
+
if (message && code)
|
|
116
|
+
return `${message} (${code})`;
|
|
117
|
+
if (message)
|
|
118
|
+
return message;
|
|
119
|
+
if (code)
|
|
120
|
+
return code;
|
|
121
|
+
}
|
|
122
|
+
catch { }
|
|
123
|
+
return text.length > 200 ? `${text.slice(0, 197)}...` : text;
|
|
124
|
+
}
|
|
77
125
|
function decodeGetUsableModelsResponse(payload) {
|
|
78
126
|
try {
|
|
79
127
|
return fromBinary(GetUsableModelsResponseSchema, payload);
|
package/dist/proxy.d.ts
CHANGED
package/dist/proxy.js
CHANGED
|
@@ -16,8 +16,11 @@ import { create, fromBinary, fromJson, toBinary, toJson } from "@bufbuild/protob
|
|
|
16
16
|
import { ValueSchema } from "@bufbuild/protobuf/wkt";
|
|
17
17
|
import { AgentClientMessageSchema, AgentRunRequestSchema, AgentServerMessageSchema, BidiRequestIdSchema, ClientHeartbeatSchema, ConversationActionSchema, ConversationStateStructureSchema, ConversationStepSchema, AgentConversationTurnStructureSchema, ConversationTurnStructureSchema, AssistantMessageSchema, BackgroundShellSpawnResultSchema, DeleteResultSchema, DeleteRejectedSchema, DiagnosticsResultSchema, ExecClientMessageSchema, FetchErrorSchema, FetchResultSchema, GetBlobResultSchema, GrepErrorSchema, GrepResultSchema, KvClientMessageSchema, LsRejectedSchema, LsResultSchema, McpErrorSchema, McpResultSchema, McpSuccessSchema, McpTextContentSchema, McpToolDefinitionSchema, McpToolResultContentItemSchema, ModelDetailsSchema, ReadRejectedSchema, ReadResultSchema, RequestContextResultSchema, RequestContextSchema, RequestContextSuccessSchema, SetBlobResultSchema, ShellRejectedSchema, ShellResultSchema, UserMessageActionSchema, UserMessageSchema, WriteRejectedSchema, WriteResultSchema, WriteShellStdinErrorSchema, WriteShellStdinResultSchema, } from "./proto/agent_pb";
|
|
18
18
|
import { createHash } from "node:crypto";
|
|
19
|
+
import { connect as connectHttp2 } from "node:http2";
|
|
20
|
+
import { errorDetails, logPluginError, logPluginWarn } from "./logger";
|
|
19
21
|
const CURSOR_API_URL = process.env.CURSOR_API_URL ?? "https://api2.cursor.sh";
|
|
20
22
|
const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
|
|
23
|
+
const CURSOR_CONNECT_PROTOCOL_VERSION = "1";
|
|
21
24
|
const CONNECT_END_STREAM_FLAG = 0b00000010;
|
|
22
25
|
const SSE_HEADERS = {
|
|
23
26
|
"Content-Type": "text/event-stream",
|
|
@@ -47,18 +50,19 @@ function frameConnectMessage(data, flags = 0) {
|
|
|
47
50
|
return frame;
|
|
48
51
|
}
|
|
49
52
|
function buildCursorHeaders(options, contentType, extra = {}) {
|
|
50
|
-
const headers = new Headers(
|
|
53
|
+
const headers = new Headers(buildCursorHeaderValues(options, contentType, extra));
|
|
54
|
+
return headers;
|
|
55
|
+
}
|
|
56
|
+
function buildCursorHeaderValues(options, contentType, extra = {}) {
|
|
57
|
+
return {
|
|
51
58
|
authorization: `Bearer ${options.accessToken}`,
|
|
52
59
|
"content-type": contentType,
|
|
53
60
|
"x-ghost-mode": "true",
|
|
54
61
|
"x-cursor-client-version": CURSOR_CLIENT_VERSION,
|
|
55
62
|
"x-cursor-client-type": "cli",
|
|
56
63
|
"x-request-id": crypto.randomUUID(),
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
headers.set(key, value);
|
|
60
|
-
}
|
|
61
|
-
return headers;
|
|
64
|
+
...extra,
|
|
65
|
+
};
|
|
62
66
|
}
|
|
63
67
|
function encodeVarint(value) {
|
|
64
68
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
@@ -130,6 +134,11 @@ async function createCursorSession(options) {
|
|
|
130
134
|
});
|
|
131
135
|
if (!response.ok || !response.body) {
|
|
132
136
|
const errorBody = await response.text().catch(() => "");
|
|
137
|
+
logPluginError("Cursor RunSSE request failed", {
|
|
138
|
+
requestId: options.requestId,
|
|
139
|
+
status: response.status,
|
|
140
|
+
responseBody: errorBody,
|
|
141
|
+
});
|
|
133
142
|
throw new Error(`RunSSE failed: ${response.status}${errorBody ? ` ${errorBody}` : ""}`);
|
|
134
143
|
}
|
|
135
144
|
const cbs = {
|
|
@@ -160,6 +169,12 @@ async function createCursorSession(options) {
|
|
|
160
169
|
});
|
|
161
170
|
if (!appendResponse.ok) {
|
|
162
171
|
const errorBody = await appendResponse.text().catch(() => "");
|
|
172
|
+
logPluginError("Cursor BidiAppend request failed", {
|
|
173
|
+
requestId: options.requestId,
|
|
174
|
+
appendSeqno: appendSeqno - 1,
|
|
175
|
+
status: appendResponse.status,
|
|
176
|
+
responseBody: errorBody,
|
|
177
|
+
});
|
|
163
178
|
throw new Error(`BidiAppend failed: ${appendResponse.status}${errorBody ? ` ${errorBody}` : ""}`);
|
|
164
179
|
}
|
|
165
180
|
await appendResponse.arrayBuffer().catch(() => undefined);
|
|
@@ -183,7 +198,11 @@ async function createCursorSession(options) {
|
|
|
183
198
|
}
|
|
184
199
|
}
|
|
185
200
|
}
|
|
186
|
-
catch {
|
|
201
|
+
catch (error) {
|
|
202
|
+
logPluginWarn("Cursor stream reader closed with error", {
|
|
203
|
+
requestId: options.requestId,
|
|
204
|
+
...errorDetails(error),
|
|
205
|
+
});
|
|
187
206
|
finish(alive ? 1 : closeCode);
|
|
188
207
|
}
|
|
189
208
|
})();
|
|
@@ -196,7 +215,11 @@ async function createCursorSession(options) {
|
|
|
196
215
|
return;
|
|
197
216
|
writeChain = writeChain
|
|
198
217
|
.then(() => append(data))
|
|
199
|
-
.catch(() => {
|
|
218
|
+
.catch((error) => {
|
|
219
|
+
logPluginError("Cursor stream append failed", {
|
|
220
|
+
requestId: options.requestId,
|
|
221
|
+
...errorDetails(error),
|
|
222
|
+
});
|
|
200
223
|
try {
|
|
201
224
|
abortController.abort();
|
|
202
225
|
}
|
|
@@ -236,6 +259,17 @@ async function createCursorSession(options) {
|
|
|
236
259
|
};
|
|
237
260
|
}
|
|
238
261
|
export async function callCursorUnaryRpc(options) {
|
|
262
|
+
const target = new URL(options.rpcPath, options.url ?? CURSOR_API_URL);
|
|
263
|
+
const transport = options.transport ?? "auto";
|
|
264
|
+
if (transport === "http2" || (transport === "auto" && target.protocol === "https:")) {
|
|
265
|
+
const http2Result = await callCursorUnaryRpcOverHttp2(options, target);
|
|
266
|
+
if (transport === "http2" || http2Result.timedOut || http2Result.exitCode !== 1) {
|
|
267
|
+
return http2Result;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return callCursorUnaryRpcOverFetch(options, target);
|
|
271
|
+
}
|
|
272
|
+
async function callCursorUnaryRpcOverFetch(options, target) {
|
|
239
273
|
let timedOut = false;
|
|
240
274
|
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
241
275
|
const controller = new AbortController();
|
|
@@ -246,9 +280,13 @@ export async function callCursorUnaryRpc(options) {
|
|
|
246
280
|
}, timeoutMs)
|
|
247
281
|
: undefined;
|
|
248
282
|
try {
|
|
249
|
-
const response = await fetch(
|
|
283
|
+
const response = await fetch(target, {
|
|
250
284
|
method: "POST",
|
|
251
|
-
headers: buildCursorHeaders(options, "application/proto"
|
|
285
|
+
headers: buildCursorHeaders(options, "application/proto", {
|
|
286
|
+
accept: "application/proto, application/json",
|
|
287
|
+
"connect-protocol-version": CURSOR_CONNECT_PROTOCOL_VERSION,
|
|
288
|
+
"connect-timeout-ms": String(timeoutMs),
|
|
289
|
+
}),
|
|
252
290
|
body: toFetchBody(options.requestBody),
|
|
253
291
|
signal: controller.signal,
|
|
254
292
|
});
|
|
@@ -260,6 +298,12 @@ export async function callCursorUnaryRpc(options) {
|
|
|
260
298
|
};
|
|
261
299
|
}
|
|
262
300
|
catch {
|
|
301
|
+
logPluginError("Cursor unary fetch transport failed", {
|
|
302
|
+
rpcPath: options.rpcPath,
|
|
303
|
+
url: target.toString(),
|
|
304
|
+
timeoutMs,
|
|
305
|
+
timedOut,
|
|
306
|
+
});
|
|
263
307
|
return {
|
|
264
308
|
body: new Uint8Array(),
|
|
265
309
|
exitCode: timedOut ? 124 : 1,
|
|
@@ -271,6 +315,114 @@ export async function callCursorUnaryRpc(options) {
|
|
|
271
315
|
clearTimeout(timeout);
|
|
272
316
|
}
|
|
273
317
|
}
|
|
318
|
+
async function callCursorUnaryRpcOverHttp2(options, target) {
|
|
319
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
320
|
+
const authority = `${target.protocol}//${target.host}`;
|
|
321
|
+
return new Promise((resolve) => {
|
|
322
|
+
let settled = false;
|
|
323
|
+
let timedOut = false;
|
|
324
|
+
let session;
|
|
325
|
+
let stream;
|
|
326
|
+
const finish = (result) => {
|
|
327
|
+
if (settled)
|
|
328
|
+
return;
|
|
329
|
+
settled = true;
|
|
330
|
+
if (timeout)
|
|
331
|
+
clearTimeout(timeout);
|
|
332
|
+
try {
|
|
333
|
+
stream?.close();
|
|
334
|
+
}
|
|
335
|
+
catch { }
|
|
336
|
+
try {
|
|
337
|
+
session?.close();
|
|
338
|
+
}
|
|
339
|
+
catch { }
|
|
340
|
+
resolve(result);
|
|
341
|
+
};
|
|
342
|
+
const timeout = timeoutMs > 0
|
|
343
|
+
? setTimeout(() => {
|
|
344
|
+
timedOut = true;
|
|
345
|
+
finish({
|
|
346
|
+
body: new Uint8Array(),
|
|
347
|
+
exitCode: 124,
|
|
348
|
+
timedOut: true,
|
|
349
|
+
});
|
|
350
|
+
}, timeoutMs)
|
|
351
|
+
: undefined;
|
|
352
|
+
try {
|
|
353
|
+
session = connectHttp2(authority);
|
|
354
|
+
session.once("error", (error) => {
|
|
355
|
+
logPluginError("Cursor unary HTTP/2 session failed", {
|
|
356
|
+
rpcPath: options.rpcPath,
|
|
357
|
+
url: target.toString(),
|
|
358
|
+
timedOut,
|
|
359
|
+
...errorDetails(error),
|
|
360
|
+
});
|
|
361
|
+
finish({
|
|
362
|
+
body: new Uint8Array(),
|
|
363
|
+
exitCode: timedOut ? 124 : 1,
|
|
364
|
+
timedOut,
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
const headers = {
|
|
368
|
+
":method": "POST",
|
|
369
|
+
":path": `${target.pathname}${target.search}`,
|
|
370
|
+
...buildCursorHeaderValues(options, "application/proto", {
|
|
371
|
+
accept: "application/proto, application/json",
|
|
372
|
+
"connect-protocol-version": CURSOR_CONNECT_PROTOCOL_VERSION,
|
|
373
|
+
"connect-timeout-ms": String(timeoutMs),
|
|
374
|
+
}),
|
|
375
|
+
};
|
|
376
|
+
stream = session.request(headers);
|
|
377
|
+
let statusCode = 0;
|
|
378
|
+
const chunks = [];
|
|
379
|
+
stream.once("response", (responseHeaders) => {
|
|
380
|
+
const statusHeader = responseHeaders[":status"];
|
|
381
|
+
statusCode = typeof statusHeader === "number"
|
|
382
|
+
? statusHeader
|
|
383
|
+
: Number(statusHeader ?? 0);
|
|
384
|
+
});
|
|
385
|
+
stream.on("data", (chunk) => {
|
|
386
|
+
chunks.push(Buffer.from(chunk));
|
|
387
|
+
});
|
|
388
|
+
stream.once("end", () => {
|
|
389
|
+
const body = new Uint8Array(Buffer.concat(chunks));
|
|
390
|
+
finish({
|
|
391
|
+
body,
|
|
392
|
+
exitCode: statusCode >= 200 && statusCode < 300 ? 0 : (statusCode || 1),
|
|
393
|
+
timedOut,
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
stream.once("error", (error) => {
|
|
397
|
+
logPluginError("Cursor unary HTTP/2 stream failed", {
|
|
398
|
+
rpcPath: options.rpcPath,
|
|
399
|
+
url: target.toString(),
|
|
400
|
+
timedOut,
|
|
401
|
+
...errorDetails(error),
|
|
402
|
+
});
|
|
403
|
+
finish({
|
|
404
|
+
body: new Uint8Array(),
|
|
405
|
+
exitCode: timedOut ? 124 : 1,
|
|
406
|
+
timedOut,
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
stream.end(Buffer.from(options.requestBody));
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
logPluginError("Cursor unary HTTP/2 setup failed", {
|
|
413
|
+
rpcPath: options.rpcPath,
|
|
414
|
+
url: target.toString(),
|
|
415
|
+
timedOut,
|
|
416
|
+
...errorDetails(error),
|
|
417
|
+
});
|
|
418
|
+
finish({
|
|
419
|
+
body: new Uint8Array(),
|
|
420
|
+
exitCode: timedOut ? 124 : 1,
|
|
421
|
+
timedOut,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
}
|
|
274
426
|
let proxyServer;
|
|
275
427
|
let proxyPort;
|
|
276
428
|
let proxyAccessTokenProvider;
|
|
@@ -316,6 +468,11 @@ export async function startProxy(getAccessToken, models = []) {
|
|
|
316
468
|
}
|
|
317
469
|
catch (err) {
|
|
318
470
|
const message = err instanceof Error ? err.message : String(err);
|
|
471
|
+
logPluginError("Cursor proxy request failed", {
|
|
472
|
+
path: url.pathname,
|
|
473
|
+
method: req.method,
|
|
474
|
+
...errorDetails(err),
|
|
475
|
+
});
|
|
319
476
|
return new Response(JSON.stringify({
|
|
320
477
|
error: { message, type: "server_error", code: "internal_error" },
|
|
321
478
|
}), { status: 500, headers: { "Content-Type": "application/json" } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playwo/opencode-cursor-oauth",
|
|
3
|
-
"version": "0.0.0-dev.
|
|
3
|
+
"version": "0.0.0-dev.f7099c3761b9",
|
|
4
4
|
"description": "OpenCode plugin that connects Cursor's API to OpenCode via OAuth, model discovery, and a local OpenAI-compatible proxy.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|