pi-provider-cursor-ask 0.1.0
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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/README.zh-CN.md +87 -0
- package/UPSTREAM_CHANGELOG.md +368 -0
- package/UPSTREAM_SOURCE.md +23 -0
- package/dist/index.js +54 -0
- package/package.json +97 -0
- package/src/auth/cli-credentials.ts +275 -0
- package/src/auth/consent.ts +25 -0
- package/src/auth/index.ts +23 -0
- package/src/auth/oauth.ts +282 -0
- package/src/auth/refresh-guard.ts +93 -0
- package/src/client/bridge.ts +673 -0
- package/src/client/cursor-wire.ts +213 -0
- package/src/client/h2-unary.ts +142 -0
- package/src/client/index.ts +18 -0
- package/src/config/index.ts +69 -0
- package/src/diagnostics/diagnostics.ts +116 -0
- package/src/diagnostics/index.ts +1 -0
- package/src/extension/auth.ts +99 -0
- package/src/extension/commands.ts +163 -0
- package/src/extension/compaction-guard.ts +86 -0
- package/src/extension/debug-hooks.ts +359 -0
- package/src/extension/index.ts +8 -0
- package/src/extension/provider.ts +277 -0
- package/src/extension/quota-adapter.ts +175 -0
- package/src/extension/report-dashboard.ts +133 -0
- package/src/identity.ts +16 -0
- package/src/index.ts +186 -0
- package/src/models/ask-catalog.ts +384 -0
- package/src/models/catalog.json +1163 -0
- package/src/models/cost.ts +126 -0
- package/src/models/index.ts +6 -0
- package/src/models/limits.ts +36 -0
- package/src/models/parameterized.ts +416 -0
- package/src/models/processing.ts +313 -0
- package/src/proto/agent_pb.ts +14577 -0
- package/src/stream/bridge-session.ts +215 -0
- package/src/stream/client-transcript.ts +51 -0
- package/src/stream/config.ts +5 -0
- package/src/stream/context-normalize.ts +308 -0
- package/src/stream/context-usage.ts +168 -0
- package/src/stream/debug-log.ts +316 -0
- package/src/stream/drift.ts +122 -0
- package/src/stream/images.ts +201 -0
- package/src/stream/index.ts +68 -0
- package/src/stream/interaction-query.ts +369 -0
- package/src/stream/message-parsing.ts +402 -0
- package/src/stream/model-cache.ts +100 -0
- package/src/stream/model-discovery.ts +242 -0
- package/src/stream/model-routing.ts +100 -0
- package/src/stream/native-core.ts +2121 -0
- package/src/stream/pi-adapter.ts +414 -0
- package/src/stream/protocol.ts +63 -0
- package/src/stream/recovery.ts +494 -0
- package/src/stream/request-build.ts +668 -0
- package/src/stream/root-prompt.ts +184 -0
- package/src/stream/run-journal.ts +474 -0
- package/src/stream/run-usage.ts +107 -0
- package/src/stream/server-messages.ts +777 -0
- package/src/stream/session-state.ts +499 -0
- package/src/stream/stream-writer.ts +211 -0
- package/src/stream/thinking-filter.ts +63 -0
- package/src/stream/tool-schema.ts +185 -0
- package/src/stream/transport-errors.ts +150 -0
- package/src/stream/tuning.ts +250 -0
- package/src/stream/types.ts +330 -0
- package/src/types/enums.ts +103 -0
- package/src/types/index.ts +4 -0
- package/src/usage.ts +262 -0
- package/src/utils/cache-dir.ts +39 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/security.ts +68 -0
- package/src/utils/util.ts +43 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translation between Pi's context/model types and the OpenAI-shaped request
|
|
3
|
+
* this provider builds Cursor calls from.
|
|
4
|
+
*
|
|
5
|
+
* Everything Pi-facing lives here: content-block narrowing, tool definitions,
|
|
6
|
+
* usage/cost accounting on the assistant message, and reasoning-effort routing
|
|
7
|
+
* onto Cursor's model variants. Nothing in this module touches the wire.
|
|
8
|
+
*/
|
|
9
|
+
import type {
|
|
10
|
+
Api,
|
|
11
|
+
AssistantMessage,
|
|
12
|
+
Context,
|
|
13
|
+
ImageContent as PiImageContent,
|
|
14
|
+
Message as PiMessage,
|
|
15
|
+
Model,
|
|
16
|
+
TextContent as PiTextContent,
|
|
17
|
+
Tool as PiTool,
|
|
18
|
+
ToolCall as PiToolCall,
|
|
19
|
+
} from "@earendil-works/pi-ai";
|
|
20
|
+
|
|
21
|
+
import { redactSecrets } from "../utils/security.js";
|
|
22
|
+
import { estimateMessageTokens, positiveContextTokens } from "./context-usage.js";
|
|
23
|
+
import { takeRunReceipt, type CursorBillingInfo } from "./run-usage.js";
|
|
24
|
+
import type { CursorNativeModelRouting } from "./model-routing.js";
|
|
25
|
+
import type {
|
|
26
|
+
ChatCompletionRequest,
|
|
27
|
+
ContentPart,
|
|
28
|
+
CursorBilledUsage,
|
|
29
|
+
CursorNativeStreamConfig,
|
|
30
|
+
CursorNativeStreamOptions,
|
|
31
|
+
OpenAIMessage,
|
|
32
|
+
OpenAIToolCall,
|
|
33
|
+
OpenAIToolDef,
|
|
34
|
+
StreamState,
|
|
35
|
+
} from "./types.js";
|
|
36
|
+
|
|
37
|
+
export function emptyCursorUsage(): AssistantMessage["usage"] {
|
|
38
|
+
return {
|
|
39
|
+
input: 0,
|
|
40
|
+
output: 0,
|
|
41
|
+
cacheRead: 0,
|
|
42
|
+
cacheWrite: 0,
|
|
43
|
+
totalTokens: 0,
|
|
44
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function tokenCost(tokens: number, ratePerMillion = 0): number {
|
|
49
|
+
return (tokens * ratePerMillion) / 1_000_000;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function protoTokenCount(value: bigint | number | undefined): number | undefined {
|
|
53
|
+
if (value === undefined) return undefined;
|
|
54
|
+
const n = typeof value === "bigint" ? Number(value) : value;
|
|
55
|
+
if (!Number.isFinite(n) || n < 0) return 0;
|
|
56
|
+
return Math.trunc(n);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Decode Cursor `turnEnded` billed fields. Missing all four means fall back to usedTokens. */
|
|
60
|
+
export function billedUsageFromTurnEnded(ended: {
|
|
61
|
+
inputTokens?: bigint | number;
|
|
62
|
+
outputTokens?: bigint | number;
|
|
63
|
+
cacheReadTokens?: bigint | number;
|
|
64
|
+
cacheWriteTokens?: bigint | number;
|
|
65
|
+
}): CursorBilledUsage | undefined {
|
|
66
|
+
const input = protoTokenCount(ended.inputTokens);
|
|
67
|
+
const output = protoTokenCount(ended.outputTokens);
|
|
68
|
+
const cacheRead = protoTokenCount(ended.cacheReadTokens);
|
|
69
|
+
const cacheWrite = protoTokenCount(ended.cacheWriteTokens);
|
|
70
|
+
if (
|
|
71
|
+
input === undefined &&
|
|
72
|
+
output === undefined &&
|
|
73
|
+
cacheRead === undefined &&
|
|
74
|
+
cacheWrite === undefined
|
|
75
|
+
) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
input: input ?? 0,
|
|
80
|
+
output: output ?? 0,
|
|
81
|
+
cacheRead: cacheRead ?? 0,
|
|
82
|
+
cacheWrite: cacheWrite ?? 0,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Convert Cursor's cache-inclusive billed totals into Pi's disjoint cost buckets.
|
|
88
|
+
*
|
|
89
|
+
* One Cursor agent turn may invoke the model repeatedly around tool calls, so the billed fields
|
|
90
|
+
* are cumulative while `contextTokens` is the latest checkpoint's actual context snapshot. Pi
|
|
91
|
+
* reads `totalTokens` for context/compaction while the buckets and cost retain billed usage.
|
|
92
|
+
*/
|
|
93
|
+
function usageFromBilled(
|
|
94
|
+
billed: CursorBilledUsage,
|
|
95
|
+
model: Model<Api>,
|
|
96
|
+
contextTokens: number,
|
|
97
|
+
): AssistantMessage["usage"] {
|
|
98
|
+
const uncachedInput = Math.max(0, billed.input - billed.cacheRead - billed.cacheWrite);
|
|
99
|
+
const costInput = tokenCost(uncachedInput, model.cost?.input);
|
|
100
|
+
const costOutput = tokenCost(billed.output, model.cost?.output);
|
|
101
|
+
const costCacheRead = tokenCost(billed.cacheRead, model.cost?.cacheRead);
|
|
102
|
+
const costCacheWrite = tokenCost(billed.cacheWrite, model.cost?.cacheWrite);
|
|
103
|
+
return {
|
|
104
|
+
input: uncachedInput,
|
|
105
|
+
output: billed.output,
|
|
106
|
+
cacheRead: billed.cacheRead,
|
|
107
|
+
cacheWrite: billed.cacheWrite,
|
|
108
|
+
totalTokens: contextTokens,
|
|
109
|
+
cost: {
|
|
110
|
+
input: costInput,
|
|
111
|
+
output: costOutput,
|
|
112
|
+
cacheRead: costCacheRead,
|
|
113
|
+
cacheWrite: costCacheWrite,
|
|
114
|
+
total: costInput + costOutput + costCacheRead + costCacheWrite,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function applyCursorUsage(
|
|
120
|
+
output: AssistantMessage,
|
|
121
|
+
model: Model<Api>,
|
|
122
|
+
state?: StreamState,
|
|
123
|
+
contextTokens?: number,
|
|
124
|
+
): CursorBillingInfo {
|
|
125
|
+
const contextEstimate =
|
|
126
|
+
positiveContextTokens(contextTokens) ??
|
|
127
|
+
positiveContextTokens(state?.totalTokens) ??
|
|
128
|
+
estimateMessageTokens(output);
|
|
129
|
+
const { billed, info } = takeRunReceipt(state);
|
|
130
|
+
output.usage = billed
|
|
131
|
+
? usageFromBilled(
|
|
132
|
+
billed,
|
|
133
|
+
state?.runUsage?.rates ? { ...model, cost: state.runUsage.rates } : model,
|
|
134
|
+
contextEstimate,
|
|
135
|
+
)
|
|
136
|
+
: { ...emptyCursorUsage(), totalTokens: contextEstimate };
|
|
137
|
+
return info;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createCursorAssistantMessage(model: Model<Api>): AssistantMessage {
|
|
141
|
+
return {
|
|
142
|
+
role: "assistant",
|
|
143
|
+
content: [],
|
|
144
|
+
api: model.api,
|
|
145
|
+
provider: model.provider,
|
|
146
|
+
model: model.id,
|
|
147
|
+
usage: emptyCursorUsage(),
|
|
148
|
+
stopReason: "stop",
|
|
149
|
+
timestamp: Date.now(),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function isPiTextContent(block: unknown): block is PiTextContent {
|
|
154
|
+
return !!block && typeof block === "object" && (block as { type?: unknown }).type === "text";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function isPiImageContent(block: unknown): block is PiImageContent {
|
|
158
|
+
return !!block && typeof block === "object" && (block as { type?: unknown }).type === "image";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function isPiToolCall(block: unknown): block is PiToolCall {
|
|
162
|
+
return !!block && typeof block === "object" && (block as { type?: unknown }).type === "toolCall";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function isPiThinkingContent(
|
|
166
|
+
block: unknown,
|
|
167
|
+
): block is { type: "thinking"; thinking: string } {
|
|
168
|
+
return (
|
|
169
|
+
!!block &&
|
|
170
|
+
typeof block === "object" &&
|
|
171
|
+
(block as { type?: unknown }).type === "thinking" &&
|
|
172
|
+
typeof (block as { thinking?: unknown }).thinking === "string"
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function piContentToOpenAIContent(
|
|
177
|
+
content: string | PiMessage["content"],
|
|
178
|
+
): OpenAIMessage["content"] {
|
|
179
|
+
if (typeof content === "string") return content;
|
|
180
|
+
if (!Array.isArray(content)) return "";
|
|
181
|
+
const parts: ContentPart[] = [];
|
|
182
|
+
for (const block of content) {
|
|
183
|
+
if (isPiTextContent(block)) {
|
|
184
|
+
parts.push({ type: "text", text: block.text });
|
|
185
|
+
} else if (isPiImageContent(block)) {
|
|
186
|
+
parts.push({ type: "image", data: block.data, mimeType: block.mimeType });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return parts.length > 0 ? parts : "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function assistantTextFromPiContent(content: AssistantMessage["content"]): string {
|
|
193
|
+
return content
|
|
194
|
+
.filter((block): block is PiTextContent => isPiTextContent(block))
|
|
195
|
+
.map((block) => block.text)
|
|
196
|
+
.join("\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function assistantThinkingFromPiContent(content: AssistantMessage["content"]): string {
|
|
200
|
+
if (!Array.isArray(content)) return "";
|
|
201
|
+
return content
|
|
202
|
+
.filter(isPiThinkingContent)
|
|
203
|
+
.map((block) => block.thinking)
|
|
204
|
+
.filter(Boolean)
|
|
205
|
+
.join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function assistantToolCallsFromPiContent(
|
|
209
|
+
content: AssistantMessage["content"],
|
|
210
|
+
): OpenAIToolCall[] {
|
|
211
|
+
return content.filter(isPiToolCall).map((block) => ({
|
|
212
|
+
id: block.id,
|
|
213
|
+
type: "function" as const,
|
|
214
|
+
function: {
|
|
215
|
+
name: block.name,
|
|
216
|
+
arguments: JSON.stringify(block.arguments ?? {}),
|
|
217
|
+
},
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Longest error detail carried into the replayed notice. */
|
|
222
|
+
export const MAX_INTERRUPTED_NOTICE_ERROR_CHARS = 200;
|
|
223
|
+
|
|
224
|
+
const INTERRUPTED_NOTICE_TAIL = "the output above is incomplete and nothing further was produced";
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Describe a prior assistant turn that never ran to completion.
|
|
228
|
+
*
|
|
229
|
+
* Cursor's turn structure only carries the text a turn produced, so an aborted
|
|
230
|
+
* or failed turn replays as one that simply trails off — indistinguishable from
|
|
231
|
+
* a model that chose to stop. Observed effect on a resumed session: the model
|
|
232
|
+
* reads the gap as missing context and goes looking for it (re-listing the
|
|
233
|
+
* workspace, reading unrelated transcripts) instead of continuing the work.
|
|
234
|
+
*
|
|
235
|
+
* Returns "" for turns that completed normally, which is the common case.
|
|
236
|
+
*/
|
|
237
|
+
export function interruptedAssistantNotice(message: {
|
|
238
|
+
stopReason?: AssistantMessage["stopReason"];
|
|
239
|
+
errorMessage?: string;
|
|
240
|
+
}): string {
|
|
241
|
+
const reason = message.stopReason;
|
|
242
|
+
if (reason === "aborted") {
|
|
243
|
+
return `[pi-cursor: this assistant turn was interrupted before it finished; ${INTERRUPTED_NOTICE_TAIL}.]`;
|
|
244
|
+
}
|
|
245
|
+
if (reason === "error") {
|
|
246
|
+
// errorMessage is provider text and can carry a token; redact before it
|
|
247
|
+
// lands in a request body, and bound it so a huge error cannot dominate
|
|
248
|
+
// the replayed history.
|
|
249
|
+
const raw = redactSecrets((message.errorMessage ?? "").replace(/\s+/g, " ").trim());
|
|
250
|
+
const detail =
|
|
251
|
+
raw.length > MAX_INTERRUPTED_NOTICE_ERROR_CHARS
|
|
252
|
+
? `${raw.slice(0, MAX_INTERRUPTED_NOTICE_ERROR_CHARS - 1)}…`
|
|
253
|
+
: raw;
|
|
254
|
+
return detail
|
|
255
|
+
? `[pi-cursor: this assistant turn ended with an error before it finished (${detail}); ${INTERRUPTED_NOTICE_TAIL}.]`
|
|
256
|
+
: `[pi-cursor: this assistant turn ended with an error before it finished; ${INTERRUPTED_NOTICE_TAIL}.]`;
|
|
257
|
+
}
|
|
258
|
+
if (reason === "length") {
|
|
259
|
+
return `[pi-cursor: this assistant turn was cut off at the model's output limit; ${INTERRUPTED_NOTICE_TAIL}.]`;
|
|
260
|
+
}
|
|
261
|
+
if (reason === "pending") {
|
|
262
|
+
return `[pi-cursor: this assistant turn never completed; ${INTERRUPTED_NOTICE_TAIL}.]`;
|
|
263
|
+
}
|
|
264
|
+
return "";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function piToolToOpenAI(tool: PiTool): OpenAIToolDef {
|
|
268
|
+
return {
|
|
269
|
+
type: "function",
|
|
270
|
+
function: {
|
|
271
|
+
name: tool.name,
|
|
272
|
+
description: tool.description,
|
|
273
|
+
parameters: tool.parameters as unknown as Record<string, unknown>,
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function resolveNativeReasoningEffort(
|
|
279
|
+
model: Model<Api>,
|
|
280
|
+
options: CursorNativeStreamOptions | undefined,
|
|
281
|
+
noReasoningEffortByModelId?: Map<string, string>,
|
|
282
|
+
): string | undefined {
|
|
283
|
+
const thinkingLevelMap =
|
|
284
|
+
(
|
|
285
|
+
model as Model<Api> & {
|
|
286
|
+
thinkingLevelMap?: Partial<Record<string, string>>;
|
|
287
|
+
compat?: { reasoningEffortMap?: Partial<Record<string, string>> };
|
|
288
|
+
}
|
|
289
|
+
).thinkingLevelMap ??
|
|
290
|
+
(model.compat as { reasoningEffortMap?: Partial<Record<string, string>> } | undefined)
|
|
291
|
+
?.reasoningEffortMap;
|
|
292
|
+
const requested = options?.reasoning;
|
|
293
|
+
const supportsReasoningEffort =
|
|
294
|
+
(model.compat as { supportsReasoningEffort?: boolean } | undefined)?.supportsReasoningEffort ===
|
|
295
|
+
true;
|
|
296
|
+
if (requested) {
|
|
297
|
+
const mapped = thinkingLevelMap?.[requested];
|
|
298
|
+
if (typeof mapped === "string") return mapped;
|
|
299
|
+
if (
|
|
300
|
+
mapped === null &&
|
|
301
|
+
thinkingLevelMap &&
|
|
302
|
+
Object.prototype.hasOwnProperty.call(thinkingLevelMap, requested)
|
|
303
|
+
) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Thinking level "${requested}" is not supported by Cursor Ask model "${model.id}".`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
return supportsReasoningEffort ? requested : undefined;
|
|
309
|
+
}
|
|
310
|
+
const offMapped = thinkingLevelMap?.off;
|
|
311
|
+
if (typeof offMapped === "string") return offMapped;
|
|
312
|
+
return noReasoningEffortByModelId?.get(model.id);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function applyNativeCursorRouting(
|
|
316
|
+
body: ChatCompletionRequest,
|
|
317
|
+
rawRoutingByModelId?: Map<string, Record<string, CursorNativeModelRouting>>,
|
|
318
|
+
): void {
|
|
319
|
+
const routes = rawRoutingByModelId?.get(body.model);
|
|
320
|
+
const effort = body.reasoning_effort ?? "";
|
|
321
|
+
const routing = routes?.[effort] ?? routes?.[""];
|
|
322
|
+
if (!routing) return;
|
|
323
|
+
body.cursor_model_id = routing.modelId;
|
|
324
|
+
if (routing.parameters?.length) body.cursor_model_parameters = routing.parameters;
|
|
325
|
+
if (routing.requiresMaxMode) body.cursor_requires_max_mode = true;
|
|
326
|
+
if (typeof routing.requestedMaxMode === "boolean")
|
|
327
|
+
body.cursor_model_max_mode = routing.requestedMaxMode;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function contextToCursorChatCompletionRequest(
|
|
331
|
+
model: Model<Api>,
|
|
332
|
+
context: Context,
|
|
333
|
+
options: CursorNativeStreamOptions | undefined,
|
|
334
|
+
config: CursorNativeStreamConfig,
|
|
335
|
+
): ChatCompletionRequest {
|
|
336
|
+
const messages: OpenAIMessage[] = [];
|
|
337
|
+
if (context.systemPrompt) messages.push({ role: "system", content: context.systemPrompt });
|
|
338
|
+
|
|
339
|
+
for (const [index, message] of context.messages.entries()) {
|
|
340
|
+
if (message.role === "user") {
|
|
341
|
+
messages.push({ role: "user", content: piContentToOpenAIContent(message.content) });
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (message.role === "assistant") {
|
|
346
|
+
const tool_calls = assistantToolCallsFromPiContent(message.content);
|
|
347
|
+
const thinking = assistantThinkingFromPiContent(message.content);
|
|
348
|
+
// Only annotate turns that are genuinely history. A trailing aborted
|
|
349
|
+
// assistant message is the turn being retried, not context behind us —
|
|
350
|
+
// annotating it would turn an empty-step turn into a non-empty one and
|
|
351
|
+
// strand the live user text.
|
|
352
|
+
const interrupted_notice =
|
|
353
|
+
index < context.messages.length - 1 ? interruptedAssistantNotice(message) : "";
|
|
354
|
+
messages.push({
|
|
355
|
+
role: "assistant",
|
|
356
|
+
content: assistantTextFromPiContent(message.content),
|
|
357
|
+
...(tool_calls.length > 0 ? { tool_calls } : {}),
|
|
358
|
+
...(thinking ? { thinking } : {}),
|
|
359
|
+
...(interrupted_notice ? { interrupted_notice } : {}),
|
|
360
|
+
});
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (message.role === "toolResult") {
|
|
365
|
+
messages.push({
|
|
366
|
+
role: "tool",
|
|
367
|
+
tool_call_id: message.toolCallId,
|
|
368
|
+
content: piContentToOpenAIContent(message.content),
|
|
369
|
+
is_error: Boolean((message as { isError?: boolean }).isError),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const body: ChatCompletionRequest = {
|
|
375
|
+
model: model.id,
|
|
376
|
+
messages,
|
|
377
|
+
stream: true,
|
|
378
|
+
tools: (context.tools ?? []).map(piToolToOpenAI),
|
|
379
|
+
tool_choice: options?.toolChoice,
|
|
380
|
+
reasoning_effort: resolveNativeReasoningEffort(
|
|
381
|
+
model,
|
|
382
|
+
options,
|
|
383
|
+
config.getNoReasoningEffortByModelId?.(),
|
|
384
|
+
),
|
|
385
|
+
pi_session_id: options?.sessionId,
|
|
386
|
+
user: options?.sessionId,
|
|
387
|
+
temperature: options?.temperature,
|
|
388
|
+
max_tokens: options?.maxTokens,
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
applyNativeCursorRouting(body, config.getRawModelRoutingByModelId?.());
|
|
392
|
+
return body;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function nativeRequestParameterError(body: ChatCompletionRequest): string | undefined {
|
|
396
|
+
if (body.temperature !== undefined)
|
|
397
|
+
return "Unsupported Cursor provider parameter(s): temperature";
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function resolveToolsForToolChoice(
|
|
402
|
+
tools: OpenAIToolDef[],
|
|
403
|
+
toolChoice: unknown,
|
|
404
|
+
): { tools: OpenAIToolDef[] } | { error: string } {
|
|
405
|
+
if (toolChoice == null || toolChoice === "auto") return { tools };
|
|
406
|
+
if (toolChoice === "none") return { tools: [] };
|
|
407
|
+
if (
|
|
408
|
+
typeof toolChoice === "object" &&
|
|
409
|
+
toolChoice &&
|
|
410
|
+
(toolChoice as Record<string, unknown>).type === "none"
|
|
411
|
+
)
|
|
412
|
+
return { tools: [] };
|
|
413
|
+
return { error: "Only tool_choice 'auto' and 'none' are supported by pi-cursor-provider." };
|
|
414
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol-drift helpers: auth error detection, decode error framing, client version.
|
|
3
|
+
*/
|
|
4
|
+
import { getCursorClientVersion } from "./config.js";
|
|
5
|
+
import { formatDriftSummary, hasStrandingDrift } from "./drift.js";
|
|
6
|
+
|
|
7
|
+
const AUTH_ERROR_RE =
|
|
8
|
+
/\b(unauthenticated|unauthorized|permission[_ ]?denied|auth(?:entication)?[_ ]?failed|invalid[_ ]?token|expired[_ ]?token|401)\b/i;
|
|
9
|
+
|
|
10
|
+
const PROTOCOL_ERROR_RE =
|
|
11
|
+
/\b(failed to parse|decode|invalid wire|protocol|connect error|unknown field|premature eof)\b/i;
|
|
12
|
+
|
|
13
|
+
export function isAuthErrorMessage(message: string): boolean {
|
|
14
|
+
return AUTH_ERROR_RE.test(message);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isProtocolMismatchMessage(message: string): boolean {
|
|
18
|
+
return PROTOCOL_ERROR_RE.test(message);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatProtocolMismatchHint(message: string): string {
|
|
22
|
+
const version = getCursorClientVersion();
|
|
23
|
+
return (
|
|
24
|
+
`${message} ` +
|
|
25
|
+
`[protocol-hint: Cursor wire may have drifted. ` +
|
|
26
|
+
`clientVersion=${version}. Try bumping PI_CURSOR_CLIENT_VERSION or re-run /cursor.doctor.]`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Appends what actually drifted, when anything did.
|
|
32
|
+
*
|
|
33
|
+
* A turn that fails after we skipped an unrecognized server message would
|
|
34
|
+
* otherwise surface as a bare timeout. Naming the unhandled case turns "it hung"
|
|
35
|
+
* into a reproducible bug report.
|
|
36
|
+
*/
|
|
37
|
+
export function appendDriftDiagnostic(message: string): string {
|
|
38
|
+
const summary = formatDriftSummary();
|
|
39
|
+
if (!summary) return message;
|
|
40
|
+
const severity = hasStrandingDrift()
|
|
41
|
+
? "unhandled wire messages — the turn may have been left waiting on one"
|
|
42
|
+
: "unknown wire fields — schema is likely behind Cursor";
|
|
43
|
+
return (
|
|
44
|
+
`${message} ` +
|
|
45
|
+
`[wire-drift: ${severity}. Observed: ${summary}. ` +
|
|
46
|
+
`Regenerate the schema (see proto/README.md) or bump PI_CURSOR_CLIENT_VERSION; ` +
|
|
47
|
+
`/cursor.doctor shows the full list.]`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function enhanceCursorStreamError(message: string): string {
|
|
52
|
+
if (isAuthErrorMessage(message)) {
|
|
53
|
+
return (
|
|
54
|
+
`${message} ` +
|
|
55
|
+
`[auth-hint: token may be expired. Idle stream retries force-refresh credentials; ` +
|
|
56
|
+
`if this persists run /login cursor or check /cursor.doctor tokenSource.]`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (isProtocolMismatchMessage(message)) {
|
|
60
|
+
return appendDriftDiagnostic(formatProtocolMismatchHint(message));
|
|
61
|
+
}
|
|
62
|
+
return appendDriftDiagnostic(message);
|
|
63
|
+
}
|