cursor-opencode-provider 0.8.1 → 0.8.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.
Files changed (96) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +204 -37
  3. package/SECURITY.md +20 -8
  4. package/dist/activity.d.ts +73 -1
  5. package/dist/activity.js +231 -5
  6. package/dist/agent-url.js +37 -9
  7. package/dist/auth-renewal.d.ts +1 -1
  8. package/dist/auth-renewal.js +58 -26
  9. package/dist/billing.d.ts +105 -0
  10. package/dist/billing.js +212 -0
  11. package/dist/classic-tool-results.d.ts +1 -0
  12. package/dist/classic-tool-results.js +42 -0
  13. package/dist/context/build.d.ts +1 -0
  14. package/dist/context/build.js +32 -5
  15. package/dist/context/env.js +27 -3
  16. package/dist/context/epoch.js +9 -5
  17. package/dist/context/git.d.ts +5 -2
  18. package/dist/context/git.js +17 -7
  19. package/dist/context/paths.d.ts +1 -1
  20. package/dist/context/paths.js +7 -4
  21. package/dist/debug.js +13 -2
  22. package/dist/early-tool-rows.d.ts +60 -0
  23. package/dist/early-tool-rows.js +96 -0
  24. package/dist/errors.d.ts +49 -7
  25. package/dist/errors.js +163 -8
  26. package/dist/host-interrupt.d.ts +12 -0
  27. package/dist/host-interrupt.js +18 -0
  28. package/dist/host-steer.d.ts +71 -0
  29. package/dist/host-steer.js +232 -0
  30. package/dist/image-input.js +4 -3
  31. package/dist/index.d.ts +2 -2
  32. package/dist/language-model.d.ts +114 -23
  33. package/dist/language-model.js +2290 -469
  34. package/dist/lost-request.d.ts +39 -0
  35. package/dist/lost-request.js +111 -0
  36. package/dist/model-config.d.ts +3 -1
  37. package/dist/model-config.js +50 -29
  38. package/dist/models.d.ts +26 -3
  39. package/dist/models.js +101 -45
  40. package/dist/opencode2/tool-results.d.ts +18 -0
  41. package/dist/opencode2/tool-results.js +66 -0
  42. package/dist/plugin-opencode2.js +185 -25
  43. package/dist/plugin.js +28 -5
  44. package/dist/pricing-data.d.ts +26 -2
  45. package/dist/pricing-data.js +26 -2
  46. package/dist/pricing.d.ts +1 -1
  47. package/dist/pricing.js +7 -4
  48. package/dist/process-shared.d.ts +14 -0
  49. package/dist/process-shared.js +18 -0
  50. package/dist/protocol/apply-patch.d.ts +25 -13
  51. package/dist/protocol/apply-patch.js +79 -30
  52. package/dist/protocol/blob-store.d.ts +5 -1
  53. package/dist/protocol/blob-store.js +10 -6
  54. package/dist/protocol/client-version.js +6 -4
  55. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  56. package/dist/protocol/conversation-cache-header.js +149 -0
  57. package/dist/protocol/conversation-persistence.d.ts +27 -1
  58. package/dist/protocol/conversation-persistence.js +628 -126
  59. package/dist/protocol/conversation-state.d.ts +8 -1
  60. package/dist/protocol/conversation-state.js +28 -7
  61. package/dist/protocol/host-notes.d.ts +31 -0
  62. package/dist/protocol/host-notes.js +85 -0
  63. package/dist/protocol/messages.js +91 -8
  64. package/dist/protocol/request.d.ts +35 -19
  65. package/dist/protocol/request.js +53 -28
  66. package/dist/protocol/sent-history-images.d.ts +49 -0
  67. package/dist/protocol/sent-history-images.js +102 -0
  68. package/dist/protocol/tool-call-bridge.d.ts +2 -0
  69. package/dist/protocol/tool-call-bridge.js +10 -0
  70. package/dist/protocol/tools.d.ts +74 -8
  71. package/dist/protocol/tools.js +431 -141
  72. package/dist/protocol/turn-provenance.d.ts +19 -4
  73. package/dist/protocol/turn-provenance.js +77 -20
  74. package/dist/replay-safety.d.ts +16 -4
  75. package/dist/replay-safety.js +34 -25
  76. package/dist/retry-budget.d.ts +21 -0
  77. package/dist/retry-budget.js +94 -0
  78. package/dist/runtime-files.d.ts +23 -0
  79. package/dist/runtime-files.js +581 -0
  80. package/dist/session.d.ts +94 -9
  81. package/dist/session.js +153 -34
  82. package/dist/shared.d.ts +1 -1
  83. package/dist/shared.js +1 -1
  84. package/dist/shell-timeout.d.ts +54 -10
  85. package/dist/shell-timeout.js +362 -92
  86. package/dist/stream-write.d.ts +20 -0
  87. package/dist/stream-write.js +87 -0
  88. package/dist/terminal-runner.d.ts +15 -0
  89. package/dist/terminal-runner.js +528 -0
  90. package/dist/transport/connect.d.ts +8 -1
  91. package/dist/transport/connect.js +103 -23
  92. package/dist/usage.d.ts +37 -17
  93. package/dist/usage.js +63 -29
  94. package/package.json +3 -2
  95. package/dist/protocol/thinking.d.ts +0 -15
  96. package/dist/protocol/thinking.js +0 -17
