@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 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
- * `http_<status>` (unexpected HTTP).
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.1",
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.1",
42
- "@oh-my-pi/pi-utils": "17.2.1",
43
- "@oh-my-pi/pi-wire": "17.2.1",
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
  },
@@ -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
- * `http_<status>` (unexpected HTTP).
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
- if (options.observedCredential && !authCredentialEquals(current, options.observedCredential)) {
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 && Date.now() + refreshSkewMs < current.expires) {
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
- if (options.observedCredential && !authCredentialEquals(current, options.observedCredential)) {
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 && Date.now() + refreshSkewMs < current.expires) {
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
- const credit = list?.credits.find(entry => (entry.status ?? "available") === "available") ?? list?.credits[0];
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
  }
@@ -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.output !== usedTokens) {
3918
- output.usage.output = usedTokens;
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
- * Extract text content from an assistant message.
4052
- */
4053
- function extractAssistantMessageText(msg: Message): string {
4054
- if (msg.role !== "assistant") return "";
4055
- if (!Array.isArray(msg.content)) return "";
4056
- return msg.content
4057
- .filter((c): c is TextContent => c.type === "text")
4058
- .map(c => c.text)
4059
- .join("\n");
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 text = extractAssistantMessageText(msg);
4126
- if (!text) continue;
4127
- pushJson({ role: "assistant", content: [{ type: "text", text }] });
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
- const text = toolResultToText(msg);
4130
- if (!text) continue;
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: "user",
4134
- content: [{ type: "text", text: `${prefix}\n${text}` }],
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 userMessageBytes = toBinary(UserMessageSchema, userMessage);
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 text = extractAssistantMessageText(stepMsg);
4199
- if (text) {
4200
- const step = create(ConversationStepSchema, {
4201
- message: {
4202
- case: "assistantMessage",
4203
- value: create(AssistantMessageSchema, { text }),
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(messages, [], blobStore, activeUserMessageIndex).map(
4255
- blobId => JSON.parse(new TextDecoder().decode(readCursorBlob(blobStore, blobId))),
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(context.messages, blobStore, activeUserMessage ? activeUserMessageIndex : -1);
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
- const SENSITIVE_TOKEN_RE =
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;
@@ -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