@bitkyc08/opencodex 2.6.32 → 2.7.1-preview.20260710

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 (61) hide show
  1. package/README.ko.md +9 -5
  2. package/README.md +7 -4
  3. package/README.zh-CN.md +8 -4
  4. package/gui/dist/assets/index-BUAMcKFd.css +1 -0
  5. package/gui/dist/assets/index-KorpEKW8.js +34 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic.ts +62 -1
  9. package/src/adapters/cursor/cursor-errors.ts +28 -1
  10. package/src/adapters/cursor/discovery.ts +60 -10
  11. package/src/adapters/cursor/effort-map.ts +38 -7
  12. package/src/adapters/cursor/live-models.ts +3 -0
  13. package/src/adapters/cursor/live-transport.ts +136 -7
  14. package/src/adapters/cursor/protobuf-request.ts +24 -1
  15. package/src/adapters/cursor/request-builder.ts +6 -5
  16. package/src/adapters/cursor/transport-retry.ts +22 -3
  17. package/src/adapters/cursor.ts +2 -1
  18. package/src/adapters/openai-chat.ts +75 -26
  19. package/src/bridge.ts +42 -3
  20. package/src/cli/debug.ts +203 -0
  21. package/src/cli/doctor.ts +11 -0
  22. package/src/cli/help.ts +11 -0
  23. package/src/cli/index.ts +10 -0
  24. package/src/cli/v2.ts +131 -0
  25. package/src/codex/auth-api.ts +7 -3
  26. package/src/codex/catalog.ts +334 -31
  27. package/src/codex/data/upstream-models.json +830 -0
  28. package/src/codex/features.ts +178 -0
  29. package/src/codex/project-config-warnings.ts +388 -0
  30. package/src/codex/sync.ts +8 -0
  31. package/src/codex/warmup.ts +62 -7
  32. package/src/config.ts +7 -5
  33. package/src/lib/debug-log-buffer.ts +42 -0
  34. package/src/lib/debug-settings.ts +84 -0
  35. package/src/lib/debug.ts +18 -9
  36. package/src/lib/errors.ts +104 -1
  37. package/src/oauth/cursor.ts +35 -12
  38. package/src/oauth/store.ts +4 -3
  39. package/src/providers/derive.ts +8 -0
  40. package/src/providers/registry.ts +56 -21
  41. package/src/reasoning-effort.ts +37 -9
  42. package/src/responses/parser.ts +7 -2
  43. package/src/router.ts +5 -0
  44. package/src/server/adapter-resolve.ts +1 -1
  45. package/src/server/index.ts +27 -3
  46. package/src/server/management-api.ts +189 -7
  47. package/src/server/relay.ts +2 -2
  48. package/src/server/request-decompress.ts +8 -2
  49. package/src/server/request-log.ts +78 -0
  50. package/src/server/responses.ts +241 -9
  51. package/src/types.ts +34 -1
  52. package/src/usage/debug.ts +32 -5
  53. package/src/usage/summary.ts +6 -6
  54. package/src/vision/describe.ts +4 -0
  55. package/src/web-search/executor.ts +4 -0
  56. package/src/web-search/format-result.ts +11 -3
  57. package/src/web-search/index.ts +31 -2
  58. package/src/web-search/loop.ts +112 -61
  59. package/src/web-search/parse.ts +4 -1
  60. package/gui/dist/assets/index-ByGC8-Bm.css +0 -1
  61. package/gui/dist/assets/index-D_JZzI0r.js +0 -15
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-D_JZzI0r.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-ByGC8-Bm.css">
19
+ <script type="module" crossorigin src="/assets/index-KorpEKW8.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-BUAMcKFd.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.6.32",
3
+ "version": "2.7.1-preview.20260710",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -482,11 +482,72 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: (
482
482
  const converted = tools.map(t => ({
483
483
  name: toolNames.toWire(namespacedToolName(t.namespace, t.name)),
484
484
  description: t.description,
485
- input_schema: t.parameters,
485
+ input_schema: normalizeAnthropicInputSchema(t.parameters),
486
486
  }));
487
487
  return converted;
488
488
  }
489
489
 
