@oh-my-pi/pi-ai 17.2.1 → 17.2.3

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,34 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.3] - 2026-08-01
6
+
7
+ ### Added
8
+
9
+ - Added the ai& (`aiand`) provider registry entry with API-key paste login validated against `https://api.aiand.com/v1/models`.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed Anthropic OAuth (Claude Pro/Max subscription) requests hard-429ing (`Usage credits are required for long context requests`) on every beta-gated 1M model — e.g. `claude-sonnet-4-6`, which the default `task`/`smol`/`scout` subagent roles resolve to — regardless of prompt size, breaking all subagents. The 17.2.1 cowork request profile reintroduced the `context-1m-2025-08-07` beta for any model with a 1M catalog window, but subscription credentials have no long-context credit balance so Anthropic rejects the request outright. The beta is no longer advertised on OAuth requests; subscription accounts transparently get the standard 200k window. ([#7238](https://github.com/can1357/oh-my-pi/issues/7238))
14
+ - Fixed OpenAI Codex Responses ignoring disabled cache retention when deriving `prompt_cache_key`, while preserving transport session identity ([#7219](https://github.com/can1357/oh-my-pi/issues/7219)).
15
+
16
+ ## [17.2.2] - 2026-07-31
17
+
18
+ ### Added
19
+
20
+ - Added support for the `gmi-cloud` provider registry, including API-key paste login validation and integration with `@oh-my-pi/pi-catalog`.
21
+
22
+ ### Changed
23
+
24
+ - 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.
25
+ - Exported `SENSITIVE_TOKEN_RE` from `providers/transform-messages` to allow hosts to route credential shapes through reversible obfuscation instead of irreversible redaction.
26
+
27
+ ### Fixed
28
+
29
+ - Fixed an issue where Cursor conversation checkpoints were incorrectly recorded as billable output tokens, ensuring accurate usage totals.
30
+ - 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.
31
+ - 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.
32
+
5
33
  ## [17.2.1] - 2026-07-30
6
34
 
7
35
  ### 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 loginAiand: (options: import("./oauth/index.js").OAuthController) => Promise<string>;
3
+ export declare const aiandProvider: {
4
+ readonly id: "aiand";
5
+ readonly name: "ai&";
6
+ readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
7
+ };
@@ -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
+ };
@@ -7,6 +7,10 @@ import type { ProviderDefinition } from "./types.js";
7
7
  * list for the loginable providers; non-login model providers are appended.
8
8
  */
9
9
  declare const ALL: ({
10
+ readonly id: "aiand";
11
+ readonly name: "ai&";
12
+ readonly login: (cb: import("./oauth/index.js").OAuthLoginCallbacks) => Promise<string>;
13
+ } | {
10
14
  readonly id: "aimlapi";
11
15
  readonly name: "AIML API";
12
16
  } | {
@@ -97,6 +101,10 @@ declare const ALL: ({
97
101
  readonly refreshToken: (credentials: import("./oauth/index.js").OAuthCredentials) => Promise<import("./oauth/index.js").OAuthCredentials>;
98
102
  readonly callbackPort: 8080;
99
103
  readonly pasteCodeFlow: true;
104
+ } | {
105
+ readonly id: "gmi-cloud";
106
+ readonly name: "GMI Cloud";
107
+ readonly login: (cb: import("./oauth/index.js").OAuthLoginCallbacks) => Promise<string>;
100
108
  } | {
101
109
  readonly id: "google";
102
110
  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.3",
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.3",
42
+ "@oh-my-pi/pi-utils": "17.2.3",
43
+ "@oh-my-pi/pi-wire": "17.2.3",
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
  }
@@ -157,7 +157,6 @@ function mergeAnthropicBetaHeader(callerHeaders: Record<string, string>, beta: s
157
157
  const midConversationSystemBeta = "mid-conversation-system-2026-04-07";
158
158
  const contextManagementBeta = "context-management-2025-06-27";
159
159
  const structuredOutputsBeta = "structured-outputs-2025-12-15";
160
- const context1mBeta = "context-1m-2025-08-07";
161
160
  const thinkingTokenCountBeta = "thinking-token-count-2026-05-13";
162
161
  const fallbackCreditBeta = "fallback-credit-2026-06-01";
163
162
  const coworkUtilityBetaDefaults = [
@@ -187,15 +186,18 @@ const serverSideFallbackBeta = "server-side-fallback-2026-06-01";
187
186
  function buildCoworkBetas(
188
187
  agentRequest: boolean,
189
188
  thinkingRequest: boolean,
190
- longContext: boolean,
191
189
  disableStrictTools = false,
192
190
  ): readonly string[] {
191
+ // `context-1m-2025-08-07` is intentionally never advertised. OAuth
192
+ // subscription credentials have no long-context credit balance, so Anthropic
193
+ // hard-429s ("Usage credits are required for long context requests") on any
194
+ // beta-gated 1M model regardless of prompt size (#7238). Natively-1M models
195
+ // (e.g. claude-sonnet-5) serve their full window without the beta anyway.
193
196
  if (!agentRequest && !disableStrictTools) return coworkUtilityBetaDefaults;
194
197
  const betas: string[] = [];
195
198
  for (const beta of agentRequest ? coworkAgentBetaDefaults : coworkUtilityBetaDefaults) {
196
199
  if (disableStrictTools && beta === structuredOutputsBeta) continue;
197
200
  betas.push(beta);
198
- if (agentRequest && longContext && beta === "claude-code-20250219") betas.push(context1mBeta);
199
201
  }
200
202
  if (!agentRequest) return betas;
201
203
  if (thinkingRequest) betas.push(effortBeta);
@@ -245,7 +247,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
245
247
  // Cowork's beta profile is part of the OAuth fingerprint; API-key requests
246
248
  // default to extras only, matching the streaming path.
247
249
  const betaHeader = buildBetaHeader(
248
- options.coworkBetas ?? (oauthToken ? buildCoworkBetas(true, true, false) : []),
250
+ options.coworkBetas ?? (oauthToken ? buildCoworkBetas(true, true) : []),
249
251
  extraBetas,
250
252
  );
251
253
  const acceptHeader = oauthToken ? "application/json" : stream ? "text/event-stream" : "application/json";
@@ -2949,14 +2951,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
2949
2951
  isCloudflareAiGateway: model.provider === "cloudflare-ai-gateway",
2950
2952
  allowAnthropicHeaderOverrides: model.compat.allowAnthropicHeaderOverrides,
2951
2953
  claudeCodeSessionId,
2952
- coworkBetas: oauthToken
2953
- ? buildCoworkBetas(
2954
- hasTools || thinkingEnabled,
2955
- thinkingEnabled,
2956
- (model.contextWindow ?? 0) >= 1_000_000,
2957
- disableStrictTools,
2958
- )
2959
- : [],
2954
+ coworkBetas: oauthToken ? buildCoworkBetas(hasTools || thinkingEnabled, thinkingEnabled, disableStrictTools) : [],
2960
2955
  });
2961
2956
 
2962
2957
  if (model.provider === "cloudflare-ai-gateway") {
@@ -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.)
@@ -111,6 +111,7 @@ import {
111
111
  finalizePendingResponsesToolCalls,
112
112
  finalizeReasoningThinking,
113
113
  finalizeToolCallArgumentsDone,
114
+ getOpenAIPromptCacheKey,
114
115
  hasExecutableIncompleteResponsesToolCalls,
115
116
  isOpenAIResponsesProgressEvent,
116
117
  mapOpenAIResponsesStopReason,
@@ -1373,7 +1374,7 @@ async function buildCodexRequestContext(
1373
1374
  const accountId = getCodexAccountId(apiKey);
1374
1375
  const baseUrl = model.baseUrl || CODEX_BASE_URL;
1375
1376
  const url = resolveCodexResponsesUrl(baseUrl);
1376
- const promptCacheKey = normalizeOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
1377
+ const promptCacheKey = getOpenAIPromptCacheKey(options);
1377
1378
  const transportSessionId = normalizeOpenAIPromptCacheKey(options?.sessionId);
1378
1379
  const codexClientVersion = CODEX_CLIENT_VERSION;
1379
1380
  const transformedBody = await buildTransformedCodexRequestBody(model, context, options, promptCacheKey);
@@ -1463,7 +1464,7 @@ export async function buildTransformedCodexRequestBody(
1463
1464
  model: Model<"openai-codex-responses">,
1464
1465
  context: Context,
1465
1466
  options: OpenAICodexResponsesOptions | undefined,
1466
- promptCacheKey = normalizeOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId),
1467
+ promptCacheKey = getOpenAIPromptCacheKey(options),
1467
1468
  ): Promise<RequestBody> {
1468
1469
  const params: RequestBody = {
1469
1470
  model: model.requestModelId ?? model.id,
@@ -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 loginAiand = createApiKeyLogin({
6
+ providerLabel: "ai&",
7
+ authUrl: "https://console.aiand.com/api-keys",
8
+ instructions: "Copy your API key from the ai& console",
9
+ promptMessage: "Paste your ai& API key",
10
+ placeholder: "sk-...",
11
+ validation: {
12
+ kind: "models-endpoint",
13
+ provider: "ai&",
14
+ modelsUrl: "https://api.aiand.com/v1/models",
15
+ },
16
+ });
17
+
18
+ export const aiandProvider = {
19
+ id: "aiand",
20
+ name: "ai&",
21
+ login: (cb: OAuthLoginCallbacks) => loginAiand(cb),
22
+ } as const satisfies ProviderDefinition;
@@ -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;
@@ -1,4 +1,5 @@
1
1
  import type { KnownProvider } from "@oh-my-pi/pi-catalog";
2
+ import { aiandProvider } from "./aiand";
2
3
  import { aimlApiProvider } from "./aimlapi";
3
4
  import { alibabaCodingPlanProvider } from "./alibaba-coding-plan";
4
5
  import { alibabaTokenPlanProvider } from "./alibaba-token-plan";
@@ -18,6 +19,7 @@ import { fireworksProvider } from "./fireworks";
18
19
  import { githubCopilotProvider } from "./github-copilot";
19
20
  import { gitlabDuoProvider } from "./gitlab-duo";
20
21
  import { gitLabDuoWorkflowProvider } from "./gitlab-duo-workflow";
22
+ import { gmiCloudProvider } from "./gmi-cloud";
21
23
  import { googleProvider } from "./google";
22
24
  import { googleAntigravityProvider } from "./google-antigravity";
23
25
  import { googleGeminiCliProvider } from "./google-gemini-cli";
@@ -100,6 +102,7 @@ const ALL = [
100
102
  gitLabDuoWorkflowProvider,
101
103
  alibabaCodingPlanProvider,
102
104
  alibabaTokenPlanProvider,
105
+ aiandProvider,
103
106
  aimlApiProvider,
104
107
  zhipuCodingPlanProvider,
105
108
  umansProvider,
@@ -154,6 +157,7 @@ const ALL = [
154
157
  mistralProvider,
155
158
  minimaxProvider,
156
159
  amazonBedrockProvider,
160
+ gmiCloudProvider,
157
161
  ];
158
162
 
159
163
  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