@@ -4,6 +4,7 @@ import { AuthExchangeError, decodeJwtPayload, exchangeApiKey, isExchangeableApiK
4
4
  // OpenCode's SessionRetry keys off such words in provider error messages.
5
5
  import { CursorAuthError, CursorServerError, sanitizeHostTerminalMessage } from "./errors.js";
6
6
  import { errorMessage, trace } from "./debug.js";
7
+ import { processShared } from "./process-shared.js";
7
8
  // Credential renewal policy. The two Cursor credential kinds are renewed by
8
9
  // independent paths and never stand in for each other:
9
10
  //
@@ -76,6 +77,12 @@ function isoTime(ms) {
76
77
  function backoffMs(failures) {
77
78
  return Math.min(BACKOFF_MAX_MS, BACKOFF_INITIAL_MS * 2 ** Math.max(0, failures - 1));
78
79
  }
80
+ function terminalError(failure) {
81
+ return new CursorAuthError(failure.message, {
82
+ code: failure.code,
83
+ ...(failure.statusCode !== undefined ? { statusCode: failure.statusCode } : {}),
84
+ });
85
+ }
79
86
  function stateFor(map, key) {
80
87
  let state = map.get(key);
81
88
  if (!state) {
@@ -94,27 +101,34 @@ function recordSuccess(state) {
94
101
  state.retryAt = undefined;
95
102
  state.lastFailure = undefined;
96
103
  }
97
- const sessionStates = new Map();
98
- /** Stored refresh token → newer session token this process obtained for it. */
99
- const sessionSuccessors = new Map();
104
+ // Per credential, not per module copy: every location OpenCode 2 serves loads
105
+ // its own copy, and each would otherwise renew the same login separately.
106
+ const sessionStates = processShared("auth-renewal.session-states.v1", () => new Map());
107
+ /** API base + stored refresh token → newer session token obtained there. */
108
+ const sessionSuccessors = processShared("auth-renewal.session-successors.v2", () => new Map());
100
109
  /**
101
110
  * Follow renewals this process already made, so a caller still holding an
102
111
  * older stored credential (persisting failed, or a host that persists only
103
112
  * later) gets the newest token instead of renewing again.
104
113
  */
105
- export function latestSessionTokens(tokens) {
114
+ export function latestSessionTokens(tokens, baseUrl = API_BASE) {
106
115
  let current = tokens;
107
116
  for (let hops = 0; hops < MAX_SESSION_SUCCESSORS; hops++) {
108
- const next = sessionSuccessors.get(current.refreshToken);
117
+ const next = sessionSuccessors.get(`${baseUrl}\0${current.refreshToken}`);
109
118
  if (!next || next === current.refreshToken)
110
119
  break;
111
120
  current = { accessToken: next, refreshToken: next };
112
121
  }
122
+ // A verified successor replaces the token it renewed even when issued in
123
+ // the same expiry second. A separately stored newer access token still wins.
124
+ if (tokens.accessToken === tokens.refreshToken)
125
+ return current;
113
126
  return longerLived(tokens.accessToken, current.accessToken) === current.accessToken ? current : tokens;
114
127
  }
115
- function rememberSuccessor(refreshToken, accessToken) {
116
- sessionSuccessors.delete(refreshToken);
117
- sessionSuccessors.set(refreshToken, accessToken);
128
+ function rememberSuccessor(baseUrl, refreshToken, accessToken) {
129
+ const key = `${baseUrl}\0${refreshToken}`;
130
+ sessionSuccessors.delete(key);
131
+ sessionSuccessors.set(key, accessToken);
118
132
  while (sessionSuccessors.size > MAX_SESSION_SUCCESSORS) {
119
133
  const oldest = sessionSuccessors.keys().next().value;
120
134
  if (oldest === undefined)
@@ -134,22 +148,24 @@ export function isSessionRenewalDue(token, now = Date.now()) {
134
148
  */
135
149
  export async function renewSessionIfDue(tokens, options = {}) {
136
150
  const baseUrl = options.baseUrl ?? API_BASE;
137
- const base = latestSessionTokens(tokens);
151
+ const base = latestSessionTokens(tokens, baseUrl);
138
152
  const fromMemory = base.accessToken !== tokens.accessToken;
139
- if (!options.force && !isSessionRenewalDue(base.accessToken)) {
153
+ const state = stateFor(sessionStates, `${baseUrl}\0${base.refreshToken}`);
154
+ if (state.terminal)
155
+ throw terminalError(state.terminal);
156
+ if (!options.force && !isSessionRenewalDue(base.accessToken) && !state.inflight) {
140
157
  return { accessToken: base.accessToken, renewed: fromMemory };
141
158
  }
142
- const state = stateFor(sessionStates, `${baseUrl}\0${base.refreshToken}`);
143
- if (!state.terminal && (options.force || state.retryAt === undefined || Date.now() >= state.retryAt)) {
159
+ if (state.inflight || options.force || state.retryAt === undefined || Date.now() >= state.retryAt) {
144
160
  state.inflight ??= (async () => {
145
161
  const result = await refreshCursorSession(base.refreshToken, baseUrl);
146
162
  if (result.ok) {
147
163
  const { expiresAtMs } = tokenTimes(result.accessToken);
148
- if (expiresAtMs === undefined) {
149
- recordTransientFailure(state, "session refresh returned a token without expiry");
164
+ if (expiresAtMs === undefined || !isUsable(result.accessToken, Date.now())) {
165
+ recordTransientFailure(state, "session refresh returned an unusable token");
150
166
  }
151
167
  else {
152
- rememberSuccessor(base.refreshToken, result.accessToken);
168
+ rememberSuccessor(baseUrl, base.refreshToken, result.accessToken);
153
169
  recordSuccess(state);
154
170
  trace(`auth: session renewed exp=${isoTime(expiresAtMs)}`);
155
171
  return;
@@ -159,9 +175,13 @@ export async function renewSessionIfDue(tokens, options = {}) {
159
175
  recordTransientFailure(state, result.message);
160
176
  }
161
177
  else {
162
- state.terminal = new CursorAuthError(result.kind === "policy"
163
- ? `Cursor sign-in policy blocks this login (sign_in_policy_violation); ${SIGN_IN_AGAIN} with an allowed account`
164
- : `Cursor ended this login session; ${SIGN_IN_AGAIN}`, { code: result.kind === "policy" ? "sign_in_policy_violation" : "session_logout", statusCode: result.status });
178
+ state.terminal = {
179
+ message: result.kind === "policy"
180
+ ? `Cursor sign-in policy blocks this login (sign_in_policy_violation); ${SIGN_IN_AGAIN} with an allowed account`
181
+ : `Cursor ended this login session; ${SIGN_IN_AGAIN}`,
182
+ code: result.kind === "policy" ? "sign_in_policy_violation" : "session_logout",
183
+ ...(result.status !== undefined ? { statusCode: result.status } : {}),
184
+ };
165
185
  trace(`auth: session renewal final failure kind=${result.kind}`);
166
186
  return;
167
187
  }
@@ -172,12 +192,12 @@ export async function renewSessionIfDue(tokens, options = {}) {
172
192
  });
173
193
  await state.inflight;
174
194
  }
175
- const renewed = latestSessionTokens(base);
195
+ const renewed = latestSessionTokens(base, baseUrl);
176
196
  if (renewed.accessToken !== base.accessToken) {
177
197
  return { accessToken: renewed.accessToken, renewed: true };
178
198
  }
179
199
  if (state.terminal)
180
- throw state.terminal;
200
+ throw terminalError(state.terminal);
181
201
  if (!isUsable(base.accessToken, Date.now())) {
182
202
  throw new CursorAuthError(`Cursor login expired and could not be renewed (${sanitizeHostTerminalMessage(state.lastFailure ?? "no renewal attempt allowed yet")}); ${SIGN_IN_AGAIN}`, { code: "session_expired" });
183
203
  }
@@ -188,7 +208,7 @@ export async function renewSessionIfDue(tokens, options = {}) {
188
208
  };
189
209
  }
190
210
  // ── API key ──
191
- const apiKeyStates = new Map();
211
+ const apiKeyStates = processShared("auth-renewal.api-key-states.v1", () => new Map());
192
212
  /**
193
213
  * JWT for a raw `crsr_` API key: the cached or `seed` JWT while it is not
194
214
  * within five minutes of expiry, otherwise a fresh exchange. Cached per API
@@ -213,7 +233,15 @@ export async function resolveApiKeyToken(apiKey, options = {}) {
213
233
  state.inflight ??= (async () => {
214
234
  try {
215
235
  const pair = await exchangeApiKey(apiKey, baseUrl);
216
- state.token = state.token ? longerLived(state.token, pair.accessToken) : pair.accessToken;
236
+ if (isExchangeableApiKey(pair.accessToken) || tokenTimes(pair.accessToken).expiresAtMs === undefined ||
237
+ !isUsable(pair.accessToken, Date.now())) {
238
+ throw new AuthExchangeError("API key exchange returned an unusable token");
239
+ }
240
+ // A forced exchange follows a server rejection: keeping the rejected
241
+ // JWT on an expiry tie would retry the request with that same token.
242
+ state.token = options.force || !state.token
243
+ ? pair.accessToken
244
+ : longerLived(state.token, pair.accessToken);
217
245
  recordSuccess(state);
218
246
  trace(`auth: API key exchanged exp=${isoTime(tokenTimes(pair.accessToken).expiresAtMs)}`);
219
247
  }
@@ -225,9 +253,13 @@ export async function resolveApiKeyToken(apiKey, options = {}) {
225
253
  }
226
254
  else {
227
255
  const status = error instanceof AuthExchangeError ? error.status : undefined;
228
- state.terminal = new CursorAuthError(kind === "policy"
229
- ? "Cursor sign-in policy blocks this API key (sign_in_policy_violation); use an allowed account"
230
- : `Cursor rejected the API key${status ? ` (HTTP ${status})` : ""}; create a new key and sign in with it`, { code: kind === "policy" ? "sign_in_policy_violation" : "api_key_rejected", ...(status ? { statusCode: status } : {}) });
256
+ state.terminal = {
257
+ message: kind === "policy"
258
+ ? "Cursor sign-in policy blocks this API key (sign_in_policy_violation); use an allowed account"
259
+ : `Cursor rejected the API key${status ? ` (HTTP ${status})` : ""}; create a new key and sign in with it`,
260
+ code: kind === "policy" ? "sign_in_policy_violation" : "api_key_rejected",
261
+ ...(status ? { statusCode: status } : {}),
262
+ };
231
263
  trace(`auth: API key exchange final failure kind=${kind} status=${status ?? "none"}`);
232
264
  }
233
265
  }
@@ -240,7 +272,7 @@ export async function resolveApiKeyToken(apiKey, options = {}) {
240
272
  if (state.token && isUsable(state.token, Date.now()))
241
273
  return result(state.token);
242
274
  if (state.terminal)
243
- throw state.terminal;
275
+ throw terminalError(state.terminal);
244
276
  throw new CursorServerError(`Cursor API key exchange failed (${sanitizeHostTerminalMessage(state.lastFailure ?? "waiting before the next attempt")}); it is retried automatically`, { transient: true, replaySafe: true });
245
277
  }
246
278
  /**
@@ -0,0 +1,105 @@
1
+ /**
2
+ * What OpenCode is told a Cursor turn costs at catalog rates.
3
+ *
4
+ * OpenCode prices every step finish and adds it to the session: OpenCode 1.x
5
+ * from `providerMetadata.copilot.totalNanoAiu` when present
6
+ * (`session/session.ts` getUsage), OpenCode 2 always from the step's tokens at
7
+ * the model's catalog rates. Each step's token total must also stay equal to
8
+ * Cursor's context occupancy, because both hosts show context use and decide
9
+ * compaction from the latest step's tokens. Cursor reports token counters only
10
+ * once, in `TurnEnded`, aggregated over every model call of its Run.
11
+ *
12
+ * So each tool step is billed an estimate shaped like one model call (the
13
+ * context carried over from the previous step read from cache, the rest new
14
+ * input), and the turn's last step settles the difference to its catalog-priced
15
+ * cost: its input/cache split is chosen so the token-priced cost approaches the
16
+ * outstanding amount, and the same amount goes into `totalNanoAiu`. Both hosts
17
+ * then bill identical amounts at the unmodified catalog rates. What a final
18
+ * step cannot carry stays outstanding for a later priced turn-end. Settlement
19
+ * is bounded by minimum and maximum token prices and whole-token rounding;
20
+ * negative balances cannot refund prior host charges.
21
+ */
22
+ import type { LanguageModelV3Usage } from "@ai-sdk/provider";
23
+ import { type OpenCodeModelCost } from "./pricing.js";
24
+ import { type CursorUsageCounters } from "./usage.js";
25
+ /** USD per token for each category a host prices. */
26
+ export type StepPrices = {
27
+ input: number;
28
+ output: number;
29
+ cacheRead: number;
30
+ cacheWrite: number;
31
+ };
32
+ /**
33
+ * Price entry the catalog gave this model: `<wire>-fast` for a Fast variant
34
+ * with its own published rate, else the wire id (a long-context entry is
35
+ * priced from the base entry and its over-200k tier), as `model-config.ts` does.
36
+ */
37
+ export declare function cursorPricingId(wireModelId: string, parameterValues: ReadonlyArray<{
38
+ id: string;
39
+ value: string;
40
+ }>): string;
41
+ /** The rates a host applies to a step with `inputSideTokens` of prompt (its context tier). */
42
+ export declare function hostStepPrices(cost: OpenCodeModelCost | undefined, inputSideTokens: number): StepPrices | undefined;
43
+ /** What a host charges for `usage` at `prices`. */
44
+ export declare function hostUsageCostUsd(prices: StepPrices, usage: LanguageModelV3Usage): number;
45
+ /**
46
+ * Catalog-priced Run cost from aggregate `TurnEnded` counters. The final
47
+ * context approximates the prompt tier, bounded by total input so output alone
48
+ * cannot select a higher tier. Per-call tiers are unavailable: a Run crossing
49
+ * tiers or compacting internally cannot be priced exactly from these totals.
50
+ * A cache category without its own published rate is priced as input.
51
+ */
52
+ export declare function turnEndedCostUsd(cost: OpenCodeModelCost | undefined, counters: CursorUsageCounters, contextTokens: number): number | undefined;
53
+ /**
54
+ * Occupancy-shaped usage for one step: `usedTokens` in total (`output = 1`, so
55
+ * hosts accept it as the context snapshot), `prefixTokens` of it read from
56
+ * cache and the rest new input. With `targetUsd`, the split moves between
57
+ * input and cache read (and cache write only when those two cannot reach it)
58
+ * so the host-priced cost meets the target as closely as the total allows.
59
+ */
60
+ export declare function shapeStepUsage(input: {
61
+ usedTokens: number;
62
+ prefixTokens: number;
63
+ prices: StepPrices | undefined;
64
+ targetUsd?: number;
65
+ }): {
66
+ usage: LanguageModelV3Usage;
67
+ costUsd: number;
68
+ };
69
+ /** `providerMetadata.copilot.totalNanoAiu` for a step that costs `usd`. */
70
+ export declare function nanoAiuForUsd(usd: number): number;
71
+ /** Estimates belong to one Run and are replaced only by that Run's counters. */
72
+ export type RunCostEstimate = {
73
+ estimatedUsd?: number;
74
+ ledgerEpoch?: object;
75
+ };
76
+ type LedgerTotals = {
77
+ realUsd: number;
78
+ billedUsd: number;
79
+ };
80
+ type LedgerEntry = LedgerTotals & {
81
+ estimatedUsd: number;
82
+ };
83
+ /**
84
+ * Per OpenCode session: catalog-priced cost (from `TurnEnded`) and what
85
+ * this provider has reported to the host. Priced turn-ends reconcile the balance.
86
+ */
87
+ export declare class BillingLedger {
88
+ private readonly entries;
89
+ constructor(entries?: Map<string, LedgerEntry>);
90
+ private entry;
91
+ recordReal(key: string, usd: number, run?: RunCostEstimate): void;
92
+ recordBilled(key: string, usd: number, run?: RunCostEstimate): void;
93
+ /** Confirmed plus unconfirmed cost minus the amount reported to the host. */
94
+ outstanding(key: string): number;
95
+ estimated(key: string): number;
96
+ totals(key: string): LedgerTotals;
97
+ clear(): void;
98
+ }
99
+ export declare const billingLedger: BillingLedger;
100
+ /** The catalog cost entry for this Run's model, if Cursor publishes one. */
101
+ export declare function cursorRunCost(wireModelId: string, parameterValues: ReadonlyArray<{
102
+ id: string;
103
+ value: string;
104
+ }>): OpenCodeModelCost | undefined;
105
+ export {};
@@ -0,0 +1,212 @@
1
+ import { getCursorModelCost, hasCursorFastPricing } from "./pricing.js";
2
+ import { buildLanguageModelV3UsageFromCounters } from "./usage.js";
3
+ const PER_MILLION = 1_000_000;
4
+ const CONTEXT_TIER_TOKENS = 200_000;
5
+ /** OpenCode 1.x divides `totalNanoAiu` by 1e11 to get USD. */
6
+ const NANO_AIU_PER_USD = 100_000_000_000;
7
+ /**
8
+ * Price entry the catalog gave this model: `<wire>-fast` for a Fast variant
9
+ * with its own published rate, else the wire id (a long-context entry is
10
+ * priced from the base entry and its over-200k tier), as `model-config.ts` does.
11
+ */
12
+ export function cursorPricingId(wireModelId, parameterValues) {
13
+ const fast = parameterValues.some((parameter) => parameter.id === "fast" && parameter.value === "true");
14
+ return fast && hasCursorFastPricing(wireModelId) ? `${wireModelId}-fast` : wireModelId;
15
+ }
16
+ /** The rates a host applies to a step with `inputSideTokens` of prompt (its context tier). */
17
+ export function hostStepPrices(cost, inputSideTokens) {
18
+ if (!cost)
19
+ return undefined;
20
+ const tier = cost.context_over_200k && inputSideTokens > CONTEXT_TIER_TOKENS ? cost.context_over_200k : cost;
21
+ return {
22
+ input: tier.input / PER_MILLION,
23
+ output: tier.output / PER_MILLION,
24
+ // Both hosts charge an unpriced cache category nothing.
25
+ cacheRead: (tier.cache_read ?? 0) / PER_MILLION,
26
+ cacheWrite: (tier.cache_write ?? 0) / PER_MILLION,
27
+ };
28
+ }
29
+ /** What a host charges for `usage` at `prices`. */
30
+ export function hostUsageCostUsd(prices, usage) {
31
+ const input = usage.inputTokens;
32
+ return (input.noCache ?? 0) * prices.input
33
+ + (input.cacheRead ?? 0) * prices.cacheRead
34
+ + (input.cacheWrite ?? 0) * prices.cacheWrite
35
+ + (usage.outputTokens.total ?? 0) * prices.output;
36
+ }
37
+ /**
38
+ * Catalog-priced Run cost from aggregate `TurnEnded` counters. The final
39
+ * context approximates the prompt tier, bounded by total input so output alone
40
+ * cannot select a higher tier. Per-call tiers are unavailable: a Run crossing
41
+ * tiers or compacting internally cannot be priced exactly from these totals.
42
+ * A cache category without its own published rate is priced as input.
43
+ */
44
+ export function turnEndedCostUsd(cost, counters, contextTokens) {
45
+ if (!cost)
46
+ return undefined;
47
+ // Reuse the canonical partition guard; inconsistent cache counters must not
48
+ // create billable input beyond input_tokens. Keep the raw fields diagnostic.
49
+ const usage = buildLanguageModelV3UsageFromCounters(counters);
50
+ const input = usage.inputTokens;
51
+ const promptTokens = Math.min(input.total ?? 0, Math.max(0, contextTokens));
52
+ const tier = cost.context_over_200k && promptTokens > CONTEXT_TIER_TOKENS ? cost.context_over_200k : cost;
53
+ return ((input.noCache ?? 0) * tier.input
54
+ + (input.cacheRead ?? 0) * (tier.cache_read ?? tier.input)
55
+ + (input.cacheWrite ?? 0) * (tier.cache_write ?? tier.input)
56
+ + (usage.outputTokens.total ?? 0) * tier.output) / PER_MILLION;
57
+ }
58
+ function usageOf(split) {
59
+ return {
60
+ inputTokens: {
61
+ total: split.noCache + split.cacheRead + split.cacheWrite,
62
+ noCache: split.noCache,
63
+ cacheRead: split.cacheRead,
64
+ cacheWrite: split.cacheWrite,
65
+ },
66
+ outputTokens: { total: 1, text: 1, reasoning: 0 },
67
+ };
68
+ }
69
+ /**
70
+ * Split `inputTokens` between two categories so their cost is as close to
71
+ * `budget` as whole tokens allow, clamped to what the pair can carry.
72
+ */
73
+ function mix(inputTokens, budget, low, high) {
74
+ const split = { noCache: 0, cacheRead: 0, cacheWrite: 0 };
75
+ const span = high.price - low.price;
76
+ const atHigh = span > 0
77
+ ? Math.min(inputTokens, Math.max(0, Math.round((budget - inputTokens * low.price) / span)))
78
+ : 0;
79
+ split[high.category] += atHigh;
80
+ split[low.category] += inputTokens - atHigh;
81
+ return split;
82
+ }
83
+ /**
84
+ * Occupancy-shaped usage for one step: `usedTokens` in total (`output = 1`, so
85
+ * hosts accept it as the context snapshot), `prefixTokens` of it read from
86
+ * cache and the rest new input. With `targetUsd`, the split moves between
87
+ * input and cache read (and cache write only when those two cannot reach it)
88
+ * so the host-priced cost meets the target as closely as the total allows.
89
+ */
90
+ export function shapeStepUsage(input) {
91
+ const used = Math.max(0, Math.trunc(input.usedTokens));
92
+ if (used <= 0) {
93
+ return {
94
+ usage: {
95
+ inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
96
+ outputTokens: { total: 0, text: 0, reasoning: 0 },
97
+ },
98
+ costUsd: 0,
99
+ };
100
+ }
101
+ const inputTokens = used - 1;
102
+ const cacheRead = Math.min(inputTokens, Math.max(0, Math.trunc(input.prefixTokens)));
103
+ let split = { noCache: inputTokens - cacheRead, cacheRead, cacheWrite: 0 };
104
+ const prices = input.prices;
105
+ if (prices && input.targetUsd !== undefined) {
106
+ const budget = input.targetUsd - prices.output;
107
+ const options = [
108
+ { category: "noCache", price: prices.input },
109
+ { category: "cacheRead", price: prices.cacheRead },
110
+ ];
111
+ const [low, high] = [...options].sort((a, b) => a.price - b.price);
112
+ if (budget >= inputTokens * low.price && budget <= inputTokens * high.price) {
113
+ split = mix(inputTokens, budget, low, high);
114
+ }
115
+ else {
116
+ const write = { category: "cacheWrite", price: prices.cacheWrite };
117
+ const all = [...options, write].sort((a, b) => a.price - b.price);
118
+ const cheapest = all[0];
119
+ const dearest = all[all.length - 1];
120
+ split = budget < inputTokens * low.price
121
+ ? mix(inputTokens, budget, cheapest, low)
122
+ : mix(inputTokens, budget, high, dearest);
123
+ }
124
+ }
125
+ const usage = usageOf(split);
126
+ return { usage, costUsd: prices ? hostUsageCostUsd(prices, usage) : 0 };
127
+ }
128
+ /** `providerMetadata.copilot.totalNanoAiu` for a step that costs `usd`. */
129
+ export function nanoAiuForUsd(usd) {
130
+ return Math.max(0, Math.round(usd * NANO_AIU_PER_USD));
131
+ }
132
+ const MAX_LEDGERS = 1_024;
133
+ /**
134
+ * Per OpenCode session: catalog-priced cost (from `TurnEnded`) and what
135
+ * this provider has reported to the host. Priced turn-ends reconcile the balance.
136
+ */
137
+ export class BillingLedger {
138
+ entries;
139
+ constructor(entries = new Map()) {
140
+ this.entries = entries;
141
+ }
142
+ entry(key) {
143
+ let entry = this.entries.get(key);
144
+ if (entry) {
145
+ this.entries.delete(key);
146
+ }
147
+ else {
148
+ entry = { realUsd: 0, billedUsd: 0, estimatedUsd: 0 };
149
+ while (this.entries.size >= MAX_LEDGERS) {
150
+ const oldest = this.entries.keys().next().value;
151
+ if (oldest === undefined)
152
+ break;
153
+ this.entries.delete(oldest);
154
+ }
155
+ }
156
+ this.entries.set(key, entry);
157
+ return entry;
158
+ }
159
+ recordReal(key, usd, run) {
160
+ if (!Number.isFinite(usd) || usd < 0)
161
+ return;
162
+ const entry = this.entry(key);
163
+ entry.realUsd += usd;
164
+ // Missing TurnEnded is unknown cost, not free work. Other Runs' estimates
165
+ // stay provisionally covered, including Runs already closed by the host.
166
+ const priorEstimate = run?.ledgerEpoch === entry ? run.estimatedUsd ?? 0 : 0;
167
+ entry.estimatedUsd = Math.max(0, entry.estimatedUsd - priorEstimate);
168
+ if (run)
169
+ run.estimatedUsd = 0;
170
+ }
171
+ recordBilled(key, usd, run) {
172
+ if (!Number.isFinite(usd) || usd <= 0)
173
+ return;
174
+ const entry = this.entry(key);
175
+ entry.billedUsd += usd;
176
+ if (run) {
177
+ // An evicted session starts a new ledger; its old Run estimate must not
178
+ // replace another Run's estimates in the new entry.
179
+ if (run.ledgerEpoch !== entry)
180
+ run.estimatedUsd = 0;
181
+ run.ledgerEpoch = entry;
182
+ entry.estimatedUsd += usd;
183
+ run.estimatedUsd = (run.estimatedUsd ?? 0) + usd;
184
+ }
185
+ }
186
+ /** Confirmed plus unconfirmed cost minus the amount reported to the host. */
187
+ outstanding(key) {
188
+ const entry = this.entries.get(key);
189
+ return entry ? entry.realUsd + entry.estimatedUsd - entry.billedUsd : 0;
190
+ }
191
+ estimated(key) {
192
+ return this.entries.get(key)?.estimatedUsd ?? 0;
193
+ }
194
+ totals(key) {
195
+ const entry = this.entries.get(key);
196
+ return entry ? { realUsd: entry.realUsd, billedUsd: entry.billedUsd } : { realUsd: 0, billedUsd: 0 };
197
+ }
198
+ clear() {
199
+ this.entries.clear();
200
+ }
201
+ }
202
+ // Plugin and SDK loaders can evaluate separate copies of this package in one
203
+ // process. Share plain ledger data, rather than a class instance owned by one
204
+ // module graph, so session balances survive those loader transitions.
205
+ const BILLING_ENTRIES = Symbol.for("cursor-opencode-provider.billing-ledger.v2");
206
+ const processState = globalThis;
207
+ const sharedEntries = processState[BILLING_ENTRIES] ??= new Map();
208
+ export const billingLedger = new BillingLedger(sharedEntries);
209
+ /** The catalog cost entry for this Run's model, if Cursor publishes one. */
210
+ export function cursorRunCost(wireModelId, parameterValues) {
211
+ return getCursorModelCost(cursorPricingId(wireModelId, parameterValues));
212
+ }
@@ -0,0 +1 @@
1
+ export declare function normalizeClassicInterruptedResults(messages: readonly unknown[]): any[];
@@ -0,0 +1,42 @@
1
+ import { restoreOpenCodeTimeout } from "./shell-timeout.js";
2
+ /**
3
+ * OpenCode 1 exposes interrupted partial output as successful SDK output:
4
+ * https://github.com/anomalyco/opencode/blob/v1.18.31/packages/opencode/src/session/message-v2.ts#L325-L346
5
+ * Its shell also completes with exit:null after cancellation and appends its
6
+ * own metadata block (tool/shell.ts L542-L589). Preserve failure in the model
7
+ * projection while retaining progress text. A timed-out shell is stored with
8
+ * a display notice in place of that block and only `state.output` reaches the
9
+ * model, so the projection restores the block. Stored messages are not mutated.
10
+ */
11
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
12
+ export function normalizeClassicInterruptedResults(messages) {
13
+ return messages.map(message => {
14
+ const msg = record(message);
15
+ if (!msg || !Array.isArray(msg.parts))
16
+ return message;
17
+ return { ...msg, parts: msg.parts.map(item => {
18
+ const part = record(item);
19
+ const state = record(part?.state);
20
+ if (part?.type !== "tool" || typeof part.callID !== "string" || !part.callID.startsWith("cursor_") || !state)
21
+ return item;
22
+ const metadata = record(state.metadata);
23
+ const timeoutMs = metadata?.cursorShellTimeoutMs;
24
+ if (state.status === "completed" && part.tool === "bash" && typeof state.output === "string"
25
+ && typeof timeoutMs === "number" && Number.isSafeInteger(timeoutMs) && timeoutMs >= 0) {
26
+ return { ...part, state: { ...state, output: restoreOpenCodeTimeout(state.output, timeoutMs) } };
27
+ }
28
+ const partialError = state.status === "error" && metadata?.interrupted === true;
29
+ // Null exit and the native trailing block must agree. Ordinary successful
30
+ // stdout can legitimately contain the same words or markup.
31
+ const shellAbort = state.status === "completed" && part.tool === "bash" && metadata?.exit === null
32
+ && typeof state.output === "string" && /<shell_metadata>\r?\nUser aborted the command\r?\n<\/shell_metadata>\s*$/.test(state.output);
33
+ if (!partialError && !shellAbort)
34
+ return item;
35
+ const message = typeof state.error === "string" && state.error ? state.error : "Tool execution interrupted";
36
+ const progress = shellAbort ? state.output : typeof metadata?.output === "string" ? metadata.output : "";
37
+ const { output: _output, title: _title, attachments: _attachments, ...base } = state;
38
+ return { ...part, state: { ...base, status: "error", error: progress ? `${message}\n\n${progress}` : message,
39
+ metadata: { ...metadata, interrupted: false } } };
40
+ }) };
41
+ });
42
+ }
@@ -55,6 +55,7 @@ export type DynamicRequestContextKey = typeof DYNAMIC_REQUEST_CONTEXT_KEYS[numbe
55
55
  * `skills.ts`); never kept on a frozen base.
56
56
  */
57
57
  export declare const HOST_DUPLICATED_REQUEST_CONTEXT_KEYS: readonly ["agent_skills", "agent_skills_info_complete"];
58
+ export declare function resetWorkspaceFactsForTests(): void;
58
59
  /**
59
60
  * Full RequestContext payload for live UMA + exec #10 reply.
60
61
  * Workspace env/git/layout, the host system-instructions rule, and
@@ -8,7 +8,7 @@ import { buildEnv } from "./env.js";
8
8
  import { ensureOpencodeProjectDir } from "./paths.js";
9
9
  import { holdCapabilityOverlay } from "./overlay.js";
10
10
  import { agentSkillsForCursor, applyAgentSkillsToContext, hostSkillFiles, loadBridgeSkills, skillToolAdvertised, } from "./skills.js";
11
- import { traceRequestContextPaths } from "../debug.js";
11
+ import { trace, traceRequestContextPaths } from "../debug.js";
12
12
  /** Rule name Cursor shows for the host system context (not a file). */
13
13
  export const SYSTEM_INSTRUCTIONS_RULE_PATH = "OpenCode system instructions";
14
14
  /**
@@ -95,6 +95,34 @@ function stripHostDuplicatedRequestContextFields(context) {
95
95
  delete context[key];
96
96
  keepSystemInstructionsRule(context);
97
97
  }
98
+ /**
99
+ * How long one workspace's git and layout discovery serves later builds. A new
100
+ * session's first message opens two Runs moments apart (OpenCode's title
101
+ * request and the turn itself) on separate conversations; each froze its own
102
+ * copy, and `git status` dominates on a large repository (1.3 s warm on 35k
103
+ * tracked files; both discoveries of one observed session took about 5 s).
104
+ * Each conversation freezes what it got anyway.
105
+ */
106
+ const WORKSPACE_FACTS_REUSE_MS = 30_000;
107
+ const workspaceFacts = new Map();
108
+ function collectWorkspaceFacts(workspaceRoot) {
109
+ const now = Date.now();
110
+ for (const [root, entry] of workspaceFacts) {
111
+ if (now - entry.at >= WORKSPACE_FACTS_REUSE_MS)
112
+ workspaceFacts.delete(root);
113
+ }
114
+ const cached = workspaceFacts.get(workspaceRoot);
115
+ if (cached) {
116
+ trace(`workspace facts: reused ageMs=${now - cached.at}`);
117
+ return cached.facts;
118
+ }
119
+ const facts = Promise.all([collectGit(workspaceRoot), collectProjectLayout(workspaceRoot)]);
120
+ workspaceFacts.set(workspaceRoot, { at: now, facts });
121
+ return facts;
122
+ }
123
+ export function resetWorkspaceFactsForTests() {
124
+ workspaceFacts.clear();
125
+ }
98
126
  /**
99
127
  * Full RequestContext payload for live UMA + exec #10 reply.
100
128
  * Workspace env/git/layout, the host system-instructions rule, and
@@ -104,10 +132,9 @@ function stripHostDuplicatedRequestContextFields(context) {
104
132
  export async function buildRequestContext(input) {
105
133
  const workspaceRoot = path.resolve(input.workspaceRoot || process.cwd());
106
134
  const config = input.mergedConfig ?? await loadMergedConfig(workspaceRoot);
107
- const [dynamic, git, layout] = await Promise.all([
135
+ const [dynamic, [git, layout]] = await Promise.all([
108
136
  buildDynamicRequestContextFromDiscovery(input, workspaceRoot, config),
109
- collectGit(workspaceRoot),
110
- collectProjectLayout(workspaceRoot),
137
+ collectWorkspaceFacts(workspaceRoot),
111
138
  ]);
112
139
  const workspace = {
113
140
  env: buildEnv(workspaceRoot),
@@ -118,7 +145,7 @@ export async function buildRequestContext(input) {
118
145
  env_info_complete: true,
119
146
  repository_info_complete: true,
120
147
  git_repo_info_complete: true,
121
- git_status_info_complete: true,
148
+ git_status_info_complete: git.statusComplete,
122
149
  };
123
150
  const base = withSystemInstructions(workspace, input.systemInstructions);
124
151
  const skillLocations = await resolveSkillLocations(input, workspaceRoot);