@bitkyc08/opencodex 2.13.0 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/index-Co12XTT-.js +76 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/cursor/discovery.ts +4 -1
- package/src/adapters/cursor/effort-map.ts +5 -1
- package/src/adapters/cursor/request-builder.ts +3 -3
- package/src/adapters/google.ts +25 -5
- package/src/adapters/openai-chat.ts +182 -6
- package/src/adapters/openai-responses.ts +17 -7
- package/src/codex/catalog/bundled.ts +16 -0
- package/src/codex/catalog/metadata.ts +180 -5
- package/src/codex/catalog/parsing.ts +7 -6
- package/src/codex/catalog/sync.ts +73 -6
- package/src/codex/catalog.ts +1 -1
- package/src/codex/convergence.ts +20 -0
- package/src/codex/prompt-journal.ts +50 -13
- package/src/codex/prompt-layers.ts +1 -1
- package/src/config.ts +57 -0
- package/src/generated/compatibility-version.json +64 -48
- package/src/generated/model-metadata.ts +3 -3
- package/src/lib/local-provider-reload-contract.ts +100 -0
- package/src/oauth/login-cli.ts +52 -26
- package/src/providers/derive.ts +2 -0
- package/src/providers/openai-sidecar.ts +9 -2
- package/src/providers/quota.ts +57 -0
- package/src/providers/registry.ts +53 -25
- package/src/responses/state.ts +22 -0
- package/src/router.ts +1 -0
- package/src/server/claude-messages.ts +57 -11
- package/src/server/direct-local-http.ts +7 -3
- package/src/server/images.ts +6 -0
- package/src/server/index.ts +51 -11
- package/src/server/live.ts +117 -13
- package/src/server/local-provider-reload-client.ts +137 -0
- package/src/server/management/config-routes.ts +20 -3
- package/src/server/management/logs-usage-routes.ts +28 -0
- package/src/server/management/model-routes.ts +11 -3
- package/src/server/management/model-rows.ts +18 -3
- package/src/server/management/provider-routes.ts +107 -3
- package/src/server/management-auth.ts +65 -1
- package/src/server/proxy-liveness.ts +1 -0
- package/src/server/responses/agent-task-recovery-cache.ts +143 -0
- package/src/server/responses/agent-task-recovery.ts +460 -0
- package/src/server/responses/compact.ts +4 -2
- package/src/server/responses/core.ts +142 -6
- package/src/server/responses/encrypted-payload.ts +4 -1
- package/src/server/search.ts +4 -0
- package/src/types.ts +27 -0
- package/src/usage/expected-prices.ts +11 -0
- package/src/vision/describe.ts +4 -0
- package/src/web-search/anthropic-executor.ts +5 -1
- package/src/web-search/executor.ts +9 -1
- package/src/web-search/index.ts +5 -0
- package/src/web-search/loop.ts +42 -5
- package/gui/dist/assets/index-BHldBl6_.js +0 -76
|
@@ -19,6 +19,7 @@ import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractC
|
|
|
19
19
|
import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses";
|
|
20
20
|
import {
|
|
21
21
|
expandPreviousResponseInput,
|
|
22
|
+
markBodyNonPersistable,
|
|
22
23
|
previousResponseProviderState,
|
|
23
24
|
previousResponseReplayFailure,
|
|
24
25
|
rememberResponseState,
|
|
@@ -177,6 +178,10 @@ import {
|
|
|
177
178
|
relayWithAbort,
|
|
178
179
|
sanitizePassthroughHeaders,
|
|
179
180
|
} from "../relay";
|
|
181
|
+
import {
|
|
182
|
+
agentTaskRecoveryConfig,
|
|
183
|
+
recoverEncryptedAgentTask,
|
|
184
|
+
} from "./agent-task-recovery";
|
|
180
185
|
import { relaySseEagerBounded } from "../relay-eager";
|
|
181
186
|
import {
|
|
182
187
|
relayResponsesSseWithTerminalRepair,
|
|
@@ -1484,6 +1489,7 @@ async function handleResponsesInner(
|
|
|
1484
1489
|
// so an omitted value means a genuine Responses inbound.
|
|
1485
1490
|
const inboundWire = options.inboundWire ?? "responses";
|
|
1486
1491
|
const translatorBudget = options.translatorBudget;
|
|
1492
|
+
const agentTaskRecovery = agentTaskRecoveryConfig(config);
|
|
1487
1493
|
let body: unknown;
|
|
1488
1494
|
try {
|
|
1489
1495
|
body = await readJsonRequestBody(req, translatorBudget);
|
|
@@ -1494,7 +1500,7 @@ async function handleResponsesInner(
|
|
|
1494
1500
|
if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) {
|
|
1495
1501
|
return handleComboResponses(req, body, comboId, config, logCtx, options);
|
|
1496
1502
|
}
|
|
1497
|
-
|
|
1503
|
+
let unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
|
|
1498
1504
|
(body as { input?: unknown } | undefined)?.input,
|
|
1499
1505
|
);
|
|
1500
1506
|
const originalBody = body;
|
|
@@ -1621,7 +1627,9 @@ async function handleResponsesInner(
|
|
|
1621
1627
|
};
|
|
1622
1628
|
let selectedForwardHeaders = req.headers;
|
|
1623
1629
|
let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
|
|
1630
|
+
let subagentFallbackPreviewAccountId: string | null | undefined;
|
|
1624
1631
|
let subagentQuotaFailureModel = parsed.modelId;
|
|
1632
|
+
const parentThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() ?? null;
|
|
1625
1633
|
|
|
1626
1634
|
try {
|
|
1627
1635
|
if (
|
|
@@ -1645,6 +1653,7 @@ async function handleResponsesInner(
|
|
|
1645
1653
|
undefined,
|
|
1646
1654
|
previewSelectionOptions,
|
|
1647
1655
|
);
|
|
1656
|
+
subagentFallbackPreviewAccountId = previewAccountId;
|
|
1648
1657
|
subagentFallbackAccountId = previewAccountId ?? config.activeCodexAccountId ?? null;
|
|
1649
1658
|
const fallback = applySubagentModelFallback(
|
|
1650
1659
|
parsed,
|
|
@@ -1683,6 +1692,103 @@ async function handleResponsesInner(
|
|
|
1683
1692
|
previewSelectionAdmission?.release();
|
|
1684
1693
|
}
|
|
1685
1694
|
|
|
1695
|
+
// Native fallback can consume ciphertext, so recover only after final route selection.
|
|
1696
|
+
if (
|
|
1697
|
+
inboundWire === "responses"
|
|
1698
|
+
&&
|
|
1699
|
+
threadSpawn
|
|
1700
|
+
&& unreadableEncryptedAgentTask
|
|
1701
|
+
&& agentTaskRecovery
|
|
1702
|
+
&& !isCanonicalOpenAiForwardProvider(route.provider)
|
|
1703
|
+
&& !options.comboAttempt
|
|
1704
|
+
) {
|
|
1705
|
+
let recovered = false;
|
|
1706
|
+
try {
|
|
1707
|
+
recovered = await recoverEncryptedAgentTask(
|
|
1708
|
+
req,
|
|
1709
|
+
(body as { input?: unknown } | undefined)?.input,
|
|
1710
|
+
agentTaskRecovery,
|
|
1711
|
+
config,
|
|
1712
|
+
{ parentThreadId, abortSignal: options.abortSignal },
|
|
1713
|
+
);
|
|
1714
|
+
} catch {
|
|
1715
|
+
recovered = false;
|
|
1716
|
+
}
|
|
1717
|
+
if (recovered) {
|
|
1718
|
+
unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
|
|
1719
|
+
(body as { input?: unknown } | undefined)?.input,
|
|
1720
|
+
);
|
|
1721
|
+
if (!unreadableEncryptedAgentTask) {
|
|
1722
|
+
try {
|
|
1723
|
+
const reparsed = parseRequest(body);
|
|
1724
|
+
const kept: Array<keyof OcxParsedRequest> = [
|
|
1725
|
+
"_previousResponseInputExpanded",
|
|
1726
|
+
"_providerContinuation",
|
|
1727
|
+
"_cursorConversationId",
|
|
1728
|
+
"_clientThreadId",
|
|
1729
|
+
"_reasoningReplayScope",
|
|
1730
|
+
"_cursorIsolateConversation",
|
|
1731
|
+
];
|
|
1732
|
+
for (const key of kept) {
|
|
1733
|
+
if (parsed[key] !== undefined) {
|
|
1734
|
+
(reparsed as unknown as Record<string, unknown>)[key] = parsed[key];
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
parsed = reparsed;
|
|
1738
|
+
// The recovery mutated `body.input` in place, so `_rawBody` now carries decrypted task
|
|
1739
|
+
// text. Bar it from the continuation cache before any recording path can reach it —
|
|
1740
|
+
// that cache is persisted to disk, which would defeat the recovery cache's TTL.
|
|
1741
|
+
markBodyNonPersistable(parsed._rawBody);
|
|
1742
|
+
|
|
1743
|
+
// The ciphertext-only pass intentionally excludes routed candidates. Once recovery
|
|
1744
|
+
// makes the assignment readable, run selection again with the full configured chain
|
|
1745
|
+
// and keep the route in sync with any newly selected fallback.
|
|
1746
|
+
const fallback = applySubagentModelFallback(
|
|
1747
|
+
parsed,
|
|
1748
|
+
req.headers,
|
|
1749
|
+
config,
|
|
1750
|
+
subagentFallbackPreviewAccountId,
|
|
1751
|
+
Date.now(),
|
|
1752
|
+
false,
|
|
1753
|
+
previewSelectionOptions,
|
|
1754
|
+
);
|
|
1755
|
+
if (fallback) {
|
|
1756
|
+
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
|
|
1757
|
+
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
|
|
1758
|
+
if (isInjectionDebugEnabled()) {
|
|
1759
|
+
injectionDebugLog(`[opencodex] subagent model fallback ${fallback.from} -> ${fallback.to}`);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
subagentQuotaFailureModel = fallback?.to ?? parsed.modelId;
|
|
1763
|
+
|
|
1764
|
+
if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) {
|
|
1765
|
+
try {
|
|
1766
|
+
route = routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody));
|
|
1767
|
+
logCtx.routeDecision = route.routeDecision;
|
|
1768
|
+
} catch (err) {
|
|
1769
|
+
if (err instanceof NoAvailableComboTargetsError) {
|
|
1770
|
+
return comboUnavailableResponse(err.message);
|
|
1771
|
+
}
|
|
1772
|
+
if (err instanceof NoEligiblePolicyCandidateError) {
|
|
1773
|
+
logCtx.routeDecision = err.trace;
|
|
1774
|
+
}
|
|
1775
|
+
return formatErrorResponse(
|
|
1776
|
+
404,
|
|
1777
|
+
"invalid_request_error",
|
|
1778
|
+
err instanceof Error ? err.message : String(err),
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget);
|
|
1783
|
+
} catch {
|
|
1784
|
+
unreadableEncryptedAgentTask = true;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
1791
|
+
|
|
1686
1792
|
// Encrypted child tasks may only reach the canonical native backend. This check
|
|
1687
1793
|
// runs against the FINAL route so native-only fallback can rescue a routed primary.
|
|
1688
1794
|
if (!isCanonicalOpenAiForwardProvider(route.provider) && unreadableEncryptedAgentTask) {
|
|
@@ -2356,15 +2462,29 @@ async function handleResponsesInner(
|
|
|
2356
2462
|
}
|
|
2357
2463
|
if (!upstreamResponse.ok) {
|
|
2358
2464
|
if (options.comboAttempt) {
|
|
2465
|
+
// No pre-read guard here: `consumeComboFailure` -> `readBoundedResponseBody` reads
|
|
2466
|
+
// `response.body` itself and already threads the abort signal through its own read,
|
|
2467
|
+
// and the combo contract is that this body's getter is touched exactly once (pinned by
|
|
2468
|
+
// "captures passthrough failed usage from its original bounded body exactly once").
|
|
2469
|
+
// Attaching a guard would be a second `.body` access and break that contract for no
|
|
2470
|
+
// gain, since the bounded reader owns settlement on this path.
|
|
2359
2471
|
const failure = await consumeComboFailure(upstreamResponse, options.abortSignal);
|
|
2360
2472
|
options.onConsumedComboFailure?.(failure);
|
|
2361
2473
|
return failure.response;
|
|
2362
2474
|
}
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2475
|
+
// The plain passthrough error path has no bounded reader of its own: `.text()` attaches
|
|
2476
|
+
// the reader only when it runs, so an abort landing between fetch resolution and that
|
|
2477
|
+
// call orphans Bun's internal read rejection (src/lib/abort.ts).
|
|
2478
|
+
const detachPassthroughErrorGuard = cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
|
|
2479
|
+
try {
|
|
2480
|
+
const errorText = await upstreamResponse.text().catch(() => "");
|
|
2481
|
+
return formatPassthroughUpstreamError(upstreamResponse.status, errorText, {
|
|
2482
|
+
statusText: upstreamResponse.statusText,
|
|
2483
|
+
headers,
|
|
2484
|
+
});
|
|
2485
|
+
} finally {
|
|
2486
|
+
detachPassthroughErrorGuard();
|
|
2487
|
+
}
|
|
2368
2488
|
}
|
|
2369
2489
|
|
|
2370
2490
|
// Bun#32111 workaround: passthrough SSE uses tee()+native relay to avoid the
|
|
@@ -2891,6 +3011,7 @@ async function handleResponsesInner(
|
|
|
2891
3011
|
connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
|
|
2892
3012
|
routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
|
|
2893
3013
|
stallTimeoutSec: wsPlan.stallTimeoutSec,
|
|
3014
|
+
streamRoutedModelOutput: wsPlan.streamRoutedModelOutput,
|
|
2894
3015
|
on429: retryAfter => {
|
|
2895
3016
|
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
2896
3017
|
retryAfter,
|
|
@@ -3386,12 +3507,22 @@ async function handleResponsesInner(
|
|
|
3386
3507
|
}
|
|
3387
3508
|
if (!upstreamResponse.ok) {
|
|
3388
3509
|
if (options.comboAttempt) {
|
|
3510
|
+
// No pre-read guard: `consumeComboFailure` -> `readBoundedResponseBody` reads
|
|
3511
|
+
// `response.body` itself with the abort signal threaded through, and the combo
|
|
3512
|
+
// contract is that this body's getter is touched exactly once. A guard here would be
|
|
3513
|
+
// a second `.body` access for no gain, since the bounded reader owns settlement.
|
|
3389
3514
|
const failure = await consumeComboFailure(upstreamResponse, options.abortSignal)
|
|
3390
3515
|
.finally(cleanupUpstreamAbort);
|
|
3391
3516
|
options.onConsumedComboFailure?.(failure);
|
|
3392
3517
|
return failure.response;
|
|
3393
3518
|
}
|
|
3519
|
+
// The plain error path has no bounded reader of its own: `.text()` attaches the reader
|
|
3520
|
+
// only when it runs, so an abort landing between fetch resolution and that call orphans
|
|
3521
|
+
// Bun's internal read rejection — the uncatchable teardown `cancelBodyOnAbort` absorbs
|
|
3522
|
+
// (src/lib/abort.ts). Found while investigating #1419; not a fix for the native trap.
|
|
3523
|
+
const detachErrorBodyGuard = cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
|
|
3394
3524
|
const errorText = await upstreamResponse.text().catch(() => "unknown error");
|
|
3525
|
+
detachErrorBodyGuard();
|
|
3395
3526
|
cleanupUpstreamAbort();
|
|
3396
3527
|
if (!isFixedCodexAccount(authCtx)) {
|
|
3397
3528
|
recordSubagentQuotaFailureForThreadSpawn(
|
|
@@ -3655,7 +3786,12 @@ async function handleResponsesInner(
|
|
|
3655
3786
|
}
|
|
3656
3787
|
|
|
3657
3788
|
if (!response.ok) {
|
|
3789
|
+
// Same pre-read guard as the initial response's error branch: a non-2xx continuation body
|
|
3790
|
+
// is still a Bun fetch body, and an abort landing before `.text()` attaches its reader
|
|
3791
|
+
// orphans the internal rejection.
|
|
3792
|
+
const detachContinuationErrorGuard = cancelBodyOnAbort(response.body, upstream.signal);
|
|
3658
3793
|
const errorText = await response.text().catch(() => "unknown error");
|
|
3794
|
+
detachContinuationErrorGuard();
|
|
3659
3795
|
yield {
|
|
3660
3796
|
type: "error",
|
|
3661
3797
|
status: response.status,
|
|
@@ -145,6 +145,10 @@ function isStructurallyValidFernetToken(token: string): boolean {
|
|
|
145
145
|
return ciphertextLength >= 16 && ciphertextLength % 16 === 0;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
export function structurallyValidFernetTokens(payload: string): string[] {
|
|
149
|
+
return fernetTokenRuns(payload).map(run => run.token);
|
|
150
|
+
}
|
|
151
|
+
|
|
148
152
|
/** Maximal, boundary-delimited and structurally valid Fernet runs embedded in a slot. */
|
|
149
153
|
function fernetTokenRuns(payload: string): FernetTokenRun[] {
|
|
150
154
|
const runs: FernetTokenRun[] = [];
|
|
@@ -305,4 +309,3 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number {
|
|
|
305
309
|
visit(input);
|
|
306
310
|
return rewritten;
|
|
307
311
|
}
|
|
308
|
-
|
package/src/server/search.ts
CHANGED
|
@@ -152,6 +152,10 @@ export async function handleSearch(
|
|
|
152
152
|
headers,
|
|
153
153
|
body: JSON.stringify(relayBody),
|
|
154
154
|
signal: linkedSignal.signal,
|
|
155
|
+
// Credential-bearing: do not follow a cross-origin 3xx. Bun strips `Authorization`
|
|
156
|
+
// across origins but forwards nonstandard headers such as `chatgpt-account-id`,
|
|
157
|
+
// `session_id`, and `x-codex-turn-metadata` to the redirect target.
|
|
158
|
+
redirect: "manual",
|
|
155
159
|
});
|
|
156
160
|
const observed = await readBoundedResponseBytes(upstreamResponse, {
|
|
157
161
|
maxBytes: SEARCH_RESPONSE_MAX_BYTES,
|
package/src/types.ts
CHANGED
|
@@ -777,6 +777,16 @@ export interface OcxConfig {
|
|
|
777
777
|
* - "v2": force ALL models to v2 surface (override upstream pins)
|
|
778
778
|
*/
|
|
779
779
|
multiAgentMode?: "v1" | "default" | "v2";
|
|
780
|
+
/** Experimental, default-off ChatGPT recovery for encrypted V2 routed tasks. */
|
|
781
|
+
agentTaskRecovery?: {
|
|
782
|
+
enabled?: boolean;
|
|
783
|
+
/** ChatGPT model used by the recovery request. Default: gpt-5.6-sol. */
|
|
784
|
+
model?: string;
|
|
785
|
+
/** Recovery request timeout in milliseconds. Default: 45000. */
|
|
786
|
+
timeoutMs?: number;
|
|
787
|
+
/** Maximum in-memory ciphertext-to-assignment entries. Default: 200. */
|
|
788
|
+
cacheEntries?: number;
|
|
789
|
+
};
|
|
780
790
|
/** Provider-level Codex-visible context caps. Values only lower known model context windows. */
|
|
781
791
|
providerContextCaps?: Record<string, number>;
|
|
782
792
|
/** Global Codex-visible context cap value (tokens). Falls back to DEFAULT_PROVIDER_CONTEXT_CAP. */
|
|
@@ -1151,6 +1161,14 @@ export interface OcxWebSearchSidecarConfig {
|
|
|
1151
1161
|
* during a web-search turn. Default 200000. Must be an integer from 1 through 2147483647.
|
|
1152
1162
|
*/
|
|
1153
1163
|
routedModelStallTimeoutMs?: number;
|
|
1164
|
+
/**
|
|
1165
|
+
* Stream the routed model's leading output (text/thinking deltas) live instead of buffering the
|
|
1166
|
+
* whole iteration. Live delivery stops at the first tool-call boundary so web_search interception
|
|
1167
|
+
* stays atomic. Tradeoff: text the model emits BEFORE deciding to search — which buffered mode
|
|
1168
|
+
* silently drops — becomes visible to the client and may partially repeat in the post-search
|
|
1169
|
+
* answer. Default: false (buffered, previous behavior).
|
|
1170
|
+
*/
|
|
1171
|
+
streamRoutedModelOutput?: boolean;
|
|
1154
1172
|
}
|
|
1155
1173
|
|
|
1156
1174
|
export interface OpenRouterProviderRouting {
|
|
@@ -1438,6 +1456,15 @@ export interface OcxProviderConfig {
|
|
|
1438
1456
|
* fields. Default off; only enable for providers that document this parameter.
|
|
1439
1457
|
*/
|
|
1440
1458
|
promptCacheKey?: boolean;
|
|
1459
|
+
/**
|
|
1460
|
+
* Opt-in: forward `service_tier` to the upstream `/chat/completions` body.
|
|
1461
|
+
* OpenAI-specific extension with the same hazard as `promptCacheKey` — strict backends
|
|
1462
|
+
* reject unknown fields, and 66 registry providers share the `openai-chat` adapter, so a
|
|
1463
|
+
* caller-supplied `service_tier` would otherwise turn working requests into upstream 400s.
|
|
1464
|
+
* `supportsServiceTier` is the Responses-wire flag and does not apply here.
|
|
1465
|
+
* Default off; only enable for providers that document this parameter on the chat wire.
|
|
1466
|
+
*/
|
|
1467
|
+
chatServiceTier?: boolean;
|
|
1441
1468
|
/**
|
|
1442
1469
|
* Provider-local passthrough SSE repair for broken openai-responses gateways that reuse exact
|
|
1443
1470
|
* placeholder message/reasoning ids or omit the terminal id after a stable added event.
|
|
@@ -277,6 +277,17 @@ export const CONTEXT_TIERS: readonly ContextTier[] = [
|
|
|
277
277
|
source: "https://docs.x.ai/developers/pricing",
|
|
278
278
|
verifiedAt: "2026-08-03",
|
|
279
279
|
},
|
|
280
|
+
{
|
|
281
|
+
// 260813: grok-4.6 long-context tier mirrored from grok-4.5; the official pricing row
|
|
282
|
+
// was not yet published when the model page went up, so treat as provisional.
|
|
283
|
+
provider: "xai",
|
|
284
|
+
modelId: "grok-4.6",
|
|
285
|
+
thresholdInputTokens: 200_000,
|
|
286
|
+
inclusive: true,
|
|
287
|
+
multiplier: UNIFORM_DOUBLE,
|
|
288
|
+
source: "https://docs.x.ai/developers/pricing",
|
|
289
|
+
verifiedAt: "2026-08-13",
|
|
290
|
+
},
|
|
280
291
|
{
|
|
281
292
|
// daybreak-blue-latest aliases gpt-5.6-sol, which publishes the full long-context row
|
|
282
293
|
// ($10 / $1 / $12.50 / $45). Scoped to openai-apikey ON PURPOSE: Daybreak is not
|
package/src/vision/describe.ts
CHANGED
|
@@ -95,6 +95,10 @@ export async function describeImage(
|
|
|
95
95
|
headers,
|
|
96
96
|
body: JSON.stringify(body),
|
|
97
97
|
signal: linkedSignal.signal,
|
|
98
|
+
// Credential-bearing: do not follow a cross-origin 3xx. Bun strips `Authorization`
|
|
99
|
+
// across origins but forwards nonstandard headers such as `chatgpt-account-id`,
|
|
100
|
+
// `session_id`, and `x-codex-turn-metadata` to the redirect target.
|
|
101
|
+
redirect: "manual",
|
|
98
102
|
}),
|
|
99
103
|
{ abortSignal: linkedSignal.signal, label: "vision-sidecar" },
|
|
100
104
|
);
|
|
@@ -166,13 +166,17 @@ export async function runAnthropicWebSearch(
|
|
|
166
166
|
() => fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal: linkedSignal.signal }),
|
|
167
167
|
{ abortSignal: linkedSignal.signal, label: "web-search-sidecar-anthropic" },
|
|
168
168
|
);
|
|
169
|
+
// Guard before any branch reads the body: the failure branch's `res.text()` ran ahead of
|
|
170
|
+
// the success-path guard, reopening the fetch-resolution-to-reader-attach race
|
|
171
|
+
// (found investigating #1419).
|
|
172
|
+
const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
|
|
169
173
|
if (!res.ok) {
|
|
170
174
|
const t = await res.text().catch(() => "");
|
|
175
|
+
detachBodyGuard();
|
|
171
176
|
console.warn(`[web-search] anthropic sidecar HTTP ${res.status} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`);
|
|
172
177
|
// Redact before surfacing: the body can echo auth headers/tokens (#398 review).
|
|
173
178
|
return { text: "", sources: [], error: `sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` };
|
|
174
179
|
}
|
|
175
|
-
const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
|
|
176
180
|
try {
|
|
177
181
|
return await parseAnthropicSidecarSSE(res);
|
|
178
182
|
} finally {
|
|
@@ -78,16 +78,24 @@ export async function runWebSearch(
|
|
|
78
78
|
headers,
|
|
79
79
|
body: JSON.stringify(body),
|
|
80
80
|
signal: linkedSignal.signal,
|
|
81
|
+
// Credential-bearing: do not follow a cross-origin 3xx. Bun strips `Authorization`
|
|
82
|
+
// across origins but forwards nonstandard headers such as `chatgpt-account-id`,
|
|
83
|
+
// `session_id`, and `x-codex-turn-metadata` to the redirect target.
|
|
84
|
+
redirect: "manual",
|
|
81
85
|
}),
|
|
82
86
|
{ abortSignal: linkedSignal.signal, label: "web-search-sidecar" },
|
|
83
87
|
);
|
|
84
88
|
recordOutcome?.(res.status);
|
|
89
|
+
// Attach the body guard before ANY branch reads it. The success path guarded itself below,
|
|
90
|
+
// but the failure branch's `res.text()` runs first, so a cancel landing between fetch
|
|
91
|
+
// resolution and reader attach orphaned the internal rejection (found investigating #1419).
|
|
92
|
+
const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
|
|
85
93
|
if (!res.ok) {
|
|
86
94
|
const t = await res.text().catch(() => "");
|
|
95
|
+
detachBodyGuard();
|
|
87
96
|
console.warn(`[web-search] sidecar HTTP ${res.status} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`);
|
|
88
97
|
return { text: "", sources: [], error: `sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` };
|
|
89
98
|
}
|
|
90
|
-
const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
|
|
91
99
|
try {
|
|
92
100
|
return await parseSidecarSSE(res);
|
|
93
101
|
} finally {
|
package/src/web-search/index.ts
CHANGED
|
@@ -120,6 +120,8 @@ export interface SidecarPlan {
|
|
|
120
120
|
routedModelStallTimeoutMs: number;
|
|
121
121
|
/** Effective bridge stall deadline for the sidecar turn (see webSearchStallTimeoutSec). */
|
|
122
122
|
stallTimeoutSec: number;
|
|
123
|
+
/** Stream leading routed-model output live until the first tool-call boundary (opt-in). */
|
|
124
|
+
streamRoutedModelOutput: boolean;
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
export function shouldResolveOpenAiWebSearchSidecar(
|
|
@@ -166,6 +168,7 @@ export function planWebSearch(
|
|
|
166
168
|
// The routed model being text-only means the search model must verbalize image results (either backend).
|
|
167
169
|
const describeImages = modelInList(provider.noVisionModels, modelId);
|
|
168
170
|
const reasoning = cfg.reasoning ?? DEFAULT_SIDECAR_REASONING;
|
|
171
|
+
const streamRoutedModelOutput = cfg.streamRoutedModelOutput === true;
|
|
169
172
|
|
|
170
173
|
// Anthropic backend authenticates with the STORED credential — no forward provider or ChatGPT login gate.
|
|
171
174
|
// resolveSidecarBackend only returns "anthropic" when it was explicitly configured OR a usable credential
|
|
@@ -181,6 +184,7 @@ export function planWebSearch(
|
|
|
181
184
|
maxSearches,
|
|
182
185
|
routedModelStallTimeoutMs,
|
|
183
186
|
stallTimeoutSec,
|
|
187
|
+
streamRoutedModelOutput,
|
|
184
188
|
};
|
|
185
189
|
}
|
|
186
190
|
|
|
@@ -194,5 +198,6 @@ export function planWebSearch(
|
|
|
194
198
|
maxSearches,
|
|
195
199
|
routedModelStallTimeoutMs,
|
|
196
200
|
stallTimeoutSec,
|
|
201
|
+
streamRoutedModelOutput,
|
|
197
202
|
};
|
|
198
203
|
}
|
package/src/web-search/loop.ts
CHANGED
|
@@ -267,6 +267,12 @@ export interface WebSearchLoopDeps {
|
|
|
267
267
|
* sidecar search, so a legitimately slow-but-progressing unit never trips the bridge watchdog.
|
|
268
268
|
*/
|
|
269
269
|
stallTimeoutSec?: number;
|
|
270
|
+
/**
|
|
271
|
+
* Opt-in: stream the routed model's leading text/thinking deltas live instead of holding the whole
|
|
272
|
+
* iteration back. The live window closes at the first buffer-only event (tool calls above all) so
|
|
273
|
+
* the web_search interception decision stays atomic; everything after replays in order at the end.
|
|
274
|
+
*/
|
|
275
|
+
streamRoutedModelOutput?: boolean;
|
|
270
276
|
/** One-shot TTFT callback: first non-empty model output observed (WP4). */
|
|
271
277
|
onFirstOutput?: () => void;
|
|
272
278
|
/** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */
|
|
@@ -336,7 +342,14 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
336
342
|
response: Response;
|
|
337
343
|
responseAdapter: ProviderAdapter;
|
|
338
344
|
}
|
|
339
|
-
type IterationSplit = ReturnType<typeof scanEventsForWebSearch
|
|
345
|
+
type IterationSplit = ReturnType<typeof scanEventsForWebSearch> & {
|
|
346
|
+
/**
|
|
347
|
+
* How many leading passthrough events were already delivered live this iteration. They are
|
|
348
|
+
* exactly the first N passthrough entries (live delivery stops before the first event that
|
|
349
|
+
* scanEventsForWebSearch could group or reorder), so the terminal replay skips them by count.
|
|
350
|
+
*/
|
|
351
|
+
streamedPassthroughCount: number;
|
|
352
|
+
};
|
|
340
353
|
|
|
341
354
|
// Same-target 429 budget is per REQUEST, not per model iteration: later search rounds inherit
|
|
342
355
|
// what earlier rounds left of `attempts`, so a bounded multi-round turn can never exceed the
|
|
@@ -531,10 +544,23 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
531
544
|
return r.value;
|
|
532
545
|
};
|
|
533
546
|
|
|
547
|
+
// Event types that may leave the live window before the first tool-call boundary: pure
|
|
548
|
+
// text/thinking output the native (sidecar-less) path would deliver identically. Everything
|
|
549
|
+
// else — tool calls above all, and any type scanEventsForWebSearch could group or a future
|
|
550
|
+
// adapter could add — closes the window so live delivery can never reorder against the replay.
|
|
551
|
+
const LIVE_STREAMABLE = new Set<AdapterEvent["type"]>([
|
|
552
|
+
"text_delta", "thinking_delta", "reasoning_raw_delta",
|
|
553
|
+
"thinking_signature", "redacted_thinking", "kiro_redacted_reasoning",
|
|
554
|
+
]);
|
|
555
|
+
|
|
534
556
|
// Consume and validate one successful response body under a resettable raw-byte inactivity guard.
|
|
535
|
-
//
|
|
557
|
+
// By default only invisible heartbeat events escape while semantic output remains buffered for
|
|
558
|
+
// safe scanning; with `streamRoutedModelOutput` the leading text/thinking deltas stream live and
|
|
559
|
+
// the live window closes permanently at the first buffer-only event (see LIVE_STREAMABLE).
|
|
536
560
|
const consumeIterationEvents = async function* (prepared: IterationResponse): AsyncGenerator<AdapterEvent, IterationSplit> {
|
|
537
561
|
const events: AdapterEvent[] = [];
|
|
562
|
+
let liveWindowOpen = deps.streamRoutedModelOutput === true;
|
|
563
|
+
let streamedPassthroughCount = 0;
|
|
538
564
|
try {
|
|
539
565
|
const parse = prepared.responseAdapter.parseStream.bind(prepared.responseAdapter);
|
|
540
566
|
for await (const event of parseStreamWithProgress(prepared.response, parse, {
|
|
@@ -550,7 +576,16 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
550
576
|
// Tool events remain buffered below, so the decision to invoke the hosted sidecar is still
|
|
551
577
|
// atomic and no search call can escape before its stream has validated successfully.
|
|
552
578
|
else if (event.type === "text_delta" && event.phase === "commentary") yield event;
|
|
553
|
-
else
|
|
579
|
+
else if (liveWindowOpen && LIVE_STREAMABLE.has(event.type)) {
|
|
580
|
+
// Live events are ALSO buffered: the scanner still needs them for thinking extraction
|
|
581
|
+
// and the forced-answer output check; only the terminal replay skips them (by count).
|
|
582
|
+
yield event;
|
|
583
|
+
streamedPassthroughCount++;
|
|
584
|
+
events.push(event);
|
|
585
|
+
} else {
|
|
586
|
+
liveWindowOpen = false;
|
|
587
|
+
events.push(event);
|
|
588
|
+
}
|
|
554
589
|
}
|
|
555
590
|
} catch (error) {
|
|
556
591
|
if (isTranslatorBudgetExceededError(error)) throw error;
|
|
@@ -572,7 +607,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
572
607
|
}
|
|
573
608
|
throw new LoopError(502, terminal.message);
|
|
574
609
|
}
|
|
575
|
-
return scanEventsForWebSearch(events);
|
|
610
|
+
return { ...scanEventsForWebSearch(events), streamedPassthroughCount };
|
|
576
611
|
};
|
|
577
612
|
|
|
578
613
|
// Execute one model-requested web_search call. The call may batch several queries (native
|
|
@@ -743,7 +778,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
743
778
|
+ `, ${i + 1} iteration${i > 0 ? "s" : ""}, ${Date.now() - loopT0}ms`,
|
|
744
779
|
);
|
|
745
780
|
}
|
|
746
|
-
|
|
781
|
+
// Live-streamed leading events are exactly the first N passthrough entries — replay
|
|
782
|
+
// only the buffered tail so nothing reaches the client twice.
|
|
783
|
+
yield* replay(split.passthrough.slice(split.streamedPassthroughCount));
|
|
747
784
|
return;
|
|
748
785
|
}
|
|
749
786
|
// The thinking that led to the search belongs to the FIRST call's assistant replay turn.
|