@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.
- package/README.ko.md +9 -5
- package/README.md +7 -4
- package/README.zh-CN.md +8 -4
- package/gui/dist/assets/index-BGdxwydf.js +34 -0
- package/gui/dist/assets/index-DANCQ2Jt.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +62 -1
- package/src/adapters/cursor/cursor-errors.ts +28 -1
- package/src/adapters/cursor/discovery.ts +56 -10
- package/src/adapters/cursor/effort-map.ts +35 -7
- package/src/adapters/cursor/live-models.ts +3 -0
- package/src/adapters/cursor/live-transport.ts +136 -7
- package/src/adapters/cursor/protobuf-request.ts +24 -1
- package/src/adapters/cursor/request-builder.ts +6 -5
- package/src/adapters/cursor/transport-retry.ts +22 -3
- package/src/adapters/cursor.ts +2 -1
- package/src/adapters/openai-chat.ts +75 -26
- package/src/bridge.ts +42 -3
- package/src/cli/debug.ts +203 -0
- package/src/cli/doctor.ts +11 -0
- package/src/cli/help.ts +11 -0
- package/src/cli/index.ts +10 -0
- package/src/cli/v2.ts +131 -0
- package/src/codex/auth-api.ts +7 -3
- package/src/codex/catalog.ts +334 -31
- package/src/codex/data/upstream-models.json +830 -0
- package/src/codex/features.ts +178 -0
- package/src/codex/project-config-warnings.ts +388 -0
- package/src/codex/sync.ts +8 -0
- package/src/codex/warmup.ts +62 -6
- package/src/config.ts +7 -5
- package/src/lib/debug-log-buffer.ts +42 -0
- package/src/lib/debug-settings.ts +84 -0
- package/src/lib/debug.ts +18 -9
- package/src/lib/errors.ts +104 -1
- package/src/oauth/cursor.ts +35 -12
- package/src/oauth/store.ts +4 -3
- package/src/providers/derive.ts +8 -0
- package/src/providers/registry.ts +56 -21
- package/src/reasoning-effort.ts +32 -9
- package/src/responses/parser.ts +7 -2
- package/src/router.ts +5 -0
- package/src/server/adapter-resolve.ts +1 -1
- package/src/server/index.ts +27 -3
- package/src/server/management-api.ts +168 -7
- package/src/server/relay.ts +2 -2
- package/src/server/request-log.ts +78 -0
- package/src/server/responses.ts +209 -0
- package/src/types.ts +28 -1
- package/src/usage/debug.ts +32 -5
- package/src/usage/summary.ts +6 -6
- package/src/web-search/index.ts +1 -1
- package/gui/dist/assets/index-ByGC8-Bm.css +0 -1
- 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.
|
|
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)
|
|
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)
|
|
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", () => {
|
|
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 =>
|
|
588
|
-
|
|
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
|
|
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
|
-
|
|
16
|
-
* right tier for that specific model (
|
|
17
|
-
|
|
18
|
-
|
|
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 =
|
|
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)
|
|
87
|
-
|
|
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
|
}
|
package/src/adapters/cursor.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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.
|
|
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)
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|