@bitkyc08/opencodex 2.6.32 → 2.7.0

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 (55) 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-BGdxwydf.js +34 -0
  5. package/gui/dist/assets/index-DANCQ2Jt.css +1 -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 +56 -10
  11. package/src/adapters/cursor/effort-map.ts +35 -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 -6
  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 +32 -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 +168 -7
  47. package/src/server/relay.ts +2 -2
  48. package/src/server/request-log.ts +78 -0
  49. package/src/server/responses.ts +209 -0
  50. package/src/types.ts +28 -1
  51. package/src/usage/debug.ts +32 -5
  52. package/src/usage/summary.ts +6 -6
  53. package/src/web-search/index.ts +1 -1
  54. package/gui/dist/assets/index-ByGC8-Bm.css +0 -1
  55. package/gui/dist/assets/index-D_JZzI0r.js +0 -15
@@ -1,16 +1,19 @@
1
1
  export class CodexWarmupError extends Error {
2
2
  code: "http_status" | "missing_body" | "stream_failed" | "stream_incomplete" | "stream_error" | "invalid_sse" | "no_terminal" | "transport";
3
3
  status?: number;
4
+ /** Upstream error detail extracted from the response body (truncated to 512 chars). */
5
+ upstreamDetail?: string;
4
6
 
5
7
  constructor(
6
8
  code: CodexWarmupError["code"],
7
9
  message = "Codex warmup failed",
8
- options: { status?: number; cause?: unknown } = {},
10
+ options: { status?: number; cause?: unknown; upstreamDetail?: string } = {},
9
11
  ) {
10
12
  super(message);
11
13
  this.name = "CodexWarmupError";
12
14
  this.code = code;
13
15
  this.status = options.status;
16
+ this.upstreamDetail = options.upstreamDetail;
14
17
  if (options.cause !== undefined) this.cause = options.cause;
15
18
  }
16
19
  }
