@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
@@ -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
  }
@@ -22,6 +22,8 @@ import {
22
22
  McpToolResultSchema,
23
23
  ModelDetailsSchema,
24
24
  ResumeActionSchema,
25
+ RequestContextSchema,
26
+ RequestContextEnvSchema,
25
27
  ThinkingMessageSchema,
26
28
  ToolCallSchema,
27
29
  UserMessageActionSchema,
@@ -39,6 +41,24 @@ import {
39
41
 
40
42
  const encoder = new TextEncoder();
41
43
 
44
+ /** Runtime timezone for protobuf RequestContextEnv (dynamic, never hardcoded). */
45
+ function runtimeTimeZone(): string {
46
+ try {
47
+ return Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
48
+ } catch {
49
+ return "UTC";
50
+ }
51
+ }
52
+
53
+ /** Builds a RequestContext with env.timeZone populated dynamically. */
54
+ function buildRequestContext() {
55
+ return create(RequestContextSchema, {
56
+ env: create(RequestContextEnvSchema, {
57
+ timeZone: runtimeTimeZone(),
58
+ }),
59
+ });
60
+ }
61
+
42
62
  function jsonBlob(value: unknown): Uint8Array {
43
63
  return encoder.encode(JSON.stringify(value));
44
64
  }
@@ -307,11 +327,14 @@ export function encodeCursorRunRequest(request: CursorRunRequest): Uint8Array {
307
327
  text,
308
328
  messageId: crypto.randomUUID(),
309
329
  }),
330
+ requestContext: buildRequestContext(),
310
331
  }),
311
332
  }
312
333
  : {
313
334
  case: "resumeAction",
314
- value: create(ResumeActionSchema, {}),
335
+ value: create(ResumeActionSchema, {
336
+ requestContext: buildRequestContext(),
337
+ }),
315
338
  },
316
339
  });
317
340
 
@@ -8,17 +8,18 @@ import type {
8
8
  } from "../../types";
9
9
  import { namespacedToolName } from "../../types";
10
10
  import type { CursorRequestMessage, CursorRunRequest } from "./types";
11
+ import { cursorCodexToWireModelId } from "./discovery";
11
12
  import { cursorEffortSuffix } from "./effort-map";
12
13
 
13
14
  /**
14
15
  * Resolve a `cursor/<model>` selection + Codex reasoning effort to the actual Cursor model id. Cursor
15
- * encodes the effort as a per-model suffix (`claude-4.6-opus-high`); `cursorEffortSuffix` picks the
16
- * right tier for that specific model (top effort the model's top tier, e.g. `-max`/`-xhigh`) or
17
- * `undefined` for non-reasoning models like `composer-2.5`. A fully-qualified id (one that isn't a
18
- * known effort base) passes through unchanged.
16
+ * encodes the effort as a per-model suffix (`claude-4.6-opus-high`); `cursorEffortSuffix` picks the
17
+ * right tier for that specific model (literal pass-through, with rank clamp fallback) or
18
+ * `undefined` for non-reasoning models like `composer-2.5`. A fully-qualified id (one that isn't a
19
+ * known effort base) passes through unchanged.
19
20
  */
20
21
  function normalizeCursorModelId(modelId: string, reasoning?: string): string {
21
- const id = modelId.startsWith("cursor/") ? modelId.slice("cursor/".length) : modelId;
22
+ const id = cursorCodexToWireModelId(modelId);
22
23
  const suffix = cursorEffortSuffix(id, reasoning);
23
24
  return suffix ? `${id}-${suffix}` : id;
24
25
  }
@@ -1,6 +1,8 @@
1
1
  import type { CursorRunRequest, CursorServerMessage } from "./types";
2
2
  import type { CursorTransport, CursorTransportFactory, CursorTransportFactoryInput } from "./transport";
3
3
  import { abortError, sleepWithAbort } from "../../lib/upstream-retry";
4
+ import { debugProviderDiagnostic } from "../../lib/debug";
5
+ import { safeCursorErrorMessage } from "./cursor-errors";
4
6
 
5
7
  // Compat: historical name for the shared abortable sleep, kept for external callers.
6
8
  export { sleepWithAbort as abortAwareSleep } from "../../lib/upstream-retry";
