@bitkyc08/opencodex 2.29.0 → 2.31.0-preview.20260822

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 (68) hide show
  1. package/README.md +5 -5
  2. package/gui/dist/assets/index-DyWYnr-t.js +102 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +3 -3
  5. package/src/adapters/cursor/cursor-errors.ts +65 -6
  6. package/src/adapters/cursor/discovery.ts +29 -2
  7. package/src/adapters/cursor/effort-map.ts +6 -0
  8. package/src/adapters/cursor/h2-pool.ts +123 -0
  9. package/src/adapters/cursor/images.ts +704 -0
  10. package/src/adapters/cursor/live-models.ts +21 -26
  11. package/src/adapters/cursor/live-transport.ts +239 -8
  12. package/src/adapters/cursor/native-exec-common.ts +17 -0
  13. package/src/adapters/cursor/native-exec.ts +9 -4
  14. package/src/adapters/cursor/protobuf-events.ts +5 -1
  15. package/src/adapters/cursor/protobuf-request.ts +46 -9
  16. package/src/adapters/cursor/request-builder.ts +29 -14
  17. package/src/adapters/cursor/tool-definitions.ts +20 -0
  18. package/src/adapters/cursor/transport.ts +10 -0
  19. package/src/adapters/cursor/types.ts +8 -1
  20. package/src/adapters/cursor.ts +23 -5
  21. package/src/adapters/google.ts +16 -3
  22. package/src/adapters/openai-responses.ts +66 -20
  23. package/src/adapters/xai-web-search.ts +185 -0
  24. package/src/cli/agent.ts +2 -1
  25. package/src/cli/dispatch.ts +2 -2
  26. package/src/cli/doctor.ts +89 -0
  27. package/src/cli/help.ts +2 -0
  28. package/src/cli/registry.ts +7 -2
  29. package/src/codex/auth-context.ts +41 -2
  30. package/src/codex/catalog/effort.ts +1 -1
  31. package/src/codex/catalog/parsing.ts +2 -0
  32. package/src/codex/catalog/provider-fetch.ts +20 -5
  33. package/src/codex/coordinator-doctor.ts +332 -0
  34. package/src/codex/features.ts +58 -0
  35. package/src/codex/inject-coordination.ts +39 -6
  36. package/src/codex/transition-state.ts +12 -12
  37. package/src/generated/compatibility-version.json +86 -58
  38. package/src/lib/bun-stream-caps.ts +7 -4
  39. package/src/lib/errors.ts +8 -2
  40. package/src/oauth/cursor.ts +21 -0
  41. package/src/providers/command-code-efforts.ts +7 -0
  42. package/src/providers/cursor-pool.ts +72 -0
  43. package/src/providers/derive.ts +3 -0
  44. package/src/providers/fastwire.ts +12 -1
  45. package/src/providers/openai-sidecar.ts +1 -0
  46. package/src/providers/quota.ts +98 -25
  47. package/src/providers/registry.ts +115 -10
  48. package/src/providers/service-tier.ts +22 -7
  49. package/src/responses/custom-tool-compat.ts +24 -8
  50. package/src/responses/namespace-tool-compat.ts +2 -3
  51. package/src/router.ts +3 -0
  52. package/src/server/chat-completions.ts +4 -0
  53. package/src/server/chat-native.ts +20 -0
  54. package/src/server/management/agent-settings-routes.ts +16 -5
  55. package/src/server/management/config-routes.ts +25 -5
  56. package/src/server/management/vision-sidecar-options.ts +54 -19
  57. package/src/server/responses/compact.ts +1 -2
  58. package/src/server/responses/core.ts +54 -13
  59. package/src/service.ts +122 -14
  60. package/src/types/config.ts +9 -3
  61. package/src/types/provider.ts +6 -0
  62. package/src/usage/cost.ts +52 -38
  63. package/src/usage/expected-prices.ts +79 -9
  64. package/src/vision/backends.ts +97 -0
  65. package/src/vision/eligibility.ts +43 -22
  66. package/src/vision/index.ts +73 -5
  67. package/src/vision/routed-describe.ts +175 -0
  68. package/gui/dist/assets/index-BNESwCzn.js +0 -102
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BNESwCzn.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-DyWYnr-t.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-CH7ncHCC.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.29.0",
3
+ "version": "2.31.0-preview.20260822",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -62,11 +62,11 @@
62
62
  "@bufbuild/protobuf": "^2.14.0",