490
+ function normalizeAnthropicInputSchema(schema: unknown): Record<string, unknown> {
491
+ const obj = schema && typeof schema === "object" && !Array.isArray(schema)
492
+ ? schema as Record<string, unknown>
493
+ : {};
494
+ // Anthropic rejects root-level missing type and oneOf/anyOf/allOf in input_schema.
495
+ // Normalize the root only: ensure type:"object" + properties, flatten root composition
496
+ // while preserving nested schemas. Mirrors kiro-tools.ts ensureRootObjectType.
497
+ // Known limitation: Object.assign on branch properties means later branches overwrite
498
+ // earlier ones when the same property name appears with different schemas.
499
+ const compositionKeys = ["oneOf", "anyOf", "allOf"] as const;
500
+ const hasRootComposition = compositionKeys.some(key => Array.isArray(obj[key]));
501
+ const type = obj.type;
502
+ const rootObjectType = type === "object" || (Array.isArray(type) && type.includes("object"));
503
+
504
+ if (!hasRootComposition) {
505
+ const normalized: Record<string, unknown> = rootObjectType && type === "object"
506
+ ? { ...obj }
507
+ : { ...obj, type: "object" };
508
+ if (normalized.properties === undefined || normalized.properties === null) {
509
+ normalized.properties = {};
510
+ }
511
+ return normalized;
512
+ }
513
+
514
+ const properties: Record<string, unknown> = {};
515
+ const required = new Set<string>();
516
+ if (obj.properties && typeof obj.properties === "object" && !Array.isArray(obj.properties)) {
517
+ Object.assign(properties, obj.properties as Record<string, unknown>);
518
+ }
519
+ if (Array.isArray(obj.required)) {
520
+ for (const item of obj.required) if (typeof item === "string") required.add(item);
521
+ }
522
+
523
+ for (const key of compositionKeys) {
524
+ const variants = obj[key];
525
+ if (!Array.isArray(variants)) continue;
526
+ const mergeRequired = key === "allOf";
527
+ for (const variant of variants) {
528
+ if (!variant || typeof variant !== "object" || Array.isArray(variant)) continue;
529
+ const v = variant as Record<string, unknown>;
530
+ if (v.properties && typeof v.properties === "object" && !Array.isArray(v.properties)) {
531
+ Object.assign(properties, v.properties as Record<string, unknown>);
532
+ }
533
+ if (mergeRequired && Array.isArray(v.required)) {
534
+ for (const item of v.required) if (typeof item === "string") required.add(item);
535
+ }
536
+ }
537
+ }
538
+
539
+ const normalized: Record<string, unknown> = {};
540
+ for (const [key, value] of Object.entries(obj)) {
541
+ if (key === "oneOf" || key === "anyOf" || key === "allOf") continue;
542
+ if (key === "type" || key === "properties" || key === "required") continue;
543
+ normalized[key] = value;
544
+ }
545
+ normalized.type = "object";
546
+ normalized.properties = properties;
547
+ if (required.size > 0) normalized.required = [...required];
548
+ return normalized;
549
+ }
550
+
490
551
  export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long"): ProviderAdapter {
491
552
  const isOAuth = provider.authMode === "oauth";
492
553
  const toolNames = buildToolNameTransforms(provider);
@@ -11,6 +11,31 @@ function sanitize(value: string): string {
11
11
  .replace(ABSOLUTE_PATH_PATTERN, "[REDACTED_PATH]");
12
12
  }
13
13
 