@@ -20,6 +22,8 @@ export function isRetryableCursorError(err: unknown): boolean {
20
22
  const message = err instanceof Error ? err.message : typeof err === "string" ? err : "";
21
23
  const haystack = `${code} ${message}`.toLowerCase();
22
24
  if (/auth|unauthor|forbidden|invalid|permission|denied|not found|unsupported/.test(haystack)) return false;
25
+ if (/resource.exhausted|resource_exhausted|rate limit|too many requests|throttl/.test(haystack)) return false;
26
+ if (haystack.includes("nghttp2_cancel") || haystack.includes("stream suspended")) return false;
23
27
  return (
24
28
  haystack.includes("econnreset") ||
25
29
  haystack.includes("econnrefused") ||
@@ -27,7 +31,7 @@ export function isRetryableCursorError(err: unknown): boolean {
27
31
  haystack.includes("enetunreach") ||
28
32
  haystack.includes("eai_again") ||
29
33
  haystack.includes("goaway") ||
30
- haystack.includes("nghttp2") ||
34
+ (haystack.includes("nghttp2") && !haystack.includes("nghttp2_cancel")) ||
31
35
  haystack.includes("socket hang up") ||
32
36
  haystack.includes("connection reset") ||
33
37
  haystack.includes("unavailable") ||
@@ -83,8 +87,23 @@ export async function runCursorTurnWithRetry(
83
87
  !signal?.aborted &&
84
88
  requestUncommitted(transport) &&
85
89
  isRetryableCursorError(err);
86
- if (!canRetry) throw err;
87
- await sleepWithAbort(cursorRetryDelayMs(attempt), signal);
90
+ if (!canRetry) {
91
+ debugProviderDiagnostic("cursor", "no-retry", {
92
+ attempt,
93
+ reason: safeCursorErrorMessage(err instanceof Error ? err.message : String(err)),
94
+ emittedAny,
95
+ committed: !requestUncommitted(transport),
96
+ aborted: !!signal?.aborted,
97
+ });
98
+ throw err;
99
+ }
100
+ const backoffMs = cursorRetryDelayMs(attempt);
101
+ debugProviderDiagnostic("cursor", "retry", {
102
+ attempt,
103
+ reason: safeCursorErrorMessage(err instanceof Error ? err.message : String(err)),
104
+ backoffMs,
105
+ });
106
+ await sleepWithAbort(backoffMs, signal);
88
107
  } finally {
89
108
  await transport.close?.();
90
109
  }
@@ -1,7 +1,7 @@
1
1
  import type { AdapterEvent, OcxProviderConfig } from "../types";
2
2
  import type { ProviderAdapter } from "./base";
3
3
  import { cursorExecDeniedMessage } from "./cursor/exec-policy";
4
- import { safeCursorErrorMessage } from "./cursor/cursor-errors";
4
+ import { isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor/cursor-errors";
5
5
  import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store";
6
6
  import { mapCursorServerMessage } from "./cursor/message-mapper";
7
7
  import { createCursorRequest, generatedCursorConversationId } from "./cursor/request-builder";
@@ -91,6 +91,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
91
91
  },
92
92
  );
93
93
  } catch (err) {
94
+ if (isCursorBenignCancelError(err)) return;
94
95
  const partialUsage = (err as { partialUsage?: import("../types").OcxUsage }).partialUsage;
95
96
  emit({ type: "error", message: safeCursorTransportError(err), ...(partialUsage ? { usage: partialUsage } : {}) });
96
97
  }
@@ -76,7 +76,9 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
76
76
  type: "function",
77
77
  function: { name: namespacedToolName(tc.namespace, tc.name), arguments: JSON.stringify(tc.arguments) },
78
78
  }));
79
- if (!chatMsg.content) chatMsg.content = null;
79
+ // "" instead of null: strict validators (xAI: "Each message must have at least one
80
+ // content element", langchain#34140) reject content-less assistant history entries.
81
+ if (!chatMsg.content) chatMsg.content = "";
80
82
  }