63
63
  "@modelcontextprotocol/sdk": "^1.30.0",
64
64
  "@napi-rs/keyring": "1.3.0",
65
- "bun": "1.3.14",
65
+ "bun": "1.4.0",
66
66
  "zod": "4.4.3"
67
67
  },
68
68
  "devDependencies": {
69
- "@types/bun": "1.3.14",
69
+ "@types/bun": "1.4.0",
70
70
  "typescript": "7.0.2"
71
71
  },
72
72
  "overrides": {
@@ -112,6 +112,58 @@ export function isCursorInvalidArgumentError(value: unknown): boolean {
112
112
  }
113
113
 
114
114
  const QUOTA_RATE_CUES = ["too many requests", "quota", "rate limit", "rate-limit", "throttl"];
115
+ /**
116
+ * A bare `resource_exhausted` end-stream with no detail beyond a generic error wrapper
117
+ * ("Error" or empty tail) and zero tokens billed is the shape Cursor's backend emits when
118
+ * the request payload exceeded its context window — not when quota ran out (senpi #1009,
119
+ * #1036: same wording, two causes). Quota rejections always carry an explicit rate cue
120
+ * ("too many requests", "quota exhausted"), so the ABSENCE of those cues plus the
121
+ * absence of a size phrase means payload overflow. Classifying it as 429 makes Codex
122
+ * back off instead of compacting, which burns retries on an unfixable-by-retry failure.
123
+ */
124
+ const BARE_RE_TAILS = new Set(["error", "", "resource_exhausted", "resource exhausted"]);
125
+
126
+ /**
127
+ * Size prior for bare resource_exhausted classification (devlog 260, live probe 210):
128
+ * a plan-gated model returns the SAME bare RE shape on a ~20-token prompt that a real
129
+ * payload overflow produces, so the message alone cannot separate "compact and retry"
130
+ * from "this account cannot use this model". When the caller can supply how large the
131
+ * request actually was relative to the model's window, a small request keeps the
132
+ * 429-class mapping; only a plausibly-large one classifies as overflow. Unknown
133
+ * sizes keep today's overflow mapping so the prior only ever REMOVES false overflows
134
+ * it can prove.
135
+ */
136
+ export interface CursorSizeContext {
137
+ estimatedInputTokens?: number;
138
+ contextWindow?: number;
139
+ }
140
+
141
+ const OVERFLOW_MIN_FRACTION = 0.5;
142
+
143
+ function bareReLooksLikeOverflow(context?: CursorSizeContext): boolean {
144
+ if (!context) return true;
145
+ const { estimatedInputTokens, contextWindow } = context;
146
+ if (estimatedInputTokens === undefined || contextWindow === undefined || contextWindow <= 0) return true;
147
+ return estimatedInputTokens >= OVERFLOW_MIN_FRACTION * contextWindow;
148
+ }
149
+
150
+ export function isCursorZeroTokenResourceExhausted(lowerMessage: string): boolean {
151
+ if (!lowerMessage.includes("resource_exhausted") && !lowerMessage.includes("resource exhausted")) return false;
152
+ // Any explicit quota/rate cue wins: this is a real 429.
153
+ if (QUOTA_RATE_CUES.some(cue => lowerMessage.includes(cue))) return false;
154
+ // An explicit size phrase also wins (already handled by the existing classifier).
155
+ if (isCursorRequestTooLargeDetail(lowerMessage)) return false;
156
+ // Extract the tail after the resource_exhausted marker. If it names a specific
157
+ // non-quota, non-size cause, this is NOT bare overflow.
158
+ const idx = Math.max(
159
+ lowerMessage.indexOf("resource_exhausted"),
160
+ lowerMessage.indexOf("resource exhausted"),
161
+ );
162
+ const tail = lowerMessage.slice(idx + "resource_exhausted".length).trim().replace(/^[:\s]+/, "").trim();
163
+ if (!BARE_RE_TAILS.has(tail)) return false;
164
+ return true;
165
+ }
166
+
115
167
  const REQUEST_TOO_LARGE_PATTERNS: (string | RegExp)[] = [
116
168
  "tool catalog too large",
117
169
  "tool registration too large",
@@ -144,7 +196,7 @@ export function isCursorRequestTooLargeDetail(lowerMessage: string): boolean {
144
196
  * The returned prefix string is recognized by `src/lib/errors.ts` `classifyError` keywords,
145
197
  * so bridge-level error mapping produces the right Codex error type (rate_limit, auth, etc.).
146
198
  */
147
- export function classifyCursorError(message: string): string {
199
+ export function classifyCursorError(message: string, sizeContext?: CursorSizeContext): string {
148
200
  const lower = message.toLowerCase();
149
201
 
150
202
  if (isCursorBenignCancelError(message)) return "Cursor stream suspended";
@@ -158,9 +210,16 @@ export function classifyCursorError(message: string): string {
158
210
  // client-fixable 400; everything else surfaces as a 429 so Codex backs off
159
211
  // instead of hammering retries (live evidence: 6x 400 retry storm, devlog
160
212
  // 260723_cursor_context_continuity/000_plan.md).
161
- return isCursorRequestTooLargeDetail(lower)
162
- ? "Cursor resource limit exceeded"
163
- : "Cursor rate limit exceeded";
213
+ if (isCursorRequestTooLargeDetail(lower)) return "Cursor resource limit exceeded";
214
+ // A bare resource_exhausted with no quota cue and no size phrase is payload
215
+ // overflow, not rate limiting. Classifying it as 429 makes Codex back off on a
216
+ // failure that only compaction can fix (senpi #1009 / #1036; research unit T01).
217
+ // Refinement (devlog 260): plan-gated models emit the same bare shape on tiny
218
+ // requests — when the caller proves the request was small, keep the 429 class.
219
+ if (isCursorZeroTokenResourceExhausted(lower)) {
220
+ return bareReLooksLikeOverflow(sizeContext) ? "Cursor context limit exceeded" : "Cursor rate limit exceeded";
221
+ }
222
+ return "Cursor rate limit exceeded";
164
223
  }
165
224
 
166
225
  if (
@@ -220,8 +279,8 @@ export function classifyCursorError(message: string): string {
220
279
  * Produce a user-facing, secret-safe Cursor error message with an actionable category prefix.
221
280
  * Mirrors `safeKiroErrorMessage` / `safeKiroHttpErrorMessage` in kiro-errors.ts.
222
281
  */
223
- export function safeCursorErrorMessage(rawMessage: string): string {
224
- const prefix = classifyCursorError(rawMessage);
282
+ export function safeCursorErrorMessage(rawMessage: string, sizeContext?: CursorSizeContext): string {
283
+ const prefix = classifyCursorError(rawMessage, sizeContext);
225
284
  const detail = sanitize(rawMessage)
226
285
  .replace(/resource[_ ]exhausted/gi, "resource limit exceeded")
227
286
  .slice(0, 500);
@@ -103,6 +103,28 @@ export const CURSOR_ROUTER_MODEL_IDS = [
103
103
  ...CURSOR_ROUTING_LEVELS.map(level => `${CURSOR_AUTO_MODEL_ID}-${level}`),
104
104
  ] as const;
105
105
 
106
+ /**
107
+ * Cursor models that cannot see images natively. OpenCodex routes them through the vision
108
+ * sidecar (the catalog still advertises image so Codex can attach). Evidence:
109
+ * - Composer family: Cursor staff — text-only; "Model does not support images"
110
+ * - Auto / router modes: Cursor docs omit Images for Auto Cost; staff — pick Claude/GPT for images
111
+ * - glm-5.2: Cursor docs omit Images; Z.ai GLM-5.2 is text-only (vision is GLM-5V)
112
+ * - glm-5.3: same family; seeded as text-only ahead of Cursor's lineup update
113
+ *
114
+ * Composer ids are enumerated explicitly — prefix wildcard matching is deliberately out of
115
+ * scope here; a live-discovered new Composer slug stays native-path until curated. Everyone
116
+ * else in the static seed (Claude, Gemini, GPT, Kimi, Grok) takes SelectedImage. Other
117
+ * live-discovered ids stay unclassified (native path) until curated.
118
+ */
119
+ export const CURSOR_NO_VISION_MODELS = [
120
+ ...CURSOR_ROUTER_MODEL_IDS,
121
+ "composer-1",
122
+ "composer-2.5",
123
+ "composer-2.5-fast",
124
+ "glm-5.2",
125
+ "glm-5.3",
126
+ ] as const;
127
+
106
128
  /** Wire id Cursor Connect expects for the auto-router (GetUsableModels returns `default`, not `auto`). */
107
129
  export const CURSOR_AUTO_WIRE_MODEL_ID = "default";
108
130
 
@@ -214,10 +236,15 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM
214
236
  { id: "claude-4.6-opus", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
215
237
  { id: "claude-4.6-sonnet", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
216
238
  { id: "claude-opus-4-7", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
217
- // opus-4-7-fast: effort-suffix tiers unverified -> no tier picker; sent bare like live-only ids.
218
- { id: "claude-opus-4-7-fast", contextWindow: CONTEXT_200K },
239
+ // Opus Fast families: live GetUsableModels (260822) lists ONLY effort-suffixed wire ids
240
+ // ({base-without-fast}-{effort}-fast; the bare id returns not_found), so every entry
241
+ // carries a tier picker. Live-verified: claude-opus-4-8-high-fast completed a turn.
242
+ // Tiers per the 260822 dump (devlog 260822_senpi_cursor_transfer/300).
243
+ { id: "claude-opus-4-7-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
244
+ { id: "claude-opus-4-8-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
219
245
  { id: "claude-opus-4-8", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
220
246
  { id: "claude-opus-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
247
+ { id: "claude-opus-5-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
221
248
  { id: "claude-fable-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
222
249
 
223
250
  { id: "composer-1", contextWindow: CONTEXT_200K },
@@ -24,8 +24,14 @@ const CURSOR_MODEL_EFFORT_TIERS: Record<string, readonly string[]> = {
24
24
  // against Anthropic's effort ladder docs and Cursor's live model lineup.
25
25
  "claude-fable-5": ["low", "medium", "high", "xhigh", "max"],
26
26
  "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"],
27
+ // Opus Fast tiers from the 260822 GetUsableModels dump (devlog .../300): the wire
28
+ // exposes {base-without-fast}-{effort}-fast only; suffix derivation at the bottom of
29
+ // this file produces those ids. opus-5-fast has no xhigh/max (non-thinking) yet.
30
+ "claude-opus-4-7-fast": ["low", "medium", "high", "xhigh", "max"],
27
31
  "claude-opus-4-8": ["low", "medium", "high", "xhigh", "max"],
32
+ "claude-opus-4-8-fast": ["low", "medium", "high", "xhigh", "max"],
28
33
  "claude-opus-5": ["low", "medium", "high", "xhigh", "max"],
34
+ "claude-opus-5-fast": ["low", "medium", "high"],
29
35
  "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"],
30
36
  "glm-5.2": ["high", "max"],
31
37
  // 260814 preemptive: glm-5.3 seeded ahead of Cursor's lineup update. Unlike 5.2, Z.AI folds
@@ -0,0 +1,123 @@
1
+ import http2 from "node:http2";
2
+ import { registerOptionalShutdownHook } from "../../lib/optional-shutdown-hooks";
3
+
4
+ const DEFAULT_MAX_SESSIONS = 8;
5
+ const SESSION_CLOSE_TIMEOUT_MS = 2_000;
6
+
7
+ interface PoolEntry {
8
+ readonly session: http2.ClientHttp2Session;
9
+ readonly streams: Set<http2.ClientHttp2Stream>;
10
+ usable: boolean;
11
+ }
12
+
13
+ /**
14
+ * HTTP/2 connection pool for Cursor Connect DISCOVERY calls (GetUsableModels).
15
+ * Sessions are keyed by origin (scheme+host+port) and reused to avoid fresh
16
+ * TCP+TLS per call. The Run path deliberately dials its own session: Run
17
+ * streams are long-lived bidi whose lifecycle/EOF semantics are owned by
18
+ * live-transport (see devlog 260822_senpi_cursor_transfer/190 — Run-path
19
+ * pooling is a separate, deliberate unit if ever taken).
20
+ */
21
+ export class CursorH2SessionPool {
22
+ private readonly entries = new Map<string, PoolEntry>();
23
+ private closed = false;
24
+
25
+ constructor(private readonly maxSessions = DEFAULT_MAX_SESSIONS) {}
26
+
27
+ /**
28
+ * Lazily registered on first use so a process that never talks to Cursor registers
29
+ * nothing (optional-subsystem doctrine). The seam is synchronous and best-effort;
30
+ * shutdown() is fire-and-forget there because lifecycle's drainAndShutdown runs
31
+ * under its own absolute deadline.
32
+ */
33
+ private armShutdownHook: (() => void) | undefined = () => {
34
+ this.armShutdownHook = undefined;
35
+ registerOptionalShutdownHook("cursor-h2-pool", () => { void this.shutdown(); });
36
+ };
37
+
38
+ request(
39
+ url: string,
40
+ headers: http2.OutgoingHttpHeaders,
41
+ ): http2.ClientHttp2Stream {
42
+ if (this.closed) throw new Error("Cursor H2 session pool is closed");
43
+ this.armShutdownHook?.();
44
+ const origin = new URL(url).origin;
45
+ const entry = this.usableEntry(origin) ?? this.createEntry(origin);
46
+ try {
47
+ const stream = entry.session.request(headers);
48
+ entry.streams.add(stream);
49
+ stream.once("close", () => { entry.streams.delete(stream); });
50
+ return stream;
51
+ } catch (error) {
52
+ this.drain(entry, true);
53
+ throw error;
54
+ }
55
+ }
56
+
57
+ async shutdown(): Promise<void> {
58
+ if (this.closed) return;
59
+ this.closed = true;
60
+ const pending: Promise<void>[] = [];
61
+ for (const entry of [...this.entries.values()]) {
62
+ for (const stream of [...entry.streams]) stream.destroy();
63
+ entry.session.close();
64
+ if (entry.session.destroyed) continue;
65
+ pending.push(new Promise<void>(resolve => {
66
+ const timer = setTimeout(resolve, SESSION_CLOSE_TIMEOUT_MS);
67
+ timer.unref?.();
68
+ entry.session.once("close", () => { clearTimeout(timer); resolve(); });
69
+ }));
70
+ }
71
+ this.entries.clear();
72
+ await Promise.all(pending);
73
+ }
74
+
75
+ get size(): number { return this.entries.size; }
76
+
77
+ private usableEntry(origin: string): PoolEntry | undefined {
78
+ const entry = this.entries.get(origin);
79
+ if (!entry) return undefined;
80
+ if (entry.usable && !entry.session.closed && !entry.session.destroyed) return entry;
81
+ this.drain(entry, false);
82
+ return undefined;
83
+ }
84
+
85
+ private createEntry(origin: string): PoolEntry {
86
+ const session = http2.connect(origin);
87
+ const entry: PoolEntry = {
88
+ session,
89
+ streams: new Set(),
90
+ usable: true,
91
+ };
92
+ this.entries.set(origin, entry);
93
+ session.once("goaway", () => { this.drain(entry, true); });
94
+ session.on("error", () => { this.drain(entry, false); });
95
+ session.once("close", () => {
96
+ // Identity check: a stale close event from an old session must not evict
97
+ // a healthy replacement entry that was created after drain() removed the old one.
98
+ if (this.entries.get(origin) === entry) this.entries.delete(origin);
99
+ });
100
+ // Enforce bound: evict oldest when over capacity.
101
+ while (this.entries.size > this.maxSessions) {
102
+ const oldest = this.entries.keys().next().value;
103
+ if (!oldest || oldest === origin) break;
104
+ const old = this.entries.get(oldest);
105
+ if (old) this.drain(old, true);
106
+ }
107
+ return entry;
108
+ }
109
+
110
+ private drain(entry: PoolEntry, closeSession: boolean): void {
111
+ entry.usable = false;
112
+ for (const stream of [...entry.streams]) stream.destroy();
113
+ entry.streams.clear();
114
+ if (closeSession) entry.session.close();
115
+ // Remove from map by finding the matching key.
116
+ for (const [key, value] of this.entries) {
117
+ if (value === entry) { this.entries.delete(key); break; }
118
+ }
119
+ }
120
+ }
121
+
122
+ /** Shared singleton pool for all Cursor adapter H2 traffic. */
123
+ export const cursorH2Pool = new CursorH2SessionPool();