@@ -24,11 +27,39 @@ export interface CodexWarmupOptions {
24
27
 
25
28
  const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
26
29
  const DEFAULT_MODEL = "gpt-5.4-mini";
30
+ const FALLBACK_MODELS = ["gpt-5.5"];
27
31
  const DEFAULT_TIMEOUT_MS = 30_000;
32
+ const MAX_ERROR_BODY_BYTES = 2048;
33
+
34
+ /** Read the first MAX_ERROR_BODY_BYTES of a response body and extract an error message. */
35
+ async function readErrorDetail(res: Response): Promise<string | undefined> {
36
+ try {
37
+ const text = await res.text();
38
+ const trimmed = text.slice(0, MAX_ERROR_BODY_BYTES);
39
+ try {
40
+ const json = JSON.parse(trimmed) as Record<string, unknown>;
41
+ // ChatGPT backend error shape: { error: { message: "..." } } or { detail: "..." }
42
+ const nested = json.error;
43
+ if (nested && typeof nested === "object" && typeof (nested as Record<string, unknown>).message === "string") {
44
+ return ((nested as Record<string, unknown>).message as string).slice(0, 512);
45
+ }
46
+ if (typeof json.detail === "string") return json.detail.slice(0, 512);
47
+ if (typeof json.error === "string") return (json.error as string).slice(0, 512);
48
+ if (typeof json.message === "string") return json.message.slice(0, 512);
49
+ } catch {
50
+ // Non-JSON response body may contain sensitive data (tokens, credentials).
51
+ // Only surface structured error messages, never raw text.
52
+ }
53
+ return undefined;
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
28
58
 
29
59
  function safeWarmupReason(err: unknown): string {
30
60
  if (err instanceof CodexWarmupError) {
31
- return err.status ? `${err.code}:${err.status}` : err.code;
61
+ const base = err.status ? `${err.code}:${err.status}` : err.code;
62
+ return err.upstreamDetail ? `${base} — ${err.upstreamDetail}` : base;
32
63
  }
33
64
  return "transport";
34
65
  }
@@ -99,7 +130,7 @@ async function drainWarmupSse(body: ReadableStream<Uint8Array>): Promise<void> {
99
130
  }
100
131
  }
101
132
 
102
- export async function warmCodexAccount(options: CodexWarmupOptions): Promise<void> {
133
+ async function tryWarmup(options: CodexWarmupOptions, model: string): Promise<void> {
103
134
  let res: Response;
104
135
  try {
105
136
  res = await fetch(CODEX_RESPONSES_URL, {
@@ -110,7 +141,7 @@ export async function warmCodexAccount(options: CodexWarmupOptions): Promise<voi
110
141
  "Content-Type": "application/json",
111
142
  },
112
143
  body: JSON.stringify({
113
- model: options.model?.trim() || DEFAULT_MODEL,
144
+ model,
114
145
  instructions: "Reply with OK.",
115
146
  input: "hi",
116
147
  stream: true,
@@ -123,8 +154,11 @@ export async function warmCodexAccount(options: CodexWarmupOptions): Promise<voi
123
154
  }
124
155
 
125
156
  if (!res.ok) {
126
- await res.body?.cancel().catch(() => {});
127
- throw new CodexWarmupError("http_status", "Codex warmup was rejected", { status: res.status });
157
+ const upstreamDetail = await readErrorDetail(res);
158
+ throw new CodexWarmupError("http_status", "Codex warmup was rejected", {
159
+ status: res.status,
160
+ upstreamDetail,
161
+ });
128
162
  }
129
163
  if (!res.body) throw new CodexWarmupError("missing_body");
130
164
 
@@ -135,3 +169,25 @@ export async function warmCodexAccount(options: CodexWarmupOptions): Promise<voi
135
169
  }
136
170
  }
137
171
 
172
+ export async function warmCodexAccount(options: CodexWarmupOptions): Promise<void> {
173
+ const primaryModel = options.model?.trim() || DEFAULT_MODEL;
174
+ try {
175
+ await tryWarmup(options, primaryModel);
176
+ return;
177
+ } catch (err) {
178
+ // Retry with fallback models on 400 (model may not be available for this account).
179
+ if (!(err instanceof CodexWarmupError) || err.status !== 400) throw err;
180
+ let lastErr = err;
181
+ for (const fallback of FALLBACK_MODELS) {
182
+ if (fallback === primaryModel) continue;
183
+ try {
184
+ await tryWarmup(options, fallback);
185
+ return;
186
+ } catch (retryErr) {
187
+ if (retryErr instanceof CodexWarmupError) lastErr = retryErr;
188
+ }
189
+ }
190
+ throw lastErr;
191
+ }
192
+ }
193
+
package/src/config.ts CHANGED
@@ -149,12 +149,14 @@ const configSchema = z.object({
149
149
 
150
150
  /**
151
151
  * Default featured subagent models (native GPT) seeded on a fresh install and when `subagentModels`
152
- * is unset. Codex's spawn_agent advertises the first 5 featured catalog entries; these are the GPT
153
- * natives the installed Codex actually ships. The user can remove any in the GUI — once they set the
154
- * list (even to []), it is respected, so removals persist (start-up only seeds the UNSET case).
155
- * Kept to ids ChatGPT accepts; the start-up seed prefers the live catalog's native slugs.
152
+ * is unset. Codex's spawn_agent advertises the first 5 featured catalog entries, so this seed is a
153
+ * deliberate 5-list: frontier gpt-5.5 first, the gpt-5.6 preview trio, and gpt-5.4-mini as the cheap
154
+ * tier. gpt-5.4 / gpt-5.3-codex-spark stay selectable in the GUI's available list. The user can
155
+ * remove any in the GUI once they set the list (even to []), it is respected, so removals persist
156
+ * (start-up only seeds the UNSET case). Kept to ids ChatGPT accepts; the start-up seed prefers the
157
+ * live catalog's native slugs.
156
158
  */
157
- export const DEFAULT_SUBAGENT_MODELS = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"];
159
+ export const DEFAULT_SUBAGENT_MODELS = ["gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.4-mini"];
158
160
 
159
161
  export function getConfigDir(): string {
160
162
  return resolveConfigDir();
@@ -0,0 +1,42 @@
1
+ /** In-memory ring buffer of debug log lines for `ocx debug logs` / GUI tailing. */
2
+
3
+ export interface DebugLogEntry {
4
+ /** Monotonic cursor for pagination; survives same-millisecond bursts. */
5
+ seq: number;
6
+ at: number;
7
+ line: string;
8
+ }
9
+
10
+ const MAX_LINES = 2_000;
11
+ const buffer: DebugLogEntry[] = [];
12
+ const listeners = new Set<(entry: DebugLogEntry) => void>();
13
+ let nextSeq = 1;
14
+
15
+ export function appendDebugLogLine(line: string): void {
16
+ const entry: DebugLogEntry = { seq: nextSeq++, at: Date.now(), line };
17
+ buffer.push(entry);
18
+ if (buffer.length > MAX_LINES) buffer.splice(0, buffer.length - MAX_LINES);
19
+ for (const listener of listeners) {
20
+ try { listener(entry); } catch { /* listeners must not break logging */ }
21
+ }
22
+ }
23
+
24
+ export function getDebugLogEntries(options?: { after?: number; limit?: number }): DebugLogEntry[] {
25
+ const after = options?.after ?? 0;
26
+ const limit = options?.limit ?? 500;
27
+ const filtered = after > 0 ? buffer.filter(entry => entry.seq > after) : buffer;
28
+ if (filtered.length <= limit) return filtered;
29
+ return filtered.slice(-limit);
30
+ }
31
+
32
+ export function subscribeDebugLogEntries(listener: (entry: DebugLogEntry) => void): () => void {
33
+ listeners.add(listener);
34
+ return () => listeners.delete(listener);
35
+ }
36
+
37
+ /** Test isolation. */
38
+ export function resetDebugLogBufferForTests(): void {
39
+ buffer.length = 0;
40
+ listeners.clear();
41
+ nextSeq = 1;
42
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Runtime-controllable debug flags.
3
+ * Provider debug: `ocx debug provider on|off|status|reset|logs [-f]` (or OCX_DEBUG=1 on start).
4
+ * Usage capture: `ocx debug usage on|off|status|reset|logs [-f]` (or OPENCODEX_USAGE_DEBUG=1).
5
+ * `/api/debug` and `ocx debug` override env defaults without restart.
6
+ */
7
+
8
+ export const DEBUG_ENV = {
9
+ debug: "OCX_DEBUG",
10
+ usage: "OPENCODEX_USAGE_DEBUG",
11
+ } as const;
12
+
13
+ /** Legacy env var that still enables provider debug logging. */
14
+ const LEGACY_DEBUG_ENV = ["OCX_DEBUG_FRAMES"] as const;
15
+
16
+ export type DebugFlag = keyof typeof DEBUG_ENV;
17
+
18
+ export interface DebugSettingsView {
19
+ enabled: boolean;
20
+ usage: boolean;
21
+ runtimeOverride: Partial<Record<DebugFlag, boolean>>;
22
+ env: Record<DebugFlag, boolean>;
23
+ }
24
+
25
+ const runtimeOverride: Partial<Record<DebugFlag, boolean>> = {};
26
+
27
+ function envFlag(name: string): boolean {
28
+ return process.env[name] === "1";
29
+ }
30
+
31
+ function legacyDebugEnvEnabled(): boolean {
32
+ return LEGACY_DEBUG_ENV.some(name => envFlag(name));
33
+ }
34
+
35
+ export function isDebugEnabled(): boolean {
36
+ if (runtimeOverride.debug !== undefined) return runtimeOverride.debug;
37
+ return envFlag(DEBUG_ENV.debug) || legacyDebugEnvEnabled();
38
+ }
39
+
40
+ /** @deprecated Use isDebugEnabled(). */
41
+ export function isFramesDebugEnabled(): boolean {
42
+ return isDebugEnabled();
43
+ }
44
+
45
+ export function isUsageDebugEnabled(): boolean {
46
+ if (runtimeOverride.usage !== undefined) return runtimeOverride.usage;
47
+ return envFlag(DEBUG_ENV.usage);
48
+ }
49
+
50
+ export function getDebugSettings(): DebugSettingsView {
51
+ return {
52
+ enabled: isDebugEnabled(),
53
+ usage: isUsageDebugEnabled(),
54
+ runtimeOverride: { ...runtimeOverride },
55
+ env: {
56
+ debug: envFlag(DEBUG_ENV.debug) || legacyDebugEnvEnabled(),
57
+ usage: envFlag(DEBUG_ENV.usage),
58
+ },
59
+ };
60
+ }
61
+
62
+ export function setDebugSettings(partial: Partial<Record<DebugFlag, boolean>>): DebugSettingsView {
63
+ for (const key of ["debug", "usage"] as const) {
64
+ if (partial[key] !== undefined) runtimeOverride[key] = partial[key];
65
+ }
66
+ return getDebugSettings();
67
+ }
68
+
69
+ export function clearDebugSetting(flag: DebugFlag): DebugSettingsView {
70
+ delete runtimeOverride[flag];
71
+ return getDebugSettings();
72
+ }
73
+
74
+ export function clearDebugSettings(): DebugSettingsView {
75
+ for (const key of ["debug", "usage"] as const) {
76
+ delete runtimeOverride[key];
77
+ }
78
+ return getDebugSettings();
79
+ }
80
+
81
+ /** Test isolation: drop runtime overrides only (env vars untouched). */
82
+ export function resetDebugSettingsForTests(): void {
83
+ clearDebugSettings();
84
+ }
package/src/lib/debug.ts CHANGED
@@ -1,21 +1,30 @@
1
+ import { appendDebugLogLine } from "./debug-log-buffer";
2
+ import { isDebugEnabled } from "./debug-settings";
1
3
  import { redactSecrets } from "./redact";
2
4
 
3
- // Opt-in frame-drop visibility. The streaming path is intentionally quiet (no unconditional
4
- // console output), so this no-ops unless OCX_DEBUG_FRAMES=1. Lets a malformed/chunk-split
5
- // upstream frame be detected instead of silently truncating content.
6
- function debugFramesEnabled(): boolean {
7
- return process.env.OCX_DEBUG_FRAMES === "1";
5
+ function emitDebugLine(line: string): void {
6
+ if (!isDebugEnabled()) return;
7
+ try {
8
+ appendDebugLogLine(line);
9
+ console.error(line);
10
+ } catch {
11
+ /* diagnostics must never affect request handling */
12
+ }
8
13
  }
9
14
 
15
+ // Opt-in provider diagnostics. Streaming adapters stay quiet unless provider debug is on
16
+ // (`ocx debug provider on`, GUI Logs toggle, or OCX_DEBUG=1). Tail with `ocx debug provider logs -f`.
17
+
10
18
  export function debugDroppedFrame(adapter: string, payload: string): void {
11
- if (!debugFramesEnabled()) return;
12
- console.error(`[ocx:frame-drop] ${adapter}: dropped malformed upstream frame (payload redacted, bytes=${payload.length})`);
19
+ if (!isDebugEnabled()) return;
20
+ emitDebugLine(`[ocx:frame-drop] ${adapter}: dropped malformed upstream frame (payload redacted, bytes=${payload.length})`);
13
21
  }
14
22
 
23
+ /** Provider-agnostic diagnostic logging: `[ocx:<adapter>:<event>] {...}`. */
15
24
  export function debugProviderDiagnostic(adapter: string, event: string, details: Record<string, unknown>): void {
16
- if (!debugFramesEnabled()) return;
25
+ if (!isDebugEnabled()) return;
17
26
  try {
18
- console.error(`[ocx:${adapter}:${event}] ${JSON.stringify(redactSecrets(details))}`);
27
+ emitDebugLine(`[ocx:${adapter}:${event}] ${JSON.stringify(redactSecrets(details))}`);
19
28
  } catch {
20
29
  /* diagnostics must never affect request handling */
21
30
  }
package/src/lib/errors.ts CHANGED
@@ -30,7 +30,10 @@ export function classifyError(status: number, type: string, message: string): Oc
30
30
  text.includes("rate limit") ||
31
31
  text.includes("rate limited") ||
32
32
  text.includes("too many requests") ||
33
- text.includes("throttlingexception")
33
+ text.includes("resource_exhausted") ||
34
+ text.includes("resource exhausted") ||
35
+ text.includes("throttlingexception") ||
36
+ text.includes("throttling")
34
37
  ) {
35
38
  return { message, type: "rate_limit_error", code: "rate_limit_exceeded" };
36
39
  }
@@ -81,3 +84,103 @@ export function classifyError(status: number, type: string, message: string): Oc
81
84
  }
82
85
  return { message, type, code: type || null };
83
86
  }
87
+
88
+ /** Best-effort parse of a retry delay embedded in an upstream error message. */
89
+ export function parseRetryAfterFromMessage(message: string): number | undefined {
90
+ const patterns = [
91
+ /try again in (\d+(?:\.\d+)?)\s*s(?:ec(?:ond)?s?)?/i,
92
+ /retry after (\d+(?:\.\d+)?)\s*s(?:ec(?:ond)?s?)?/i,
93
+ /retry[- ]after[:\s]+(\d+)/i,
94
+ ];
95
+ for (const pattern of patterns) {
96
+ const match = message.match(pattern);
97
+ if (!match?.[1]) continue;
98
+ const seconds = Number.parseFloat(match[1]);
99
+ if (Number.isFinite(seconds) && seconds > 0) return Math.ceil(seconds);
100
+ }
101
+ return undefined;
102
+ }
103
+
104
+ /** Infer HTTP status from adapter terminal error text (provider-agnostic keyword matching). */
105
+ export function inferHttpStatusFromAdapterMessage(message: string): number {
106
+ const lower = message.toLowerCase();
107
+ if (
108
+ lower.includes("resource_exhausted") ||
109
+ lower.includes("resource exhausted") ||
110
+ lower.includes("rate limit") ||
111
+ lower.includes("too many requests") ||
112
+ lower.includes("throttling")
113
+ ) return 429;
114
+ if (
115
+ lower.includes("unauthenticated") ||
116
+ lower.includes("unauthorized") ||
117
+ lower.includes("permission_denied") ||
118
+ lower.includes("permission denied") ||
119
+ lower.includes("forbidden") ||
120
+ lower.includes("invalid token") ||
121
+ lower.includes("expired token") ||
122
+ lower.includes("authentication") ||
123
+ lower.includes("access denied")
124
+ ) return 401;
125
+ if (
126
+ lower.includes("unavailable") ||
127
+ lower.includes("overloaded") ||
128
+ lower.includes("temporarily") ||
129
+ lower.includes("server is busy")
130
+ ) return 503;
131
+ if (
132
+ lower.includes("invalid") ||
133
+ lower.includes("not found") ||
134
+ lower.includes("unsupported") ||
135
+ lower.includes("malformed") ||
136
+ lower.includes("unimplemented")
137
+ ) return 400;
138
+ if (
139
+ lower.includes("timed out") ||
140
+ lower.includes("timeout") ||
141
+ lower.includes("etimedout") ||
142
+ lower.includes("deadline")
143
+ ) return 504;
144
+ return 502;
145
+ }
146
+
147
+ /** Map an adapter terminal error message to HTTP status + classified Codex error payload. */
148
+ export function adapterFailureFromMessage(message: string): { httpStatus: number; error: OcxErrorPayload } {
149
+ const httpStatus = inferHttpStatusFromAdapterMessage(message);
150
+ let finalMessage = message;
151
+ const retryAfterSeconds = parseRetryAfterFromMessage(message);
152
+ if (retryAfterSeconds && !/please try again in /i.test(message)) {
153
+ finalMessage = `${message} Please try again in ${retryAfterSeconds}s.`;
154
+ }
155
+ const errorType = httpStatus === 429
156
+ ? "rate_limit_error"
157
+ : httpStatus === 401
158
+ ? "authentication_error"
159
+ : httpStatus === 503 || httpStatus === 504
160
+ ? "server_error"
161
+ : httpStatus === 400
162
+ ? "invalid_request_error"
163
+ : "upstream_error";
164
+ return {
165
+ httpStatus,
166
+ error: classifyError(httpStatus, errorType, finalMessage),
167
+ };
168
+ }
169
+
170
+ /** Map a terminal Responses error object to the HTTP status we record in /api/logs. */
171
+ export function httpStatusFromTerminalError(error: {
172
+ type?: string;
173
+ code?: string | null;
174
+ message?: string;
175
+ } | undefined): number {
176
+ if (!error) return 502;
177
+ if (error.type === "rate_limit_error" || error.code === "rate_limit_exceeded") return 429;
178
+ if (error.type === "authentication_error" || error.code === "invalid_api_key") return 401;
179
+ if (error.type === "insufficient_quota" || error.code === "insufficient_quota") return 429;
180
+ if (error.type === "server_error" && error.code === "server_is_overloaded") return 503;
181
+ if (error.type === "invalid_request_error") return 400;
182
+ if (error.type === "proxy_error") return 500;
183
+ const message = error.message ?? "";
184
+ if (message) return inferHttpStatusFromAdapterMessage(message);
185
+ return 502;
186
+ }
@@ -32,6 +32,37 @@ export interface CursorAuthParams {
32
32
  loginUrl: string;
33
33
  }
34
34
 
35
+ interface CursorJwtPayload {
36
+ sub?: unknown;
37
+ email?: unknown;
38
+ exp?: unknown;
39
+ }
40
+
41
+ function decodeCursorJwtPayload(token: string): CursorJwtPayload | undefined {
42
+ const parts = token.split(".");
43
+ const payload = parts[1];
44
+ if (parts.length !== 3 || !payload) return undefined;
45
+ try {
46
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")) as CursorJwtPayload;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
52
+ /** Build OAuthCredentials from Cursor tokens, extracting stable identity from JWT `sub` for multiauth. */
53
+ export function credentialsFromCursorTokens(accessToken: string, refreshToken: string): OAuthCredentials {
54
+ const payload = decodeCursorJwtPayload(accessToken) ?? decodeCursorJwtPayload(refreshToken);
55
+ const accountId = typeof payload?.sub === "string" && payload.sub.length > 0 ? payload.sub : undefined;
56
+ const email = typeof payload?.email === "string" && payload.email.length > 0 ? payload.email.toLowerCase() : undefined;
57
+ return {
58
+ access: accessToken,
59
+ refresh: refreshToken,
60
+ expires: getTokenExpiry(accessToken),
61
+ ...(accountId ? { accountId } : {}),
62
+ ...(email ? { email } : {}),
63
+ };
64
+ }
65
+
35
66
  /** Generate PKCE params + the cursor.com deep-link login URL (challenge only — never the verifier). */
36
67
  export async function generateCursorAuthParams(): Promise<CursorAuthParams> {
37
68
  const { verifier, challenge } = await generatePKCE();
@@ -113,7 +144,7 @@ export async function loginCursor(
113
144
  ctrl.onAuth?.({ url: loginUrl, instructions: "Approve the Cursor login in your browser, then return here." });
114
145
  ctrl.onProgress?.("Waiting for Cursor login approval…");
115
146
  const { accessToken, refreshToken } = await pollCursorAuth(uuid, verifier, ctrl.signal, pollBaseDelayMs);
116
- return { access: accessToken, refresh: refreshToken, expires: getTokenExpiry(accessToken) };
147
+ return credentialsFromCursorTokens(accessToken, refreshToken);
117
148
  }
118
149
 
119
150
  function isRetryableRefreshStatus(status: number): boolean {
@@ -160,7 +191,7 @@ export async function refreshCursorToken(refresh: string, signal?: AbortSignal):
160
191
  if (response.ok) {
161
192
  const data = (await response.json()) as { accessToken?: string; refreshToken?: string };
162
193
  if (!data.accessToken) throw new Error("Cursor refresh response missing access token");
163
- return { access: data.accessToken, refresh: data.refreshToken || refresh, expires: getTokenExpiry(data.accessToken) };
194
+ return credentialsFromCursorTokens(data.accessToken, data.refreshToken || refresh);
164
195
  }
165
196
  if (!isRetryableRefreshStatus(response.status) || attempt === REFRESH_ATTEMPTS - 1) {
166
197
  throw new Error(`Cursor token refresh failed: ${response.status}`);
@@ -174,15 +205,7 @@ export async function refreshCursorToken(refresh: string, signal?: AbortSignal):
174
205
 
175
206
  /** Resolve a token's expiry (epoch ms) from its JWT `exp`, minus a 5-minute skew; ~1h fallback. */
176
207
  export function getTokenExpiry(token: string): number {
177
- try {
178
- const parts = token.split(".");
179
- const payload = parts.length === 3 ? parts[1] : undefined;
180
- if (payload) {
181
- const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")) as { exp?: number };
182
- if (typeof decoded.exp === "number") return decoded.exp * 1000 - EXPIRY_SKEW_MS;
183
- }
184
- } catch {
185
- // fall through to the fixed fallback below
186
- }
208
+ const decoded = decodeCursorJwtPayload(token);
209
+ if (typeof decoded?.exp === "number") return decoded.exp * 1000 - EXPIRY_SKEW_MS;
187
210
  return Date.now() + FALLBACK_TTL_MS;
188
211
  }
@@ -10,9 +10,10 @@
10
10
  * Exceptions:
11
11
  * - `chatgpt` stays single-slot (always replaced): codex-auth-api uses it as a scratch slot
12
12
  * for Codex pool logins, which have their own ledger (codex-accounts.json).
13
- * - Credentials without identity (no accountId/email — kimi, kiro, cursor) replace the
14
- * active slot instead of appending: their refresh tokens rotate, so a derived id would
15
- * duplicate the same human on every re-login.
13
+ * - Credentials without identity (no accountId/email — kimi, kiro) replace the active slot
14
+ * instead of appending: their refresh tokens rotate, so a derived id would duplicate the
15
+ * same human on every re-login. Cursor login extracts JWT `sub` as accountId so multiauth
16
+ * can append distinct accounts.
16
17
  */
17
18
  import { createHash } from "node:crypto";
18
19
  import { copyFileSync, existsSync, mkdirSync, readFileSync, chmodSync } from "node:fs";
@@ -23,6 +23,8 @@ export interface DerivedKeyLoginProvider {
23
23
  noPenaltyModels?: string[];
24
24
  autoToolChoiceOnlyModels?: string[];
25
25
  preserveReasoningContentModels?: string[];
26
+ thinkingToggleModels?: string[];
27
+ thinkingBudgetModels?: string[];
26
28
  escapeBuiltinToolNames?: boolean;
27
29
  }
28
30
 
@@ -80,9 +82,11 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
80
82
  ...(entry.noTemperatureModels ? { noTemperatureModels: [...entry.noTemperatureModels] } : {}),
81
83
  ...(entry.noTopPModels ? { noTopPModels: [...entry.noTopPModels] } : {}),
82
84
  ...(entry.noPenaltyModels ? { noPenaltyModels: [...entry.noPenaltyModels] } : {}),
85
+ ...(entry.parallelToolCalls !== undefined ? { parallelToolCalls: entry.parallelToolCalls } : {}),
83
86
  ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
84
87
  ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
85
88
  ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}),
89
+ ...(entry.thinkingBudgetModels ? { thinkingBudgetModels: [...entry.thinkingBudgetModels] } : {}),
86
90
  ...(entry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: entry.escapeBuiltinToolNames } : {}),
87
91
  ...(entry.googleMode ? { googleMode: entry.googleMode } : {}),
88
92
  ...(entry.project ? { project: entry.project } : {}),
@@ -117,6 +121,8 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
117
121
  ...(entry.noPenaltyModels ? { noPenaltyModels: [...entry.noPenaltyModels] } : {}),
118
122
  ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
119
123
  ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
124
+ ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}),
125
+ ...(entry.thinkingBudgetModels ? { thinkingBudgetModels: [...entry.thinkingBudgetModels] } : {}),
120
126
  ...(entry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: entry.escapeBuiltinToolNames } : {}),
121
127
  ...(entry.googleMode ? { googleMode: entry.googleMode } : {}),
122
128
  ...(entry.project ? { project: entry.project } : {}),
@@ -177,9 +183,11 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
177
183
  if (!prov.noTemperatureModels && seed.noTemperatureModels) prov.noTemperatureModels = [...seed.noTemperatureModels];
178
184
  if (!prov.noTopPModels && seed.noTopPModels) prov.noTopPModels = [...seed.noTopPModels];
179
185
  if (!prov.noPenaltyModels && seed.noPenaltyModels) prov.noPenaltyModels = [...seed.noPenaltyModels];
186
+ if (prov.parallelToolCalls === undefined && seed.parallelToolCalls !== undefined) prov.parallelToolCalls = seed.parallelToolCalls;
180
187
  if (!prov.autoToolChoiceOnlyModels && seed.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...seed.autoToolChoiceOnlyModels];
181
188
  if (!prov.preserveReasoningContentModels && seed.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...seed.preserveReasoningContentModels];
182
189
  if (!prov.thinkingToggleModels && seed.thinkingToggleModels) prov.thinkingToggleModels = [...seed.thinkingToggleModels];
190
+ if (!prov.thinkingBudgetModels && seed.thinkingBudgetModels) prov.thinkingBudgetModels = [...seed.thinkingBudgetModels];
183
191
  if (prov.escapeBuiltinToolNames === undefined && seed.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = seed.escapeBuiltinToolNames;
184
192
  }
185
193