14
+ function errorMessage(value: unknown): string {
15
+ if (value instanceof Error) return value.message;
16
+ if (typeof value === "string") return value;
17
+ return String(value ?? "");
18
+ }
19
+
20
+ function errorCode(value: unknown): string {
21
+ if (typeof value !== "object" || !value || !("code" in value)) return "";
22
+ const code = (value as { code?: unknown }).code;
23
+ return code === undefined || code === null ? "" : String(code);
24
+ }
25
+
26
+ /**
27
+ * True when Cursor intentionally cancelled the HTTP/2 stream after a client-tool suspend.
28
+ * These are expected between multi-turn Responses bridge cycles, not upstream failures.
29
+ */
30
+ export function isCursorBenignCancelError(value: unknown): boolean {
31
+ const message = errorMessage(value).toLowerCase();
32
+ const code = errorCode(value).toUpperCase();
33
+ if (code === "NGHTTP2_CANCEL") return true;
34
+ if (message.includes("nghttp2_cancel")) return true;
35
+ if (message.includes("cursor stream suspended")) return true;
36
+ return false;
37
+ }
38
+
14
39
  /**
15
40
  * Classify a Cursor transport/Connect/gRPC error message into an actionable category.
16
41
  * The returned prefix string is recognized by `src/lib/errors.ts` `classifyError` keywords,
@@ -19,13 +44,15 @@ function sanitize(value: string): string {
19
44
  export function classifyCursorError(message: string): string {
20
45
  const lower = message.toLowerCase();
21
46
 
47
+ if (isCursorBenignCancelError(message)) return "Cursor stream suspended";
48
+
22
49
  if (
23
50
  lower.includes("resource_exhausted") ||
24
51
  lower.includes("resource exhausted") ||
25
52
  lower.includes("rate limit") ||
26
53
  lower.includes("rate-limit") ||
27
54
  lower.includes("too many requests") ||
28
- lower.includes("quota")
55
+ lower.includes("throttling")
29
56
  ) return "Cursor rate limit exceeded";
30
57
 
31
58
  if (
@@ -1,3 +1,5 @@
1
+ import { cursorModelEffortLadder } from "./effort-map";
2
+
1
3
  export interface CursorModelInfo {
2
4
  id: string;
3
5
  contextWindow?: number;
@@ -21,7 +23,9 @@ export function inferCursorContextWindow(modelId: string): number {
21
23
  if (id.includes("1m")) return CONTEXT_1M;
22
24
  if (id.startsWith("gemini-")) return CONTEXT_1M;
23
25
  if (id === "glm-5.2") return CONTEXT_1M;
26
+ if (id.startsWith("gpt-5.6-")) return CONTEXT_1M;
24
27
  if (id.startsWith("gpt-5") || id === "gpt-5-codex") return CONTEXT_272K;
28
+ if (id.startsWith("grok-4.5")) return 500_000;
25
29
  if (id.startsWith("grok-")) return CONTEXT_256K;
26
30
  if (id.includes("claude")) return CONTEXT_200K;
27
31
  return CURSOR_DEFAULT_CONTEXT_WINDOW;
@@ -51,6 +55,42 @@ export function normalizeCursorModels(models: readonly CursorModelInfo[]): Curso
51
55
  return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
52
56
  }
53
57
 
58
+ // Live GetUsableModels ids append effort tiers to the base id (`claude-4.6-opus-high`). Only
59
+ // these suffixes may activate a base model — otherwise a sibling model like `claude-4-sonnet-1m`
60
+ // would falsely activate `claude-4-sonnet` (PR #73 review finding).
61
+ const LIVE_EFFORT_SUFFIXES = ["low", "medium", "high", "xhigh", "max"] as const;
62
+
63
+ /**
64
+ * True when a configured Cursor base model should remain exposed after live GetUsableModels filtering.
65
+ * Live ids are full effort-suffixed variants (`claude-4.6-opus-high`); base ids match exactly or by prefix.
66
+ */
67
+ export function isCursorModelAvailableForAccount(modelId: string, liveIds: readonly string[]): boolean {
68
+ return liveIds.some(id =>
69
+ id === modelId || LIVE_EFFORT_SUFFIXES.some(suffix => id === `${modelId}-${suffix}`));
70
+ }
71
+
72
+ /** Codex-facing id for Cursor's auto-router. Always kept in the catalog even when live discovery omits it. */
73
+ export const CURSOR_AUTO_MODEL_ID = "auto";
74
+
75
+ /** Wire id Cursor Connect expects for the auto-router (GetUsableModels returns `default`, not `auto`). */
76
+ export const CURSOR_AUTO_WIRE_MODEL_ID = "default";
77
+
78
+ /** Map a Codex-facing Cursor model id to the upstream wire id. */
79
+ export function cursorCodexToWireModelId(modelId: string): string {
80
+ const normalized = modelId.startsWith("cursor/") ? modelId.slice("cursor/".length) : modelId;
81
+ return normalized === CURSOR_AUTO_MODEL_ID ? CURSOR_AUTO_WIRE_MODEL_ID : normalized;
82
+ }
83
+
84
+ /** Filter the static Cursor seed to models this account can use. */
85
+ export function filterCursorConfiguredModelsByLiveDiscovery<T extends { id: string }>(
86
+ configured: readonly T[],
87
+ liveIds: readonly string[],
88
+ ): T[] {
89
+ return configured.filter(model =>
90
+ model.id === CURSOR_AUTO_MODEL_ID || isCursorModelAvailableForAccount(model.id, liveIds),
91
+ );
92
+ }
93
+
54
94
  export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorModels([
55
95
  // Context windows and the model lineup mirror Cursor's public models/pricing docs plus the jawcode
56
96
  // SOT (../jawcode/packages/ai/src/models.json, `cursor` provider), which mirrors the real
@@ -62,7 +102,7 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM
62
102
  // gemini/grok/kimi/gpt-5-mini are reasoning models in the SOT but are sent bare (no tier picker).
63
103
  { id: "auto", contextWindow: CONTEXT_200K, supportsReasoningEffort: false },
64
104
 
65
- { id: "claude-sonnet-5", contextWindow: CONTEXT_200K },
105
+ { id: "claude-sonnet-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
66
106
  { id: "claude-4-sonnet", contextWindow: CONTEXT_200K },
67
107
  { id: "claude-4-sonnet-1m", contextWindow: CONTEXT_1M },
68
108
  { id: "claude-4.5-haiku", contextWindow: CONTEXT_200K },
@@ -71,12 +111,12 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM
71
111
  { id: "claude-4.6-opus", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
72
112
  { id: "claude-4.6-sonnet", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
73
113
  { id: "claude-opus-4-7", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
114
+ // opus-4-7-fast: effort-suffix tiers unverified -> no tier picker; sent bare like live-only ids.
115
+ { id: "claude-opus-4-7-fast", contextWindow: CONTEXT_200K },
74
116
  { id: "claude-opus-4-8", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
75
117
  { id: "claude-fable-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
76
118
 
77
119
  { id: "composer-1", contextWindow: CONTEXT_200K },
78
- { id: "composer-1.5", contextWindow: CONTEXT_200K },
79
- { id: "composer-2", contextWindow: CONTEXT_200K },
80
120
  { id: "composer-2.5", contextWindow: CONTEXT_200K },
81
121
  { id: "composer-2.5-fast", contextWindow: CONTEXT_200K },
82
122
 
@@ -101,15 +141,23 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM
101
141
  { id: "gpt-5.4-mini", contextWindow: CONTEXT_272K, supportsReasoningEffort: true },
102
142
  { id: "gpt-5.4-nano", contextWindow: CONTEXT_272K, supportsReasoningEffort: true },
103
143
  { id: "gpt-5.5", contextWindow: CONTEXT_272K, supportsReasoningEffort: true },
144
+ // gpt-5.5-extra: absent from cursor.com docs but SURVIVES the live GetUsableModels filter
145
+ // (account-verified 260709, devlog/model_update/260709_model_refresh/004_live_snapshot.md).
104
146
  { id: "gpt-5.5-extra", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
147
+ { id: "gpt-5.6-sol", contextWindow: CONTEXT_1M, supportsReasoningEffort: true },
148
+ { id: "gpt-5.6-terra", contextWindow: CONTEXT_1M, supportsReasoningEffort: true },
149
+ { id: "gpt-5.6-luna", contextWindow: CONTEXT_1M, supportsReasoningEffort: true },
150
+
151
+ // 260709 refresh: stale grok/composer/kimi/gpt ids dropped per current cursor.com docs; the
152
+ // 260709 note: grok-4.5 was deferred; confirmed live 260708 (cursor.com/models, xAI launch).
105
153
 
106
- { id: "grok-4.3", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
107
- { id: "grok-4.20", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
108
- { id: "grok-build-0.1", contextWindow: CONTEXT_256K },
109
- { id: "grok-code-fast-1", contextWindow: CONTEXT_256K },
154
+ // Conflict resolution (260709): keep the refreshed 1M context + kimi-k2.7-code from de12fc8,
155
+ // take PR #73's supportsReasoningEffort for glm-5.2 (its effort-map tiers landed with the PR).
156
+ { id: "glm-5.2", contextWindow: CONTEXT_1M, supportsReasoningEffort: true },
157
+ { id: "kimi-k2.7-code", contextWindow: CONTEXT_262K },
110
158
 
111
- { id: "glm-5.2", contextWindow: CONTEXT_200K },
112
- { id: "kimi-k2.5", contextWindow: CONTEXT_262K },
159
+ { id: "grok-4.5", contextWindow: 500_000, supportsReasoningEffort: true },
160
+ { id: "grok-4.5-fast", contextWindow: 500_000 },
113
161
  ]);
114
162
 
115
163
  export function cursorModelIds(models: readonly CursorModelInfo[] = CURSOR_STATIC_MODELS): string[] {
@@ -138,7 +186,9 @@ export function cursorModelReasoningEfforts(
138
186
  return Object.fromEntries(
139
187
  normalizeCursorModels(models).map(model => [
140
188
  model.id,
141
- model.supportsReasoningEffort === true ? [...CURSOR_REASONING_EFFORTS] : [],
189
+ model.supportsReasoningEffort === true
190
+ ? cursorModelEffortLadder(model.id) ?? [...CURSOR_REASONING_EFFORTS]
191
+ : [],
142
192
  ]),
143
193
  );
144
194
  }
@@ -6,19 +6,30 @@
6
6
  * `claude-4.6-sonnet` only has `-medium`, and `composer`/`grok`/`gemini` take no suffix at all. A bare
7
7
  * id for a model that requires a suffix is rejected `ERROR_BAD_MODEL_NAME` (devlog 350.105).
8
8
  *
9
+ * Canonical effort order is always low < medium < high < xhigh < max (max is the top tier, confirmed
10
+ * against Anthropic docs and Cursor's live lineup). Tiers are stored in ascending canonical order.
11
+ *
9
12
  * `CURSOR_MODEL_EFFORT_TIERS` is the real catalog (from the Cursor `GetUsableModels` naming, mirrored in
10
13
  * jawcode's bundle), each base model -> its available suffixes in ascending order. `cursorEffortSuffix`
11
- * clamps a Codex reasoning effort onto that model's tiers so Codex's TOP effort maps to the model's TOP
12
- * tier (a `-max` model -> max, an `-xhigh` model -> xhigh), per the requested behavior.
14
+ * is literal-first: when the requested effort is one of the model's tiers, the effort you name is the
15
+ * suffix Cursor receives. It only clamps Codex effort ranks for efforts outside that model's tier set.
13
16
  */
14
17
 
15
18
  const CURSOR_MODEL_EFFORT_TIERS: Record<string, readonly string[]> = {
16
19
  "claude-4.5-opus": ["high"],
17
20
  "claude-4.6-opus": ["high", "max"],
18
21
  "claude-4.6-sonnet": ["medium"],
19
- "claude-fable-5": ["low", "medium", "high", "max", "xhigh"],
20
- "claude-opus-4-7": ["low", "medium", "high", "max", "xhigh"],
21
- "claude-opus-4-8": ["low", "medium", "high", "max", "xhigh"],
22
+ // max is always the top tier (canonical order: low < medium < high < xhigh < max), confirmed
23
+ // against Anthropic's effort ladder docs and Cursor's live model lineup.
24
+ "claude-fable-5": ["low", "medium", "high", "xhigh", "max"],
25
+ "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"],
26
+ "claude-opus-4-8": ["low", "medium", "high", "xhigh", "max"],
27
+ "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"],
28
+ "glm-5.2": ["high", "max"],
29
+ // GetUsableModels (2026-07-09) lists grok-4.5-{medium,high,xhigh} and grok-4.5-fast-{medium,high,xhigh};
30
+ // the bare "grok-4.5-fast" id was removed upstream and now returns not_found.
31
+ "grok-4.5": ["medium", "high", "xhigh"],
32
+ "grok-4.5-fast": ["medium", "high", "xhigh"],
22
33
  "gpt-5.1": ["low", "high"],
23
34
  "gpt-5.1-codex-max": ["low", "medium", "high", "xhigh"],
24
35
  "gpt-5.1-codex-mini": ["low", "high"],
@@ -30,11 +41,21 @@ const CURSOR_MODEL_EFFORT_TIERS: Record<string, readonly string[]> = {
30
41
  "gpt-5.4-nano": ["low", "medium", "high", "xhigh"],
31
42
  "gpt-5.5": ["low", "medium", "high"],
32
43
  "gpt-5.5-extra": ["high"],
44
+ "gpt-5.6-sol": ["low", "medium", "high", "xhigh", "max"],
45
+ "gpt-5.6-terra": ["low", "medium", "high", "xhigh", "max"],
46
+ "gpt-5.6-luna": ["low", "medium", "high", "xhigh", "max"],
33
47
  };
34
48
 
49
+ const CANONICAL_CODEX_EFFORT_ORDER = ["low", "medium", "high", "xhigh", "max"] as const;
50
+
51
+ function normalizeRequestedEffort(reasoning: string | undefined): string | undefined {
52
+ const normalized = reasoning?.toLowerCase();
53
+ return normalized === "ultra" ? "max" : normalized;
54
+ }
55
+
35
56
  /** Collapse a Codex reasoning-effort label to a low/medium/high rank for clamping onto a model's tiers. */
36
57
  function codexEffortRank(reasoning: string | undefined): "low" | "medium" | "high" {
37
- switch ((reasoning ?? "").toLowerCase()) {
58
+ switch (normalizeRequestedEffort(reasoning) ?? "") {
38
59
  case "none":
39
60
  case "minimal":
40
61
  case "low":
@@ -53,11 +74,13 @@ function codexEffortRank(reasoning: string | undefined): "low" | "medium" | "hig
53
74
 
54
75
  /**
55
76
  * The Cursor effort suffix to use for `baseModelId` given a Codex reasoning effort, or `undefined` when
56
- * the model takes no suffix (bare). Codex's top effort maps to the model's top tier (max / xhigh).
77
+ * the model takes no suffix (bare). Literal model tiers pass through; unknown efforts clamp by rank.
57
78
  */
58
79
  export function cursorEffortSuffix(baseModelId: string, reasoning: string | undefined): string | undefined {
59
80
  const tiers = CURSOR_MODEL_EFFORT_TIERS[baseModelId];
60
81
  if (!tiers || tiers.length === 0) return undefined;
82
+ const requested = normalizeRequestedEffort(reasoning);
83
+ if (requested && tiers.includes(requested)) return requested;
61
84
  switch (codexEffortRank(reasoning)) {
62
85
  case "low":
63
86
  return tiers[0];
@@ -68,6 +91,14 @@ export function cursorEffortSuffix(baseModelId: string, reasoning: string | unde
68
91
  }
69
92
  }
70
93
 
94
+ /** The Codex-facing picker ladder for a Cursor model, sorted in canonical Codex effort order. */
95
+ export function cursorModelEffortLadder(baseModelId: string): string[] | undefined {
96
+ const tiers = CURSOR_MODEL_EFFORT_TIERS[baseModelId];
97
+ if (!tiers || tiers.length === 0) return undefined;
98
+ const tierSet = new Set(tiers);
99
+ return CANONICAL_CODEX_EFFORT_ORDER.filter(effort => tierSet.has(effort));
100
+ }
101
+
71
102
  /** Base models known to carry a reasoning-effort suffix (everything else is sent bare). */
72
103
  export function cursorModelHasEffortTiers(baseModelId: string): boolean {
73
104
  return (CURSOR_MODEL_EFFORT_TIERS[baseModelId]?.length ?? 0) > 0;
@@ -66,6 +66,7 @@ export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions):
66
66
  "x-ghost-mode": "true",
67
67
  "x-cursor-client-version": opts.clientVersion ?? CURSOR_DISCOVERY_CLIENT_VERSION,
68
68
  "x-cursor-client-type": "cli",
69
+ "x-session-id": crypto.randomUUID(),
69
70
  });
70
71
 
71
72
  let status = 0;
@@ -79,6 +80,8 @@ export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions):
79
80
  if (status !== 200) return close(null);
80
81
  try {
81
82
  const response = fromBinary(GetUsableModelsResponseSchema, new Uint8Array(Buffer.concat(chunks)));
83
+ // Account filtering uses wire `model_id` values only. Aliases like `composer-2-5` must not
84
+ // make stale configured ids such as `composer-2` look activated.
82
85
  const ids = (response.models ?? [])
83
86
  .map(model => (model as { modelId?: string }).modelId)
84
87
  .filter((id): id is string => typeof id === "string" && id.length > 0);
@@ -31,6 +31,7 @@ import {
31
31
  type InteractionResponse,
32
32
  } from "./gen/agent_pb";
33
33
  import { debugProviderDiagnostic } from "../../lib/debug";
34
+ import { classifyCursorError, isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor-errors";
34
35
  import { mcpArgsFromToolCall } from "./protobuf-events";
35
36
  import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";
36
37
  import { cursorUnsafeNativeLocalExecEnabled, handleCursorNativeExec, handleCursorNativeKv, type CursorNativeExecContext } from "./native-exec";
@@ -53,7 +54,7 @@ import type { CursorClientMessage, CursorRunRequest, CursorServerMessage } from
53
54
  import type { CursorTransport, CursorTransportFactoryInput } from "./transport";
54
55
 
55
56
  const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run";
56
- const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
57
+ const CURSOR_CLIENT_VERSION = "cli-2026.07.08-0c04a8a";
57
58
  const HEARTBEAT_MS = 5_000;
58
59
  const CURSOR_FIRST_FRAME_TIMEOUT_MS = 30_000;
59
60
  const CLIENT_TOOL_FINALIZE_GRACE_MS = 50;
@@ -323,6 +324,14 @@ class LiveCursorTransport implements CursorTransport {
323
324
  private readonly desktopDeps: CursorNativeToolDeps;
324
325
  private execContext: CursorNativeExecContext = {};
325
326
  private mcpPrepared?: Promise<void>;
327
+ // Per-turn diagnostic counters/timestamps when provider debug is on (`ocx debug provider on`). Stamped in open(), cleared on
328
+ // close; safe to read after a stream failure because open() owns the only writer before run().
329
+ private turnStartedAt = 0;
330
+ private framesReceived = 0;
331
+ private firstFrameAt?: number;
332
+ private firstFrameLogged = false;
333
+ /** Stable session identifier sent as x-session-id; mirrors IDE session semantics. */
334
+ private readonly sessionId = crypto.randomUUID();
326
335
 
327
336
  constructor(private readonly input: CursorTransportFactoryInput) {
328
337
  this.token = resolveCursorToken(input.provider, input.headers);
@@ -379,6 +388,27 @@ class LiveCursorTransport implements CursorTransport {
379
388
  let done = false;
380
389
  let failure: Error | undefined;
381
390
  let state = createCursorProtobufEventState();
391
+ let failureLogged = false;
392
+ // One per-turn summary of the failure path (end-stream error, socket reset, abort) so the
393
+ // operator can see how far the turn got and how it was classified without re-scanning every
394
+ // frame. Gated behind provider debug (`ocx debug provider on`).
395
+ const summarizeFailure = (err: Error): Error => {
396
+ if (!failureLogged && !(this.expectedClose && isCursorBenignCancelError(err))) {
397
+ failureLogged = true;
398
+ debugProviderDiagnostic("cursor", "turn-failed", {
399
+ committed: this.committed,
400
+ framesReceived: this.framesReceived,
401
+ outputTokens: state.usage.outputTokens,
402
+ contextTokens: state.contextTokens,
403
+ firstFrameMs: this.firstFrameAt ? this.firstFrameAt - this.turnStartedAt : undefined,
404
+ elapsedMs: this.turnStartedAt ? Date.now() - this.turnStartedAt : undefined,
405
+ classified: classifyCursorError(err.message),
406
+ errorCode: (err as { code?: unknown }).code ?? undefined,
407
+ message: redactCursorForLog(err.message),
408
+ });
409
+ }
410
+ return err;
411
+ };
382
412
  const wake = () => {
383
413
  const fn = notify;
384
414
  notify = undefined;
@@ -428,13 +458,21 @@ class LiveCursorTransport implements CursorTransport {
428
458
  const message = queue.shift();
429
459
  if (message) yield message;
430
460
  }
431
- if (failure) throw attachPartialUsage(failure, state);
461
+ if (failure) {
462
+ // A CANCEL is benign only on the client-tool suspend path (expectedClose); an
463
+ // unexpected server-side NGHTTP2_CANCEL must surface as a real transport error.
464
+ if (this.expectedClose && isCursorBenignCancelError(failure)) return;
465
+ throw attachPartialUsage(summarizeFailure(failure), state);
466
+ }
432
467
  if (done) break;
433
468
  await new Promise<void>(resolve => {
434
469
  notify = resolve;
435
470
  });
436
471
  }
437
- if (failure) throw attachPartialUsage(failure, state);
472
+ if (failure) {
473
+ if (this.expectedClose && isCursorBenignCancelError(failure)) return;
474
+ throw attachPartialUsage(summarizeFailure(failure), state);
475
+ }
438
476
  }
439
477
 
440
478
  writeClient(_message: CursorClientMessage): void {}
@@ -506,6 +544,11 @@ class LiveCursorTransport implements CursorTransport {
506
544
  const terminal = finalizeAfterDrain(state);
507
545
  if (terminal.length === 0) return;
508
546
  for (const event of terminal) push(event);
547
+ debugProviderDiagnostic("cursor", "client-tool-suspend", {
548
+ reason: "Responses bridge owns client tools; ending turn without fake mcpResult",
549
+ framesReceived: this.framesReceived,
550
+ elapsedMs: Date.now() - this.turnStartedAt,
551
+ });
509
552
  this.cancelCursorRun();
510
553
  }, this.activeClientToolFinalizeGraceMs);
511
554
  }
@@ -518,11 +561,20 @@ class LiveCursorTransport implements CursorTransport {
518
561
  fail: (error: Error) => void,
519
562
  finish: () => void,
520
563
  ): void {
564
+ this.turnStartedAt = Date.now();
565
+ this.framesReceived = 0;
566
+ this.firstFrameAt = undefined;
567
+ this.firstFrameLogged = false;
568
+ const dialHost = cursorHostLabel(this.input.provider.baseUrl || "https://api2.cursor.sh");
569
+ debugProviderDiagnostic("cursor", "dial", { host: dialHost });
521
570
  this.session = http2.connect(this.input.provider.baseUrl || "https://api2.cursor.sh");
522
571
  // The run request is buffered until the HTTP/2 session connects. Failures before `connect`
523
572
  // (DNS, ECONNREFUSED, TLS, connect timeout) mean the server never received the request, so they
524
573
  // are safe to retry. Once connected, bytes flush to the server and the turn must not be replayed.
525
- this.session.on("connect", () => { this.committed = true; });
574
+ this.session.on("connect", () => {
575
+ this.committed = true;
576
+ debugProviderDiagnostic("cursor", "connected", { connectMs: Date.now() - this.turnStartedAt });
577
+ });
526
578
  this.stream = this.session.request({
527
579
  ":method": "POST",
528
580
  ":path": CURSOR_RUN_PATH,
@@ -534,6 +586,7 @@ class LiveCursorTransport implements CursorTransport {
534
586
  "x-cursor-client-version": CURSOR_CLIENT_VERSION,
535
587
  "x-cursor-client-type": "cli",
536
588
  "x-request-id": crypto.randomUUID(),
589
+ "x-session-id": this.sessionId,
537
590
  });
538
591
 
539
592
  // Single owner of the pre-first-frame deadline. Cleared by the first server frame/end-stream and
@@ -543,6 +596,12 @@ class LiveCursorTransport implements CursorTransport {
543
596
  if (this.expectedClose) {
544
597
  // We already emitted a terminal `done` and cancelled the run (client-tool suspension). The
545
598
  // RST_STREAM CANCEL surfaces here as a stream error/abort; it is expected, not a failure.
599
+ debugProviderDiagnostic("cursor", "stream-cancel-expected", {
600
+ code: (error as { code?: unknown }).code,
601
+ message: redactCursorForLog(error.message),
602
+ framesReceived: this.framesReceived,
603
+ elapsedMs: Date.now() - this.turnStartedAt,
604
+ });
546
605
  finish();
547
606
  return;
548
607
  }
@@ -552,6 +611,7 @@ class LiveCursorTransport implements CursorTransport {
552
611
  const stream = this.stream;
553
612
  this.firstFrameTimer = setTimeout(() => {
554
613
  this.firstFrameTimer = undefined;
614
+ debugProviderDiagnostic("cursor", "first-frame-timeout", { timeoutMs: this.input.firstFrameTimeoutMs ?? CURSOR_FIRST_FRAME_TIMEOUT_MS });
555
615
  try { stream.close(); } catch { /* already closing */ }
556
616
  try { session.close(); } catch { /* already closing */ }
557
617
  fail(new Error("Cursor transport timed out before first response"));
@@ -560,6 +620,11 @@ class LiveCursorTransport implements CursorTransport {
560
620
  let pending: Uint8Array<ArrayBufferLike> = new Uint8Array();
561
621
  this.stream.on("data", chunk => {
562
622
  this.clearFirstFrameTimer();
623
+ if (!this.firstFrameLogged) {
624
+ this.firstFrameLogged = true;
625
+ this.firstFrameAt = Date.now();
626
+ debugProviderDiagnostic("cursor", "first-frame", { latencyMs: this.firstFrameAt - this.turnStartedAt });
627
+ }
563
628
  const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
564
629
  pending = concatBytes(pending, bytes);
565
630
  try {
@@ -567,8 +632,16 @@ class LiveCursorTransport implements CursorTransport {
567
632
  pending = decoded.remainder;
568
633
  const frames = decoded.frames;
569
634
  for (const frame of frames) {
635
+ this.framesReceived++;
570
636
  if ((frame.flags & CONNECT_FLAG_END_STREAM) === CONNECT_FLAG_END_STREAM) {
571
637
  const endError = parseConnectEndStreamError(frame.payload);
638
+ debugProviderDiagnostic("cursor", "connect-end-stream", endError ? {
639
+ code: cursorConnectErrorCode(frame.payload),
640
+ message: redactCursorForLog(endError.message),
641
+ classified: classifyCursorError(endError.message),
642
+ framesReceived: this.framesReceived,
643
+ elapsedMs: Date.now() - this.turnStartedAt,
644
+ } : { framesReceived: this.framesReceived, elapsedMs: Date.now() - this.turnStartedAt });
572
645
  if (endError) failAndClear(endError);
573
646
  continue;
574
647
  }
@@ -582,10 +655,38 @@ class LiveCursorTransport implements CursorTransport {
582
655
  });
583
656
  this.stream.on("trailers", trailers => {
584
657
  const status = trailers["grpc-status"];
658
+ if (status !== undefined) debugProviderDiagnostic("cursor", "trailers", { grpcStatus: String(status) });
585
659
  if (status && status !== "0") failAndClear(new Error(`Cursor gRPC error ${status}`));
586
660
  });
587
- this.stream.on("error", err => failAndClear(err instanceof Error ? err : new Error(String(err))));
588
- this.stream.on("end", () => { this.clearFirstFrameTimer(); finish(); });
661
+ this.stream.on("error", err => {
662
+ const realErr = err instanceof Error ? err : new Error(String(err));
663
+ if (this.expectedClose) {
664
+ failAndClear(realErr);
665
+ return;
666
+ }
667
+ const code = (realErr as { code?: unknown }).code;
668
+ const errno = (realErr as { errno?: unknown }).errno;
669
+ debugProviderDiagnostic("cursor", "stream-error", {
670
+ code: typeof code === "string" || typeof code === "number" ? String(code) : undefined,
671
+ errno: typeof errno === "string" || typeof errno === "number" ? String(errno) : undefined,
672
+ name: realErr.name,
673
+ message: redactCursorForLog(realErr.message),
674
+ committed: this.committed,
675
+ framesReceived: this.framesReceived,
676
+ elapsedMs: Date.now() - this.turnStartedAt,
677
+ });
678
+ failAndClear(realErr);
679
+ });
680
+ this.stream.on("end", () => {
681
+ this.clearFirstFrameTimer();
682
+ debugProviderDiagnostic("cursor", "stream-end", {
683
+ committed: this.committed,
684
+ framesReceived: this.framesReceived,
685
+ expectedClose: this.expectedClose,
686
+ elapsedMs: Date.now() - this.turnStartedAt,
687
+ });
688
+ finish();
689
+ });
589
690
 
590
691
  signal?.addEventListener("abort", () => {
591
692
  this.close();
@@ -689,7 +790,7 @@ function attachPartialUsage(failure: Error, state: ReturnType<typeof createCurso
689
790
  }
690
791
 
691
792
  /**
692
- * Compact frame descriptor for OCX_DEBUG_FRAMES diagnostics: outer case plus the inner
793
+ * Compact frame descriptor for provider debug (`ocx debug provider on`): outer case plus the inner
693
794
  * interactionUpdate/exec case and tool-call union case when present. No payload content is logged.
694
795
  */
695
796
  function describeCursorServerFrame(message: AgentServerMessage): Record<string, unknown> {
@@ -758,6 +859,34 @@ function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
758
859
  return out;
759
860
  }
760
861
 
862
+ /** Host-only label for Cursor transport diagnostics — never leaks path/query/credentials. */
863
+ function cursorHostLabel(baseUrl: string): string {
864
+ try {
865
+ return new URL(baseUrl).host;
866
+ } catch {
867
+ return "cursor";
868
+ }
869
+ }
870
+
871
+ /** Redact a Cursor error message for diagnostic output. Cursor error strings can carry raw
872
+ * credential key=value pairs beyond what redactSecretString covers; safeCursorErrorMessage
873
+ * already applies the full sanitizer plus the classified prefix, so reuse it verbatim. */
874
+ function redactCursorForLog(message: string): string {
875
+ return safeCursorErrorMessage(message).slice(0, 300);
876
+ }
877
+
878
+ /** Extract the Connect end-stream `error.code` from the raw trailer frame payload without
879
+ * surfacing the (potentially secret-bearing) message — used for `[ocx:cursor:connect-end-stream]`
880
+ * diagnostics. Returns undefined when the payload is not the expected Connect error shape. */
881
+ function cursorConnectErrorCode(payload: Uint8Array): string | undefined {
882
+ try {
883
+ const parsed = JSON.parse(new TextDecoder().decode(payload)) as { error?: { code?: string } };
884
+ return parsed?.error?.code;
885
+ } catch {
886
+ return undefined;
887
+ }
888
+ }
889
+
761
890
  export function createLiveCursorTransport(input: CursorTransportFactoryInput): CursorTransport {
762
891
  return new LiveCursorTransport(input);
763
892
  }