@bitkyc08/opencodex 2.87.0 → 2.88.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/{App-ZfYbLHkB.js → App-CHC89EXQ.js} +3 -3
- package/gui/dist/assets/{Tray-DCd-A1az.js → Tray-B5bC7AWL.js} +1 -1
- package/gui/dist/assets/index-B5bsvxor.js +94 -0
- package/gui/dist/assets/{quota-summary-ChvmKGOk.js → quota-summary-D8y2wTsB.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic-rejection-memo.ts +71 -0
- package/src/adapters/anthropic-replay.ts +135 -0
- package/src/adapters/anthropic-tool-catalog.ts +11 -0
- package/src/adapters/anthropic.ts +76 -45
- package/src/adapters/google-progress-history.ts +53 -0
- package/src/adapters/google.ts +17 -1
- package/src/bridge/internal.ts +6 -2
- package/src/bridge/response-json.ts +2 -2
- package/src/bridge/sse.ts +7 -6
- package/src/claude/inbound-content-options.ts +66 -0
- package/src/claude/outbound.ts +13 -5
- package/src/cli/cross-home-owner.ts +16 -2
- package/src/cli/loopback-family.ts +31 -0
- package/src/codex/catalog/routed-gather.ts +3 -1
- package/src/codex/websocket-activity.ts +161 -0
- package/src/codex/websocket-registry.ts +25 -1
- package/src/config/opencode-zen-upgrade.ts +9 -8
- package/src/generated/compatibility-version.json +88 -52
- package/src/images/loop.ts +72 -21
- package/src/lib/request-failure-model.ts +2 -0
- package/src/oauth/generic-account-failover.ts +7 -4
- package/src/protocols/encoders/adapter-events.ts +5 -5
- package/src/protocols/encoders/messages.ts +2 -2
- package/src/providers/opencode-zen-rate-limit.ts +7 -2
- package/src/responses/reasoning-replay-cache.ts +50 -4
- package/src/routing/analytics.ts +7 -15
- package/src/server/claude-messages.ts +2 -1
- package/src/server/index/live-sideband.ts +3 -2
- package/src/server/index/serve-options.ts +11 -5
- package/src/server/index/websocket-handler.ts +18 -4
- package/src/server/index.ts +7 -7
- package/src/server/lifecycle.ts +13 -1
- package/src/server/messages-native.ts +16 -3
- package/src/server/responses/adapter-continuation.ts +34 -18
- package/src/server/responses/adapter-delivery.ts +53 -6
- package/src/server/responses/adapter-dispatch.ts +27 -1
- package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
- package/src/server/responses/combo-stream-preflight.ts +3 -2
- package/src/server/responses/compaction-recovery.ts +2 -0
- package/src/server/responses/core-codex-account.ts +2 -2
- package/src/server/responses/core-combo.ts +3 -2
- package/src/server/responses/core-lifetime.ts +3 -2
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core-replay.ts +5 -1
- package/src/server/responses/passthrough-execution.ts +3 -2
- package/src/server/responses/request-prepare.ts +1 -0
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/response-tool-ownership.ts +21 -0
- package/src/server/responses/sidecar-execution.ts +43 -7
- package/src/server/responses/sidecar-send-budget.ts +2 -2
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/ws-bridge.ts +2 -0
- package/src/types/opencode-zen.ts +9 -3
- package/src/types/request.ts +10 -2
- package/src/types/wire.ts +4 -2
- package/src/types.ts +1 -0
- package/src/usage/summary.ts +9 -3
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/web-search/anthropic-history.ts +59 -0
- package/src/web-search/loop.ts +181 -68
- package/src/web-search/progress-stream.ts +11 -1
- package/gui/dist/assets/index-CfYDMj34.js +0 -94
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { captureAnthropicRejectionPayloads, keepAnthropicReplayBlock } from "./anthropic-rejection-memo";
|
|
2
|
+
import { anthropicDeclaredTools } from "./anthropic-tool-catalog";
|
|
3
|
+
import { anthropicReplayContent, createAnthropicReplayCapture } from "./anthropic-replay";
|
|
1
4
|
import { withAnthropicProgressUpdates } from "./anthropic-progress";
|
|
2
5
|
import type { IncomingMeta, ProviderAdapter } from "./base";
|
|
3
6
|
import { createAdapterTierMetadata, type AdapterTierMetadata } from "../providers/fastwire";
|
|
@@ -17,7 +20,7 @@ import type {
|
|
|
17
20
|
OcxToolResultMessage,
|
|
18
21
|
OcxUsage,
|
|
19
22
|
} from "../types";
|
|
20
|
-
import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName
|
|
23
|
+
import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName } from "../types";
|
|
21
24
|
import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION, applyClaudeToolPrefix, stripClaudeToolPrefix } from "../oauth/anthropic";
|
|
22
25
|
import { parseDataUrl } from "./image";
|
|
23
26
|
import { enforceAnthropicImageLimits } from "./anthropic-image-guard";
|
|
@@ -283,7 +286,7 @@ function normalizeTtlOrdering(body: Record<string, unknown>): void {
|
|
|
283
286
|
}
|
|
284
287
|
}
|
|
285
288
|
|
|
286
|
-
function isLikelyRealAnthropicThinkingSignature(signature: string | undefined): signature is string {
|
|
289
|
+
export function isLikelyRealAnthropicThinkingSignature(signature: string | undefined): signature is string {
|
|
287
290
|
if (typeof signature !== "string" || signature.length < 16) return false;
|
|
288
291
|
if (/^(fc|call|msg|rs|resp|reasoning|item|ws|tool|func|function)[-_]/i.test(signature)) return false;
|
|
289
292
|
return /^[A-Za-z0-9+/_=-]+$/.test(signature);
|
|
@@ -522,13 +525,13 @@ function streamedToolArgumentsParse(assembled: string): boolean {
|
|
|
522
525
|
}
|
|
523
526
|
}
|
|
524
527
|
|
|
525
|
-
/** A
|
|
526
|
-
function
|
|
528
|
+
/** A server tool's complete streamed input; undefined when it cannot be recovered. */
|
|
529
|
+
function serverToolInput(json: string): Record<string, unknown> | undefined {
|
|
527
530
|
try {
|
|
528
|
-
const
|
|
529
|
-
return
|
|
531
|
+
const input = JSON.parse(json) as unknown;
|
|
532
|
+
return isAnthropicRecord(input) ? input : undefined;
|
|
530
533
|
} catch {
|
|
531
|
-
return
|
|
534
|
+
return undefined;
|
|
532
535
|
}
|
|
533
536
|
}
|
|
534
537
|
|
|
@@ -735,7 +738,16 @@ function orphanToolResultContent(msg: OcxToolResultMessage): string | unknown[]
|
|
|
735
738
|
function messagesToAnthropicFormat(
|
|
736
739
|
parsed: OcxParsedRequest,
|
|
737
740
|
toolNames: { toWire: (name: string) => string },
|
|
741
|
+
serverReplay?: ReadonlyMap<string, readonly Record<string, unknown>[]>,
|
|
738
742
|
): { system: string | undefined; messages: unknown[] } {
|
|
743
|
+
// The client's server-tool exchanges go back ahead of the block they preceded. Their anchors are
|
|
744
|
+
// unique in the caller's history (replayedAnthropicServerBlocks drops repeated ones); each is used once.
|
|
745
|
+
const pendingReplay = new Map(serverReplay);
|
|
746
|
+
const replayed = (anchor: string) => {
|
|
747
|
+
const blocks = pendingReplay.get(anchor) ?? [];
|
|
748
|
+
pendingReplay.delete(anchor);
|
|
749
|
+
return blocks;
|
|
750
|
+
};
|
|
739
751
|
// One allocator for the whole request: a tool_result must resolve to the SAME wire id its
|
|
740
752
|
// call got, and two distinct raw ids must never collapse into one. Conforming ids are claimed
|
|
741
753
|
// first so a rewritten id can never squat on an id another call legitimately owns.
|
|
@@ -750,8 +762,8 @@ function messagesToAnthropicFormat(
|
|
|
750
762
|
}
|
|
751
763
|
}
|
|
752
764
|
const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools(
|
|
753
|
-
parsed
|
|
754
|
-
|
|
765
|
+
anthropicDeclaredTools(parsed),
|
|
766
|
+
undefined,
|
|
755
767
|
tool => toolNames.toWire(namespacedToolName(tool.namespace, tool.name)),
|
|
756
768
|
);
|
|
757
769
|
const systemParts = [...(parsed.context.systemPrompt ?? []), ...(toolCatalogNudge ? [toolCatalogNudge] : [])];
|
|
@@ -781,6 +793,7 @@ function messagesToAnthropicFormat(
|
|
|
781
793
|
case "assistant": {
|
|
782
794
|
const aMsg = msg as OcxAssistantMessage;
|
|
783
795
|
const preface: unknown[] = [];
|
|
796
|
+
|
|
784
797
|
const toolUses: unknown[] = [];
|
|
785
798
|
const originalOrder: unknown[] = [];
|
|
786
799
|
const appendPreface = (block: unknown) => { preface.push(block); originalOrder.push(block); };
|
|
@@ -789,9 +802,13 @@ function messagesToAnthropicFormat(
|
|
|
789
802
|
for (const part of aMsg.content) {
|
|
790
803
|
if (part.type === "text") {
|
|
791
804
|
const text = (part as OcxTextContent).text;
|
|
805
|
+
for (const b of replayed(`text:${text}`)) appendPreface(b);
|
|
792
806
|
if (text) appendPreface({ type: "text", text });
|
|
793
807
|
} else if (part.type === "thinking") {
|
|
794
808
|
const t = part as OcxThinkingContent;
|
|
809
|
+
// Server blocks that preceded this reasoning keep their place, even when it is dropped below.
|
|
810
|
+
for (const data of t.redacted ?? []) for (const b of replayed(`redacted:${data}`)) appendPreface(b);
|
|
811
|
+
for (const b of replayed(`thinking:${t.signature}`)) appendPreface(b);
|
|
795
812
|
// History minted under another serving identity (or already rejected as opaque) is not
|
|
796
813
|
// this destination's to verify: drop its opaque blocks, as the Responses passthrough does.
|
|
797
814
|
if (parsed._stripReasoningEncryptedContent === true) continue;
|
|
@@ -806,6 +823,7 @@ function messagesToAnthropicFormat(
|
|
|
806
823
|
}
|
|
807
824
|
} else if (part.type === "toolCall") {
|
|
808
825
|
const tc = part as OcxToolCall;
|
|
826
|
+
for (const b of replayed(`call:${tc.id}`)) appendPreface(b);
|
|
809
827
|
const flatName = namespacedToolName(tc.namespace, tc.name);
|
|
810
828
|
// Normalized here, and identically for the matching tool_result above, so a history
|
|
811
829
|
// replayed from another provider path keeps its call/result pairing (#1767).
|
|
@@ -824,9 +842,15 @@ function messagesToAnthropicFormat(
|
|
|
824
842
|
}
|
|
825
843
|
// Anthropic treats text/thinking after tool_use as ending the tool turn, which makes
|
|
826
844
|
// earlier tool_use ids look unpaired (#620 / common multi-step history shape).
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
845
|
+
const captured = anthropicReplayContent(aMsg);
|
|
846
|
+
if (captured) {
|
|
847
|
+
const expected = toolUses as Array<{ id: string; name: string }>;
|
|
848
|
+
const actual = captured.filter(block => block.type === "tool_use");
|
|
849
|
+
if (actual.length !== expected.length || actual.some((block, index) => block.id !== expected[index]?.id || block.name !== expected[index]?.name)) {
|
|
850
|
+
throw new Error("Anthropic search replay tool identity changed");
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
const content = (captured ?? (signed ? originalOrder : [...preface, ...toolUses])).filter(block => keepAnthropicReplayBlock(parsed, block as Record<string, unknown>));
|
|
830
854
|
if (content.length === 0) break;
|
|
831
855
|
messages.push({ role: "assistant", content });
|
|
832
856
|
if (toolUseIds.length > 0) {
|
|
@@ -889,9 +913,7 @@ function messagesToAnthropicFormat(
|
|
|
889
913
|
|
|
890
914
|
function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: (name: string) => string }): unknown[] | undefined {
|
|
891
915
|
if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
|
|
892
|
-
const tools =
|
|
893
|
-
? parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools))
|
|
894
|
-
: parsed.context.tools;
|
|
916
|
+
const tools = anthropicDeclaredTools(parsed);
|
|
895
917
|
if (tools.length === 0) return undefined;
|
|
896
918
|
const converted = tools.map(t => ({
|
|
897
919
|
name: toolNames.toWire(namespacedToolName(t.namespace, t.name)),
|
|
@@ -998,7 +1020,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
|
|
|
998
1020
|
throw new Error("anthropic provider requires a non-empty apiKey (authMode: key)");
|
|
999
1021
|
}
|
|
1000
1022
|
|
|
1001
|
-
|
|
1023
|
+
// Only api.anthropic.com runs Anthropic's server tools, so only it gets their history back.
|
|
1024
|
+
const { system, messages } = messagesToAnthropicFormat(parsed, toolNames,
|
|
1025
|
+
usesNativeAnthropicEndpoint(provider) ? parsed._anthropicServerReplay : undefined);
|
|
1002
1026
|
// Before image normalization, so the framing block is present for every downstream pass.
|
|
1003
1027
|
if (isAgentRouterEndpoint(provider.baseUrl)) applyAgentRouterLanguageFraming(messages);
|
|
1004
1028
|
// Primary image layer: resize/re-encode to fit Anthropic limits without dropping
|
|
@@ -1210,7 +1234,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
|
|
|
1210
1234
|
enforceCacheControlLimit(body, explicitLimit);
|
|
1211
1235
|
normalizeTtlOrdering(body);
|
|
1212
1236
|
|
|
1213
|
-
|
|
1237
|
+
const request = {
|
|
1214
1238
|
url,
|
|
1215
1239
|
method: "POST",
|
|
1216
1240
|
headers,
|
|
@@ -1224,6 +1248,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
|
|
|
1224
1248
|
fastSpeed?.value ?? null,
|
|
1225
1249
|
),
|
|
1226
1250
|
};
|
|
1251
|
+
captureAnthropicRejectionPayloads(request, body, incoming?.translatorBudget);
|
|
1252
|
+
return request;
|
|
1227
1253
|
},
|
|
1228
1254
|
|
|
1229
1255
|
async *parseStream(
|
|
@@ -1236,6 +1262,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
|
|
|
1236
1262
|
return;
|
|
1237
1263
|
}
|
|
1238
1264
|
|
|
1265
|
+
const replayCapture = createAnthropicReplayCapture(response, budget);
|
|
1239
1266
|
const budgetEncoder = new TextEncoder();
|
|
1240
1267
|
let currentBlockType = "";
|
|
1241
1268
|
let currentToolCallId = "";
|
|
@@ -1270,11 +1297,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
|
|
|
1270
1297
|
yield anthropicContentFilterIncomplete(pendingStopReason, usageFromAnthropic(pendingUsage));
|
|
1271
1298
|
return;
|
|
1272
1299
|
}
|
|
1273
|
-
|
|
1300
|
+
const done: AdapterEvent = {
|
|
1274
1301
|
type: "done",
|
|
1275
1302
|
usage: usageFromAnthropic(pendingUsage),
|
|
1276
1303
|
...(pendingStopReason ? { stopReason: pendingStopReason } : {}),
|
|
1277
1304
|
};
|
|
1305
|
+
replayCapture?.finish(done);
|
|
1306
|
+
yield done;
|
|
1278
1307
|
};
|
|
1279
1308
|
|
|
1280
1309
|
try {
|
|
@@ -1294,6 +1323,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
|
|
|
1294
1323
|
try {
|
|
1295
1324
|
parsed = JSON.parse(payload);
|
|
1296
1325
|
} catch {
|
|
1326
|
+
replayCapture?.invalidate();
|
|
1297
1327
|
debugDroppedFrame("anthropic", payload);
|
|
1298
1328
|
continue;
|
|
1299
1329
|
}
|
|
@@ -1301,10 +1331,12 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
|
|
|
1301
1331
|
// it and the `data.type` read below crashed the stream. Drop a non-record frame the same
|
|
1302
1332
|
// way an unparseable one is dropped, so the message_stop check still governs the outcome.
|
|
1303
1333
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1334
|
+
replayCapture?.invalidate();
|
|
1304
1335
|
debugDroppedFrame("anthropic", payload);
|
|
1305
1336
|
continue;
|
|
1306
1337
|
}
|
|
1307
1338
|
const data = parsed as Record<string, unknown>;
|
|
1339
|
+
replayCapture?.accept({ ...data, type: record.event || data.type });
|
|
1308
1340
|
|
|
1309
1341
|
switch (record.event || data.type) {
|
|
1310
1342
|
case "message_start": {
|
|
@@ -1317,6 +1349,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
|
|
|
1317
1349
|
case "content_block_start": {
|
|
1318
1350
|
const block = data.content_block as { type: string; id?: string; name?: unknown; data?: string; thinking?: string; tool_use_id?: unknown; content?: unknown } | undefined;
|
|
1319
1351
|
if (!block) break;
|
|
1352
|
+
// Even an unmodeled server tool or empty block is semantic progress.
|
|
1353
|
+
// Fence replays before a later refusal; unknown blocks must not look like pings.
|
|
1354
|
+
yield { type: "heartbeat", replayUnsafe: true };
|
|
1320
1355
|
currentBlockType = block.type;
|
|
1321
1356
|
if (block.type === "thinking") {
|
|
1322
1357
|
// Preserve even a display:omitted block boundary. The bridge can then
|
|
@@ -1336,36 +1371,26 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
|
|
|
1336
1371
|
// no result in this response and must not surface as a completed search.
|
|
1337
1372
|
serverSearch = { id: block.id, json: "", streaming: true };
|
|
1338
1373
|
}
|
|
1339
|
-
if (block.type === "web_search_tool_result" && typeof block.tool_use_id === "string") {
|
|
1374
|
+
if ((block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result") && typeof block.tool_use_id === "string") {
|
|
1375
|
+
// The begin/end pair the sidecar emits, plus both blocks verbatim so the Claude client holds a
|
|
1376
|
+
// replayable copy. Without this response's matching call and complete input, drop the
|
|
1377
|
+
// exchange instead of inventing a call that could poison the client's next request.
|
|
1378
|
+
const fetch = block.type === "web_fetch_tool_result";
|
|
1379
|
+
const input = serverSearch?.id === block.tool_use_id ? serverToolInput(serverSearch.json) : undefined;
|
|
1380
|
+
if (!input) break;
|
|
1340
1381
|
const hits = Array.isArray(block.content) ? block.content as Array<{ url?: unknown; title?: unknown }> : undefined;
|
|
1341
|
-
// The begin/end pair the sidecar emits. A result whose call came in an earlier response has no query here.
|
|
1342
|
-
const query = serverSearch?.id === block.tool_use_id ? serverToolInputField(serverSearch.json, "query") : "";
|
|
1343
1382
|
yield { type: "web_search_call_begin", id: block.tool_use_id };
|
|
1344
1383
|
yield {
|
|
1345
1384
|
type: "web_search_call_end",
|
|
1346
1385
|
id: block.tool_use_id,
|
|
1347
|
-
queries: query ? [query] : [],
|
|
1348
|
-
// A failed
|
|
1349
|
-
status: hits ? "completed" : "failed",
|
|
1386
|
+
queries: typeof input.query === "string" && input.query.length > 0 ? [input.query] : [],
|
|
1387
|
+
// A failed call carries one error object instead of the hit list / fetched page.
|
|
1388
|
+
status: (fetch ? (block.content as { type?: unknown } | undefined)?.type === "web_fetch_result" : hits) ? "completed" : "failed",
|
|
1350
1389
|
sources: (hits ?? []).flatMap(h => typeof h.url === "string"
|
|
1351
1390
|
? [{ url: h.url, ...(typeof h.title === "string" ? { title: h.title } : {}) }] : []),
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
if (block.type === "web_fetch_tool_result" && typeof block.tool_use_id === "string") {
|
|
1356
|
-
// Relayed as the same cell, opened at the result for the same reason; the result is
|
|
1357
|
-
// carried verbatim (a web_fetch_result document or a web_fetch_tool_error).
|
|
1358
|
-
const result = block.content ?? { type: "web_fetch_tool_error", error_code: "unavailable" };
|
|
1359
|
-
const url = (result as { url?: unknown }).url;
|
|
1360
|
-
yield { type: "web_search_call_begin", id: block.tool_use_id };
|
|
1361
|
-
yield {
|
|
1362
|
-
type: "web_search_call_end",
|
|
1363
|
-
id: block.tool_use_id,
|
|
1364
|
-
queries: [],
|
|
1365
|
-
status: (result as { type?: unknown }).type === "web_fetch_result" ? "completed" : "failed",
|
|
1366
|
-
fetch: {
|
|
1367
|
-
url: typeof url === "string" ? url : serverSearch?.id === block.tool_use_id ? serverToolInputField(serverSearch.json, "url") : "",
|
|
1368
|
-
result,
|
|
1391
|
+
anthropic: {
|
|
1392
|
+
use: { type: "server_tool_use", id: block.tool_use_id, name: fetch ? "web_fetch" : "web_search", input },
|
|
1393
|
+
result: block as Record<string, unknown>,
|
|
1369
1394
|
},
|
|
1370
1395
|
};
|
|
1371
1396
|
serverSearch = undefined;
|
|
@@ -1415,8 +1440,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
|
|
|
1415
1440
|
yield { type: "tool_call_delta", arguments: delta.partial_json };
|
|
1416
1441
|
} else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && serverSearch?.streaming
|
|
1417
1442
|
&& serverSearch.json.length < 4096) {
|
|
1418
|
-
// Capped:
|
|
1419
|
-
serverSearch.json += delta.partial_json;
|
|
1443
|
+
// Capped: input that cannot be recovered drops its exchange instead of inventing a call.
|
|
1444
|
+
serverSearch.json += delta.partial_json.slice(0, 4096 - serverSearch.json.length);
|
|
1420
1445
|
}
|
|
1421
1446
|
break;
|
|
1422
1447
|
}
|
|
@@ -1463,8 +1488,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
|
|
|
1463
1488
|
break;
|
|
1464
1489
|
}
|
|
1465
1490
|
case "error": {
|
|
1466
|
-
const err = data.error as { message?:
|
|
1467
|
-
yield { type: "error", message: err?.message
|
|
1491
|
+
const err = data.error as { message?: unknown; type?: unknown; code?: unknown; status?: unknown } | undefined;
|
|
1492
|
+
yield { type: "error", message: typeof err?.message === "string" ? err.message : "Anthropic error",
|
|
1493
|
+
...(typeof err?.type === "string" ? { errorType: err.type } : {}),
|
|
1494
|
+
...(typeof err?.code === "string" ? { code: err.code } : {}),
|
|
1495
|
+
...(typeof err?.status === "number" && Number.isInteger(err.status) && err.status >= 400 && err.status <= 599
|
|
1496
|
+
? { status: err.status } : {}),
|
|
1497
|
+
};
|
|
1468
1498
|
return;
|
|
1469
1499
|
}
|
|
1470
1500
|
}
|
|
@@ -1483,6 +1513,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
|
|
|
1483
1513
|
// one-terminal-event contract for consumers that keep draining the generator.
|
|
1484
1514
|
return;
|
|
1485
1515
|
} finally {
|
|
1516
|
+
replayCapture?.discard();
|
|
1486
1517
|
if (currentToolCallId) budget.closeCall(currentToolCallId);
|
|
1487
1518
|
}
|
|
1488
1519
|
if (!emittedDone) {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini progress-text history trimming.
|
|
3
|
+
*
|
|
4
|
+
* Gemini models, observed on Gemini 4 Argon over Cloud Code Assist, sometimes skip their thinking
|
|
5
|
+
* channel on a tool-calling turn and plan in the visible text instead. The provider sends that
|
|
6
|
+
* planning as an ordinary text part without a thought marker, for example a progress line in the
|
|
7
|
+
* user's language followed by "Let's read google.ts 580-1700 first." Once one turn like that is
|
|
8
|
+
* replayed as history, the model copies the pattern on the turns that follow, so the leak grows
|
|
9
|
+
* through the rest of the session. Replaying the same 300K-token request with the leaked tails
|
|
10
|
+
* removed from the history stopped the leak, while stronger system-prompt wording did not.
|
|
11
|
+
*
|
|
12
|
+
* The fix is on the history side only. Text that precedes the tool call of a replayed model turn is
|
|
13
|
+
* intermediate progress, and the Google brevity instruction already asks for a single short line
|
|
14
|
+
* there, so it is replayed as its lead sentence. A turn Codex marks `final_answer` and text after a
|
|
15
|
+
* tool call are replayed whole. Visible output is never rewritten, final
|
|
16
|
+
* answers (turns without tool calls) are replayed in full, and the split is punctuation-based so
|
|
17
|
+
* it works the same in every language without matching any wording.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Sentence end: ASCII terminal punctuation followed by whitespace or the end (so "google.ts" and
|
|
22
|
+
* "3.5" are not boundaries), a script's own sentence terminator (CJK 。!?, Devanagari
|
|
23
|
+
* ।॥, Arabic ۔؟, Armenian ։, Ethiopic ።, Myanmar ။), or a line break. Closing quotes, brackets and
|
|
24
|
+
* Markdown emphasis right after the punctuation belong to the sentence.
|
|
25
|
+
*/
|
|
26
|
+
const SENTENCE_END = /[.!?]["'\u201D\u2019)\]*_`]*(?=\s|$)|[\u3002\uFF01\uFF1F\u0964\u0965\u06D4\u061F\u0589\u1362\u104B]["'\u201D\u2019\u300D\u300F)\]*_`]*|\n/g;
|
|
27
|
+
|
|
28
|
+
/** A boundary that only closes a leading list marker ("1.", "a.") or a common abbreviation ("e.g."). */
|
|
29
|
+
const NOT_A_SENTENCE = /^(?:\d{1,3}|[a-z])\.$|(?:^|\s)(?:e\.g|i\.e|etc|vs)\.$/i;
|
|
30
|
+
|
|
31
|
+
/** Whether replayed progress text for this model is trimmed to its lead sentence. */
|
|
32
|
+
export function trimsGeminiProgressHistory(modelId: string): boolean {
|
|
33
|
+
return /gemini/i.test(modelId);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The lead sentence of a replayed progress text, or undefined when nothing usable remains.
|
|
38
|
+
* Text without a sentence boundary is already a single sentence and is returned whole.
|
|
39
|
+
*/
|
|
40
|
+
export function geminiProgressLead(text: string): string | undefined {
|
|
41
|
+
const trimmed = text.trim();
|
|
42
|
+
if (trimmed.length === 0) return undefined;
|
|
43
|
+
SENTENCE_END.lastIndex = 0;
|
|
44
|
+
let end = trimmed.length;
|
|
45
|
+
for (let match = SENTENCE_END.exec(trimmed); match; match = SENTENCE_END.exec(trimmed)) {
|
|
46
|
+
const boundary = match.index + match[0].length;
|
|
47
|
+
if (!NOT_A_SENTENCE.test(trimmed.slice(0, boundary).trim())) { end = boundary; break; }
|
|
48
|
+
}
|
|
49
|
+
const lead = trimmed.slice(0, end).trim();
|
|
50
|
+
// A code-fence opener is not a sentence; replaying it alone would leave an unclosed fence.
|
|
51
|
+
if (lead.length === 0 || lead.startsWith("```")) return undefined;
|
|
52
|
+
return lead;
|
|
53
|
+
}
|
package/src/adapters/google.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
type TranslatorBudget,
|
|
45
45
|
} from "../lib/translator-budget";
|
|
46
46
|
import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
|
|
47
|
+
import { geminiProgressLead, trimsGeminiProgressHistory } from "./google-progress-history";
|
|
47
48
|
import { configuredReasoningEfforts, mapReasoningEffort } from "../reasoning-effort";
|
|
48
49
|
|
|
49
50
|
// Google-family models (Gemini/Vertex/Antigravity) tend to emit long running commentary between
|
|
@@ -466,11 +467,26 @@ function messagesToGeminiFormat(
|
|
|
466
467
|
const aMsg = msg as OcxAssistantMessage;
|
|
467
468
|
const parts: unknown[] = [];
|
|
468
469
|
const toolCalls: Array<{ wireCallId: string; wireName: string }> = [];
|
|
470
|
+
// Progress text before a Gemini tool call replays as its lead sentence, so planning the
|
|
471
|
+
// model leaked into visible text is not copied forward (google-progress-history.ts).
|
|
472
|
+
const trimProgress = trimsGeminiProgressHistory(identityModelId)
|
|
473
|
+
&& aMsg.phase !== "final_answer"
|
|
474
|
+
&& aMsg.content.some(part => part.type === "toolCall");
|
|
475
|
+
let progressLeadKept = false;
|
|
476
|
+
let afterToolCall = false;
|
|
469
477
|
for (const p of aMsg.content) {
|
|
470
478
|
if (p.type === "text") {
|
|
471
|
-
|
|
479
|
+
let text: unknown = (p as OcxTextContent).text;
|
|
480
|
+
if (trimProgress && !afterToolCall) {
|
|
481
|
+
if (progressLeadKept || typeof text !== "string") continue;
|
|
482
|
+
text = geminiProgressLead(text);
|
|
483
|
+
if (text === undefined) continue;
|
|
484
|
+
progressLeadKept = true;
|
|
485
|
+
}
|
|
486
|
+
const textPart = geminiTextPart(text);
|
|
472
487
|
if (textPart) parts.push(textPart);
|
|
473
488
|
} else if (p.type === "toolCall") {
|
|
489
|
+
afterToolCall = true;
|
|
474
490
|
const tc = p as OcxToolCall;
|
|
475
491
|
// Preserve the thought signature on the function-call part so Antigravity/Gemini-3
|
|
476
492
|
// reasoning continuity survives history-driven (stateless) turns, not just same-process
|
package/src/bridge/internal.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AdapterEvent,
|
|
3
|
+
AnthropicServerBlocks,
|
|
3
4
|
OcxMessagePhase,
|
|
4
5
|
OcxProviderContinuationState,
|
|
5
6
|
OcxProviderOpaqueToolCallMetadata,
|
|
@@ -186,9 +187,12 @@ export function adapterFailureFromEvent(event: Extract<AdapterEvent, { type: "er
|
|
|
186
187
|
* This fixes items created from here on. History recorded before it is repaired at the
|
|
187
188
|
* replay boundary by `backfillWebSearchQueries()` in the Responses adapter.
|
|
188
189
|
*/
|
|
189
|
-
export function webSearchAction(queries: string[],
|
|
190
|
+
export function webSearchAction(queries: string[], anthropic?: AnthropicServerBlocks): Record<string, unknown> {
|
|
190
191
|
// Anthropic's server-side web_fetch opens one page instead of searching.
|
|
191
|
-
if (
|
|
192
|
+
if (anthropic?.use.name === "web_fetch") {
|
|
193
|
+
const url = (anthropic.result.content as { url?: unknown } | undefined)?.url ?? (anthropic.use.input as { url?: unknown }).url;
|
|
194
|
+
return { type: "open_page", url: typeof url === "string" ? url : "" };
|
|
195
|
+
}
|
|
192
196
|
const first = queries[0] ?? "";
|
|
193
197
|
return { type: "search", query: first, queries: queries.length > 0 ? queries : [first] };
|
|
194
198
|
}
|
|
@@ -547,9 +547,9 @@ function buildResponseJSONWithBudget(
|
|
|
547
547
|
const safeSources = safeWebSearchSources(e.sources);
|
|
548
548
|
pushOutput({
|
|
549
549
|
type: "web_search_call", id: `ws_${uuid()}`, status: e.status ?? "completed",
|
|
550
|
-
action: webSearchAction(e.queries, e.
|
|
550
|
+
action: webSearchAction(e.queries, e.anthropic),
|
|
551
551
|
...(safeSources.length > 0 ? { sources: safeSources } : {}),
|
|
552
|
-
...(e.
|
|
552
|
+
...(e.anthropic ? { anthropic_blocks: e.anthropic } : {}),
|
|
553
553
|
});
|
|
554
554
|
if (safeSources.length > 0) {
|
|
555
555
|
for (const source of safeSources) {
|
package/src/bridge/sse.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { anthropicProgressItems } from "../responses/anthropic-progress";
|
|
2
2
|
import type {
|
|
3
3
|
AdapterEvent,
|
|
4
|
+
AnthropicServerBlocks,
|
|
4
5
|
OcxMessagePhase,
|
|
5
6
|
OcxProviderContinuationState,
|
|
6
7
|
OcxProviderOpaqueToolCallMetadata,
|
|
@@ -685,15 +686,15 @@ export function bridgeToResponsesSSE(
|
|
|
685
686
|
// leaves a "Searching the web" spinner spinning forever.
|
|
686
687
|
// `sources` rides on the done item (additive field; codex-rs serde ignores unknown fields) so
|
|
687
688
|
// downstream translators (claude outbound) can fill web_search_tool_result content.
|
|
688
|
-
// `
|
|
689
|
-
//
|
|
690
|
-
const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[],
|
|
689
|
+
// `anthropic` (Anthropic's server-side call) rides verbatim as `anthropic_blocks`, for the same
|
|
690
|
+
// claude outbound translator; a web_fetch makes the cell a page open.
|
|
691
|
+
const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], anthropic?: AnthropicServerBlocks) => {
|
|
691
692
|
if (!currentWebSearch) return;
|
|
692
693
|
const item = {
|
|
693
694
|
type: "web_search_call", id: currentWebSearch.itemId, status,
|
|
694
|
-
action: webSearchAction(queries,
|
|
695
|
+
action: webSearchAction(queries, anthropic),
|
|
695
696
|
...(sources && sources.length > 0 ? { sources } : {}),
|
|
696
|
-
...(
|
|
697
|
+
...(anthropic ? { anthropic_blocks: anthropic } : {}),
|
|
697
698
|
};
|
|
698
699
|
emit("response.output_item.done", { output_index: currentWebSearch.outputIndex, item });
|
|
699
700
|
retainFinishedItem(item as OutputItem);
|
|
@@ -1231,7 +1232,7 @@ export function bridgeToResponsesSSE(
|
|
|
1231
1232
|
currentWebSearch = { itemId: wsItemId2, eventId: event.id, outputIndex };
|
|
1232
1233
|
}
|
|
1233
1234
|
const safeSources = safeWebSearchSources(event.sources);
|
|
1234
|
-
closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources, event.
|
|
1235
|
+
closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources, event.anthropic);
|
|
1235
1236
|
// Queue this search's sources for the next assistant message (dedup by URL).
|
|
1236
1237
|
if (safeSources.length > 0) {
|
|
1237
1238
|
for (const source of safeSources) {
|
|
@@ -87,6 +87,72 @@ export function declaredAnthropicServerTools(raw: unknown): Rec[] | undefined {
|
|
|
87
87
|
return tools.length > 0 ? tools : undefined;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Validate original call/result order without inventing inputs or pairing a later call backwards. */
|
|
91
|
+
function replayableServerRun(run: readonly Rec[]): boolean {
|
|
92
|
+
const pending = new Map<string, Rec>();
|
|
93
|
+
const completed = new Set<string>();
|
|
94
|
+
for (const block of run) {
|
|
95
|
+
if (block.type === "server_tool_use") {
|
|
96
|
+
const { id, name, input } = block;
|
|
97
|
+
if (typeof id !== "string" || !id.startsWith("srvtoolu_") || pending.has(id) || completed.has(id)
|
|
98
|
+
|| (name !== "web_search" && name !== "web_fetch") || !isRec(input)) return false;
|
|
99
|
+
const prototype = Object.getPrototypeOf(input);
|
|
100
|
+
const required = input[name === "web_search" ? "query" : "url"];
|
|
101
|
+
if ((prototype !== Object.prototype && prototype !== null)
|
|
102
|
+
|| typeof required !== "string" || required.trim().length === 0) return false;
|
|
103
|
+
pending.set(id, block);
|
|
104
|
+
} else {
|
|
105
|
+
const id = block.tool_use_id;
|
|
106
|
+
if (typeof id !== "string") return false;
|
|
107
|
+
const call = pending.get(id);
|
|
108
|
+
if (!call || block.type !== `${call.name}_tool_result`
|
|
109
|
+
|| (Array.isArray(block.content) && !block.content.every(h => isRec(h) && typeof h.encrypted_content === "string"))) return false;
|
|
110
|
+
pending.delete(id);
|
|
111
|
+
completed.add(id);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return completed.size > 0 && pending.size === 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Completed Anthropic server-tool exchanges (`server_tool_use` + `web_search_tool_result` /
|
|
119
|
+
* `web_fetch_tool_result`) in the caller's assistant history. The Responses body drops them, so
|
|
120
|
+
* an Anthropic destination re-inserts them out of body, keyed by the block that follows each run
|
|
121
|
+
* in its message: `call:<tool_use id>`, `text:<text>`, `thinking:<signature>` or
|
|
122
|
+
* `redacted:<data>`, all of which survive translation. An anchor that occurs more than once in the
|
|
123
|
+
* assistant history (string content included) cannot name one owner, so its run stays dropped,
|
|
124
|
+
* as it was before replay existed. A run with none, an unpaired call, a
|
|
125
|
+
* non-Anthropic id, or a search hit without `encrypted_content` is not replayable and stays dropped.
|
|
126
|
+
*/
|
|
127
|
+
export function replayedAnthropicServerBlocks(raw: unknown): Map<string, Rec[]> | undefined {
|
|
128
|
+
if (!isRec(raw) || !Array.isArray(raw.messages)) return undefined;
|
|
129
|
+
const out = new Map<string, Rec[]>();
|
|
130
|
+
const seen = new Map<string, number>();
|
|
131
|
+
const count = (anchor: string) => seen.set(anchor, (seen.get(anchor) ?? 0) + 1);
|
|
132
|
+
for (const message of raw.messages) {
|
|
133
|
+
if (!isRec(message) || message.role !== "assistant") continue;
|
|
134
|
+
if (typeof message.content === "string") count(`text:${message.content}`);
|
|
135
|
+
if (!Array.isArray(message.content)) continue;
|
|
136
|
+
let run: Rec[] = [];
|
|
137
|
+
for (const block of message.content.filter(isRec)) {
|
|
138
|
+
if (block.type === "server_tool_use" || block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result") {
|
|
139
|
+
// The caller's breakpoints are dropped on this lane, as for the declared tools above.
|
|
140
|
+
run.push({ ...block, cache_control: undefined });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const anchor = block.type === "tool_use" ? `call:${String(block.id)}`
|
|
144
|
+
: block.type === "text" ? `text:${String(block.text)}`
|
|
145
|
+
: block.type === "thinking" ? `thinking:${String(block.signature)}`
|
|
146
|
+
: block.type === "redacted_thinking" ? `redacted:${String(block.data)}` : undefined;
|
|
147
|
+
if (anchor) count(anchor);
|
|
148
|
+
if (anchor && replayableServerRun(run)) out.set(anchor, run);
|
|
149
|
+
run = [];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const anchor of out.keys()) if ((seen.get(anchor) ?? 0) > 1) out.delete(anchor);
|
|
153
|
+
return out.size > 0 ? out : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
90
156
|
export function toolChoiceToResponses(choice: unknown, body: Rec): void {
|
|
91
157
|
if (!isRec(choice)) return;
|
|
92
158
|
if (choice.disable_parallel_tool_use === true) body.parallel_tool_calls = false;
|
package/src/claude/outbound.ts
CHANGED
|
@@ -223,16 +223,24 @@ export function sanitizeWebSearchInput(input: unknown): Rec {
|
|
|
223
223
|
/**
|
|
224
224
|
* Map a Responses `web_search_call` item to its Anthropic pair: the server_tool_use
|
|
225
225
|
* input (query/queries) and the web_search_tool_result content (hits, or the error
|
|
226
|
-
* object when the search failed). An item carrying `
|
|
227
|
-
* server-side
|
|
228
|
-
*
|
|
226
|
+
* object when the search failed). An item carrying `anthropic_blocks` came from Anthropic's
|
|
227
|
+
* own server-side call and maps back to those blocks verbatim (`srvtoolu_` id,
|
|
228
|
+
* `encrypted_content`, web_fetch), so the client can replay them on its next turn.
|
|
229
|
+
* Shared by the SSE, JSON and direct translation paths.
|
|
229
230
|
*/
|
|
230
231
|
export function webSearchPairFromItem(item: Rec): {
|
|
231
|
-
id: string; name:
|
|
232
|
+
id: string; name: string; resultType: string; input: Rec; resultContent: unknown; completed: boolean;
|
|
232
233
|
} {
|
|
233
234
|
const action = isRec(item.action) ? item.action : {};
|
|
234
|
-
const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`;
|
|
235
235
|
const completed = item.status !== "failed";
|
|
236
|
+
const { use, result } = isRec(item.anthropic_blocks) ? item.anthropic_blocks : {};
|
|
237
|
+
if (isRec(use) && isRec(result) && typeof use.id === "string" && isRec(use.input)
|
|
238
|
+
&& (use.name === "web_search" || use.name === "web_fetch")
|
|
239
|
+
&& result.tool_use_id === use.id && result.type === `${use.name}_tool_result`) {
|
|
240
|
+
return { id: use.id, name: use.name, resultType: result.type, input: use.input, resultContent: result.content, completed };
|
|
241
|
+
}
|
|
242
|
+
const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`;
|
|
243
|
+
// An item emitted before `anthropic_blocks` carried a server web_fetch as `fetch_result`.
|
|
236
244
|
if (item.fetch_result !== undefined) {
|
|
237
245
|
const input = { url: typeof action.url === "string" ? action.url : "" };
|
|
238
246
|
return { id, name: "web_fetch", resultType: "web_fetch_tool_result", input, resultContent: item.fetch_result, completed };
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from "../server/proxy-liveness";
|
|
27
27
|
import { directLocalHttpFetch } from "../server/direct-local-http";
|
|
28
28
|
import type { RuntimePortState } from "../config/process-state";
|
|
29
|
+
import { loopbackFamilyAvailability } from "./loopback-family";
|
|
29
30
|
|
|
30
31
|
const MAX_HINT_BYTES = 256 * 1024;
|
|
31
32
|
// Far above any real Grok config; discovery must not stall startup when Grok sync is off.
|
|
@@ -128,13 +129,19 @@ function loopbackPort(raw: string | null): number | null {
|
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
type CrossHomeOwnerOptions = {
|
|
133
|
+
homeDir?: string;
|
|
134
|
+
io?: LivenessIo;
|
|
135
|
+
loopbackAvailabilityFn?: typeof loopbackFamilyAvailability;
|
|
136
|
+
};
|
|
137
|
+
|
|
131
138
|
/** Returns only a different process with an identity-checked /healthz response. */
|
|
132
|
-
export async function findCrossHomeOwner(options:
|
|
139
|
+
export async function findCrossHomeOwner(options: CrossHomeOwnerOptions = {}): Promise<number | null> {
|
|
133
140
|
const verdict = await findCrossHomeOwnerDetailed(options);
|
|
134
141
|
return verdict.kind === "owner" ? verdict.port : null;
|
|
135
142
|
}
|
|
136
143
|
|
|
137
|
-
export async function findCrossHomeOwnerDetailed(options:
|
|
144
|
+
export async function findCrossHomeOwnerDetailed(options: CrossHomeOwnerOptions = {}): Promise<CrossHomeOwnerVerdict> {
|
|
138
145
|
const io = options.io ?? {};
|
|
139
146
|
const nowFn = io.nowFn ?? Date.now;
|
|
140
147
|
const timeoutMs = io.timeoutMs ?? START_OWNERSHIP_LIVENESS.timeoutMs ?? 1_500;
|
|
@@ -205,6 +212,8 @@ export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io
|
|
|
205
212
|
}
|
|
206
213
|
|
|
207
214
|
const queue = [...candidates];
|
|
215
|
+
const availability = new Map<string, ReturnType<typeof loopbackFamilyAvailability>>();
|
|
216
|
+
const checkAvailability = options.loopbackAvailabilityFn ?? loopbackFamilyAvailability;
|
|
208
217
|
const probed = new Set<number>();
|
|
209
218
|
for (const port of queue) {
|
|
210
219
|
if (probed.has(port)) continue;
|
|
@@ -225,6 +234,11 @@ export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io
|
|
|
225
234
|
noteIndeterminate(port, "ownership discovery ran out of its shared time budget");
|
|
226
235
|
break;
|
|
227
236
|
}
|
|
237
|
+
// Disabled loopback IPv6 can route ::1 off-host and time out. An ephemeral
|
|
238
|
+
// local bind proves address absence without treating an available family's
|
|
239
|
+
// timeout as empty. Unknown bind failures retain the existing probes.
|
|
240
|
+
if (!availability.has(hostname)) availability.set(hostname, checkAvailability(hostname));
|
|
241
|
+
if (availability.get(hostname) === "unavailable") continue;
|
|
228
242
|
const identity = await proxyIdentityAt(port, { hostname }, probeIo);
|
|
229
243
|
if (identity === null) {
|
|
230
244
|
// A null identity is not "free": distinguish a unanimous connect refusal
|