81
83
  if (chatMsg.reasoning_content !== undefined && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
82
84
  chatMsg.content = "";
@@ -97,7 +99,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
97
99
  const name = safeToolName(msg.toolName);
98
100
  out.push({
99
101
  role: "assistant",
100
- content: null,
102
+ content: "",
101
103
  tool_calls: [{
102
104
  id: toolCallId,
103
105
  type: "function",
@@ -166,6 +168,20 @@ function usageFromOpenAIChat(usage: Record<string, unknown> | undefined): OcxUsa
166
168
  };
167
169
  }
168
170
 
171
+ function thinkingBudgetForEffort(parsed: OcxParsedRequest, reasoningEffort: string): number | undefined {
172
+ if (parsed.options.reasoning === "minimal") return 0;
173
+ const maxBudget = parsed.options.maxOutputTokens ?? 32768;
174
+ const fractions: Record<string, number> = {
175
+ low: 0.20,
176
+ medium: 0.50,
177
+ high: 0.75,
178
+ xhigh: 0.90,
179
+ max: 1.0,
180
+ };
181
+ const fraction = fractions[reasoningEffort];
182
+ return fraction === undefined ? undefined : Math.max(1, Math.floor(maxBudget * fraction));
183
+ }
184
+
169
185
  export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter {
170
186
  return {
171
187
  name: "openai-chat",
@@ -196,7 +212,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
196
212
  if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences;
197
213
  const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
198
214
  if (reasoningEffort !== undefined) {
199
- if (modelInList(provider.thinkingToggleModels, parsed.modelId)) {
215
+ if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) {
216
+ const budget = thinkingBudgetForEffort(parsed, reasoningEffort);
217
+ if (budget !== undefined) body.thinking_budget = budget;
218
+ } else if (modelInList(provider.thinkingToggleModels, parsed.modelId)) {
200
219
  // Vendor thinking-toggle wire (MiMo v2.x, GLM 5/5.1): the mapped value is the toggle
201
220
  // state, sent as `thinking: {type}` — these models ignore/reject reasoning_effort.
202
221
  if (reasoningEffort === "enabled" || reasoningEffort === "disabled") {
@@ -213,7 +232,15 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
213
232
  body.frequency_penalty = parsed.options.frequencyPenalty;
214
233
  }
215
234
 
216
- if (tools) body.parallel_tool_calls = false;
235
+ if (tools) {
236
+ // Default-ON for chat-completions providers (user decision 260709): the buffered
237
+ // parser assembles multi-call streams safely, so `parallelToolCalls: false` is the
238
+ // only per-provider opt-out; Codex's request bit can still force false per request.
239
+ // Rationale + provider evidence: devlog/_plan/260709_parallel_tool_calls.
240
+ body.parallel_tool_calls = provider.parallelToolCalls === false
241
+ ? false
242
+ : parsed.options.parallelToolCalls !== false;
243
+ }
217
244
  if (parsed.stream) {
218
245
  body.stream_options = { include_usage: true };
219
246
  }
@@ -235,8 +262,26 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
235
262
  const reader = response.body.getReader();
236
263
  const decoder = new TextDecoder();
237
264
  let buffer = "";
238
- let currentToolCallId = "";
239
- let currentToolCallName = "";
265
+ // Streamed tool calls are BUFFERED until a terminal signal, then flushed as atomic
266
+ // start/delta/end sequences. The bridge treats text/reasoning deltas as barriers that
267
+ // close an open tool-call item (bridge.ts closeCurrentToolCall on text_delta), so
268
+ // emitting calls incrementally would orphan later argument deltas whenever a provider
269
+ // interleaves content — and parallel tool calls (multiple ids, index-keyed continuation
270
+ // chunks, whole-chunk calls) cannot be represented live without overlapping sequences.
271
+ // Keyed by `index` (OpenAI wire standard), falling back to `id`, falling back to the
272
+ // last-seen call for providers that omit both on continuation chunks.
273
+ interface PendingToolCall { key: string; id: string; name: string; args: string }
274
+ const pendingToolCalls: PendingToolCall[] = [];
275
+ let toolCallSeq = 0;
276
+ const flushToolCalls = function* (): Generator<AdapterEvent> {
277
+ for (const call of pendingToolCalls) {
278
+ if (!call.id) call.id = `call_${++toolCallSeq}`;
279
+ yield { type: "tool_call_start", id: call.id, name: call.name };
280
+ if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args };
281
+ yield { type: "tool_call_end" };
282
+ }
283
+ pendingToolCalls.length = 0;
284
+ };
240
285
  let pendingUsage: OcxUsage | undefined;
241
286
  // Track terminal signals so a socket EOF without any terminator can fail closed instead of
242
287
  // being reported as a clean completion (silent truncation). A graceful close is either an
@@ -252,10 +297,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
252
297
  if (!line.startsWith("data: ")) return "continue";
253
298
  const payload = line.slice(6).trim();
254
299
  if (payload === "[DONE]") {
255
- if (currentToolCallId) {
256
- yield { type: "tool_call_end" };
257
- currentToolCallId = "";
258
- }
300
+ yield* flushToolCalls();
259
301
  yield { type: "done", usage: pendingUsage };
260
302
  return "terminate";
261
303
  }
@@ -273,7 +315,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
273
315
  // classified response.failed (bridge case "error") — never a truncated completion.
274
316
  if (chunk.error) {
275
317
  const err = chunk.error as { message?: string } | undefined;
276
- if (currentToolCallId) yield { type: "tool_call_end" };
318
+ yield* flushToolCalls();
277
319
  yield { type: "error", message: err?.message ?? "upstream error" };
278
320
  return "terminate";
279
321
  }
@@ -302,25 +344,34 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
302
344
  yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
303
345
  }
304
346
 
305
- const toolCalls = delta.tool_calls as { index: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
347
+ const toolCalls = delta.tool_calls as { index?: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
306
348
  if (toolCalls) {
307
349
  for (const tc of toolCalls) {
308
- if (tc.id && tc.id !== currentToolCallId) {
309
- if (currentToolCallId) yield { type: "tool_call_end" };
310
- currentToolCallId = tc.id;
311
- currentToolCallName = tc.function?.name ?? "";
312
- yield { type: "tool_call_start", id: tc.id, name: currentToolCallName };
313
- }
314
- if (tc.function?.arguments) {
315
- yield { type: "tool_call_delta", arguments: tc.function.arguments };
350
+ const key = typeof tc.index === "number"
351
+ ? `i:${tc.index}`
352
+ : tc.id
353
+ ? `id:${tc.id}`
354
+ : pendingToolCalls[pendingToolCalls.length - 1]?.key;
355
+ let call = key !== undefined ? pendingToolCalls.find(c => c.key === key) : undefined;
356
+ // Mixed keying rescue: a call opened under an index key must still absorb an
357
+ // id-only continuation for the same provider id (and vice versa) instead of
358
+ // splitting into two calls that share one call_id downstream.
359
+ if (!call && tc.id) call = pendingToolCalls.find(c => c.id === tc.id);
360
+ if (!call) {
361
+ call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "" };
362
+ pendingToolCalls.push(call);
316
363
  }
364
+ if (tc.id && !call.id) call.id = tc.id;
365
+ if (tc.function?.name && !call.name) call.name = tc.function.name;
366
+ if (tc.function?.arguments) call.args += tc.function.arguments;
317
367
  }
318
368
  }
319
369
  }
320
370
 
321
- if (choices[0].finish_reason === "tool_calls" && currentToolCallId) {
322
- yield { type: "tool_call_end" };
323
- currentToolCallId = "";
371
+ // Any non-empty finish_reason ends the generation: flush assembled tool calls as
372
+ // atomic sequences (covers "tool_calls" AND providers that close tool turns with "stop").
373
+ if (typeof choices[0].finish_reason === "string" && choices[0].finish_reason) {
374
+ yield* flushToolCalls();
324
375
  }
325
376
  return "continue";
326
377
  };
@@ -347,9 +398,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
347
398
  if (buffer.length > 0) {
348
399
  if ((yield* handleDataLine(buffer)) === "terminate") return;
349
400
  }
350
- if (currentToolCallId) {
351
- yield { type: "tool_call_end" };
352
- }
401
+ yield* flushToolCalls();
353
402
  // Reader EOF. A graceful close shows at least one terminal signal: `[DONE]` (returns above),
354
403
  // a non-null finish_reason (sawFinish), or a trailing usage chunk (providers emit usage only
355
404
  // at end-of-generation). If NONE of those were seen, the stream was cut mid-flight — fail