@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.
Files changed (49) hide show
  1. package/README.md +4 -4
  2. package/gui/dist/assets/index-CMip1DzF.css +1 -0
  3. package/gui/dist/assets/index-cydcmbzC.js +52 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +2 -2
  6. package/src/adapters/cursor/arg-normalize.ts +23 -7
  7. package/src/adapters/cursor/live-transport.ts +26 -14
  8. package/src/adapters/cursor/native-exec-fs.ts +1 -1
  9. package/src/adapters/cursor/native-exec-network.ts +1 -1
  10. package/src/adapters/cursor/native-exec-shell.ts +1 -1
  11. package/src/adapters/cursor/protobuf-events.ts +72 -13
  12. package/src/adapters/cursor/protobuf-request.ts +82 -11
  13. package/src/adapters/cursor/request-builder.ts +35 -11
  14. package/src/adapters/cursor/tool-definitions.ts +175 -30
  15. package/src/adapters/openai-chat.ts +28 -7
  16. package/src/adapters/openai-responses.ts +150 -4
  17. package/src/bridge.ts +20 -1
  18. package/src/claude/outbound.ts +91 -6
  19. package/src/codex/auth-api.ts +12 -25
  20. package/src/codex/auth-context.ts +48 -3
  21. package/src/codex/catalog/provider-fetch.ts +56 -24
  22. package/src/codex/model-cache.ts +23 -0
  23. package/src/codex/quota.ts +120 -0
  24. package/src/codex/routing.ts +178 -9
  25. package/src/config.ts +56 -1
  26. package/src/providers/openai-sidecar.ts +8 -1
  27. package/src/providers/openai-tiers.ts +18 -0
  28. package/src/server/adapter-resolve.ts +24 -10
  29. package/src/server/auth-cors.ts +3 -0
  30. package/src/server/chat-completions.ts +4 -0
  31. package/src/server/claude-messages.ts +4 -0
  32. package/src/server/index.ts +3 -1
  33. package/src/server/live.ts +56 -0
  34. package/src/server/memory-watchdog.ts +1 -1
  35. package/src/server/responses/compact.ts +40 -10
  36. package/src/server/responses/core.ts +180 -26
  37. package/src/server/responses/terminal-guard.ts +230 -0
  38. package/src/service.ts +113 -30
  39. package/src/types.ts +52 -0
  40. package/src/usage/expected-prices.ts +12 -0
  41. package/src/web-search/anthropic-executor.ts +3 -1
  42. package/src/web-search/index.ts +7 -1
  43. package/src/web-search/loop.ts +17 -3
  44. package/README.ja.md +0 -445
  45. package/README.ko.md +0 -435
  46. package/README.ru.md +0 -486
  47. package/README.zh-CN.md +0 -411
  48. package/gui/dist/assets/index-B-cheu55.js +0 -52
  49. 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
- if (route.provider.adapter === "openai-responses") {
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
- const compactionItem = (json.output ?? []).find(
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
- const summary = compactionItem?.encrypted_content
336
- ? decodeCompactionSummary(compactionItem.encrypted_content) ?? ""
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, { threadId })
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, { threadId });
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, { threadId });
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
- const routedCompaction = parsed._compactionRequest === true && !("passthrough" in adapter && adapter.passthrough);
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
- if (weeklyRaw || monthlyRaw) {
1160
- const { updateAccountQuota } = await import("../../codex/auth-api");
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: [primaryResetRaw, secondaryResetRaw, monthlyResetRaw].filter(Boolean),
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 eventStream = activeAdapter.parseStream(upstreamResponse);
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
- events = await activeAdapter.parseResponse(upstreamResponse);
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
+ }