@oh-my-pi/pi-ai 17.2.1 → 17.2.2
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 +17 -0
- package/dist/types/auth-storage.d.ts +3 -1
- package/dist/types/providers/cursor.d.ts +1 -1
- package/dist/types/providers/transform-messages.d.ts +18 -0
- package/dist/types/registry/gmi-cloud.d.ts +7 -0
- package/dist/types/registry/registry.d.ts +4 -0
- package/dist/types/usage/openai-codex-reset.d.ts +9 -0
- package/package.json +4 -4
- package/src/auth-storage.ts +31 -6
- package/src/providers/cursor.ts +240 -56
- package/src/providers/transform-messages.ts +9 -1
- package/src/registry/gmi-cloud.ts +22 -0
- package/src/registry/registry.ts +2 -0
- package/src/usage/openai-codex-reset.ts +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.2.2] - 2026-07-31
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added support for the `gmi-cloud` provider registry, including API-key paste login validation and integration with `@oh-my-pi/pi-catalog`.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Updated `AuthStorage.redeemResetCredit` to prioritize spending the soonest-expiring available saved reset credit, and improved error handling to distinguish between transport failures (`credit_list_failed`) and a genuine lack of credits.
|
|
14
|
+
- Exported `SENSITIVE_TOKEN_RE` from `providers/transform-messages` to allow hosts to route credential shapes through reversible obfuscation instead of irreversible redaction.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Fixed an issue where Cursor conversation checkpoints were incorrectly recorded as billable output tokens, ensuring accurate usage totals.
|
|
19
|
+
- Fixed an issue in `AuthStorage.refreshStoredOAuthCredential` where expired OAuth credentials were returned without being refreshed when a credential mismatch occurred, which previously resulted in misleading "No API key found" errors.
|
|
20
|
+
- Fixed Cursor history replay issues by preserving structured message order for assistant tool calls/results, retaining Kimi K3 thinking blocks, and preventing unsafe mid-session switches to K3.
|
|
21
|
+
|
|
5
22
|
## [17.2.1] - 2026-07-30
|
|
6
23
|
|
|
7
24
|
### Added
|
|
@@ -676,7 +676,9 @@ export interface ResetCreditRedeemOutcome {
|
|
|
676
676
|
* Result code. Backend codes: `reset` (success), `already_redeemed`,
|
|
677
677
|
* `no_credit`, `nothing_to_reset`. Locally-synthesized: `no_account`
|
|
678
678
|
* (target not found), `account_unavailable` (token refresh failed),
|
|
679
|
-
* `
|
|
679
|
+
* `credit_list_failed` (transport/auth failure while listing credits —
|
|
680
|
+
* retryable, unlike a genuine `no_credit`), `http_<status>` (unexpected
|
|
681
|
+
* HTTP).
|
|
680
682
|
*/
|
|
681
683
|
code: CodexResetConsumeCode;
|
|
682
684
|
accountId?: string;
|
|
@@ -214,7 +214,7 @@ export declare function buildMcpToolDefinitions(tools: Tool[] | undefined): McpT
|
|
|
214
214
|
*/
|
|
215
215
|
export declare function buildCursorSystemPromptJsons(systemPrompt: readonly string[] | undefined): string[];
|
|
216
216
|
/** Exported for tests: decodes Cursor history blobs built from conversation messages. */
|
|
217
|
-
export declare function buildCursorHistoryForTest(messages: Message[], activeUserMessageIndex?: number): {
|
|
217
|
+
export declare function buildCursorHistoryForTest(messages: Message[], activeUserMessageIndex?: number, targetModelId?: string): {
|
|
218
218
|
rootPromptMessagesJson: unknown[];
|
|
219
219
|
turnUserMessagesJson: JsonValue[];
|
|
220
220
|
turnStepMessagesJson: JsonValue[][];
|
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
import type { Api, AssistantMessage, Message, Model } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Normalize tool call ID for cross-provider compatibility.
|
|
4
|
+
* OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
|
|
5
|
+
* Anthropic APIs require IDs matching ^[a-zA-Z0-9_-]+$ (max 64 chars).
|
|
6
|
+
*
|
|
7
|
+
* For aborted/errored turns, this function:
|
|
8
|
+
* - Preserves tool call structure (unlike converting to text summaries)
|
|
9
|
+
* - Injects synthetic "aborted" tool results
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Credential-shaped token patterns scrubbed from outbound provider traffic when
|
|
13
|
+
* credential redaction is enabled. Exported so hosts can route the same shapes
|
|
14
|
+
* through reversible obfuscation (keyed placeholders restored before local tool
|
|
15
|
+
* execution) instead of the irreversible `[*_token_redacted]` rewrite below —
|
|
16
|
+
* an irreversible placeholder echoed back in edit-tool `old_text` can never
|
|
17
|
+
* match the real bytes on disk.
|
|
18
|
+
*/
|
|
19
|
+
export declare const SENSITIVE_TOKEN_RE: RegExp;
|
|
2
20
|
/**
|
|
3
21
|
* Toggle outbound credential-pattern redaction. When disabled (the default),
|
|
4
22
|
* {@link redactSensitiveCredentials} and {@link redactSensitiveInObject} are
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { OAuthLoginCallbacks } from "./oauth/types.js";
|
|
2
|
+
export declare const loginGmiCloud: (options: import("./oauth/index.js").OAuthController) => Promise<string>;
|
|
3
|
+
export declare const gmiCloudProvider: {
|
|
4
|
+
readonly id: "gmi-cloud";
|
|
5
|
+
readonly name: "GMI Cloud";
|
|
6
|
+
readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
|
|
7
|
+
};
|
|
@@ -97,6 +97,10 @@ declare const ALL: ({
|
|
|
97
97
|
readonly refreshToken: (credentials: import("./oauth/index.js").OAuthCredentials) => Promise<import("./oauth/index.js").OAuthCredentials>;
|
|
98
98
|
readonly callbackPort: 8080;
|
|
99
99
|
readonly pasteCodeFlow: true;
|
|
100
|
+
} | {
|
|
101
|
+
readonly id: "gmi-cloud";
|
|
102
|
+
readonly name: "GMI Cloud";
|
|
103
|
+
readonly login: (cb: import("./oauth/index.js").OAuthLoginCallbacks) => Promise<string>;
|
|
100
104
|
} | {
|
|
101
105
|
readonly id: "google";
|
|
102
106
|
readonly name: "Google Gemini";
|
|
@@ -67,6 +67,15 @@ interface CodexResetAuth {
|
|
|
67
67
|
* failure (non-2xx or thrown), letting callers treat it the same as "no data".
|
|
68
68
|
*/
|
|
69
69
|
export declare function listCodexResetCredits(auth: CodexResetAuth): Promise<CodexResetCreditList | null>;
|
|
70
|
+
/**
|
|
71
|
+
* Pick the credit to spend: the available one that expires soonest.
|
|
72
|
+
*
|
|
73
|
+
* Credits are perishable, so spending in expiry order maximizes the bank's
|
|
74
|
+
* lifetime value. Available credits without a parseable `expiresAt` rank after
|
|
75
|
+
* dated ones; when nothing is available the first credit is returned unchanged
|
|
76
|
+
* (the consume then surfaces the backend's business outcome verbatim).
|
|
77
|
+
*/
|
|
78
|
+
export declare function pickSoonestExpiringCredit(credits: readonly CodexResetCredit[]): CodexResetCredit | undefined;
|
|
70
79
|
/**
|
|
71
80
|
* Spend one saved reset. `redeemRequestId` is the idempotency key; one is
|
|
72
81
|
* generated when omitted, so retrying with the SAME id is safe and won't
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "17.2.
|
|
4
|
+
"version": "17.2.2",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.1",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "17.2.
|
|
42
|
-
"@oh-my-pi/pi-utils": "17.2.
|
|
43
|
-
"@oh-my-pi/pi-wire": "17.2.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "17.2.2",
|
|
42
|
+
"@oh-my-pi/pi-utils": "17.2.2",
|
|
43
|
+
"@oh-my-pi/pi-wire": "17.2.2",
|
|
44
44
|
"arktype": "2.2.3",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
package/src/auth-storage.ts
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
type CodexResetCredit,
|
|
63
63
|
consumeCodexResetCredit,
|
|
64
64
|
listCodexResetCredits,
|
|
65
|
+
pickSoonestExpiringCredit,
|
|
65
66
|
} from "./usage/openai-codex-reset";
|
|
66
67
|
import { opencodeGoUsageProvider } from "./usage/opencode-go";
|
|
67
68
|
import { syntheticUsageProvider } from "./usage/synthetic";
|
|
@@ -952,7 +953,9 @@ export interface ResetCreditRedeemOutcome {
|
|
|
952
953
|
* Result code. Backend codes: `reset` (success), `already_redeemed`,
|
|
953
954
|
* `no_credit`, `nothing_to_reset`. Locally-synthesized: `no_account`
|
|
954
955
|
* (target not found), `account_unavailable` (token refresh failed),
|
|
955
|
-
* `
|
|
956
|
+
* `credit_list_failed` (transport/auth failure while listing credits —
|
|
957
|
+
* retryable, unlike a genuine `no_credit`), `http_<status>` (unexpected
|
|
958
|
+
* HTTP).
|
|
956
959
|
*/
|
|
957
960
|
code: CodexResetConsumeCode;
|
|
958
961
|
accountId?: string;
|
|
@@ -2327,10 +2330,19 @@ export class AuthStorage {
|
|
|
2327
2330
|
if (!current) {
|
|
2328
2331
|
return { credential: undefined, refreshed: false, removed: false };
|
|
2329
2332
|
}
|
|
2330
|
-
|
|
2333
|
+
const currentIsFresh = Date.now() + refreshSkewMs < current.expires;
|
|
2334
|
+
// A peer rotated the credential out from under the caller's observation.
|
|
2335
|
+
// Adopt the stored copy only when it is still usable; a stored copy that
|
|
2336
|
+
// is itself expired must fall through to a refresh rather than be handed
|
|
2337
|
+
// back and fail the downstream `getOAuthApiKey` expiry precondition.
|
|
2338
|
+
if (
|
|
2339
|
+
options.observedCredential &&
|
|
2340
|
+
!authCredentialEquals(current, options.observedCredential) &&
|
|
2341
|
+
currentIsFresh
|
|
2342
|
+
) {
|
|
2331
2343
|
return { credential: current, refreshed: false, removed: false };
|
|
2332
2344
|
}
|
|
2333
|
-
if (!options.forceRefresh &&
|
|
2345
|
+
if (!options.forceRefresh && currentIsFresh) {
|
|
2334
2346
|
return { credential: current, refreshed: false, removed: false };
|
|
2335
2347
|
}
|
|
2336
2348
|
if (options.canRefresh && !options.canRefresh(current)) {
|
|
@@ -2370,10 +2382,17 @@ export class AuthStorage {
|
|
|
2370
2382
|
if (!current) {
|
|
2371
2383
|
return { credential: undefined, refreshed: false, removed: false };
|
|
2372
2384
|
}
|
|
2373
|
-
|
|
2385
|
+
const currentIsFresh = Date.now() + refreshSkewMs < current.expires;
|
|
2386
|
+
// Re-check after acquiring the lease: only adopt the stored copy on an
|
|
2387
|
+
// observed mismatch when it is still usable, mirroring the pre-lease guard.
|
|
2388
|
+
if (
|
|
2389
|
+
options.observedCredential &&
|
|
2390
|
+
!authCredentialEquals(current, options.observedCredential) &&
|
|
2391
|
+
currentIsFresh
|
|
2392
|
+
) {
|
|
2374
2393
|
return { credential: current, refreshed: false, removed: false };
|
|
2375
2394
|
}
|
|
2376
|
-
if (!options.forceRefresh &&
|
|
2395
|
+
if (!options.forceRefresh && currentIsFresh) {
|
|
2377
2396
|
return { credential: current, refreshed: false, removed: false };
|
|
2378
2397
|
}
|
|
2379
2398
|
if (options.canRefresh && !options.canRefresh(current)) {
|
|
@@ -5590,7 +5609,13 @@ export class AuthStorage {
|
|
|
5590
5609
|
fetch: this.#usageFetch,
|
|
5591
5610
|
signal: options.signal,
|
|
5592
5611
|
});
|
|
5593
|
-
|
|
5612
|
+
// Transport/auth failure is NOT "no credits": callers treat `no_credit`
|
|
5613
|
+
// as terminal for the episode, so conflating them would bury a live
|
|
5614
|
+
// credit behind one flaky request.
|
|
5615
|
+
if (!list) {
|
|
5616
|
+
return { ok: false, code: "credit_list_failed", accountId: match.accountId, email: match.email };
|
|
5617
|
+
}
|
|
5618
|
+
const credit = pickSoonestExpiringCredit(list.credits);
|
|
5594
5619
|
if (!credit) return { ok: false, code: "no_credit", accountId: match.accountId, email: match.email };
|
|
5595
5620
|
creditId = credit.id;
|
|
5596
5621
|
}
|
package/src/providers/cursor.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
|
|
|
3
3
|
import http2 from "node:http2";
|
|
4
4
|
import { create, fromBinary, fromJson, type JsonValue, toBinary, toJson } from "@bufbuild/protobuf";
|
|
5
5
|
import { ValueSchema } from "@bufbuild/protobuf/wkt";
|
|
6
|
-
import type { McpToolDefinition } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
|
|
6
|
+
import type { ConversationStep, McpToolDefinition } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
|
|
7
7
|
import {
|
|
8
8
|
AgentClientMessageSchema,
|
|
9
9
|
AgentConversationTurnStructureSchema,
|
|
@@ -76,15 +76,19 @@ import {
|
|
|
76
76
|
LsSuccessSchema,
|
|
77
77
|
McpAllowlistPrecheckResultSchema,
|
|
78
78
|
McpApprovedSchema,
|
|
79
|
+
McpArgsSchema,
|
|
79
80
|
McpErrorSchema,
|
|
80
81
|
McpImageContentSchema,
|
|
81
82
|
McpRejectedSchema,
|
|
82
83
|
McpResultSchema,
|
|
83
84
|
McpSuccessSchema,
|
|
84
85
|
McpTextContentSchema,
|
|
86
|
+
McpToolCallSchema,
|
|
85
87
|
McpToolDefinitionSchema,
|
|
88
|
+
McpToolErrorSchema,
|
|
86
89
|
McpToolNotFoundSchema,
|
|
87
90
|
McpToolResultContentItemSchema,
|
|
91
|
+
McpToolResultSchema,
|
|
88
92
|
ModelDetailsSchema,
|
|
89
93
|
ReadErrorSchema,
|
|
90
94
|
ReadMcpResourceErrorSchema,
|
|
@@ -124,6 +128,8 @@ import {
|
|
|
124
128
|
SubagentAwaitResultSchema,
|
|
125
129
|
SubagentErrorSchema,
|
|
126
130
|
SubagentResultSchema,
|
|
131
|
+
ThinkingMessageSchema,
|
|
132
|
+
ToolCallSchema,
|
|
127
133
|
UserMessageActionSchema,
|
|
128
134
|
UserMessageSchema,
|
|
129
135
|
WebFetchAllowlistPrecheckResultSchema,
|
|
@@ -134,6 +140,7 @@ import {
|
|
|
134
140
|
WriteShellStdinResultSchema,
|
|
135
141
|
WriteSuccessSchema,
|
|
136
142
|
} from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
|
|
143
|
+
import { isKimiK3ModelId } from "@oh-my-pi/pi-catalog/identity";
|
|
137
144
|
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
|
|
138
145
|
import {
|
|
139
146
|
$env,
|
|
@@ -3914,9 +3921,8 @@ function handleConversationCheckpointUpdate(
|
|
|
3914
3921
|
if (usedTokens <= 0) {
|
|
3915
3922
|
return;
|
|
3916
3923
|
}
|
|
3917
|
-
if (output.usage.
|
|
3918
|
-
output.usage.
|
|
3919
|
-
output.usage.totalTokens = output.usage.input + output.usage.output;
|
|
3924
|
+
if (output.usage.contextTokens !== usedTokens) {
|
|
3925
|
+
output.usage.contextTokens = usedTokens;
|
|
3920
3926
|
}
|
|
3921
3927
|
}
|
|
3922
3928
|
|
|
@@ -4047,16 +4053,74 @@ function cursorUserContentKey(content: string | (TextContent | ImageContent)[]):
|
|
|
4047
4053
|
return hash.digest("hex");
|
|
4048
4054
|
}
|
|
4049
4055
|
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4056
|
+
type CursorRootPromptAssistantContentPart =
|
|
4057
|
+
| { type: "text"; text: string }
|
|
4058
|
+
| {
|
|
4059
|
+
type: "reasoning";
|
|
4060
|
+
text: string;
|
|
4061
|
+
providerOptions: { cursor: { modelName: string } };
|
|
4062
|
+
signature?: string;
|
|
4063
|
+
}
|
|
4064
|
+
| { type: "tool-call"; toolCallId: string; toolName: string; args: Record<string, unknown> };
|
|
4065
|
+
|
|
4066
|
+
function canReplayCursorThinking(msg: AssistantMessage, targetModelId: string | undefined): boolean {
|
|
4067
|
+
return (
|
|
4068
|
+
targetModelId !== undefined &&
|
|
4069
|
+
isKimiK3ModelId(targetModelId) &&
|
|
4070
|
+
msg.api === "cursor-agent" &&
|
|
4071
|
+
msg.provider === "cursor" &&
|
|
4072
|
+
msg.model === targetModelId
|
|
4073
|
+
);
|
|
4074
|
+
}
|
|
4075
|
+
|
|
4076
|
+
function buildCursorAssistantContent(
|
|
4077
|
+
msg: AssistantMessage,
|
|
4078
|
+
targetModelId: string | undefined,
|
|
4079
|
+
): CursorRootPromptAssistantContentPart[] {
|
|
4080
|
+
const content: CursorRootPromptAssistantContentPart[] = [];
|
|
4081
|
+
const replayThinking = canReplayCursorThinking(msg, targetModelId);
|
|
4082
|
+
for (const item of msg.content) {
|
|
4083
|
+
if (item.type === "text") {
|
|
4084
|
+
if (item.text) content.push({ type: "text", text: item.text });
|
|
4085
|
+
} else if (item.type === "thinking") {
|
|
4086
|
+
if (replayThinking && item.thinking) {
|
|
4087
|
+
content.push({
|
|
4088
|
+
type: "reasoning",
|
|
4089
|
+
text: item.thinking,
|
|
4090
|
+
providerOptions: { cursor: { modelName: msg.model } },
|
|
4091
|
+
...(item.thinkingSignature ? { signature: item.thinkingSignature } : {}),
|
|
4092
|
+
});
|
|
4093
|
+
}
|
|
4094
|
+
} else if (item.type === "toolCall") {
|
|
4095
|
+
content.push({
|
|
4096
|
+
type: "tool-call",
|
|
4097
|
+
toolCallId: item.id,
|
|
4098
|
+
toolName: item.name,
|
|
4099
|
+
args: item.arguments,
|
|
4100
|
+
});
|
|
4101
|
+
}
|
|
4102
|
+
}
|
|
4103
|
+
return content;
|
|
4104
|
+
}
|
|
4105
|
+
|
|
4106
|
+
function assertCursorKimiK3HistoryReplayable(
|
|
4107
|
+
messages: Message[],
|
|
4108
|
+
activeUserMessageIndex: number,
|
|
4109
|
+
targetModelId: string | undefined,
|
|
4110
|
+
): void {
|
|
4111
|
+
if (!targetModelId || !isKimiK3ModelId(targetModelId)) return;
|
|
4112
|
+
const historyEnd = activeUserMessageIndex >= 0 ? activeUserMessageIndex : messages.length;
|
|
4113
|
+
for (let i = 0; i < historyEnd; i++) {
|
|
4114
|
+
const msg = messages[i];
|
|
4115
|
+
if (msg.role !== "assistant") continue;
|
|
4116
|
+
const isSameCursorModel = msg.api === "cursor-agent" && msg.provider === "cursor" && msg.model === targetModelId;
|
|
4117
|
+
const hasThinking = msg.content.some(item => item.type === "thinking" && item.thinking.length > 0);
|
|
4118
|
+
if (!isSameCursorModel || !hasThinking) {
|
|
4119
|
+
throw new AIError.ValidationError(
|
|
4120
|
+
`Cursor ${targetModelId} requires complete same-model thinking history; start a new session instead of continuing history from ${msg.provider}/${msg.model}.`,
|
|
4121
|
+
);
|
|
4122
|
+
}
|
|
4123
|
+
}
|
|
4060
4124
|
}
|
|
4061
4125
|
|
|
4062
4126
|
/**
|
|
@@ -4107,7 +4171,9 @@ function buildRootPromptMessagesJson(
|
|
|
4107
4171
|
systemPromptIds: Uint8Array[],
|
|
4108
4172
|
blobStore: Map<string, Uint8Array>,
|
|
4109
4173
|
activeUserMessageIndex = findLastUserMessageIndex(messages),
|
|
4174
|
+
targetModelId?: string,
|
|
4110
4175
|
): Uint8Array[] {
|
|
4176
|
+
assertCursorKimiK3HistoryReplayable(messages, activeUserMessageIndex, targetModelId);
|
|
4111
4177
|
const entries: Uint8Array[] = [...systemPromptIds];
|
|
4112
4178
|
const pushJson = (obj: unknown) => {
|
|
4113
4179
|
const bytes = new TextEncoder().encode(JSON.stringify(obj));
|
|
@@ -4122,16 +4188,24 @@ function buildRootPromptMessagesJson(
|
|
|
4122
4188
|
if (content.length === 0) continue;
|
|
4123
4189
|
pushJson({ role: "user", content });
|
|
4124
4190
|
} else if (msg.role === "assistant") {
|
|
4125
|
-
const
|
|
4126
|
-
if (
|
|
4127
|
-
pushJson({ role: "assistant", content
|
|
4191
|
+
const content = buildCursorAssistantContent(msg, targetModelId);
|
|
4192
|
+
if (content.length === 0) continue;
|
|
4193
|
+
pushJson({ role: "assistant", content });
|
|
4128
4194
|
} else if (msg.role === "toolResult") {
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
const prefix = msg.isError ? "[Tool Error]" : "[Tool Result]";
|
|
4195
|
+
// Emit even when the result text is empty: the assistant `tool-call` is
|
|
4196
|
+
// already in history, so dropping the pair would replay an orphaned call.
|
|
4132
4197
|
pushJson({
|
|
4133
|
-
role: "
|
|
4134
|
-
|
|
4198
|
+
role: "tool",
|
|
4199
|
+
id: msg.toolCallId,
|
|
4200
|
+
content: [
|
|
4201
|
+
{
|
|
4202
|
+
type: "tool-result",
|
|
4203
|
+
toolName: msg.toolName,
|
|
4204
|
+
toolCallId: msg.toolCallId,
|
|
4205
|
+
result: toolResultToText(msg),
|
|
4206
|
+
...(msg.isError ? { isError: true } : {}),
|
|
4207
|
+
},
|
|
4208
|
+
],
|
|
4135
4209
|
});
|
|
4136
4210
|
}
|
|
4137
4211
|
}
|
|
@@ -4139,6 +4213,91 @@ function buildRootPromptMessagesJson(
|
|
|
4139
4213
|
return entries;
|
|
4140
4214
|
}
|
|
4141
4215
|
|
|
4216
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
4217
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
4218
|
+
const prototype = Object.getPrototypeOf(value);
|
|
4219
|
+
return prototype === Object.prototype || prototype === null;
|
|
4220
|
+
}
|
|
4221
|
+
|
|
4222
|
+
function isJsonValue(value: unknown): value is JsonValue {
|
|
4223
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
4224
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
4225
|
+
if (Array.isArray(value)) return value.every(isJsonValue);
|
|
4226
|
+
if (!isPlainRecord(value)) return false;
|
|
4227
|
+
for (const key in value) {
|
|
4228
|
+
if (!isJsonValue(value[key])) return false;
|
|
4229
|
+
}
|
|
4230
|
+
return true;
|
|
4231
|
+
}
|
|
4232
|
+
|
|
4233
|
+
function encodeCursorMcpArguments(toolCall: ToolCall): Record<string, Uint8Array> {
|
|
4234
|
+
const encoded: Record<string, Uint8Array> = {};
|
|
4235
|
+
for (const name in toolCall.arguments) {
|
|
4236
|
+
const value = toolCall.arguments[name];
|
|
4237
|
+
if (value === undefined) continue;
|
|
4238
|
+
if (!isJsonValue(value)) {
|
|
4239
|
+
throw new AIError.ValidationError(`Cursor tool argument ${toolCall.name}.${name} is not JSON-serializable`);
|
|
4240
|
+
}
|
|
4241
|
+
encoded[name] = toBinary(ValueSchema, fromJson(ValueSchema, value));
|
|
4242
|
+
}
|
|
4243
|
+
return encoded;
|
|
4244
|
+
}
|
|
4245
|
+
|
|
4246
|
+
function createCursorMcpResult(result: ToolResultMessage) {
|
|
4247
|
+
if (result.isError) {
|
|
4248
|
+
return create(McpToolResultSchema, {
|
|
4249
|
+
result: {
|
|
4250
|
+
case: "error",
|
|
4251
|
+
value: create(McpToolErrorSchema, { error: toolResultToText(result) }),
|
|
4252
|
+
},
|
|
4253
|
+
});
|
|
4254
|
+
}
|
|
4255
|
+
return create(McpToolResultSchema, {
|
|
4256
|
+
result: {
|
|
4257
|
+
case: "success",
|
|
4258
|
+
value: create(McpSuccessSchema, {
|
|
4259
|
+
content: result.content.map(item =>
|
|
4260
|
+
item.type === "text"
|
|
4261
|
+
? create(McpToolResultContentItemSchema, {
|
|
4262
|
+
content: { case: "text", value: create(McpTextContentSchema, { text: item.text }) },
|
|
4263
|
+
})
|
|
4264
|
+
: create(McpToolResultContentItemSchema, {
|
|
4265
|
+
content: {
|
|
4266
|
+
case: "image",
|
|
4267
|
+
value: create(McpImageContentSchema, {
|
|
4268
|
+
data: Uint8Array.from(Buffer.from(item.data, "base64")),
|
|
4269
|
+
mimeType: item.mimeType,
|
|
4270
|
+
}),
|
|
4271
|
+
},
|
|
4272
|
+
}),
|
|
4273
|
+
),
|
|
4274
|
+
}),
|
|
4275
|
+
},
|
|
4276
|
+
});
|
|
4277
|
+
}
|
|
4278
|
+
|
|
4279
|
+
function createCursorToolCallStep(toolCall: ToolCall, result: ToolResultMessage | undefined) {
|
|
4280
|
+
const mcpCall = create(McpToolCallSchema, {
|
|
4281
|
+
args: create(McpArgsSchema, {
|
|
4282
|
+
name: toolCall.name,
|
|
4283
|
+
args: encodeCursorMcpArguments(toolCall),
|
|
4284
|
+
toolCallId: toolCall.id,
|
|
4285
|
+
providerIdentifier: "pi-agent",
|
|
4286
|
+
toolName: toolCall.name,
|
|
4287
|
+
}),
|
|
4288
|
+
...(result ? { result: createCursorMcpResult(result) } : {}),
|
|
4289
|
+
});
|
|
4290
|
+
return create(ConversationStepSchema, {
|
|
4291
|
+
message: {
|
|
4292
|
+
case: "toolCall",
|
|
4293
|
+
value: create(ToolCallSchema, {
|
|
4294
|
+
tool: { case: "mcpToolCall", value: mcpCall },
|
|
4295
|
+
toolCallId: toolCall.id,
|
|
4296
|
+
}),
|
|
4297
|
+
},
|
|
4298
|
+
});
|
|
4299
|
+
}
|
|
4300
|
+
|
|
4142
4301
|
/**
|
|
4143
4302
|
* Convert context.messages to Cursor's ConversationTurnStructure blob IDs.
|
|
4144
4303
|
* Groups messages into turns: each turn is a user message followed by the assistant's response.
|
|
@@ -4151,28 +4310,32 @@ function buildConversationTurns(
|
|
|
4151
4310
|
messages: Message[],
|
|
4152
4311
|
blobStore: Map<string, Uint8Array>,
|
|
4153
4312
|
activeUserMessageIndex = findLastUserMessageIndex(messages),
|
|
4313
|
+
targetModelId?: string,
|
|
4154
4314
|
): Uint8Array[] {
|
|
4155
4315
|
const turns: Uint8Array[] = [];
|
|
4316
|
+
const historyEnd = activeUserMessageIndex >= 0 ? activeUserMessageIndex : messages.length;
|
|
4317
|
+
const toolResults = new Map<string, ToolResultMessage>();
|
|
4318
|
+
const pairedToolCallIds = new Set<string>();
|
|
4319
|
+
for (let index = 0; index < historyEnd; index++) {
|
|
4320
|
+
const message = messages[index];
|
|
4321
|
+
if (message.role === "toolResult") {
|
|
4322
|
+
toolResults.set(message.toolCallId, message);
|
|
4323
|
+
} else if (message.role === "assistant") {
|
|
4324
|
+
for (const item of message.content) {
|
|
4325
|
+
if (item.type === "toolCall") pairedToolCallIds.add(item.id);
|
|
4326
|
+
}
|
|
4327
|
+
}
|
|
4328
|
+
}
|
|
4156
4329
|
|
|
4157
|
-
// Find turn boundaries - each turn starts with a user message
|
|
4158
4330
|
let i = 0;
|
|
4159
4331
|
while (i < messages.length) {
|
|
4160
4332
|
const msg = messages[i];
|
|
4161
|
-
|
|
4162
|
-
// Skip non-user messages at the start
|
|
4163
4333
|
if (msg.role !== "user" && msg.role !== "developer") {
|
|
4164
4334
|
i++;
|
|
4165
4335
|
continue;
|
|
4166
4336
|
}
|
|
4337
|
+
if (i === activeUserMessageIndex) break;
|
|
4167
4338
|
|
|
4168
|
-
// The active user message goes in the action, not turns. A prior user
|
|
4169
|
-
// followed by assistant/tool-result messages is complete history and
|
|
4170
|
-
// must remain serialized for resume actions.
|
|
4171
|
-
if (i === activeUserMessageIndex) {
|
|
4172
|
-
break;
|
|
4173
|
-
}
|
|
4174
|
-
|
|
4175
|
-
// Create and serialize user message
|
|
4176
4339
|
const userText = extractUserMessageText(msg);
|
|
4177
4340
|
if (userText.length === 0 && !hasUserMessageImages(msg)) {
|
|
4178
4341
|
i++;
|
|
@@ -4184,29 +4347,42 @@ function buildConversationTurns(
|
|
|
4184
4347
|
userText,
|
|
4185
4348
|
deterministicUuid(`u:${turns.length}:${cursorUserContentKey(msg.content)}`),
|
|
4186
4349
|
);
|
|
4187
|
-
const
|
|
4188
|
-
const userMessageBlobId = storeCursorBlob(blobStore, userMessageBytes);
|
|
4189
|
-
|
|
4190
|
-
// Collect and serialize steps until next user message
|
|
4350
|
+
const userMessageBlobId = storeCursorBlob(blobStore, toBinary(UserMessageSchema, userMessage));
|
|
4191
4351
|
const stepBlobIds: Uint8Array[] = [];
|
|
4192
4352
|
i++;
|
|
4193
4353
|
|
|
4194
4354
|
while (i < messages.length && messages[i].role !== "user" && messages[i].role !== "developer") {
|
|
4195
4355
|
const stepMsg = messages[i];
|
|
4196
|
-
|
|
4197
4356
|
if (stepMsg.role === "assistant") {
|
|
4198
|
-
const
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4357
|
+
for (const item of stepMsg.content) {
|
|
4358
|
+
let step: ConversationStep;
|
|
4359
|
+
if (item.type === "text") {
|
|
4360
|
+
if (!item.text) continue;
|
|
4361
|
+
step = create(ConversationStepSchema, {
|
|
4362
|
+
message: {
|
|
4363
|
+
case: "assistantMessage",
|
|
4364
|
+
value: create(AssistantMessageSchema, { text: item.text }),
|
|
4365
|
+
},
|
|
4366
|
+
});
|
|
4367
|
+
} else if (item.type === "thinking") {
|
|
4368
|
+
// Same guard as root-prompt replay: only same-model Cursor K3
|
|
4369
|
+
// thinking is replayed, so foreign/hidden reasoning never leaks
|
|
4370
|
+
// into Cursor's turn history as native thinking.
|
|
4371
|
+
if (!item.thinking || !canReplayCursorThinking(stepMsg, targetModelId)) continue;
|
|
4372
|
+
step = create(ConversationStepSchema, {
|
|
4373
|
+
message: {
|
|
4374
|
+
case: "thinkingMessage",
|
|
4375
|
+
value: create(ThinkingMessageSchema, { text: item.thinking }),
|
|
4376
|
+
},
|
|
4377
|
+
});
|
|
4378
|
+
} else if (item.type === "toolCall") {
|
|
4379
|
+
step = createCursorToolCallStep(item, toolResults.get(item.id));
|
|
4380
|
+
} else {
|
|
4381
|
+
continue;
|
|
4382
|
+
}
|
|
4206
4383
|
stepBlobIds.push(storeCursorBlob(blobStore, toBinary(ConversationStepSchema, step)));
|
|
4207
4384
|
}
|
|
4208
|
-
} else if (stepMsg.role === "toolResult") {
|
|
4209
|
-
// Include tool results as assistant text for context
|
|
4385
|
+
} else if (stepMsg.role === "toolResult" && !pairedToolCallIds.has(stepMsg.toolCallId)) {
|
|
4210
4386
|
const text = toolResultToText(stepMsg);
|
|
4211
4387
|
if (text) {
|
|
4212
4388
|
const prefix = stepMsg.isError ? "[Tool Error]" : "[Tool Result]";
|
|
@@ -4219,12 +4395,9 @@ function buildConversationTurns(
|
|
|
4219
4395
|
stepBlobIds.push(storeCursorBlob(blobStore, toBinary(ConversationStepSchema, step)));
|
|
4220
4396
|
}
|
|
4221
4397
|
}
|
|
4222
|
-
|
|
4223
4398
|
i++;
|
|
4224
4399
|
}
|
|
4225
4400
|
|
|
4226
|
-
// Create the serialized turn using Structure types. The bytes fields
|
|
4227
|
-
// (user_message, steps) are blob IDs resolved through the KV store.
|
|
4228
4401
|
const agentTurn = create(AgentConversationTurnStructureSchema, {
|
|
4229
4402
|
userMessage: userMessageBlobId,
|
|
4230
4403
|
steps: stepBlobIds,
|
|
@@ -4245,18 +4418,23 @@ function buildConversationTurns(
|
|
|
4245
4418
|
export function buildCursorHistoryForTest(
|
|
4246
4419
|
messages: Message[],
|
|
4247
4420
|
activeUserMessageIndex = findLastUserMessageIndex(messages),
|
|
4421
|
+
targetModelId?: string,
|
|
4248
4422
|
): {
|
|
4249
4423
|
rootPromptMessagesJson: unknown[];
|
|
4250
4424
|
turnUserMessagesJson: JsonValue[];
|
|
4251
4425
|
turnStepMessagesJson: JsonValue[][];
|
|
4252
4426
|
} {
|
|
4253
4427
|
const blobStore = new Map<string, Uint8Array>();
|
|
4254
|
-
const rootPromptMessagesJson = buildRootPromptMessagesJson(
|
|
4255
|
-
|
|
4256
|
-
|
|
4428
|
+
const rootPromptMessagesJson = buildRootPromptMessagesJson(
|
|
4429
|
+
messages,
|
|
4430
|
+
[],
|
|
4431
|
+
blobStore,
|
|
4432
|
+
activeUserMessageIndex,
|
|
4433
|
+
targetModelId,
|
|
4434
|
+
).map(blobId => JSON.parse(new TextDecoder().decode(readCursorBlob(blobStore, blobId))));
|
|
4257
4435
|
const turnUserMessagesJson: JsonValue[] = [];
|
|
4258
4436
|
const turnStepMessagesJson: JsonValue[][] = [];
|
|
4259
|
-
for (const turnBlobId of buildConversationTurns(messages, blobStore, activeUserMessageIndex)) {
|
|
4437
|
+
for (const turnBlobId of buildConversationTurns(messages, blobStore, activeUserMessageIndex, targetModelId)) {
|
|
4260
4438
|
const turn = fromBinary(ConversationTurnStructureSchema, readCursorBlob(blobStore, turnBlobId));
|
|
4261
4439
|
if (turn.turn.case !== "agentConversationTurn") {
|
|
4262
4440
|
continue;
|
|
@@ -4360,7 +4538,12 @@ function buildGrpcRequest(
|
|
|
4360
4538
|
|
|
4361
4539
|
// Build conversation turns from prior messages, excluding only the active user message
|
|
4362
4540
|
// when the request is sending one. Resume actions must preserve trailing tool results.
|
|
4363
|
-
const turns = buildConversationTurns(
|
|
4541
|
+
const turns = buildConversationTurns(
|
|
4542
|
+
context.messages,
|
|
4543
|
+
blobStore,
|
|
4544
|
+
activeUserMessage ? activeUserMessageIndex : -1,
|
|
4545
|
+
model.id,
|
|
4546
|
+
);
|
|
4364
4547
|
|
|
4365
4548
|
// Build `rootPromptMessagesJson` from prior messages. Cursor's server uses this
|
|
4366
4549
|
// field (not `turns[]`) to construct the actual model prompt; if we only send the
|
|
@@ -4371,6 +4554,7 @@ function buildGrpcRequest(
|
|
|
4371
4554
|
systemPromptIds,
|
|
4372
4555
|
blobStore,
|
|
4373
4556
|
activeUserMessage ? activeUserMessageIndex : -1,
|
|
4557
|
+
model.id,
|
|
4374
4558
|
);
|
|
4375
4559
|
|
|
4376
4560
|
// Preserve cached non-history state fields (todos, file states, summaries, etc.)
|
|
@@ -295,7 +295,15 @@ function normalizeAnthropicTargetToolCallId<TApi extends Api>(
|
|
|
295
295
|
* - Preserves tool call structure (unlike converting to text summaries)
|
|
296
296
|
* - Injects synthetic "aborted" tool results
|
|
297
297
|
*/
|
|
298
|
-
|
|
298
|
+
/**
|
|
299
|
+
* Credential-shaped token patterns scrubbed from outbound provider traffic when
|
|
300
|
+
* credential redaction is enabled. Exported so hosts can route the same shapes
|
|
301
|
+
* through reversible obfuscation (keyed placeholders restored before local tool
|
|
302
|
+
* execution) instead of the irreversible `[*_token_redacted]` rewrite below —
|
|
303
|
+
* an irreversible placeholder echoed back in edit-tool `old_text` can never
|
|
304
|
+
* match the real bytes on disk.
|
|
305
|
+
*/
|
|
306
|
+
export const SENSITIVE_TOKEN_RE =
|
|
299
307
|
/(?<![a-zA-Z0-9_*-])(gh[opusr]_[a-zA-Z0-9_*]{36,}|github_pat_[a-zA-Z0-9_*]{36,}|glpat-[a-zA-Z0-9_*-]{20,}|sk-proj-[a-zA-Z0-9_*-]{36,}|sk-ant-[a-zA-Z0-9_*-]{36,}|sk-[a-zA-Z0-9_*-]{48,})(?![a-zA-Z0-9_*-])/gi;
|
|
300
308
|
|
|
301
309
|
function hasPlausibleCredentialEntropy(token: string): boolean {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createApiKeyLogin } from "./api-key-login";
|
|
2
|
+
import type { OAuthLoginCallbacks } from "./oauth/types";
|
|
3
|
+
import type { ProviderDefinition } from "./types";
|
|
4
|
+
|
|
5
|
+
export const loginGmiCloud = createApiKeyLogin({
|
|
6
|
+
providerLabel: "GMI Cloud",
|
|
7
|
+
authUrl: "https://console.gmicloud.ai",
|
|
8
|
+
instructions: "Create or copy your GMI Cloud API key",
|
|
9
|
+
promptMessage: "Paste your GMI Cloud API key",
|
|
10
|
+
placeholder: "eyJ...",
|
|
11
|
+
validation: {
|
|
12
|
+
kind: "models-endpoint",
|
|
13
|
+
provider: "GMI Cloud",
|
|
14
|
+
modelsUrl: "https://api.gmi-serving.com/v1/models",
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const gmiCloudProvider = {
|
|
19
|
+
id: "gmi-cloud",
|
|
20
|
+
name: "GMI Cloud",
|
|
21
|
+
login: (cb: OAuthLoginCallbacks) => loginGmiCloud(cb),
|
|
22
|
+
} as const satisfies ProviderDefinition;
|
package/src/registry/registry.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { fireworksProvider } from "./fireworks";
|
|
|
18
18
|
import { githubCopilotProvider } from "./github-copilot";
|
|
19
19
|
import { gitlabDuoProvider } from "./gitlab-duo";
|
|
20
20
|
import { gitLabDuoWorkflowProvider } from "./gitlab-duo-workflow";
|
|
21
|
+
import { gmiCloudProvider } from "./gmi-cloud";
|
|
21
22
|
import { googleProvider } from "./google";
|
|
22
23
|
import { googleAntigravityProvider } from "./google-antigravity";
|
|
23
24
|
import { googleGeminiCliProvider } from "./google-gemini-cli";
|
|
@@ -154,6 +155,7 @@ const ALL = [
|
|
|
154
155
|
mistralProvider,
|
|
155
156
|
minimaxProvider,
|
|
156
157
|
amazonBedrockProvider,
|
|
158
|
+
gmiCloudProvider,
|
|
157
159
|
];
|
|
158
160
|
|
|
159
161
|
export type RegistryDef = (typeof ALL)[number];
|
|
@@ -146,6 +146,33 @@ export async function listCodexResetCredits(auth: CodexResetAuth): Promise<Codex
|
|
|
146
146
|
return { credits, availableCount };
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Pick the credit to spend: the available one that expires soonest.
|
|
151
|
+
*
|
|
152
|
+
* Credits are perishable, so spending in expiry order maximizes the bank's
|
|
153
|
+
* lifetime value. Available credits without a parseable `expiresAt` rank after
|
|
154
|
+
* dated ones; when nothing is available the first credit is returned unchanged
|
|
155
|
+
* (the consume then surfaces the backend's business outcome verbatim).
|
|
156
|
+
*/
|
|
157
|
+
export function pickSoonestExpiringCredit(credits: readonly CodexResetCredit[]): CodexResetCredit | undefined {
|
|
158
|
+
let best: CodexResetCredit | undefined;
|
|
159
|
+
let bestExpiry = Number.POSITIVE_INFINITY;
|
|
160
|
+
let undated: CodexResetCredit | undefined;
|
|
161
|
+
for (const credit of credits) {
|
|
162
|
+
if ((credit.status ?? "available") !== "available") continue;
|
|
163
|
+
const expiry = credit.expiresAt ? Date.parse(credit.expiresAt) : Number.NaN;
|
|
164
|
+
if (Number.isNaN(expiry)) {
|
|
165
|
+
undated ??= credit;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (expiry < bestExpiry) {
|
|
169
|
+
best = credit;
|
|
170
|
+
bestExpiry = expiry;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return best ?? undated ?? credits[0];
|
|
174
|
+
}
|
|
175
|
+
|
|
149
176
|
/**
|
|
150
177
|
* Spend one saved reset. `redeemRequestId` is the idempotency key; one is
|
|
151
178
|
* generated when omitted, so retrying with the SAME id is safe and won't
|