@bitkyc08/opencodex 2.57.0 → 2.58.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/gui/dist/assets/{index-Cz7CLdif.js → index-BbrHOIY0.js} +2 -2
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
- package/src/adapters/command-code.ts +13 -4
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +65 -1
- package/src/adapters/cursor/live-transport.ts +5 -1
- package/src/adapters/cursor/protobuf-events.ts +110 -11
- package/src/adapters/cursor/protobuf-request.ts +19 -1
- package/src/adapters/cursor/text-toolcall.ts +230 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/types.ts +5 -0
- package/src/adapters/cursor.ts +55 -5
- package/src/adapters/google-http.ts +38 -13
- package/src/adapters/mimo-free.ts +32 -17
- package/src/adapters/ollama-native.ts +42 -8
- package/src/adapters/openai-responses/passthrough.ts +30 -4
- package/src/adapters/openai-responses/request-strips.ts +43 -0
- package/src/adapters/physical-send.ts +50 -0
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +1 -1
- package/src/claude/outbound.ts +14 -4
- package/src/cli/config-command.ts +35 -18
- package/src/cli/dispatch.ts +17 -4
- package/src/cli/index.ts +44 -2
- package/src/cli/system-command.ts +70 -1
- package/src/cli/uninstall-client-state.ts +12 -0
- package/src/codex/auth-context.ts +42 -8
- package/src/codex/desktop-switches.ts +145 -0
- package/src/codex/history-job.ts +5 -1
- package/src/codex/history-provider.ts +33 -4
- package/src/codex/history-worker.ts +14 -1
- package/src/codex/inject/remove.ts +145 -7
- package/src/codex/inject/restore.ts +204 -32
- package/src/codex/inject.ts +3 -7
- package/src/codex/loopback-target.ts +9 -0
- package/src/codex/native-profile-startup.ts +64 -20
- package/src/config/atomic-write.ts +83 -8
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +132 -68
- package/src/lib/bounded-subprocess.ts +62 -10
- package/src/lib/windows-secret-acl.ts +151 -15
- package/src/lib/windows-user-principal.ts +5 -1
- package/src/providers/derive.ts +6 -0
- package/src/providers/model-discovery.ts +19 -7
- package/src/providers/registry/entries-core.ts +11 -0
- package/src/providers/registry/entries-extended.ts +50 -28
- package/src/providers/registry/model-seeds.ts +67 -17
- package/src/providers/registry/types.ts +9 -0
- package/src/responses/spill-store.ts +17 -0
- package/src/responses/state/body-policy.ts +25 -0
- package/src/responses/state/spill-queue.ts +8 -6
- package/src/responses/state.ts +3 -22
- package/src/router.ts +4 -0
- package/src/server/auth-cors.ts +1 -0
- package/src/server/index/websocket-handler.ts +48 -1
- package/src/server/management/config-routes.ts +27 -5
- package/src/server/models-capabilities.ts +24 -3
- package/src/server/responses/codex-ws-exchange.ts +65 -4
- package/src/server/responses/combo-stream-preflight.ts +68 -5
- package/src/server/responses/core-combo.ts +26 -0
- package/src/server/responses/core-options.ts +3 -0
- package/src/server/responses/fetch-helpers.ts +4 -1
- package/src/server/responses/native-injection-protocol.ts +42 -0
- package/src/server/responses/native-injection-replay.ts +105 -0
- package/src/server/responses/native-injection.ts +242 -0
- package/src/server/responses/native-response-control.ts +56 -0
- package/src/server/responses/native-response-json.ts +14 -0
- package/src/server/responses/native-response-output.ts +37 -0
- package/src/server/responses/native-steering-log.ts +44 -0
- package/src/server/responses/native-steering-policy.ts +49 -0
- package/src/server/responses/native-steering-replay.ts +126 -0
- package/src/server/responses/native-steering-settings.ts +76 -0
- package/src/server/responses/native-steering.ts +400 -0
- package/src/server/responses/native-tool-results.ts +130 -0
- package/src/server/responses/passthrough-delivery.ts +11 -0
- package/src/server/responses/passthrough-dispatch.ts +33 -1
- package/src/server/responses/request-prepare.ts +41 -0
- package/src/server/responses/ws-upstream.ts +21 -1
- package/src/server/stop-teardown.ts +8 -1
- package/src/server/ws-bridge.ts +16 -1
- package/src/service/cli.ts +13 -1
- package/src/types/config.ts +4 -0
- package/src/types/provider.ts +13 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quarantine textual pseudo tool-call markers that Cursor models emit inside
|
|
3
|
+
* `textDelta` instead of (or in addition to) real `toolCall*` frames.
|
|
4
|
+
*
|
|
5
|
+
* `#2305` only rewrote the display alias *inside* `[TOOL_CALL]…[ARGS]`. The
|
|
6
|
+
* marker still reached Codex/Claude as assistant text, which few-shot-mimics
|
|
7
|
+
* later calls as inert text and stalls multi-tool turns. This drain strips
|
|
8
|
+
* every complete marker from visible text and yields the parsed calls so the
|
|
9
|
+
* protobuf mapper can promote advertised names onto the real tool-call path.
|
|
10
|
+
*
|
|
11
|
+
* Incomplete markers (split across streaming deltas) stay in `pending` until
|
|
12
|
+
* the JSON object closes. Once the retained prefix exceeds the byte cap, the
|
|
13
|
+
* parser switches to a constant-space suppressed scan until the object closes.
|
|
14
|
+
*/
|
|
15
|
+
import { debugProviderDiagnostic } from "../../lib/debug";
|
|
16
|
+
import { normalizeCursorWireName } from "./tool-naming";
|
|
17
|
+
|
|
18
|
+
export const MAX_PENDING_TEXT_TOOLCALL_BYTES = 64 * 1024;
|
|
19
|
+
const TOOL_CALL_OPEN = /\[TOOL_CALL\]/gi;
|
|
20
|
+
|
|
21
|
+
export interface DrainedTextToolCall {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly args: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SuppressedTextToolCallScan {
|
|
27
|
+
phase: "args-tag" | "json-start" | "json";
|
|
28
|
+
argsTagProgress: number;
|
|
29
|
+
depth: number;
|
|
30
|
+
inString: boolean;
|
|
31
|
+
escape: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DrainTextToolCallsResult {
|
|
35
|
+
readonly text: string;
|
|
36
|
+
readonly pending: string;
|
|
37
|
+
readonly calls: readonly DrainedTextToolCall[];
|
|
38
|
+
readonly suppressed?: SuppressedTextToolCallScan;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findJsonObjectEnd(source: string, start: number): number | undefined {
|
|
42
|
+
if (source[start] !== "{") return undefined;
|
|
43
|
+
let depth = 0;
|
|
44
|
+
let inString = false;
|
|
45
|
+
let escape = false;
|
|
46
|
+
for (let i = start; i < source.length; i++) {
|
|
47
|
+
const ch = source[i];
|
|
48
|
+
if (inString) {
|
|
49
|
+
if (escape) {
|
|
50
|
+
escape = false;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (ch === "\\") {
|
|
54
|
+
escape = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (ch === "\"") inString = false;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (ch === "\"") {
|
|
61
|
+
inString = true;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (ch === "{") depth += 1;
|
|
65
|
+
else if (ch === "}") {
|
|
66
|
+
depth -= 1;
|
|
67
|
+
if (depth === 0) return i + 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function byteLength(text: string): number {
|
|
74
|
+
return new TextEncoder().encode(text).byteLength;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function newSuppressedScan(): SuppressedTextToolCallScan {
|
|
78
|
+
return {
|
|
79
|
+
phase: "args-tag",
|
|
80
|
+
argsTagProgress: 0,
|
|
81
|
+
depth: 0,
|
|
82
|
+
inString: false,
|
|
83
|
+
escape: false,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ARGS_TAG = "[args]";
|
|
88
|
+
|
|
89
|
+
function consumeSuppressedScan(
|
|
90
|
+
state: SuppressedTextToolCallScan,
|
|
91
|
+
source: string,
|
|
92
|
+
start = 0,
|
|
93
|
+
): { readonly state?: SuppressedTextToolCallScan; readonly resumeAt: number } {
|
|
94
|
+
for (let i = start; i < source.length; i++) {
|
|
95
|
+
const ch = source[i] ?? "";
|
|
96
|
+
if (state.phase === "args-tag") {
|
|
97
|
+
const lower = ch.toLowerCase();
|
|
98
|
+
if (lower === ARGS_TAG[state.argsTagProgress]) {
|
|
99
|
+
state.argsTagProgress += 1;
|
|
100
|
+
if (state.argsTagProgress === ARGS_TAG.length) {
|
|
101
|
+
state.phase = "json-start";
|
|
102
|
+
state.argsTagProgress = 0;
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
state.argsTagProgress = lower === ARGS_TAG[0] ? 1 : 0;
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (state.phase === "json-start") {
|
|
110
|
+
if (/\s/.test(ch)) continue;
|
|
111
|
+
if (ch !== "{") {
|
|
112
|
+
debugProviderDiagnostic("cursor", "text-toolcall-invalid-arguments", {
|
|
113
|
+
reason: "arguments-did-not-start-with-object",
|
|
114
|
+
});
|
|
115
|
+
return { resumeAt: source.length };
|
|
116
|
+
}
|
|
117
|
+
state.phase = "json";
|
|
118
|
+
state.depth = 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (state.inString) {
|
|
122
|
+
if (state.escape) {
|
|
123
|
+
state.escape = false;
|
|
124
|
+
} else if (ch === "\\") {
|
|
125
|
+
state.escape = true;
|
|
126
|
+
} else if (ch === "\"") {
|
|
127
|
+
state.inString = false;
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (ch === "\"") state.inString = true;
|
|
132
|
+
else if (ch === "{") state.depth += 1;
|
|
133
|
+
else if (ch === "}") {
|
|
134
|
+
state.depth -= 1;
|
|
135
|
+
if (state.depth === 0) return { resumeAt: i + 1 };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { state, resumeAt: source.length };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function holdOrSuppress(hold: string, afterOpen: number): Pick<DrainTextToolCallsResult, "pending" | "suppressed"> {
|
|
142
|
+
if (byteLength(hold) <= MAX_PENDING_TEXT_TOOLCALL_BYTES) return { pending: hold };
|
|
143
|
+
const suppressed = newSuppressedScan();
|
|
144
|
+
const scanned = consumeSuppressedScan(suppressed, hold, afterOpen);
|
|
145
|
+
return scanned.state ? { pending: "", suppressed: scanned.state } : { pending: "" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Fold `pending + chunk`, emit surrounding prose, and extract every complete
|
|
150
|
+
* `[TOOL_CALL]name[ARGS]{…}` block. Names are folded through
|
|
151
|
+
* `normalizeCursorWireName` so `mcp_opencodex-responses_*` display aliases
|
|
152
|
+
* become the advertised wire name before promotion.
|
|
153
|
+
*/
|
|
154
|
+
export function drainCursorTextToolCalls(
|
|
155
|
+
pending: string,
|
|
156
|
+
chunk: string,
|
|
157
|
+
suppressed?: SuppressedTextToolCallScan,
|
|
158
|
+
): DrainTextToolCallsResult {
|
|
159
|
+
let combined = pending + chunk;
|
|
160
|
+
if (suppressed) {
|
|
161
|
+
const scanned = consumeSuppressedScan(suppressed, chunk);
|
|
162
|
+
if (scanned.state) return { text: "", pending: "", calls: [], suppressed: scanned.state };
|
|
163
|
+
combined = chunk.slice(scanned.resumeAt);
|
|
164
|
+
}
|
|
165
|
+
let cursor = 0;
|
|
166
|
+
let text = "";
|
|
167
|
+
const calls: DrainedTextToolCall[] = [];
|
|
168
|
+
const opener = new RegExp(TOOL_CALL_OPEN.source, TOOL_CALL_OPEN.flags);
|
|
169
|
+
|
|
170
|
+
while (cursor < combined.length) {
|
|
171
|
+
opener.lastIndex = cursor;
|
|
172
|
+
const match = opener.exec(combined);
|
|
173
|
+
if (!match || match.index === undefined) {
|
|
174
|
+
text += combined.slice(cursor);
|
|
175
|
+
return { text, pending: "", calls };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
text += combined.slice(cursor, match.index);
|
|
179
|
+
const afterOpen = match.index + match[0].length;
|
|
180
|
+
const rest = combined.slice(afterOpen);
|
|
181
|
+
const argsTag = rest.match(/^([^\[\]]*)\[ARGS\]/i);
|
|
182
|
+
if (!argsTag) {
|
|
183
|
+
const hold = combined.slice(match.index);
|
|
184
|
+
return { text, calls, ...holdOrSuppress(hold, afterOpen - match.index) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const name = argsTag[1]?.trim() ?? "";
|
|
188
|
+
let jsonStart = afterOpen + argsTag[0].length;
|
|
189
|
+
while (jsonStart < combined.length && /\s/.test(combined[jsonStart] ?? "")) jsonStart += 1;
|
|
190
|
+
if (jsonStart >= combined.length) {
|
|
191
|
+
const hold = combined.slice(match.index);
|
|
192
|
+
return { text, calls, ...holdOrSuppress(hold, afterOpen - match.index) };
|
|
193
|
+
}
|
|
194
|
+
if (combined[jsonStart] !== "{") {
|
|
195
|
+
debugProviderDiagnostic("cursor", "text-toolcall-invalid-arguments", {
|
|
196
|
+
reason: "arguments-did-not-start-with-object",
|
|
197
|
+
...(name ? { toolName: normalizeCursorWireName(name) } : {}),
|
|
198
|
+
});
|
|
199
|
+
// No delimiter identifies where a non-JSON payload ends. Resume marker
|
|
200
|
+
// scanning after `[ARGS]`, while suppressing the malformed payload itself.
|
|
201
|
+
opener.lastIndex = jsonStart;
|
|
202
|
+
const nextMarker = opener.exec(combined);
|
|
203
|
+
if (!nextMarker || nextMarker.index === undefined) return { text, pending: "", calls };
|
|
204
|
+
cursor = nextMarker.index;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const jsonEnd = findJsonObjectEnd(combined, jsonStart);
|
|
209
|
+
if (jsonEnd === undefined) {
|
|
210
|
+
const hold = combined.slice(match.index);
|
|
211
|
+
return { text, calls, ...holdOrSuppress(hold, afterOpen - match.index) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const args = combined.slice(jsonStart, jsonEnd);
|
|
215
|
+
try {
|
|
216
|
+
JSON.parse(args);
|
|
217
|
+
if (name.length > 0) {
|
|
218
|
+
calls.push({ name: normalizeCursorWireName(name), args });
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
debugProviderDiagnostic("cursor", "text-toolcall-invalid-arguments", {
|
|
222
|
+
reason: "arguments-json-parse-failed",
|
|
223
|
+
...(name ? { toolName: normalizeCursorWireName(name) } : {}),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
cursor = jsonEnd;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { text, pending: "", calls };
|
|
230
|
+
}
|
|
@@ -158,3 +158,70 @@ export function cursorOverflowRemintCountForTests(): number {
|
|
|
158
158
|
pruneOverflowRemints(now());
|
|
159
159
|
return overflowRemintByScope.size;
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
/** Max next-turn conversation-id rotations after incomplete client-tool streams per retained scope. */
|
|
163
|
+
export const CURSOR_INCOMPLETE_TOOL_REMINT_MAX = 3;
|
|
164
|
+
export const CURSOR_INCOMPLETE_TOOL_REMINT_TTL_MS = CURSOR_OVERFLOW_REMINT_TTL_MS;
|
|
165
|
+
export const CURSOR_INCOMPLETE_TOOL_REMINT_MAX_ENTRIES = CURSOR_OVERFLOW_REMINT_MAX_ENTRIES;
|
|
166
|
+
|
|
167
|
+
type IncompleteToolRemintState = {
|
|
168
|
+
remintCount: number;
|
|
169
|
+
updatedAt: number;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const incompleteToolRemintByScope = new Map<string, IncompleteToolRemintState>();
|
|
173
|
+
|
|
174
|
+
function pruneIncompleteToolRemints(at: number): void {
|
|
175
|
+
for (const [scopeKey, entry] of incompleteToolRemintByScope) {
|
|
176
|
+
if (at - entry.updatedAt > CURSOR_INCOMPLETE_TOOL_REMINT_TTL_MS) {
|
|
177
|
+
incompleteToolRemintByScope.delete(scopeKey);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
while (incompleteToolRemintByScope.size > CURSOR_INCOMPLETE_TOOL_REMINT_MAX_ENTRIES) {
|
|
181
|
+
const oldest = incompleteToolRemintByScope.keys().next().value;
|
|
182
|
+
if (oldest === undefined) break;
|
|
183
|
+
incompleteToolRemintByScope.delete(oldest);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Incomplete-tool and overflow recovery share ownership scope, but keep independent budgets. */
|
|
188
|
+
export function cursorIncompleteToolRemintScopeKey(
|
|
189
|
+
threadOwner: string | undefined,
|
|
190
|
+
identityScope?: string,
|
|
191
|
+
): string | null {
|
|
192
|
+
return cursorOverflowRemintScopeKey(threadOwner, identityScope);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Record one incomplete-tool remint; returns false when the independent cap is exhausted. */
|
|
196
|
+
export function recordCursorIncompleteToolRemint(scopeKey: string): boolean {
|
|
197
|
+
const at = now();
|
|
198
|
+
pruneIncompleteToolRemints(at);
|
|
199
|
+
const existing = incompleteToolRemintByScope.get(scopeKey);
|
|
200
|
+
if (existing && existing.remintCount >= CURSOR_INCOMPLETE_TOOL_REMINT_MAX) {
|
|
201
|
+
existing.updatedAt = at;
|
|
202
|
+
incompleteToolRemintByScope.delete(scopeKey);
|
|
203
|
+
incompleteToolRemintByScope.set(scopeKey, existing);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
const entry = existing ?? { remintCount: 0, updatedAt: at };
|
|
207
|
+
entry.remintCount += 1;
|
|
208
|
+
entry.updatedAt = at;
|
|
209
|
+
incompleteToolRemintByScope.delete(scopeKey);
|
|
210
|
+
incompleteToolRemintByScope.set(scopeKey, entry);
|
|
211
|
+
pruneIncompleteToolRemints(at);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A clean turn replenishes this recovery without changing the overflow retry budget. */
|
|
216
|
+
export function clearCursorIncompleteToolRemint(scopeKey: string): void {
|
|
217
|
+
incompleteToolRemintByScope.delete(scopeKey);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function clearCursorIncompleteToolRemintForTests(): void {
|
|
221
|
+
incompleteToolRemintByScope.clear();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function cursorIncompleteToolRemintCountForTests(): number {
|
|
225
|
+
pruneIncompleteToolRemints(now());
|
|
226
|
+
return incompleteToolRemintByScope.size;
|
|
227
|
+
}
|
|
@@ -11,6 +11,11 @@ export interface CursorRequestedModelParameter {
|
|
|
11
11
|
|
|
12
12
|
export interface CursorRunRequest {
|
|
13
13
|
modelId: string;
|
|
14
|
+
/**
|
|
15
|
+
* Normalized Cursor identity scope carried from request parsing so checkpoint
|
|
16
|
+
* observations stay scope-local. Transport metadata only; never serialized on the wire.
|
|
17
|
+
*/
|
|
18
|
+
_cursorIdentityScope?: string;
|
|
14
19
|
/** Cursor model-picker parameters encoded through AgentRunRequest.requested_model. */
|
|
15
20
|
requestedModelParameters?: readonly CursorRequestedModelParameter[];
|
|
16
21
|
/** Cursor Router optimization parameter; valid only while modelId is the `default` wire model. */
|
package/src/adapters/cursor.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { AdapterEvent, OcxProviderConfig } from "../types";
|
|
|
3
3
|
import type { ProviderAdapter } from "./base";
|
|
4
4
|
import { isTranslatorBudgetExceededError } from "../lib/translator-budget";
|
|
5
5
|
import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy";
|
|
6
|
-
import { isCursorBenignCancelError, isCursorInvalidArgumentError, isCursorOverflowRemintCandidate, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors";
|
|
6
|
+
import { isCursorBenignCancelError, isCursorIncompleteToolCallMessage, isCursorInvalidArgumentError, isCursorOverflowRemintCandidate, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors";
|
|
7
7
|
import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery";
|
|
8
8
|
import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store";
|
|
9
9
|
import { mapCursorServerMessage } from "./cursor/message-mapper";
|
|
@@ -32,8 +32,11 @@ import { isDebugEnabled } from "../lib/debug-settings";
|
|
|
32
32
|
import { createAdapterTierMetadata } from "../providers/fastwire";
|
|
33
33
|
import { estimateTokens } from "../lib/token-estimate";
|
|
34
34
|
import {
|
|
35
|
+
clearCursorIncompleteToolRemint,
|
|
36
|
+
cursorIncompleteToolRemintScopeKey,
|
|
35
37
|
cursorOverflowRemintScopeKey,
|
|
36
38
|
markCursorOverflowSurfaced,
|
|
39
|
+
recordCursorIncompleteToolRemint,
|
|
37
40
|
recordCursorOverflowRemint,
|
|
38
41
|
rememberCursorThreadConversation,
|
|
39
42
|
shouldSkipCursorOverflowRemint,
|
|
@@ -99,11 +102,20 @@ function safeCursorTransportError(err: unknown, sizeContext?: CursorSizeContext)
|
|
|
99
102
|
* estimate over the outgoing text vs the model's context window. Only used to keep
|
|
100
103
|
* SMALL requests on the 429 class — unknown/large stays on the overflow mapping.
|
|
101
104
|
*/
|
|
102
|
-
function cursorRequestSizeContext(request: {
|
|
105
|
+
function cursorRequestSizeContext(request: {
|
|
106
|
+
modelId: string;
|
|
107
|
+
_cursorIdentityScope?: string;
|
|
108
|
+
system: string[];
|
|
109
|
+
messages: { content: string }[];
|
|
110
|
+
}): CursorSizeContext {
|
|
103
111
|
const text = [...request.system, ...request.messages.map(message => message.content)].join("\n");
|
|
104
112
|
return {
|
|
105
113
|
estimatedInputTokens: estimateTokens(text, request.modelId),
|
|
106
|
-
|
|
114
|
+
// Prefers this identity scope's checkpoint `maxTokens` over the id heuristic
|
|
115
|
+
// so a plan-gated ceiling participates in the 0.5-window overflow vs 429 prior.
|
|
116
|
+
contextWindow: inferCursorContextWindow(request.modelId, {
|
|
117
|
+
identityScope: request._cursorIdentityScope,
|
|
118
|
+
}),
|
|
107
119
|
};
|
|
108
120
|
}
|
|
109
121
|
|
|
@@ -171,7 +183,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
|
|
|
171
183
|
}
|
|
172
184
|
const inheritedCheckpointRef = _parsed._providerContinuation?.cursor?.checkpointRef;
|
|
173
185
|
const previousConversationId = _parsed._cursorConversationId;
|
|
174
|
-
let request =
|
|
186
|
+
let request = {
|
|
187
|
+
...createCursorRequest(_parsed),
|
|
188
|
+
_cursorIdentityScope: _parsed._cursorIdentityScope?.trim() || "local",
|
|
189
|
+
};
|
|
175
190
|
requestSizeContext = cursorRequestSizeContext(request);
|
|
176
191
|
// The builder may derive a stable provider id from the client thread when Responses state
|
|
177
192
|
// is unavailable. Rekey only existing state; there is nothing to migrate on a fresh turn,
|
|
@@ -190,6 +205,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
|
|
|
190
205
|
let completedNormally = false;
|
|
191
206
|
let lastTransport: { captured?: Uint8Array } | undefined;
|
|
192
207
|
let emittedClientTool = false;
|
|
208
|
+
let sawIncompleteToolCall = false;
|
|
193
209
|
// Ordering proof for tool-suspended checkpoints: true only when the newest captured
|
|
194
210
|
// checkpoint bytes arrived AFTER the turn emitted a client tool call, i.e. upstream
|
|
195
211
|
// serialized its suspended-on-tool-call state. Only that snapshot can safely resume
|
|
@@ -332,6 +348,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
|
|
|
332
348
|
},
|
|
333
349
|
});
|
|
334
350
|
for (const event of events) {
|
|
351
|
+
if (event.type === "error" && isCursorIncompleteToolCallMessage(event.message)) {
|
|
352
|
+
sawIncompleteToolCall = true;
|
|
353
|
+
}
|
|
335
354
|
if (!guardsSettled()) {
|
|
336
355
|
if (event.type === "text_delta") {
|
|
337
356
|
guardHeld.push(event);
|
|
@@ -413,7 +432,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
|
|
|
413
432
|
const remintConversationId = (failedConversationId: string) => {
|
|
414
433
|
lastTransport = undefined;
|
|
415
434
|
_parsed._cursorConversationId = undefined;
|
|
416
|
-
const next =
|
|
435
|
+
const next = {
|
|
436
|
+
...createCursorRequest(_parsed, { forceFreshConversation: true }),
|
|
437
|
+
_cursorIdentityScope: _parsed._cursorIdentityScope?.trim() || "local",
|
|
438
|
+
};
|
|
417
439
|
rekeyContextUsage(failedConversationId, next.conversationId);
|
|
418
440
|
_parsed._cursorConversationId = next.conversationId;
|
|
419
441
|
// Persist recovery for store:false clients that send any stable Cursor thread owner, so
|
|
@@ -514,6 +536,34 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
|
|
|
514
536
|
}
|
|
515
537
|
}
|
|
516
538
|
}
|
|
539
|
+
const incompleteToolRemintScopeKey =
|
|
540
|
+
_parsed._cursorIsolateConversation !== true
|
|
541
|
+
&& request.contextUsageStoreCheckpoints !== false
|
|
542
|
+
? cursorIncompleteToolRemintScopeKey(
|
|
543
|
+
cursorClientThreadOwner(_parsed),
|
|
544
|
+
_parsed._cursorIdentityScope,
|
|
545
|
+
)
|
|
546
|
+
: null;
|
|
547
|
+
// Incomplete-tool errors are streamed, not thrown. Do not retry this turn; rotate only
|
|
548
|
+
// the next turn's id. request-prepare currently isolates compaction, but adapter callers
|
|
549
|
+
// can bypass that upstream invariant, so checkpoint storage is the local isolation boundary.
|
|
550
|
+
if (sawIncompleteToolCall && incompleteToolRemintScopeKey) {
|
|
551
|
+
if (recordCursorIncompleteToolRemint(incompleteToolRemintScopeKey)) {
|
|
552
|
+
if (inheritedCheckpointRef) invalidateCursorCheckpoint(inheritedCheckpointRef);
|
|
553
|
+
debugProviderDiagnostic("cursor", "incomplete-tool-remint", {
|
|
554
|
+
wireModel: request.modelId,
|
|
555
|
+
conversationHash: request.conversationId.slice(0, 16),
|
|
556
|
+
});
|
|
557
|
+
remintConversationId(request.conversationId);
|
|
558
|
+
} else {
|
|
559
|
+
debugProviderDiagnostic("cursor", "incomplete-tool-remint-exhausted", {
|
|
560
|
+
wireModel: request.modelId,
|
|
561
|
+
conversationHash: request.conversationId.slice(0, 16),
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
} else if (!sawIncompleteToolCall && completedNormally && incompleteToolRemintScopeKey) {
|
|
565
|
+
clearCursorIncompleteToolRemint(incompleteToolRemintScopeKey);
|
|
566
|
+
}
|
|
517
567
|
if (
|
|
518
568
|
request.checkpointInvalidationReason
|
|
519
569
|
&& request.checkpointInvalidationReason !== "missing_ref"
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { AdapterFetchContext, AdapterRequest } from "./base";
|
|
2
|
+
import { createAdapterPhysicalSend } from "./physical-send";
|
|
3
|
+
import type { SendClass } from "../lib/request-execution-budget";
|
|
4
|
+
import type { AttemptRecoveryKind } from "../usage/log";
|
|
2
5
|
import { isQuotaExhaustedBody, retryableGoogleStatus, safeGoogleHttpErrorMessage } from "./google-errors";
|
|
3
6
|
import { repairGoogleInvalidRequestBody } from "./google-wire-compiler";
|
|
4
7
|
import { normalizeUpstreamHttpErrorResponse, readDisplaySafeErrorPayloadText } from "./upstream-http-error";
|
|
@@ -8,6 +11,8 @@ import {
|
|
|
8
11
|
fetchWithAttemptDeadline,
|
|
9
12
|
retryBackoffDelayMs,
|
|
10
13
|
sleepWithAbort,
|
|
14
|
+
SendBudgetExhaustedError,
|
|
15
|
+
isConnectionResetError,
|
|
11
16
|
} from "../lib/upstream-retry";
|
|
12
17
|
|
|
13
18
|
const GOOGLE_RETRY_ATTEMPTS = 3;
|
|
@@ -41,18 +46,30 @@ export async function fetchGoogleWithRetry(
|
|
|
41
46
|
): Promise<Response> {
|
|
42
47
|
const repairInvalid400 = opts.repairInvalid400 ?? true;
|
|
43
48
|
const timeoutMs = ctx.timeoutMs ?? 200_000;
|
|
44
|
-
const
|
|
49
|
+
const send = createAdapterPhysicalSend(ctx);
|
|
45
50
|
let lastError: unknown;
|
|
46
51
|
let activeRequest = request;
|
|
47
52
|
let compatibilityReplayUsed = false;
|
|
53
|
+
let pendingResponse: Response | undefined;
|
|
54
|
+
let retryDelayMs = 0;
|
|
55
|
+
let sendClass: SendClass = "transient";
|
|
56
|
+
let recovery: AttemptRecoveryKind | undefined;
|
|
48
57
|
for (let attempt = 0; attempt < GOOGLE_RETRY_ATTEMPTS; attempt++) {
|
|
49
58
|
if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
|
|
50
59
|
try {
|
|
51
|
-
const res = await
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
const res = await send({ url: activeRequest.url, sendClass, recovery,
|
|
61
|
+
beforeDispatch: async () => {
|
|
62
|
+
if (retryDelayMs > 0) await sleepWithAbort(retryDelayMs, ctx.abortSignal);
|
|
63
|
+
if (pendingResponse) cancelResponseBodyBestEffort(pendingResponse);
|
|
64
|
+
pendingResponse = undefined;
|
|
65
|
+
},
|
|
66
|
+
dispatch: executor => fetchWithAttemptDeadline(activeRequest.url, {
|
|
67
|
+
method: activeRequest.method, headers: activeRequest.headers, body: activeRequest.body,
|
|
68
|
+
}, timeoutMs, ctx.abortSignal, ctx.stream, executor),
|
|
69
|
+
});
|
|
70
|
+
retryDelayMs = 0;
|
|
71
|
+
sendClass = "transient";
|
|
72
|
+
recovery = undefined;
|
|
56
73
|
if (res.status === 400 && repairInvalid400 && !compatibilityReplayUsed) {
|
|
57
74
|
let payloadText = "";
|
|
58
75
|
try {
|
|
@@ -64,7 +81,8 @@ export async function fetchGoogleWithRetry(
|
|
|
64
81
|
if (repairedBody !== undefined) {
|
|
65
82
|
compatibilityReplayUsed = true;
|
|
66
83
|
activeRequest = { ...activeRequest, body: repairedBody };
|
|
67
|
-
|
|
84
|
+
pendingResponse = res;
|
|
85
|
+
sendClass = "repair";
|
|
68
86
|
attempt--; // The changed-request replay is separate from transient retry accounting.
|
|
69
87
|
continue;
|
|
70
88
|
}
|
|
@@ -75,7 +93,7 @@ export async function fetchGoogleWithRetry(
|
|
|
75
93
|
// A 429 may be a transient rate limit (retry) or hard quota exhaustion (do NOT retry —
|
|
76
94
|
// it won't recover for hours and burns retries). Peek the body to tell them apart.
|
|
77
95
|
if (res.status === 429) {
|
|
78
|
-
const peekTarget =
|
|
96
|
+
const peekTarget = res.clone();
|
|
79
97
|
const peek = await readDisplaySafeErrorPayloadText(peekTarget, ctx.abortSignal);
|
|
80
98
|
if (isQuotaExhaustedBody(peek)) {
|
|
81
99
|
return ctx.returnRawErrors ? res : normalizeUpstreamHttpErrorResponse(res, {
|
|
@@ -84,20 +102,27 @@ export async function fetchGoogleWithRetry(
|
|
|
84
102
|
});
|
|
85
103
|
}
|
|
86
104
|
}
|
|
87
|
-
|
|
88
|
-
|
|
105
|
+
pendingResponse = res;
|
|
106
|
+
recovery = res.status === 429 ? "rate-limit-429" : "transient-5xx";
|
|
107
|
+
retryDelayMs = retryBackoffDelayMs(attempt, {
|
|
89
108
|
baseDelayMs: GOOGLE_RETRY_BASE_MS,
|
|
90
109
|
maxDelayMs: GOOGLE_RETRY_MAX_MS,
|
|
91
110
|
headers: res.headers,
|
|
92
|
-
})
|
|
111
|
+
});
|
|
93
112
|
} catch (err) {
|
|
94
113
|
if (ctx.abortSignal?.aborted) throw err;
|
|
114
|
+
if (err instanceof SendBudgetExhaustedError) {
|
|
115
|
+
if (pendingResponse) return ctx.returnRawErrors ? pendingResponse : normalizeFinalGoogleError(label, pendingResponse, ctx.abortSignal);
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
95
118
|
lastError = err;
|
|
96
119
|
if (attempt === GOOGLE_RETRY_ATTEMPTS - 1) throw err;
|
|
97
|
-
|
|
120
|
+
sendClass = "transient";
|
|
121
|
+
recovery = isConnectionResetError(err) ? "connection-reset" : undefined;
|
|
122
|
+
retryDelayMs = retryBackoffDelayMs(attempt, {
|
|
98
123
|
baseDelayMs: GOOGLE_RETRY_BASE_MS,
|
|
99
124
|
maxDelayMs: GOOGLE_RETRY_MAX_MS,
|
|
100
|
-
})
|
|
125
|
+
});
|
|
101
126
|
}
|
|
102
127
|
}
|
|
103
128
|
throw lastError ?? new Error(`${label} fetch failed`);
|
|
@@ -6,6 +6,8 @@ import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
|
6
6
|
import type { OcxProviderConfig, OcxParsedRequest } from "../types";
|
|
7
7
|
import { createOpenAIChatAdapter } from "./openai-chat";
|
|
8
8
|
import type { ProviderAdapter, AdapterRequest, IncomingMeta } from "./base";
|
|
9
|
+
import { createAdapterPhysicalSend } from "./physical-send";
|
|
10
|
+
import { SendBudgetExhaustedError } from "../lib/upstream-retry";
|
|
9
11
|
|
|
10
12
|
const BOOTSTRAP_URL = "https://api.xiaomimimo.com/api/free-ai/bootstrap";
|
|
11
13
|
export const MIMO_CHAT_URL = "https://api.xiaomimimo.com/api/free-ai/openai/chat";
|
|
@@ -248,33 +250,46 @@ export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdap
|
|
|
248
250
|
},
|
|
249
251
|
|
|
250
252
|
async fetchResponse(request: AdapterRequest, ctx): Promise<Response> {
|
|
251
|
-
const
|
|
253
|
+
const send = createAdapterPhysicalSend(ctx);
|
|
254
|
+
const response = await send({ url: request.url, dispatch: executor => executor(request.url, {
|
|
252
255
|
method: request.method,
|
|
253
256
|
redirect: "manual",
|
|
254
257
|
headers: request.headers as Record<string, string>,
|
|
255
258
|
body: request.body,
|
|
256
259
|
signal: ctx?.abortSignal,
|
|
257
|
-
});
|
|
260
|
+
}) });
|
|
258
261
|
|
|
259
262
|
// Retry predicate: 401 (expired/invalid JWT) retries ONCE with a fresh token.
|
|
260
263
|
// 403 is NOT retried — Xiaomi uses it for anti-abuse "Illegal access" and there is
|
|
261
264
|
// no documented token-expiry signature that would mark a 403 as retryable.
|
|
262
265
|
if (response.status === 401) {
|
|
263
|
-
|
|
264
|
-
try {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
266
|
+
let retryHeaders = request.headers;
|
|
267
|
+
try {
|
|
268
|
+
return await send({ url: request.url, sendClass: "auth-recovery", recovery: "oauth-401",
|
|
269
|
+
beforeDispatch: async () => {
|
|
270
|
+
// Drain the first response body and refresh the JWT only after admission: a
|
|
271
|
+
// refused replay still returns THIS response to the caller, body intact.
|
|
272
|
+
// Draining comes first within the block because getMimoJwt issues its own
|
|
273
|
+
// network call and may throw, and the 401 body would then never be released.
|
|
274
|
+
try { void response.body?.cancel().catch(() => {}); } catch { /* already consumed */ }
|
|
275
|
+
resetMimoJwtCache();
|
|
276
|
+
const freshJwt = await getMimoJwt(ctx?.abortSignal);
|
|
277
|
+
retryHeaders = {
|
|
278
|
+
...(request.headers as Record<string, string>),
|
|
279
|
+
"Authorization": `Bearer ${freshJwt}`,
|
|
280
|
+
};
|
|
281
|
+
},
|
|
282
|
+
dispatch: executor => executor(request.url, {
|
|
283
|
+
method: request.method,
|
|
284
|
+
redirect: "manual",
|
|
285
|
+
headers: retryHeaders,
|
|
286
|
+
body: request.body,
|
|
287
|
+
signal: ctx?.abortSignal,
|
|
288
|
+
}) });
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (error instanceof SendBudgetExhaustedError) return response;
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
278
293
|
}
|
|
279
294
|
|
|
280
295
|
return response;
|