@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
package/src/images/loop.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { createAnthropicThinkingRecovery } from "../server/responses/anthropic-thinking-recovery";
|
|
2
|
+
import { bindAnthropicRejectionResponse } from "../adapters/anthropic-rejection-memo";
|
|
1
3
|
/**
|
|
2
4
|
* Media bridge agentic loop — supports both image and video generation sidecars.
|
|
3
5
|
*
|
|
@@ -23,7 +25,7 @@ import { bridgeToResponsesSSE } from "../bridge";
|
|
|
23
25
|
import { isTerminalRefusalIncomplete } from "../bridge/terminal-refusal";
|
|
24
26
|
import { clearableDeadline, idleDeadline } from "../lib/abort";
|
|
25
27
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
26
|
-
import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry";
|
|
28
|
+
import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait, isNonReplayableResponse, SendBudgetExhaustedError } from "../lib/upstream-retry";
|
|
27
29
|
import { rateLimitRetryDelayMs } from "../providers/key-failover";
|
|
28
30
|
import { releaseProviderRequestSlot, sendTrackingRequestSlot, type ProviderRequestSlot } from "../providers/request-pacing";
|
|
29
31
|
import {
|
|
@@ -33,7 +35,7 @@ import {
|
|
|
33
35
|
TRANSLATOR_MAX_TURN_BYTES,
|
|
34
36
|
TranslatorBudgetExceededError,
|
|
35
37
|
} from "../lib/translator-budget";
|
|
36
|
-
import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError } from "../web-search/progress-stream";
|
|
38
|
+
import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError, WebSearchAdapterEventError } from "../web-search/progress-stream";
|
|
37
39
|
import { fulfillImageCall } from "./fulfill";
|
|
38
40
|
import { parseVideoCallArgs, pollVideoWithHeartbeats, buildVideoResult, createVideoBudget } from "./fulfill-video";
|
|
39
41
|
import { submitVideoJob } from "./xai-video-client";
|
|
@@ -270,17 +272,18 @@ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[]
|
|
|
270
272
|
return parts;
|
|
271
273
|
}
|
|
272
274
|
|
|
273
|
-
function jsonError(status: number, message: string): Response {
|
|
274
|
-
return new Response(JSON.stringify({ error: { message, type: "upstream_error", code:
|
|
275
|
+
function jsonError(status: number, message: string, event?: Extract<AdapterEvent, { type: "error" }>): Response {
|
|
276
|
+
return new Response(JSON.stringify({ error: { message, type: event?.errorType ?? "upstream_error", code: event?.code ?? null,
|
|
277
|
+
...(event?.retryable === false ? { retryable: false } : {}) } }), {
|
|
275
278
|
status,
|
|
276
|
-
headers: { "Content-Type": "application/json" },
|
|
279
|
+
headers: { "Content-Type": "application/json", ...(event?.retryable === false ? { "x-should-retry": "false" } : {}) },
|
|
277
280
|
});
|
|
278
281
|
}
|
|
279
282
|
|
|
280
283
|
/** Hard provider/parse failure inside an iteration. The eager first iteration converts it to a
|
|
281
284
|
* non-2xx jsonError; later (already-streaming) iterations surface it as an in-stream error event. */
|
|
282
285
|
class LoopError extends Error {
|
|
283
|
-
constructor(readonly status: number, message: string) {
|
|
286
|
+
constructor(readonly status: number, message: string, readonly event?: Extract<AdapterEvent, { type: "error" }>) {
|
|
284
287
|
super(message);
|
|
285
288
|
this.name = "LoopError";
|
|
286
289
|
}
|
|
@@ -291,6 +294,7 @@ class LoopError extends Error {
|
|
|
291
294
|
* metadata, and the optional image/video bridge plans.
|
|
292
295
|
*/
|
|
293
296
|
export interface ImageBridgeDeps {
|
|
297
|
+
thinkingRecovery?: ReturnType<typeof createAnthropicThinkingRecovery>;
|
|
294
298
|
parsed: OcxParsedRequest;
|
|
295
299
|
clientToolCatalogRestriction?: ClientToolCatalogRestriction;
|
|
296
300
|
adapter: ProviderAdapter;
|
|
@@ -321,7 +325,7 @@ export interface ImageBridgeDeps {
|
|
|
321
325
|
/** Provider-specific fetch (e.g. xAI transport wrapper). Falls back to global fetch. */
|
|
322
326
|
fetchImpl?: typeof globalThis.fetch;
|
|
323
327
|
/** Bind physical dispatch to this iteration's built request; pacing remains owned by the loop. */
|
|
324
|
-
fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest, adapter: ProviderAdapter) => typeof globalThis.fetch;
|
|
328
|
+
fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest, adapter: ProviderAdapter, recovery?: AttemptRecoveryKind) => typeof globalThis.fetch;
|
|
325
329
|
/** Reserve the routed provider's next request-start slot before each adapter dispatch. */
|
|
326
330
|
waitForRequestSlot?: (signal?: AbortSignal) => Promise<ProviderRequestSlot | void>;
|
|
327
331
|
/** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */
|
|
@@ -464,6 +468,8 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
464
468
|
interface IterationResponse {
|
|
465
469
|
response: Response;
|
|
466
470
|
responseAdapter: ProviderAdapter;
|
|
471
|
+
parsed?: OcxParsedRequest;
|
|
472
|
+
retryThinking?: () => Promise<IterationResponse>;
|
|
467
473
|
/** runTurn events are already bounded; consume them without replaying or charging twice. */
|
|
468
474
|
collectedEvents?: AdapterEvent[];
|
|
469
475
|
}
|
|
@@ -634,7 +640,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
634
640
|
cachedRequest = request;
|
|
635
641
|
cachedAdapter = requestAdapter;
|
|
636
642
|
}
|
|
637
|
-
const requestFetch = deps.fetchForRequest?.(request, iterParsed, requestAdapter) ?? fetchImpl;
|
|
643
|
+
const requestFetch = deps.fetchForRequest?.(request, iterParsed, requestAdapter, recovery) ?? fetchImpl;
|
|
638
644
|
let response: Response;
|
|
639
645
|
try {
|
|
640
646
|
if (requestAdapter.fetchResponse) {
|
|
@@ -685,7 +691,8 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
685
691
|
} finally {
|
|
686
692
|
request.releaseBodyObservation?.();
|
|
687
693
|
}
|
|
688
|
-
|
|
694
|
+
bindAnthropicRejectionResponse(request, response);
|
|
695
|
+
return { response, responseAdapter: requestAdapter, parsed: iterParsed };
|
|
689
696
|
};
|
|
690
697
|
|
|
691
698
|
let prepared = await fetchOnce(adapter);
|
|
@@ -739,9 +746,18 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
739
746
|
prepared = await fetchOnce(adapter, rotated.recoveryKind);
|
|
740
747
|
}
|
|
741
748
|
|
|
749
|
+
if (await deps.thinkingRecovery?.prepareHttp(prepared.response, prepared.responseAdapter.name, iterParsed)) {
|
|
750
|
+
try { void prepared.response.body?.cancel().catch(() => {}); } catch { /* closed */ }
|
|
751
|
+
cachedRequest = undefined;
|
|
752
|
+
prepared = await fetchOnce(adapter, "opaque-blob-rejection");
|
|
753
|
+
}
|
|
742
754
|
// Final headers have arrived. Clear only the deadline timer before ANY body read.
|
|
743
755
|
headerDeadline.clear();
|
|
744
756
|
if (!prepared.response.ok) {
|
|
757
|
+
if (deps.thinkingRecovery && prepared.responseAdapter.name === "anthropic") {
|
|
758
|
+
const event = await deps.thinkingRecovery.projectHttpFailure(prepared.response, signal);
|
|
759
|
+
throw new LoopError(event.status ?? prepared.response.status, event.message, event);
|
|
760
|
+
}
|
|
745
761
|
let body: Awaited<ReturnType<typeof readBoundedResponseBody>>;
|
|
746
762
|
try {
|
|
747
763
|
body = await readBoundedResponseBody(prepared.response, { signal });
|
|
@@ -762,9 +778,21 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
762
778
|
const suffix = formatted ? `: ${formatted.slice(0, 400)}` : "";
|
|
763
779
|
throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}${suffix}`);
|
|
764
780
|
}
|
|
765
|
-
return prepared
|
|
781
|
+
return { ...prepared, retryThinking: async () => {
|
|
782
|
+
cachedRequest = undefined;
|
|
783
|
+
headerDeadline = clearableDeadline(connectTimeoutMs, signal);
|
|
784
|
+
try { return await fetchOnce(adapter, "opaque-blob-rejection"); }
|
|
785
|
+
catch (error) {
|
|
786
|
+
if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge");
|
|
787
|
+
if (headerDeadline.didExpire()) throw new LoopError(504,
|
|
788
|
+
`Provider response-header timeout after ${connectTimeoutMs}ms during image-bridge`);
|
|
789
|
+
throw error;
|
|
790
|
+
} finally { headerDeadline.clear(); deps.onIterationEnd?.(); }
|
|
791
|
+
} };
|
|
766
792
|
} catch (error) {
|
|
767
793
|
if (isTranslatorBudgetExceededError(error)) throw error;
|
|
794
|
+
if (error instanceof SendBudgetExhaustedError) throw new LoopError(429, error.message,
|
|
795
|
+
{ type: "error", status: 429, errorType: "rate_limit_error", code: error.code, message: error.message, retryable: false });
|
|
768
796
|
if (headerDeadline.didExpire()) {
|
|
769
797
|
throw new LoopError(504, `Provider response-header timeout after ${connectTimeoutMs}ms during image-bridge`);
|
|
770
798
|
}
|
|
@@ -791,14 +819,31 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
791
819
|
const iterationBudget = prepared.collectedEvents ? undefined : createIterationEventBudget();
|
|
792
820
|
try {
|
|
793
821
|
if (iterationBudget) {
|
|
794
|
-
const parse =
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
822
|
+
const parse = async function* (current: IterationResponse): AsyncGenerator<AdapterEvent> {
|
|
823
|
+
if (!current.response.ok && deps.thinkingRecovery) {
|
|
824
|
+
yield await deps.thinkingRecovery.projectHttpFailure(current.response, signal);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
try {
|
|
828
|
+
yield* parseStreamWithProgress(current.response, current.responseAdapter.parseStream.bind(current.responseAdapter), {
|
|
829
|
+
signal, inactivityTimeoutMs: stallTimeoutMs, translatorBudget,
|
|
830
|
+
preserveAdapterError: deps.thinkingRecovery !== undefined,
|
|
831
|
+
onErrorUsage: usage => { hiddenUsage = addUsage(hiddenUsage, usage); },
|
|
832
|
+
});
|
|
833
|
+
} catch (error) {
|
|
834
|
+
if (!(error instanceof WebSearchAdapterEventError)) throw error;
|
|
835
|
+
if (!error.replaySafe) yield { type: "heartbeat", replayUnsafe: true };
|
|
836
|
+
yield isNonReplayableResponse(current.response)
|
|
837
|
+
? { ...error.event, message: "Provider stream error: upstream diagnostic withheld for a non-replayable failure", retryable: false }
|
|
838
|
+
: error.event;
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
const source = deps.thinkingRecovery && prepared.retryThinking
|
|
842
|
+
? deps.thinkingRecovery.wrap(parse(prepared), async function* () {
|
|
843
|
+
yield* parse(await prepared.retryThinking!());
|
|
844
|
+
}, () => prepared.responseAdapter.name, !isNonReplayableResponse(prepared.response), prepared.response, prepared.parsed)
|
|
845
|
+
: parse(prepared);
|
|
846
|
+
for await (const event of source) {
|
|
802
847
|
if (event.type === "heartbeat") yield event;
|
|
803
848
|
else {
|
|
804
849
|
iterationBudget.retain(event);
|
|
@@ -812,6 +857,9 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
812
857
|
throw error;
|
|
813
858
|
}
|
|
814
859
|
if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge");
|
|
860
|
+
if (error instanceof LoopError) throw error;
|
|
861
|
+
if (error instanceof SendBudgetExhaustedError) throw new LoopError(429, error.message,
|
|
862
|
+
{ type: "error", status: 429, errorType: "rate_limit_error", code: error.code, message: error.message, retryable: false });
|
|
815
863
|
if (error instanceof RoutedModelInactivityError) throw new LoopError(504, error.message);
|
|
816
864
|
if (error instanceof WebSearchStreamProtocolError) throw new LoopError(502, error.message);
|
|
817
865
|
throw new LoopError(502, `Provider stream error: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -829,8 +877,9 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
829
877
|
if (terminal.code === "translation_buffer_limit") {
|
|
830
878
|
throw new TranslatorBudgetExceededError("retained_collectors", TRANSLATOR_MAX_TURN_BYTES);
|
|
831
879
|
}
|
|
832
|
-
throw new LoopError(502, terminal.message);
|
|
880
|
+
throw new LoopError(terminal.status ?? 502, terminal.message, terminal);
|
|
833
881
|
}
|
|
882
|
+
deps.thinkingRecovery?.observeIteration(events);
|
|
834
883
|
return scanEventsForImageCall(events, mediaToolNames);
|
|
835
884
|
};
|
|
836
885
|
|
|
@@ -845,7 +894,9 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
845
894
|
firstPrepared = await prepareIterationDrained(maxRounds <= 0);
|
|
846
895
|
} catch (e) {
|
|
847
896
|
if (abortSignal) abortSignal.removeEventListener("abort", linkAbort);
|
|
848
|
-
if (e instanceof LoopError) return jsonError(e.status, e.message);
|
|
897
|
+
if (e instanceof LoopError) return jsonError(e.status, e.message, e.event);
|
|
898
|
+
if (e instanceof SendBudgetExhaustedError) return jsonError(429, e.message,
|
|
899
|
+
{ type: "error", status: 429, errorType: "rate_limit_error", code: e.code, message: e.message, retryable: false });
|
|
849
900
|
throw e;
|
|
850
901
|
}
|
|
851
902
|
}
|
|
@@ -1101,7 +1152,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
1101
1152
|
yield {
|
|
1102
1153
|
type: "error",
|
|
1103
1154
|
message: e instanceof LoopError ? e.message : (e instanceof Error ? e.message : String(e)),
|
|
1104
|
-
...(e instanceof LoopError ? { status: e.status } : {}),
|
|
1155
|
+
...(e instanceof LoopError ? { status: e.status, ...(e.event ?? {}) } : {}),
|
|
1105
1156
|
...(usage ? { usage } : {}),
|
|
1106
1157
|
};
|
|
1107
1158
|
}
|
|
@@ -223,6 +223,8 @@ const RECOVERY_KIND_CAUSE = {
|
|
|
223
223
|
"rate-limit-429": "rate-limit",
|
|
224
224
|
"anthropic-oauth-429": "rate-limit",
|
|
225
225
|
"oauth-account-429": "rate-limit",
|
|
226
|
+
// A 402 is the account's quota, not a transient limit; only another account can serve it.
|
|
227
|
+
"oauth-account-402": "quota-exhausted",
|
|
226
228
|
"image-413": "payload-too-large",
|
|
227
229
|
// The gateway rejects a body it accepts seconds later and the replay is byte-identical, so
|
|
228
230
|
// nothing about the payload was wrong; the origin declined to take it at that moment.
|
|
@@ -483,7 +483,8 @@ export function noteGenericPoolSelection(
|
|
|
483
483
|
* Cool the account that actually 429'd and name the next eligible one, or null.
|
|
484
484
|
*
|
|
485
485
|
* Returns the id only; the caller mints the credential so a failed refresh does not leave the
|
|
486
|
-
* cooldown applied to an account we then could not use.
|
|
486
|
+
* cooldown applied to an account we then could not use. `commit: false` still cools the failed
|
|
487
|
+
* account but only peeks the round-robin ring, for callers that will not dispatch the answer.
|
|
487
488
|
*/
|
|
488
489
|
export function rotateGenericOAuthAccountOn429(
|
|
489
490
|
config: OcxConfig,
|
|
@@ -492,9 +493,10 @@ export function rotateGenericOAuthAccountOn429(
|
|
|
492
493
|
retryAfterHeader: string | null | undefined,
|
|
493
494
|
now = Date.now(),
|
|
494
495
|
requestedModelId?: string | null,
|
|
496
|
+
commit = true,
|
|
495
497
|
): string | null {
|
|
496
498
|
return rotateGenericOAuthAccountOnRefusal(config, providerName, failedAccountId, "rate",
|
|
497
|
-
retryAfterHeader, now, requestedModelId);
|
|
499
|
+
retryAfterHeader, now, requestedModelId, undefined, commit);
|
|
498
500
|
}
|
|
499
501
|
|
|
500
502
|
/** Kiro adds account-scoped refusal classes; all other providers retain their 429 policy. */
|
|
@@ -507,6 +509,7 @@ export function rotateGenericOAuthAccountOnRefusal(
|
|
|
507
509
|
now = Date.now(),
|
|
508
510
|
requestedModelId?: string | null,
|
|
509
511
|
monthlyCooldownMs?: number,
|
|
512
|
+
commit = true,
|
|
510
513
|
): string | null {
|
|
511
514
|
if (kind === "other" || (providerName !== "kiro" && kind !== "rate")) return null;
|
|
512
515
|
if (!isGenericOAuthFailoverEnabled(config, providerName)) return null;
|
|
@@ -566,8 +569,8 @@ export function rotateGenericOAuthAccountOnRefusal(
|
|
|
566
569
|
}
|
|
567
570
|
if (strategy === "round-robin") {
|
|
568
571
|
// PICK here, not peek: the failure already happened and this answer is the one being used,
|
|
569
|
-
// so the ring genuinely advances.
|
|
570
|
-
return pickRoundRobinAccount(
|
|
572
|
+
// so the ring genuinely advances. A caller that only asks whether a sibling exists peeks.
|
|
573
|
+
return (commit ? pickRoundRobinAccount : peekRoundRobinAccount)(
|
|
571
574
|
genericPoolKey(providerName),
|
|
572
575
|
candidates,
|
|
573
576
|
genericStickyLimit(config, providerName),
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* completed-response effects belong to the caller, which folds the same events with
|
|
29
29
|
* `buildResponseJSON` at the terminal through `beforeTerminal`.
|
|
30
30
|
*/
|
|
31
|
-
import type { AdapterEvent, OcxMessagePhase, OcxProviderContinuationState, OcxUsage } from "../../types";
|
|
31
|
+
import type { AdapterEvent, AnthropicServerBlocks, OcxMessagePhase, OcxProviderContinuationState, OcxUsage } from "../../types";
|
|
32
32
|
import { normalizeDeclaredToolName } from "../../types";
|
|
33
33
|
import { coerceIntegerToolArguments } from "../../lib/tool-argument-integers";
|
|
34
34
|
import { classifyError, isCyberPolicyCode, type OcxErrorPayload } from "../../lib/errors";
|
|
@@ -61,7 +61,7 @@ export interface ClientWebSearch {
|
|
|
61
61
|
status: "completed" | "failed";
|
|
62
62
|
queries: string[];
|
|
63
63
|
sources: { url: string; title?: string }[];
|
|
64
|
-
|
|
64
|
+
anthropic?: AnthropicServerBlocks;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/**
|
|
@@ -412,9 +412,9 @@ export function encodeAdapterEventStream(
|
|
|
412
412
|
budget.closeCall(call.callId);
|
|
413
413
|
currentToolCall = null;
|
|
414
414
|
};
|
|
415
|
-
const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[],
|
|
415
|
+
const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], anthropic?: AnthropicServerBlocks) => {
|
|
416
416
|
if (!currentWebSearch) return;
|
|
417
|
-
writer.webSearchDone({ itemId: currentWebSearch.itemId, status, queries, sources: sources ?? [], ...(
|
|
417
|
+
writer.webSearchDone({ itemId: currentWebSearch.itemId, status, queries, sources: sources ?? [], ...(anthropic ? { anthropic } : {}) });
|
|
418
418
|
currentWebSearch = null;
|
|
419
419
|
};
|
|
420
420
|
const appendToolArgs = (call: OpenToolCall, fragment: string) => {
|
|
@@ -689,7 +689,7 @@ export function encodeAdapterEventStream(
|
|
|
689
689
|
if (currentWebSearch) closeCurrentWebSearch("completed", []);
|
|
690
690
|
currentWebSearch = { itemId: `ws_${uuid()}`, eventId: event.id };
|
|
691
691
|
}
|
|
692
|
-
closeCurrentWebSearch(event.status ?? "completed", event.queries, safeWebSearchSources(event.sources), event.
|
|
692
|
+
closeCurrentWebSearch(event.status ?? "completed", event.queries, safeWebSearchSources(event.sources), event.anthropic);
|
|
693
693
|
break;
|
|
694
694
|
}
|
|
695
695
|
case "done": {
|
|
@@ -345,9 +345,9 @@ function createAnthropicMessageWriter(
|
|
|
345
345
|
type: "web_search_call",
|
|
346
346
|
id: search.itemId,
|
|
347
347
|
status: search.status,
|
|
348
|
-
action: webSearchAction(search.queries, search.
|
|
348
|
+
action: webSearchAction(search.queries, search.anthropic),
|
|
349
349
|
...(search.sources.length > 0 ? { sources: search.sources } : {}),
|
|
350
|
-
...(search.
|
|
350
|
+
...(search.anthropic ? { anthropic_blocks: search.anthropic } : {}),
|
|
351
351
|
});
|
|
352
352
|
const toolIndex = blockIndex++;
|
|
353
353
|
emit("content_block_start", {
|
|
@@ -70,6 +70,9 @@ export function enrichOpenCodeZenRateLimitMessage(
|
|
|
70
70
|
message: string,
|
|
71
71
|
opts: {
|
|
72
72
|
status: number;
|
|
73
|
+
/** Parsed upstream error type, when available. */
|
|
74
|
+
upstreamErrorType?: string | null;
|
|
75
|
+
upstreamErrorCode?: string | null;
|
|
73
76
|
providerName?: string;
|
|
74
77
|
baseUrl?: string;
|
|
75
78
|
adapter?: string;
|
|
@@ -84,7 +87,9 @@ export function enrichOpenCodeZenRateLimitMessage(
|
|
|
84
87
|
): string {
|
|
85
88
|
if (opts.status !== 429) return message;
|
|
86
89
|
if (!isOpenCodeZenRateLimitProvider(opts)) return message;
|
|
87
|
-
|
|
90
|
+
const errorText = `${opts.upstreamErrorType ?? ""} ${opts.upstreamErrorCode ?? ""} ${message}`.replace(/[_-]/g, " ");
|
|
91
|
+
if (/\b(?:quota|billing)\b|\busage\s+limit\b|\binsufficient\s+(?:credits?|balance|funds)\b|\b(?:credits?|balance)\s+(?:exhausted|depleted)\b/i.test(errorText)) return message;
|
|
92
|
+
if (opts.upstreamErrorType !== "rate_limit_error" && !/rate\s*limit/i.test(message)) return message;
|
|
88
93
|
if (message.includes(ENRICHMENT_MARKER)) return message;
|
|
89
94
|
|
|
90
95
|
const upstreamRetry = validateClientRetryAfterHeader(
|
|
@@ -165,7 +170,7 @@ export function enrichOpenCodeZenFreeTierMessage(
|
|
|
165
170
|
}
|
|
166
171
|
|
|
167
172
|
/**
|
|
168
|
-
* Single entry point for Zen upstream-error guidance on
|
|
173
|
+
* Single entry point for Zen upstream-error guidance on Responses and native Messages: short-window
|
|
169
174
|
* rate limits first, then the keyless free-tier admission refusal. Each layer is a no-op
|
|
170
175
|
* outside its own case, so the composition is safe for every other upstream failure.
|
|
171
176
|
*/
|
|
@@ -46,6 +46,7 @@ export function nativeReasoningTagMatches(expected: string | undefined, presente
|
|
|
46
46
|
const MAX_TOTAL_BYTES = 256 * 1024;
|
|
47
47
|
const TTL_MS = 60 * 60 * 1000;
|
|
48
48
|
const OPAQUE_BLOB_REJECTION_TTL_MS = 5 * 60 * 1000;
|
|
49
|
+
const MAX_OPAQUE_BLOB_REJECTION_FINGERPRINTS = 256;
|
|
49
50
|
const replayIdentityKey = randomBytes(32);
|
|
50
51
|
const CREDENTIAL_HEADER_NAMES = new Set([
|
|
51
52
|
"authorization",
|
|
@@ -78,8 +79,14 @@ interface ServingIdentityEntry {
|
|
|
78
79
|
interface OpaqueBlobRejectionEntry {
|
|
79
80
|
bytes: number;
|
|
80
81
|
at: number;
|
|
82
|
+
/** Undefined retains the legacy blanket rejection; only payload digests are stored here. */
|
|
83
|
+
fingerprints?: ReadonlySet<string>;
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
export type ReasoningReplayOpaqueBlobRejection =
|
|
87
|
+
| { mode: "blanket" }
|
|
88
|
+
| { mode: "selective"; fingerprints: ReadonlySet<string> };
|
|
89
|
+
|
|
83
90
|
const entries = new Map<string, CacheEntry>();
|
|
84
91
|
const servingIdentities = new Map<string, ServingIdentityEntry>();
|
|
85
92
|
const opaqueBlobRejections = new Map<string, OpaqueBlobRejectionEntry>();
|
|
@@ -310,18 +317,57 @@ export function reasoningReplayOpaqueBlobRejectionMemoized(
|
|
|
310
317
|
return opaqueBlobRejections.has(key);
|
|
311
318
|
}
|
|
312
319
|
|
|
320
|
+
/** Undefined means no live memo; a selective result owns a defensive copy of its digests. */
|
|
321
|
+
export function getReasoningReplayOpaqueBlobRejection(
|
|
322
|
+
scope: OcxReasoningReplayScopeRef | undefined,
|
|
323
|
+
): ReasoningReplayOpaqueBlobRejection | undefined {
|
|
324
|
+
const key = opaqueBlobRejectionKeyFor(scope);
|
|
325
|
+
if (!key) return undefined;
|
|
326
|
+
sweepExpiredOpaqueBlobRejections(now());
|
|
327
|
+
const entry = opaqueBlobRejections.get(key);
|
|
328
|
+
return entry === undefined ? undefined : entry.fingerprints === undefined ? { mode: "blanket" }
|
|
329
|
+
: { mode: "selective", fingerprints: new Set(entry.fingerprints) };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Incomplete, invalid or unbounded enumerations cannot safely narrow the legacy rejection. */
|
|
333
|
+
function boundedRejectionFingerprints(fingerprints: ReadonlySet<string> | undefined): Set<string> | undefined {
|
|
334
|
+
if (fingerprints === undefined) return undefined;
|
|
335
|
+
const copy = new Set<string>();
|
|
336
|
+
try {
|
|
337
|
+
// The intrinsic validates a real Set without trusting an overridden iterator or size getter.
|
|
338
|
+
for (const value of Set.prototype.values.call(fingerprints) as IterableIterator<unknown>) {
|
|
339
|
+
if (typeof value !== "string" || value.length !== 64 || !/^[a-f0-9]{64}$/.test(value)
|
|
340
|
+
|| copy.size >= MAX_OPAQUE_BLOB_REJECTION_FINGERPRINTS) return undefined;
|
|
341
|
+
copy.add(value);
|
|
342
|
+
}
|
|
343
|
+
} catch { return undefined; }
|
|
344
|
+
return copy.size > 0 ? copy : undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
313
347
|
/** Record only after a blobless retry succeeded for this durable serving identity. */
|
|
314
348
|
export function rememberReasoningReplayOpaqueBlobRejection(
|
|
315
349
|
scope: OcxReasoningReplayScopeRef | undefined,
|
|
350
|
+
fingerprints?: ReadonlySet<string>,
|
|
316
351
|
): void {
|
|
317
352
|
const key = opaqueBlobRejectionKeyFor(scope);
|
|
318
353
|
if (!key) return;
|
|
319
|
-
const
|
|
320
|
-
if (
|
|
354
|
+
const keyBytes = Buffer.byteLength(key, "utf8");
|
|
355
|
+
if (keyBytes > MAX_TOTAL_BYTES) return;
|
|
321
356
|
const at = now();
|
|
322
357
|
sweepExpiredOpaqueBlobRejections(at);
|
|
323
|
-
|
|
324
|
-
|
|
358
|
+
const previous = opaqueBlobRejections.get(key);
|
|
359
|
+
let merged = previous && previous.fingerprints === undefined ? undefined : boundedRejectionFingerprints(fingerprints);
|
|
360
|
+
if (merged && previous?.fingerprints) {
|
|
361
|
+
for (const fingerprint of previous.fingerprints) {
|
|
362
|
+
merged.add(fingerprint);
|
|
363
|
+
if (merged.size > MAX_OPAQUE_BLOB_REJECTION_FINGERPRINTS) { merged = undefined; break; }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// A near-limit key can still retain its existing blanket memo if the digest set will not fit.
|
|
367
|
+
if (merged && keyBytes + merged.size * 64 > MAX_TOTAL_BYTES) merged = undefined;
|
|
368
|
+
const bytes = keyBytes + (merged?.size ?? 0) * 64;
|
|
369
|
+
if (previous) deleteOpaqueBlobRejection(key);
|
|
370
|
+
opaqueBlobRejections.set(key, { bytes, at, ...(merged ? { fingerprints: merged } : {}) });
|
|
325
371
|
opaqueBlobRejectionTotalBytes += bytes;
|
|
326
372
|
while (
|
|
327
373
|
(opaqueBlobRejectionTotalBytes > MAX_TOTAL_BYTES || opaqueBlobRejections.size > MAX_ENTRIES)
|
package/src/routing/analytics.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { PersistedUsageEntry } from "../usage/log";
|
|
15
|
-
import {
|
|
15
|
+
import { computeEntryCost } from "../usage/summary";
|
|
16
16
|
import { openRequestHistoryIndex, requestHistoryDb } from "./history/indexer";
|
|
17
17
|
|
|
18
18
|
export const ANALYTICS_MAX_ROWS = 50_000;
|
|
@@ -122,6 +122,7 @@ const COOLDOWN_RECOVERY_KINDS = new Set([
|
|
|
122
122
|
"oauth-account-403",
|
|
123
123
|
"anthropic-oauth-429",
|
|
124
124
|
"oauth-account-429",
|
|
125
|
+
"oauth-account-402",
|
|
125
126
|
]);
|
|
126
127
|
|
|
127
128
|
function percentile(sorted: number[], p: number): number | undefined {
|
|
@@ -158,19 +159,10 @@ function cooldownTriggering(entry: PersistedUsageEntry | null, status: number):
|
|
|
158
159
|
});
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (!entry.usage) return null;
|
|
166
|
-
const estimate = estimateRequestCost({
|
|
167
|
-
provider: row.provider,
|
|
168
|
-
model: row.model,
|
|
169
|
-
usage: entry.usage,
|
|
170
|
-
usageStatus: entry.usageStatus,
|
|
171
|
-
serviceTier: serviceTierContext(entry),
|
|
172
|
-
});
|
|
173
|
-
return estimate ? estimate.cost.total : null;
|
|
162
|
+
/** Same attempt-aware pricing as /api/usage, so a refused retry before a measured send is priced. */
|
|
163
|
+
function successCostUsd(entry: PersistedUsageEntry): number | null {
|
|
164
|
+
const { isPriced, costTotal } = computeEntryCost(entry);
|
|
165
|
+
return isPriced ? costTotal : null;
|
|
174
166
|
}
|
|
175
167
|
|
|
176
168
|
export async function computeRoutingAnalytics(
|
|
@@ -245,7 +237,7 @@ export async function computeRoutingAnalytics(
|
|
|
245
237
|
if (kind === "success") {
|
|
246
238
|
const entry = parseEntry(row.rowJson);
|
|
247
239
|
if (entry) {
|
|
248
|
-
rowCostUsd = successCostUsd(
|
|
240
|
+
rowCostUsd = successCostUsd(entry);
|
|
249
241
|
if (rowCostUsd !== null) {
|
|
250
242
|
costTotalUsd += rowCostUsd;
|
|
251
243
|
costCount += 1;
|
|
@@ -33,7 +33,7 @@ import { resolveAlias, claudeCodeNativeAlias, legacyAliasForNative } from "../cl
|
|
|
33
33
|
import { recordDesktopRequest } from "../claude/desktop-health";
|
|
34
34
|
import { stripOneMillionMarker } from "../claude/context-windows";
|
|
35
35
|
import { captureClaudeInbound } from "../claude/inbound-debug";
|
|
36
|
-
import { declaredAnthropicServerTools } from "../claude/inbound-content-options";
|
|
36
|
+
import { declaredAnthropicServerTools, replayedAnthropicServerBlocks } from "../claude/inbound-content-options";
|
|
37
37
|
import { claudeCodeForIngress } from "../claude/intercept/model-bindings";
|
|
38
38
|
import { classifyInterceptClient } from "../claude/intercept/client-class";
|
|
39
39
|
import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility";
|
|
@@ -1133,6 +1133,7 @@ async function handleClaudeMessagesWithBudget(
|
|
|
1133
1133
|
nativeReasoningMint,
|
|
1134
1134
|
// Out of body like the replay above: the core hands these to an Anthropic destination only.
|
|
1135
1135
|
anthropicServerTools: declaredAnthropicServerTools(anthropicBody),
|
|
1136
|
+
anthropicServerReplay: replayedAnthropicServerBlocks(anthropicBody),
|
|
1136
1137
|
claudeGoAffinity: { sessionLane: claudeGoSessionLane },
|
|
1137
1138
|
claudeNativeSessionId,
|
|
1138
1139
|
stripClaudeMainAuthForNoncanonicalForward: true,
|
|
@@ -40,8 +40,9 @@ export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
|
|
|
40
40
|
*
|
|
41
41
|
* What must never happen is neither: an immortal socket plus a destination that silently accepts
|
|
42
42
|
* the orphaned delta. `tests/responses/ws-endpoint.test.ts` holds exactly that pair together.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
43
|
+
* Responses admission separately reclaims its least recently used idle connection under
|
|
44
|
+
* capacity pressure. That is not a time limit, so expired-reference validation remains required.
|
|
45
|
+
* Keeping this shared timeout at 0 also preserves quiet live/voice sessions.
|
|
45
46
|
*/
|
|
46
47
|
export const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
|
|
47
48
|
/** Ceiling a finite websocket idle timeout must stay under, in seconds. See above. */
|
|
@@ -1618,11 +1618,17 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1618
1618
|
// Promote a caller `x-session-id` before admission, as Responses and Messages do (#6520):
|
|
1619
1619
|
// the ChatGPT bridge forwards only canonical session headers, so the raw marker is lost.
|
|
1620
1620
|
const sessionReq = withCallerSessionIdentity(req, admission);
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1621
|
+
// Only expose an id when the handler or a logged refusal owns its history row.
|
|
1622
|
+
let ownsRequestLog = false;
|
|
1623
|
+
const response = await runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => {
|
|
1624
|
+
ownsRequestLog = true;
|
|
1625
|
+
return withCors(
|
|
1626
|
+
await handleChatCompletions(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }),
|
|
1627
|
+
req,
|
|
1628
|
+
policy,
|
|
1629
|
+
);
|
|
1630
|
+
}, { requestId, start, logCtx, onLogged: () => { ownsRequestLog = true; } });
|
|
1631
|
+
return ownsRequestLog ? withRequestLogId(response, requestId) : response;
|
|
1626
1632
|
}
|
|
1627
1633
|
|
|
1628
1634
|
if (url.pathname === "/v1/audio/transcriptions" && req.method === "POST") {
|
|
@@ -89,6 +89,7 @@ import {
|
|
|
89
89
|
import type { ServeOptionsContext } from "./serve-options";
|
|
90
90
|
import type { RequestMetricsRecorder } from "../request-metrics";
|
|
91
91
|
import { resolveInboundBodyLimitBytes } from "../request-decompress";
|
|
92
|
+
import { responseToolOwnership } from "../responses/response-tool-ownership";
|
|
92
93
|
|
|
93
94
|
/**
|
|
94
95
|
* The WebSocket half of the Bun.serve options, split out of serve-options.ts to keep that file
|
|
@@ -184,6 +185,8 @@ export function createWebsocketHandler(
|
|
|
184
185
|
}
|
|
185
186
|
return;
|
|
186
187
|
}
|
|
188
|
+
if (ws.data.responsesActivity?.closing) return;
|
|
189
|
+
ws.data.responsesActivity?.touch();
|
|
187
190
|
const rawBytes = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength;
|
|
188
191
|
if (rawBytes > MAX_WS_FRAME_BYTES) {
|
|
189
192
|
sendJsonFrame(ws, buildWsErrorFrame(413, {
|
|
@@ -223,6 +226,7 @@ export function createWebsocketHandler(
|
|
|
223
226
|
if (frame.type === "response.inject") {
|
|
224
227
|
if (!ws.data.nativeControl?.inject) throw new NativeSteeringError("injection_not_supported", "Native injection is disabled or unavailable on this route.");
|
|
225
228
|
ws.data.nativeControl.inject(frame);
|
|
229
|
+
ws.data.responsesActivity?.acceptResults(frame.input);
|
|
226
230
|
return;
|
|
227
231
|
}
|
|
228
232
|
if (frame.type === "response.steer") {
|
|
@@ -230,7 +234,10 @@ export function createWebsocketHandler(
|
|
|
230
234
|
ws.data.nativeControl.steer(frame);
|
|
231
235
|
return;
|
|
232
236
|
}
|
|
233
|
-
if (ws.data.nativeControl?.continue(frame))
|
|
237
|
+
if (ws.data.nativeControl?.continue(frame)) {
|
|
238
|
+
ws.data.responsesActivity?.acceptResults(frame.input);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
234
241
|
} catch (error) {
|
|
235
242
|
sendJsonFrame(ws, buildWsErrorFrame(400, {
|
|
236
243
|
type: "invalid_request_error",
|
|
@@ -294,6 +301,7 @@ export function createWebsocketHandler(
|
|
|
294
301
|
}
|
|
295
302
|
|
|
296
303
|
// Only a genuinely admitted turn may receive steering or continuations.
|
|
304
|
+
const finishActivity = ws.data.responsesActivity?.beginTurn(frame.input);
|
|
297
305
|
ws.data.nativeControl = nativeControl;
|
|
298
306
|
const payload: Record<string, unknown> = { ...frame };
|
|
299
307
|
delete payload.type;
|
|
@@ -346,11 +354,16 @@ export function createWebsocketHandler(
|
|
|
346
354
|
terminalRecorder = recorder;
|
|
347
355
|
},
|
|
348
356
|
});
|
|
357
|
+
const observeLog = nativeControl?.relayActive
|
|
358
|
+
? createNativeSteeringLogObserver(logCtx, () => recordFirstOutput(logCtx, start))
|
|
359
|
+
: (payload: string) => inspectResponseLogSsePayload(logCtx, payload);
|
|
360
|
+
const providerExecuted = responseToolOwnership(response);
|
|
349
361
|
await sendResponseToWebSocket(ws, response, isCurrent, {
|
|
350
362
|
untilEof: nativeControl?.relayActive === true,
|
|
351
|
-
onSsePayload:
|
|
352
|
-
|
|
353
|
-
|
|
363
|
+
onSsePayload: payload => {
|
|
364
|
+
ws.data.responsesActivity?.observePayload(payload, providerExecuted);
|
|
365
|
+
observeLog(payload);
|
|
366
|
+
},
|
|
354
367
|
onTerminal: status => {
|
|
355
368
|
terminalRecorder?.(status, logCtx.terminalHttpStatus);
|
|
356
369
|
finalizeLog(httpStatusForRequestLogTerminal(status, logCtx), {
|
|
@@ -392,6 +405,7 @@ export function createWebsocketHandler(
|
|
|
392
405
|
/* socket already gone or send dropped */
|
|
393
406
|
}
|
|
394
407
|
} finally {
|
|
408
|
+
finishActivity?.();
|
|
395
409
|
turnAdmissionLease.release();
|
|
396
410
|
if (ws.data.nativeControl === nativeControl) ws.data.nativeControl = undefined;
|
|
397
411
|
if (!logged && turnAbort.signal.aborted) finalizeLog(499);
|