@bitkyc08/opencodex 2.7.39 → 2.7.40
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.md +4 -4
- package/gui/dist/assets/index-CMip1DzF.css +1 -0
- package/gui/dist/assets/index-cydcmbzC.js +52 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/cursor/arg-normalize.ts +23 -7
- package/src/adapters/cursor/live-transport.ts +26 -14
- package/src/adapters/cursor/native-exec-fs.ts +1 -1
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +1 -1
- package/src/adapters/cursor/protobuf-events.ts +72 -13
- package/src/adapters/cursor/protobuf-request.ts +82 -11
- package/src/adapters/cursor/request-builder.ts +35 -11
- package/src/adapters/cursor/tool-definitions.ts +175 -30
- package/src/adapters/openai-chat.ts +28 -7
- package/src/adapters/openai-responses.ts +150 -4
- package/src/bridge.ts +20 -1
- package/src/claude/outbound.ts +91 -6
- package/src/codex/auth-api.ts +12 -25
- package/src/codex/auth-context.ts +48 -3
- package/src/codex/catalog/provider-fetch.ts +56 -24
- package/src/codex/model-cache.ts +23 -0
- package/src/codex/quota.ts +120 -0
- package/src/codex/routing.ts +178 -9
- package/src/config.ts +56 -1
- package/src/providers/openai-sidecar.ts +8 -1
- package/src/providers/openai-tiers.ts +18 -0
- package/src/server/adapter-resolve.ts +24 -10
- package/src/server/auth-cors.ts +3 -0
- package/src/server/chat-completions.ts +4 -0
- package/src/server/claude-messages.ts +4 -0
- package/src/server/index.ts +3 -1
- package/src/server/live.ts +56 -0
- package/src/server/memory-watchdog.ts +1 -1
- package/src/server/responses/compact.ts +40 -10
- package/src/server/responses/core.ts +180 -26
- package/src/server/responses/terminal-guard.ts +230 -0
- package/src/service.ts +113 -30
- package/src/types.ts +52 -0
- package/src/usage/expected-prices.ts +12 -0
- package/src/web-search/anthropic-executor.ts +3 -1
- package/src/web-search/index.ts +7 -1
- package/src/web-search/loop.ts +17 -3
- package/README.ja.md +0 -445
- package/README.ko.md +0 -435
- package/README.ru.md +0 -486
- package/README.zh-CN.md +0 -411
- package/gui/dist/assets/index-B-cheu55.js +0 -52
- package/gui/dist/assets/index-oOZcqVmj.css +0 -1
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
headersForCodexAuthContext,
|
|
50
50
|
isCodexAuthContextUsable,
|
|
51
51
|
resolveCodexAuthContext,
|
|
52
|
+
codexProbeLeaseId,
|
|
52
53
|
type CodexAuthContext,
|
|
53
54
|
} from "../../codex/auth-context";
|
|
54
55
|
import {
|
|
@@ -59,7 +60,7 @@ import {
|
|
|
59
60
|
import { fetchWithResetRetry, fetchWithTransientRetry, applyUpstreamRecoveryInit } from "../../lib/upstream-retry";
|
|
60
61
|
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors";
|
|
61
62
|
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
|
|
62
|
-
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
63
|
+
import { isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers";
|
|
63
64
|
import { slugsEquivalent } from "../../providers/slug-codec";
|
|
64
65
|
import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
|
|
65
66
|
import { isUsageDebugEnabled } from "../../usage/debug";
|
|
@@ -202,7 +203,10 @@ export async function handleResponsesCompact(
|
|
|
202
203
|
}
|
|
203
204
|
}
|
|
204
205
|
|
|
205
|
-
|
|
206
|
+
// Native /responses/compact exists on the canonical ChatGPT backend and on the
|
|
207
|
+
// official OpenAI API. Any other Responses-shaped gateway must take the routed
|
|
208
|
+
// summarizer path below, or compaction fails against an endpoint it never had (#422).
|
|
209
|
+
if (supportsNativeResponsesCompactEndpoint(route.providerName, route.provider)) {
|
|
206
210
|
// Native ChatGPT/OpenAI model: forward the compact request verbatim to the real backend.
|
|
207
211
|
// Resolve the SAME pool/thread auth context as /v1/responses — forwarding the caller's raw
|
|
208
212
|
// headers would run compaction on the wrong account (or 401) whenever a pool account is
|
|
@@ -255,6 +259,7 @@ export async function handleResponsesCompact(
|
|
|
255
259
|
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
256
260
|
...meta,
|
|
257
261
|
threadId: compactThreadId,
|
|
262
|
+
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
258
263
|
});
|
|
259
264
|
};
|
|
260
265
|
let upstream: Response;
|
|
@@ -322,21 +327,46 @@ export async function handleResponsesCompact(
|
|
|
322
327
|
});
|
|
323
328
|
const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal });
|
|
324
329
|
if (!response.ok) return response;
|
|
325
|
-
let json: { output?: unknown[] };
|
|
330
|
+
let json: { output?: unknown[]; status?: unknown; error?: unknown };
|
|
326
331
|
try {
|
|
327
|
-
json = await response.json() as { output?: unknown[] };
|
|
332
|
+
json = await response.json() as { output?: unknown[]; status?: unknown; error?: unknown };
|
|
328
333
|
} catch {
|
|
329
334
|
return formatErrorResponse(502, "server_error", "compaction turn returned a non-JSON response");
|
|
330
335
|
}
|
|
331
|
-
|
|
336
|
+
// The internal turn answers 200 even when it failed or was truncated, so the body
|
|
337
|
+
// has to be inspected. Reporting a failure beats installing "(no summary
|
|
338
|
+
// available)" as replacement history and silently losing the conversation (#422).
|
|
339
|
+
if (json.error) {
|
|
340
|
+
const message = typeof json.error === "string"
|
|
341
|
+
? json.error
|
|
342
|
+
: (json.error as { message?: unknown })?.message;
|
|
343
|
+
return formatErrorResponse(502, "upstream_error", typeof message === "string" ? message : "compaction turn failed");
|
|
344
|
+
}
|
|
345
|
+
if (json.status !== "completed") {
|
|
346
|
+
return formatErrorResponse(
|
|
347
|
+
502,
|
|
348
|
+
"upstream_error",
|
|
349
|
+
`compaction turn did not complete (status: ${String(json.status ?? "unknown")})`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const compactionItems = (json.output ?? []).filter(
|
|
332
353
|
(item): item is { type: string; encrypted_content?: string } =>
|
|
333
354
|
!!item && typeof item === "object" && (item as { type?: string }).type === "compaction",
|
|
334
355
|
);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
356
|
+
if (compactionItems.length !== 1) {
|
|
357
|
+
return formatErrorResponse(
|
|
358
|
+
502,
|
|
359
|
+
"invalid_response_error",
|
|
360
|
+
`compaction turn produced ${compactionItems.length} compaction items, expected exactly 1`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
const encrypted = compactionItems[0]!.encrypted_content;
|
|
364
|
+
const decoded = typeof encrypted === "string" ? decodeCompactionSummary(encrypted) : null;
|
|
365
|
+
// An empty `ocx1:` envelope decodes to "" rather than null, so length is what matters.
|
|
366
|
+
if (decoded === null || decoded.trim().length === 0) {
|
|
367
|
+
return formatErrorResponse(502, "invalid_response_error", "compaction turn produced an empty summary");
|
|
368
|
+
}
|
|
369
|
+
const summary = decoded;
|
|
338
370
|
const output = buildCompactV1Output(extractCompactUserMessages(inputItems), summary);
|
|
339
371
|
return new Response(JSON.stringify({ output }), { headers: { "Content-Type": "application/json" } });
|
|
340
372
|
}
|
|
341
|
-
|
|
342
|
-
|
|
@@ -49,6 +49,8 @@ import {
|
|
|
49
49
|
headersForCodexAuthContext,
|
|
50
50
|
isCodexAuthContextUsable,
|
|
51
51
|
resolveCodexAuthContext,
|
|
52
|
+
codexProbeLeaseId,
|
|
53
|
+
releaseCodexAuthContextProbeLease,
|
|
52
54
|
stripCodexRuntimeProviderFields,
|
|
53
55
|
type CodexAuthContext,
|
|
54
56
|
} from "../../codex/auth-context";
|
|
@@ -105,6 +107,7 @@ import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/cat
|
|
|
105
107
|
import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
|
|
106
108
|
import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload";
|
|
107
109
|
import { fetchWithHeaderTimeout, providerFetch, safeHostLabel } from "./fetch-helpers";
|
|
110
|
+
import { guardTerminalEventStream } from "./terminal-guard";
|
|
108
111
|
|
|
109
112
|
/**
|
|
110
113
|
* Adapters whose continuation state must survive Codex's store:false requests.
|
|
@@ -119,7 +122,10 @@ export function sidecarOutcomeRecorder(
|
|
|
119
122
|
threadId?: string | null,
|
|
120
123
|
): ((outcome: CodexUpstreamOutcome) => void) | undefined {
|
|
121
124
|
return authCtx.kind === "pool" || authCtx.kind === "main-pool"
|
|
122
|
-
? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
125
|
+
? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
126
|
+
threadId,
|
|
127
|
+
probeLeaseId: authCtx.probeLeaseId,
|
|
128
|
+
})
|
|
123
129
|
: undefined;
|
|
124
130
|
}
|
|
125
131
|
|
|
@@ -206,7 +212,10 @@ export function codexForwardTerminalOutcomeRecorder(
|
|
|
206
212
|
// Normal limit/content-filter/stall terminal — the account served the
|
|
207
213
|
// request. Don't penalize account health; record success to clear any
|
|
208
214
|
// prior soft-avoid so a healthy account isn't stuck avoided.
|
|
209
|
-
recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
|
|
215
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
|
|
216
|
+
threadId,
|
|
217
|
+
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
218
|
+
});
|
|
210
219
|
return;
|
|
211
220
|
}
|
|
212
221
|
// status === "completed" or "failed": use the semantic HTTP status derived
|
|
@@ -221,7 +230,10 @@ export function codexForwardTerminalOutcomeRecorder(
|
|
|
221
230
|
const outcome = status === "completed"
|
|
222
231
|
? 200
|
|
223
232
|
: (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
|
|
224
|
-
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
233
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
234
|
+
threadId,
|
|
235
|
+
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
236
|
+
});
|
|
225
237
|
};
|
|
226
238
|
}
|
|
227
239
|
|
|
@@ -720,6 +732,10 @@ export async function handleResponses(
|
|
|
720
732
|
}
|
|
721
733
|
parsed.modelId = route.modelId;
|
|
722
734
|
}
|
|
735
|
+
// Settle the wire once, right after the native model id is known, so logging,
|
|
736
|
+
// fast-mode injection, auth, and sidecar decisions all read the adapter this
|
|
737
|
+
// request will actually use rather than the provider-wide default (#404).
|
|
738
|
+
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
|
|
723
739
|
logCtx.model = route.modelId;
|
|
724
740
|
logCtx.provider = route.providerName;
|
|
725
741
|
logCtx.providerAdapter = route.provider.adapter;
|
|
@@ -851,6 +867,8 @@ export async function handleResponses(
|
|
|
851
867
|
throw err;
|
|
852
868
|
}
|
|
853
869
|
if (!isCodexAuthContextUsable(authCtx, config)) {
|
|
870
|
+
// Nothing reaches upstream on this path, so give the probe back.
|
|
871
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
854
872
|
return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
|
|
855
873
|
}
|
|
856
874
|
route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
|
|
@@ -973,7 +991,12 @@ export async function handleResponses(
|
|
|
973
991
|
// one `{type:"compaction"}` output item (codex-rs compact_remote_v2.rs). Passthrough handles it
|
|
974
992
|
// natively upstream; here we run the routed model as a plain summarizer — no tools, no web-search
|
|
975
993
|
// sidecar — and the bridge appends the synthetic compaction item (src/responses/compaction.ts).
|
|
976
|
-
|
|
994
|
+
// A Responses-shaped wire does not imply support for Codex's private
|
|
995
|
+
// `compaction_trigger` item — only the canonical ChatGPT backend speaks that
|
|
996
|
+
// contract. An API-key gateway would receive the trigger, answer with an ordinary
|
|
997
|
+
// message, and leave Codex fataling on a missing compaction item (#422).
|
|
998
|
+
const routedCompaction = parsed._compactionRequest === true
|
|
999
|
+
&& !isCanonicalOpenAiForwardProvider(route.provider);
|
|
977
1000
|
if (routedCompaction) {
|
|
978
1001
|
delete parsed.context.tools;
|
|
979
1002
|
delete parsed._webSearch;
|
|
@@ -982,7 +1005,7 @@ export async function handleResponses(
|
|
|
982
1005
|
parsed.context.messages.push({ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() });
|
|
983
1006
|
}
|
|
984
1007
|
|
|
985
|
-
if ("passthrough" in adapter && adapter.passthrough) {
|
|
1008
|
+
if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) {
|
|
986
1009
|
// Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
|
|
987
1010
|
// previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
|
|
988
1011
|
// REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY
|
|
@@ -1025,6 +1048,7 @@ export async function handleResponses(
|
|
|
1025
1048
|
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1026
1049
|
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
1027
1050
|
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
1051
|
+
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
1028
1052
|
});
|
|
1029
1053
|
}
|
|
1030
1054
|
const msg = outcome === "timeout"
|
|
@@ -1079,6 +1103,7 @@ export async function handleResponses(
|
|
|
1079
1103
|
if (retryAuthCtx?.kind === "pool" || retryAuthCtx?.kind === "main-pool") {
|
|
1080
1104
|
recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, 400, {
|
|
1081
1105
|
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
1106
|
+
probeLeaseId: codexProbeLeaseId(firstAuthCtx),
|
|
1082
1107
|
});
|
|
1083
1108
|
|
|
1084
1109
|
const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx);
|
|
@@ -1147,25 +1172,9 @@ export async function handleResponses(
|
|
|
1147
1172
|
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1148
1173
|
// primary was the 5h window; it now carries weekly data for GPT plans.
|
|
1149
1174
|
// Prefer primary when present, fall back to secondary for compatibility.
|
|
1150
|
-
const primaryRaw = upstreamResponse.headers.get("x-codex-primary-used-percent");
|
|
1151
|
-
const secondaryRaw = upstreamResponse.headers.get("x-codex-secondary-used-percent");
|
|
1152
|
-
const weeklyRaw = primaryRaw ?? secondaryRaw;
|
|
1153
|
-
const monthlyRaw = upstreamResponse.headers.get("x-codex-tertiary-used-percent");
|
|
1154
|
-
const primaryResetRaw = upstreamResponse.headers.get("x-codex-primary-reset-at");
|
|
1155
|
-
const secondaryResetRaw = upstreamResponse.headers.get("x-codex-secondary-reset-at");
|
|
1156
|
-
const weeklyResetRaw = primaryRaw ? primaryResetRaw : secondaryResetRaw;
|
|
1157
|
-
const monthlyResetRaw = upstreamResponse.headers.get("x-codex-tertiary-reset-at");
|
|
1158
1175
|
const retryAfterRaw = upstreamResponse.headers.get("retry-after");
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
updateAccountQuota(
|
|
1162
|
-
authCtx.accountId,
|
|
1163
|
-
weeklyRaw,
|
|
1164
|
-
weeklyResetRaw,
|
|
1165
|
-
monthlyRaw,
|
|
1166
|
-
monthlyResetRaw,
|
|
1167
|
-
);
|
|
1168
|
-
}
|
|
1176
|
+
const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
|
|
1177
|
+
applyAccountQuotaFromUpstreamHeaders(authCtx.accountId, upstreamResponse.headers);
|
|
1169
1178
|
if (terminalBodyWillRecord) {
|
|
1170
1179
|
options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => {
|
|
1171
1180
|
terminalRecorder(status, httpStatusOverride);
|
|
@@ -1174,8 +1183,13 @@ export async function handleResponses(
|
|
|
1174
1183
|
} else {
|
|
1175
1184
|
recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
|
|
1176
1185
|
retryAfter: retryAfterRaw,
|
|
1177
|
-
resetAt: [
|
|
1186
|
+
resetAt: [
|
|
1187
|
+
upstreamResponse.headers.get("x-codex-primary-reset-at"),
|
|
1188
|
+
upstreamResponse.headers.get("x-codex-secondary-reset-at"),
|
|
1189
|
+
upstreamResponse.headers.get("x-codex-tertiary-reset-at"),
|
|
1190
|
+
].filter(Boolean),
|
|
1178
1191
|
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
1192
|
+
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
1179
1193
|
});
|
|
1180
1194
|
}
|
|
1181
1195
|
}
|
|
@@ -1634,8 +1648,136 @@ export async function handleResponses(
|
|
|
1634
1648
|
|
|
1635
1649
|
cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
|
|
1636
1650
|
|
|
1651
|
+
// Claude can return a clean end_turn after announcing an edit without emitting any tool call.
|
|
1652
|
+
// Keep the normal request/recovery path above intact, and use this bounded callback only for the
|
|
1653
|
+
// one internal continuation pass. A continuation failure becomes an in-stream adapter error so
|
|
1654
|
+
// the client never sees a second hidden HTTP response or an unbounded retry loop.
|
|
1655
|
+
const terminalGuardEnabled = activeAdapter.name === "anthropic" && !options.comboAttempt && !routedCompaction;
|
|
1656
|
+
const fetchTerminalGuardContinuation = async function* (nextParsed: OcxParsedRequest): AsyncGenerator<AdapterEvent> {
|
|
1657
|
+
let imageTierBias = 0;
|
|
1658
|
+
let response: Response | undefined;
|
|
1659
|
+
while (true) {
|
|
1660
|
+
try {
|
|
1661
|
+
const continuationRequest = await activeAdapter.buildRequest(nextParsed, {
|
|
1662
|
+
headers: selectedForwardHeaders,
|
|
1663
|
+
...(imageTierBias > 0 ? { imageTierBias } : {}),
|
|
1664
|
+
});
|
|
1665
|
+
const continuationEstimate = typeof continuationRequest.usageLog?.inputTokens === "number"
|
|
1666
|
+
? continuationRequest.usageLog.inputTokens
|
|
1667
|
+
: undefined;
|
|
1668
|
+
if (continuationEstimate !== undefined) logCtx.usageLogInputTokens = continuationEstimate;
|
|
1669
|
+
if (activeAdapter.fetchResponse) {
|
|
1670
|
+
noteAttemptSend(logCtx.activeAttempt, continuationEstimate);
|
|
1671
|
+
response = await activeAdapter.fetchResponse(continuationRequest, {
|
|
1672
|
+
abortSignal: upstream.signal,
|
|
1673
|
+
timeoutMs: connectMs,
|
|
1674
|
+
stream: nextParsed.stream,
|
|
1675
|
+
});
|
|
1676
|
+
} else {
|
|
1677
|
+
response = await fetchWithResetRetry(
|
|
1678
|
+
recovery => {
|
|
1679
|
+
noteAttemptSend(logCtx.activeAttempt, continuationEstimate, recovery);
|
|
1680
|
+
return fetchWithHeaderTimeout(
|
|
1681
|
+
continuationRequest.url,
|
|
1682
|
+
applyUpstreamRecoveryInit({
|
|
1683
|
+
method: continuationRequest.method,
|
|
1684
|
+
headers: continuationRequest.headers,
|
|
1685
|
+
body: continuationRequest.body,
|
|
1686
|
+
}, recovery),
|
|
1687
|
+
upstream.signal,
|
|
1688
|
+
connectMs,
|
|
1689
|
+
nextParsed.stream,
|
|
1690
|
+
providerFetch(route.provider),
|
|
1691
|
+
);
|
|
1692
|
+
},
|
|
1693
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(continuationRequest.url) },
|
|
1694
|
+
);
|
|
1695
|
+
}
|
|
1696
|
+
} catch (error) {
|
|
1697
|
+
if (options.abortSignal?.aborted) {
|
|
1698
|
+
yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
|
|
1699
|
+
} else {
|
|
1700
|
+
yield { type: "error", message: `Provider continuation failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
1701
|
+
}
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
if (response.status === 429 && hasKeyPoolFailover(route.provider)) {
|
|
1706
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, {
|
|
1707
|
+
retryAfter: response.headers.get("retry-after"),
|
|
1708
|
+
now: Date.now(),
|
|
1709
|
+
attemptedKey: route.provider.apiKey,
|
|
1710
|
+
promptCacheKey: nextParsed.options.promptCacheKey,
|
|
1711
|
+
});
|
|
1712
|
+
if (rotated) {
|
|
1713
|
+
try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
1714
|
+
route.provider = rotated;
|
|
1715
|
+
activeAdapter = resolveAdapter(
|
|
1716
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
1717
|
+
config.cacheRetention,
|
|
1718
|
+
);
|
|
1719
|
+
continue;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
if (shouldAttemptImageTierRetry({
|
|
1723
|
+
status: response.status,
|
|
1724
|
+
adapterName: activeAdapter.name,
|
|
1725
|
+
parsed: nextParsed,
|
|
1726
|
+
alreadyAttempted: imageTierBias > 0,
|
|
1727
|
+
})) {
|
|
1728
|
+
imageTierBias = 1;
|
|
1729
|
+
try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
1730
|
+
continue;
|
|
1731
|
+
}
|
|
1732
|
+
break;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
if (!response.ok) {
|
|
1736
|
+
const errorText = await response.text().catch(() => "unknown error");
|
|
1737
|
+
yield {
|
|
1738
|
+
type: "error",
|
|
1739
|
+
status: response.status,
|
|
1740
|
+
message: `Provider continuation error ${response.status}: ${redactSecretString(errorText.slice(0, 500))}`,
|
|
1741
|
+
};
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
try {
|
|
1746
|
+
// Protect the continuation body against a client abort landing between fetch resolution and
|
|
1747
|
+
// reader attach, exactly as the initial response is guarded above (#390/366e3053). Without
|
|
1748
|
+
// this, a client cancel during the continuation reopens the Bun fetch-to-reader abort race.
|
|
1749
|
+
const detachContinuationBodyGuard = cancelBodyOnAbort(response.body, upstream.signal);
|
|
1750
|
+
try {
|
|
1751
|
+
if (nextParsed.stream) {
|
|
1752
|
+
yield* activeAdapter.parseStream(response);
|
|
1753
|
+
} else if (activeAdapter.parseResponse) {
|
|
1754
|
+
yield* await activeAdapter.parseResponse(response);
|
|
1755
|
+
} else {
|
|
1756
|
+
yield { type: "error", message: "Provider continuation does not support response parsing" };
|
|
1757
|
+
}
|
|
1758
|
+
} finally {
|
|
1759
|
+
detachContinuationBodyGuard();
|
|
1760
|
+
}
|
|
1761
|
+
} catch (error) {
|
|
1762
|
+
if (options.abortSignal?.aborted) {
|
|
1763
|
+
yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
|
|
1764
|
+
} else {
|
|
1765
|
+
yield { type: "error", message: `Provider continuation parse failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` };
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
};
|
|
1769
|
+
|
|
1637
1770
|
if (parsed.stream) {
|
|
1638
|
-
const
|
|
1771
|
+
const initialEventStream = activeAdapter.parseStream(upstreamResponse);
|
|
1772
|
+
const eventStream = terminalGuardEnabled
|
|
1773
|
+
? guardTerminalEventStream({
|
|
1774
|
+
parsed,
|
|
1775
|
+
firstEvents: initialEventStream,
|
|
1776
|
+
adapterName: activeAdapter.name,
|
|
1777
|
+
maxAutoContinuations: 1,
|
|
1778
|
+
continuation: fetchTerminalGuardContinuation,
|
|
1779
|
+
})
|
|
1780
|
+
: initialEventStream;
|
|
1639
1781
|
const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
|
|
1640
1782
|
const sseStream = bridgeToResponsesSSE(
|
|
1641
1783
|
eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
|
|
@@ -1670,7 +1812,19 @@ export async function handleResponses(
|
|
|
1670
1812
|
if (activeAdapter.parseResponse) {
|
|
1671
1813
|
let events: AdapterEvent[];
|
|
1672
1814
|
try {
|
|
1673
|
-
|
|
1815
|
+
const initialEvents = await activeAdapter.parseResponse(upstreamResponse);
|
|
1816
|
+
if (terminalGuardEnabled) {
|
|
1817
|
+
events = [];
|
|
1818
|
+
for await (const event of guardTerminalEventStream({
|
|
1819
|
+
parsed,
|
|
1820
|
+
firstEvents: (async function* () { yield* initialEvents; })(),
|
|
1821
|
+
adapterName: activeAdapter.name,
|
|
1822
|
+
maxAutoContinuations: 1,
|
|
1823
|
+
continuation: fetchTerminalGuardContinuation,
|
|
1824
|
+
})) events.push(event);
|
|
1825
|
+
} else {
|
|
1826
|
+
events = initialEvents;
|
|
1827
|
+
}
|
|
1674
1828
|
} finally {
|
|
1675
1829
|
cleanupUpstreamAbort();
|
|
1676
1830
|
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdapterEvent,
|
|
3
|
+
OcxAssistantContentPart,
|
|
4
|
+
OcxAssistantMessage,
|
|
5
|
+
OcxParsedRequest,
|
|
6
|
+
OcxUsage,
|
|
7
|
+
} from "../../types";
|
|
8
|
+
|
|
9
|
+
const ACTIONABLE_REQUEST_RE = /(?:\b(?:add|change|check|continue|create|debug|delete|deploy|edit|execute|fix|implement|inspect|keep going|modify|patch|proceed|refactor|remove|review|run|test|update|write)\b|继续|接着|往下|升级|修改|改(?:一下|下)?|修复|实现|添加|新增|删除|重构|更新|运行|执行|检查|查看|排查|调试|创建|写入|提交|推送|部署|看下|改成|修一下)/iu;
|
|
10
|
+
const PLAN_ONLY_REQUEST_RE = /(?:暂时不要(?:调用|使用)工具|不要调用工具|不用执行|只回复(?:计划|方案)|(?:只|先|给|说|写|出|来)(?:我)?(?:一个|个|一下|份)?[^。!?\n]{0,8}?(?:计划|方案|草案|提案|思路)|(?:计划|方案|草案|提案|思路)(?:就(?:行|好|可以)|即可)|\b(?:just give|provide)\s+(?:me\s+)?(?:a\s+)?plan\b|\b(?:do not|don't)\s+(?:use|call)\s+tools?\b|\b(?:write|draft|outline|propose|give|provide|create|make|share|suggest|sketch)\s+(?:me\s+)?(?:a\s+|an\s+|the\s+|your\s+)?(?:(?:brief|concise|short|detailed|high[-\s]?level|rough|quick|step[-\s]?by[-\s]?step|implementation|migration|refactor(?:ing)?|design|technical)\s+)*(?:plan|proposal|draft|outline|approach|strategy|design\s+doc)\b)/iu;
|
|
11
|
+
const PLAN_INTENT_RE = /(?:\b(?:i(?:'|’)m going to|i will|i(?:'|’)ll|let me|next i)\b|我(?:先|会|将|接下来)|下一步)/iu;
|
|
12
|
+
const PLAN_OR_COMPLETION_RE = /(?:\b(?:i(?:'|’)m going to|i will|i(?:'|’)ll|let me|next i|i(?:'|’)ve (?:already )?(?:added|applied|changed|completed|fixed|implemented|modified|updated))\b|\b(?:done|completed|fixed|implemented|updated|applied)\b|我(?:先|会|将|接下来)|下一步|已(?:经)?(?:修改|修复|完成|应用|更新|实现|处理)|完成了|已经好了)/iu;
|
|
13
|
+
const WAITING_FOR_USER_RE = /(?:[??]\s*$|需要我|请(?:确认|选择|提供)|是否|要不要|可以吗|\b(?:do you want|should i|which file|please confirm|please provide)\b)/iu;
|
|
14
|
+
const EXPLICIT_CONTINUE_RE = /^(?:继续|接着|往下|go on|continue|proceed|keep going)\s*[.!。!]?$/iu;
|
|
15
|
+
const MAX_ANNOUNCEMENT_CHARS = 280;
|
|
16
|
+
|
|
17
|
+
export const TERMINAL_GUARD_NUDGE =
|
|
18
|
+
"你刚才只描述了计划,没有执行任何工具。不要再次解释计划,现在立即调用必要工具执行用户任务。" +
|
|
19
|
+
"只有工具返回后才总结;如果确实无法执行,请明确说明阻塞原因。";
|
|
20
|
+
|
|
21
|
+
export type TerminalGuardDecision = "pass" | "continue" | "ambiguous";
|
|
22
|
+
|
|
23
|
+
export interface TerminalTurnAnalysis {
|
|
24
|
+
decision: TerminalGuardDecision;
|
|
25
|
+
reason: "normal" | "waiting_for_user" | "no_tools" | "no_actionable_request" | "no_execution_claim" | "recent_tool_activity" | "substantive_answer" | "suspicious_no_tool";
|
|
26
|
+
assistantText: string;
|
|
27
|
+
userText: string;
|
|
28
|
+
hasToolCall: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function messageText(value: unknown): string {
|
|
32
|
+
if (typeof value === "string") return value;
|
|
33
|
+
if (!Array.isArray(value)) return "";
|
|
34
|
+
return value
|
|
35
|
+
.filter((part): part is { type?: unknown; text?: unknown } => !!part && typeof part === "object")
|
|
36
|
+
.filter(part => part.type === "text" && typeof part.text === "string")
|
|
37
|
+
.map(part => part.text as string)
|
|
38
|
+
.join("");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function latestUserText(parsed: OcxParsedRequest): string {
|
|
42
|
+
for (let i = parsed.context.messages.length - 1; i >= 0; i -= 1) {
|
|
43
|
+
const message = parsed.context.messages[i];
|
|
44
|
+
if (message.role === "user") return messageText(message.content);
|
|
45
|
+
}
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function recentTurnHasToolActivity(parsed: OcxParsedRequest): boolean {
|
|
50
|
+
let latestUserIndex = -1;
|
|
51
|
+
for (let i = parsed.context.messages.length - 1; i >= 0; i -= 1) {
|
|
52
|
+
if (parsed.context.messages[i]?.role === "user") {
|
|
53
|
+
latestUserIndex = i;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (latestUserIndex < 0) return false;
|
|
58
|
+
let lastAssistant: OcxAssistantMessage | undefined;
|
|
59
|
+
for (let i = latestUserIndex - 1; i >= 0; i -= 1) {
|
|
60
|
+
const message = parsed.context.messages[i];
|
|
61
|
+
if (message.role === "assistant") {
|
|
62
|
+
lastAssistant = message;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (lastAssistant?.role === "assistant") {
|
|
67
|
+
const hasToolCall = lastAssistant.content.some(part => part.type === "toolCall");
|
|
68
|
+
const text = lastAssistant.content
|
|
69
|
+
.filter(part => part.type === "text")
|
|
70
|
+
.map(part => part.text)
|
|
71
|
+
.join("");
|
|
72
|
+
if (!hasToolCall && PLAN_INTENT_RE.test(text)) return false;
|
|
73
|
+
}
|
|
74
|
+
for (let i = latestUserIndex - 1; i >= 0 && parsed.context.messages[i]?.role !== "user"; i -= 1) {
|
|
75
|
+
const message = parsed.context.messages[i];
|
|
76
|
+
if (message.role === "toolResult") return true;
|
|
77
|
+
if (message.role === "assistant" && message.content.some(part => part.type === "toolCall")) return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function assistantText(events: readonly AdapterEvent[]): string {
|
|
83
|
+
return events
|
|
84
|
+
.filter((event): event is Extract<AdapterEvent, { type: "text_delta" }> => event.type === "text_delta")
|
|
85
|
+
.map(event => event.text)
|
|
86
|
+
.join("");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function analyzeTerminalTurn(parsed: OcxParsedRequest, events: readonly AdapterEvent[]): TerminalTurnAnalysis {
|
|
90
|
+
const userText = latestUserText(parsed);
|
|
91
|
+
const text = assistantText(events);
|
|
92
|
+
const hasToolCall = events.some(event => event.type === "tool_call_start");
|
|
93
|
+
if (hasToolCall) {
|
|
94
|
+
return { decision: "pass", reason: "normal", assistantText: text, userText, hasToolCall };
|
|
95
|
+
}
|
|
96
|
+
if (!parsed.context.tools || parsed.context.tools.length === 0 || parsed.options.toolChoice === "none") {
|
|
97
|
+
return { decision: "pass", reason: "no_tools", assistantText: text, userText, hasToolCall };
|
|
98
|
+
}
|
|
99
|
+
if (!ACTIONABLE_REQUEST_RE.test(userText)) {
|
|
100
|
+
return { decision: "pass", reason: "no_actionable_request", assistantText: text, userText, hasToolCall };
|
|
101
|
+
}
|
|
102
|
+
if (PLAN_ONLY_REQUEST_RE.test(userText)) {
|
|
103
|
+
return { decision: "pass", reason: "no_actionable_request", assistantText: text, userText, hasToolCall };
|
|
104
|
+
}
|
|
105
|
+
if (EXPLICIT_CONTINUE_RE.test(userText) && recentTurnHasToolActivity(parsed)) {
|
|
106
|
+
return { decision: "pass", reason: "recent_tool_activity", assistantText: text, userText, hasToolCall };
|
|
107
|
+
}
|
|
108
|
+
if (WAITING_FOR_USER_RE.test(text)) {
|
|
109
|
+
return { decision: "pass", reason: "waiting_for_user", assistantText: text, userText, hasToolCall };
|
|
110
|
+
}
|
|
111
|
+
if (text.trim().length > MAX_ANNOUNCEMENT_CHARS) {
|
|
112
|
+
return { decision: "pass", reason: "substantive_answer", assistantText: text, userText, hasToolCall };
|
|
113
|
+
}
|
|
114
|
+
if (!PLAN_OR_COMPLETION_RE.test(text)) {
|
|
115
|
+
return { decision: "ambiguous", reason: "no_execution_claim", assistantText: text, userText, hasToolCall };
|
|
116
|
+
}
|
|
117
|
+
return { decision: "continue", reason: "suspicious_no_tool", assistantText: text, userText, hasToolCall };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function assistantMessageFromEvents(events: readonly AdapterEvent[]): OcxAssistantMessage | undefined {
|
|
121
|
+
let text = "";
|
|
122
|
+
let thinking = "";
|
|
123
|
+
let signature: string | undefined;
|
|
124
|
+
const redacted: string[] = [];
|
|
125
|
+
for (const event of events) {
|
|
126
|
+
if (event.type === "text_delta") text += event.text;
|
|
127
|
+
else if (event.type === "thinking_delta") thinking += event.thinking;
|
|
128
|
+
else if (event.type === "thinking_signature") signature = event.signature;
|
|
129
|
+
else if (event.type === "redacted_thinking") redacted.push(event.data);
|
|
130
|
+
}
|
|
131
|
+
const content: OcxAssistantContentPart[] = [];
|
|
132
|
+
if (thinking || signature || redacted.length > 0) {
|
|
133
|
+
content.push({ type: "thinking", thinking, ...(signature ? { signature } : {}), ...(redacted.length > 0 ? { redacted } : {}) });
|
|
134
|
+
}
|
|
135
|
+
if (text) content.push({ type: "text", text });
|
|
136
|
+
if (content.length === 0) return undefined;
|
|
137
|
+
return { role: "assistant", content, timestamp: Date.now() };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function buildContinuationRequest(parsed: OcxParsedRequest, events: readonly AdapterEvent[]): OcxParsedRequest {
|
|
141
|
+
const messages = [...parsed.context.messages];
|
|
142
|
+
const assistant = assistantMessageFromEvents(events);
|
|
143
|
+
if (assistant) messages.push(assistant);
|
|
144
|
+
messages.push({ role: "developer", content: TERMINAL_GUARD_NUDGE, timestamp: Date.now() });
|
|
145
|
+
return { ...parsed, context: { ...parsed.context, messages } };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface GuardedEventStreamOptions {
|
|
149
|
+
parsed: OcxParsedRequest;
|
|
150
|
+
firstEvents: AsyncIterable<AdapterEvent>;
|
|
151
|
+
continuation: (parsed: OcxParsedRequest) => AsyncIterable<AdapterEvent> | Promise<AsyncIterable<AdapterEvent>>;
|
|
152
|
+
adapterName?: string;
|
|
153
|
+
maxAutoContinuations?: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined): OcxUsage | undefined {
|
|
157
|
+
if (!first) return second;
|
|
158
|
+
if (!second) return first;
|
|
159
|
+
const sumOptional = (key: keyof OcxUsage): number | undefined => {
|
|
160
|
+
const left = first[key];
|
|
161
|
+
const right = second[key];
|
|
162
|
+
return typeof left === "number" || typeof right === "number"
|
|
163
|
+
? (typeof left === "number" ? left : 0) + (typeof right === "number" ? right : 0)
|
|
164
|
+
: undefined;
|
|
165
|
+
};
|
|
166
|
+
const cachedInputTokens = sumOptional("cachedInputTokens");
|
|
167
|
+
const cacheReadInputTokens = sumOptional("cacheReadInputTokens");
|
|
168
|
+
const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens");
|
|
169
|
+
const reasoningOutputTokens = sumOptional("reasoningOutputTokens");
|
|
170
|
+
const inputTokens = first.inputTokens + second.inputTokens;
|
|
171
|
+
const outputTokens = first.outputTokens + second.outputTokens;
|
|
172
|
+
return {
|
|
173
|
+
inputTokens,
|
|
174
|
+
outputTokens,
|
|
175
|
+
totalTokens: inputTokens + outputTokens,
|
|
176
|
+
...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
|
|
177
|
+
...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
|
|
178
|
+
...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
|
|
179
|
+
...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
|
|
180
|
+
...(first.estimated || second.estimated ? { estimated: true } : {}),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Preserve normal terminals, but withhold one suspicious no-tool terminal for a bounded re-ask. */
|
|
185
|
+
export async function* guardTerminalEventStream(options: GuardedEventStreamOptions): AsyncGenerator<AdapterEvent> {
|
|
186
|
+
const maxContinuations = Math.max(0, Math.min(2, Math.floor(options.maxAutoContinuations ?? 1)));
|
|
187
|
+
let parsed = options.parsed;
|
|
188
|
+
let continuations = 0;
|
|
189
|
+
let accumulatedUsage: OcxUsage | undefined;
|
|
190
|
+
let source: AsyncIterable<AdapterEvent> = options.firstEvents;
|
|
191
|
+
|
|
192
|
+
while (true) {
|
|
193
|
+
const seen: AdapterEvent[] = [];
|
|
194
|
+
let terminalSeen = false;
|
|
195
|
+
for await (const event of source) {
|
|
196
|
+
if (event.type === "done") {
|
|
197
|
+
terminalSeen = true;
|
|
198
|
+
const analysis = options.adapterName === "anthropic"
|
|
199
|
+
? analyzeTerminalTurn(parsed, seen)
|
|
200
|
+
: { decision: "pass" as const };
|
|
201
|
+
const normalStop = event.stopReason !== "max_tokens" && event.stopReason !== "content_filter";
|
|
202
|
+
if (normalStop && analysis.decision === "continue" && continuations < maxContinuations) {
|
|
203
|
+
accumulatedUsage = mergeUsage(accumulatedUsage, event.usage);
|
|
204
|
+
continuations += 1;
|
|
205
|
+
parsed = buildContinuationRequest(parsed, seen);
|
|
206
|
+
yield { type: "assistant_boundary" };
|
|
207
|
+
try {
|
|
208
|
+
source = await options.continuation(parsed);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
yield { type: "error", message: error instanceof Error ? error.message : String(error) };
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
const usage = mergeUsage(accumulatedUsage, event.usage);
|
|
216
|
+
yield usage ? { ...event, usage } : event;
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (event.type === "incomplete" || event.type === "error") {
|
|
220
|
+
terminalSeen = true;
|
|
221
|
+
const usage = mergeUsage(accumulatedUsage, event.usage);
|
|
222
|
+
yield usage ? { ...event, usage } : event;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
seen.push(event);
|
|
226
|
+
yield event;
|
|
227
|
+
}
|
|
228
|
+
if (!terminalSeen) return;
|
|
229
|
+
}
|
|
230
|
+
}
|