@bitkyc08/opencodex 2.43.0 → 2.44.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/README.md +25 -17
- package/gui/dist/assets/index-B7_K1Hsj.js +115 -0
- package/gui/dist/assets/index-ltx3L-WS.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +23 -2
- package/src/adapters/cursor/native-exec.ts +63 -11
- package/src/adapters/cursor/tool-definitions.ts +1 -1
- package/src/adapters/cursor/tool-schemas.ts +59 -0
- package/src/adapters/kiro.ts +54 -8
- package/src/adapters/openai-chat.ts +23 -6
- package/src/adapters/openai-responses.ts +3 -1
- package/src/adapters/opencode-go.ts +35 -0
- package/src/claude/agents-inject.ts +12 -5
- package/src/claude/desktop-3p-library.ts +89 -0
- package/src/claude/desktop-3p.ts +166 -95
- package/src/claude/desktop-discovery-inputs.ts +44 -0
- package/src/claude/desktop-profile.ts +1 -1
- package/src/claude/desktop-remote-store-artifact.ts +185 -0
- package/src/claude/desktop-remote-store-io.ts +91 -0
- package/src/claude/desktop-remote-store-state.ts +127 -0
- package/src/claude/desktop-remote-store.ts +269 -0
- package/src/claude/inbound-model-options.ts +10 -2
- package/src/claude/inbound-records.ts +7 -0
- package/src/claude/inbound.ts +1 -1
- package/src/claude/model-info.ts +16 -3
- package/src/cli/account-api.ts +7 -1
- package/src/cli/aside-profiles.ts +17 -0
- package/src/cli/capabilities.ts +39 -0
- package/src/cli/claude-agent-startup-sync.ts +3 -1
- package/src/cli/claude-desktop.ts +127 -18
- package/src/cli/connect.ts +27 -14
- package/src/cli/dispatch.ts +30 -16
- package/src/cli/export-command.ts +3 -1
- package/src/cli/index.ts +28 -16
- package/src/cli/integrations.ts +46 -12
- package/src/cli/opencode.ts +11 -4
- package/src/cli/runtime-api.ts +7 -2
- package/src/cli/uninstall-client-state.ts +78 -0
- package/src/client/connect.ts +476 -223
- package/src/client/hub-client.ts +81 -0
- package/src/client/hub-relay.ts +38 -6
- package/src/client/lifecycle-lock.ts +129 -0
- package/src/client/state.ts +71 -42
- package/src/clients/aside-profiles.ts +224 -0
- package/src/codex/account-lifecycle.ts +5 -9
- package/src/codex/auth-api.ts +50 -4
- package/src/codex/catalog/metadata.ts +0 -2
- package/src/codex/catalog/provider-fetch.ts +7 -5
- package/src/codex/catalog/sync.ts +88 -24
- package/src/codex/convergence.ts +2 -0
- package/src/codex/inject.ts +45 -14
- package/src/codex/journal.ts +6 -6
- package/src/codex/quota-refresh-outcome.ts +27 -0
- package/src/codex/shim.ts +7 -1
- package/src/combos/resolve.ts +10 -7
- package/src/config.ts +18 -5
- package/src/generated/compatibility-version.json +209 -105
- package/src/images/loop.ts +5 -2
- package/src/integrations/aside-profile-context.ts +270 -0
- package/src/integrations/aside-profile-journal.ts +229 -0
- package/src/integrations/aside-profiles.ts +176 -0
- package/src/integrations/catalog-refresh.ts +40 -0
- package/src/integrations/config-io.ts +16 -1
- package/src/integrations/owned-refresh.ts +14 -2
- package/src/integrations/state.ts +3 -1
- package/src/integrations/writer.ts +3 -1
- package/src/lab/fabric/producer-isolate.ts +84 -46
- package/src/lib/account-selection-events.ts +32 -0
- package/src/lib/errors.ts +9 -1
- package/src/lib/provider-outbound.ts +2 -45
- package/src/lib/proxy-env.ts +67 -0
- package/src/oauth/account-quota-rank.ts +9 -1
- package/src/oauth/anthropic-routing.ts +91 -23
- package/src/oauth/generic-account-failover.ts +15 -53
- package/src/oauth/index.ts +67 -68
- package/src/oauth/pool-settings-capability.ts +2 -1
- package/src/oauth/store.ts +91 -7
- package/src/oauth/types.ts +8 -0
- package/src/providers/api-key-selection.ts +110 -0
- package/src/providers/api-keys.ts +38 -31
- package/src/providers/context-cap.ts +27 -3
- package/src/providers/key-failover.ts +27 -17
- package/src/providers/provider-id-rewrite.ts +10 -8
- package/src/providers/registry.ts +1 -0
- package/src/responses/custom-tool-compat.ts +10 -0
- package/src/responses/function-call-compat.ts +173 -0
- package/src/responses/namespace-tool-compat.ts +27 -4
- package/src/responses/parser.ts +8 -0
- package/src/responses/task-input.ts +36 -0
- package/src/responses/tool-name-aliases.ts +79 -0
- package/src/router.ts +2 -0
- package/src/routing/capability.ts +10 -3
- package/src/routing/compatibility/assemble.ts +16 -1
- package/src/routing/evaluator.ts +5 -1
- package/src/server/auth-cors.ts +3 -0
- package/src/server/chat-native.ts +44 -9
- package/src/server/claude-messages.ts +71 -36
- package/src/server/grok-responses-snapshot-repair.ts +333 -0
- package/src/server/index.ts +26 -10
- package/src/server/management/account-selection-stream.ts +70 -0
- package/src/server/management/aside-profile-routes.ts +231 -0
- package/src/server/management/config-routes.ts +36 -41
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +61 -3
- package/src/server/management/logs-usage-routes.ts +1 -0
- package/src/server/management/model-routes.ts +20 -9
- package/src/server/management/model-rows.ts +10 -2
- package/src/server/management/oauth-account-routes.ts +7 -0
- package/src/server/management/provider-routes.ts +10 -8
- package/src/server/management/route-registry.ts +20 -2
- package/src/server/management-auth.ts +16 -0
- package/src/server/relay-eager.ts +17 -5
- package/src/server/relay.ts +43 -4
- package/src/server/request-log.ts +29 -1
- package/src/server/responses/agent-task-recovery-cache.ts +11 -0
- package/src/server/responses/agent-task-recovery.ts +24 -4
- package/src/server/responses/codex-ws-pool.ts +4 -4
- package/src/server/responses/codex-ws-session.ts +2 -2
- package/src/server/responses/collaboration.ts +2 -1
- package/src/server/responses/combo-stream-preflight.ts +26 -7
- package/src/server/responses/core.ts +668 -151
- package/src/server/responses/fetch-helpers.ts +6 -1
- package/src/server/responses/ws-upstream.ts +8 -3
- package/src/server/responses-custom-tool-repair.ts +29 -9
- package/src/server/responses-function-tool-repair.ts +183 -0
- package/src/server/responses-snapshot-codec.ts +14 -0
- package/src/server/responses-snapshot-repair.ts +1 -13
- package/src/server/responses-undeclared-tool-guard.ts +1 -68
- package/src/server/sse-payload-rewrite.ts +3 -1
- package/src/service.ts +6 -2
- package/src/types/config.ts +23 -16
- package/src/types/provider.ts +14 -3
- package/src/types/request.ts +2 -0
- package/src/usage/log.ts +9 -0
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/index-DS1NE4Jn.css +0 -1
- package/gui/dist/assets/index-Djowl68T.js +0 -112
|
@@ -60,7 +60,7 @@ import { clearThreadAccountMap } from "../../codex/routing";
|
|
|
60
60
|
import { primeCodexPoolQuotas } from "../../codex/auth-api";
|
|
61
61
|
import { clearModelCache, getProviderDiscoveryStatus } from "../../codex/model-cache";
|
|
62
62
|
import { getCodexModelEntitlementStatus } from "../../codex/model-entitlements";
|
|
63
|
-
import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
|
|
63
|
+
import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, selectedProviderContextCaps, forgetProviderContextCap, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
|
|
64
64
|
import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget";
|
|
65
65
|
import { resolveCodexHomeDir } from "../../codex/home";
|
|
66
66
|
import { readUsageEntries } from "../../usage/log";
|
|
@@ -268,7 +268,7 @@ function providerEditorCandidate(
|
|
|
268
268
|
candidate.providers = providers;
|
|
269
269
|
for (const name of removedProviders) {
|
|
270
270
|
dropProviderCustomModels(candidate, name);
|
|
271
|
-
|
|
271
|
+
forgetProviderContextCap(candidate, name);
|
|
272
272
|
}
|
|
273
273
|
const validated = validateConfigCandidate(candidate);
|
|
274
274
|
if (!validated.ok) {
|
|
@@ -289,6 +289,8 @@ function adoptProviderEditorCandidate(live: OcxConfig, persisted: OcxConfig): vo
|
|
|
289
289
|
else live.customModels = structuredClone(persisted.customModels);
|
|
290
290
|
if (persisted.providerContextCaps === undefined) delete live.providerContextCaps;
|
|
291
291
|
else live.providerContextCaps = structuredClone(persisted.providerContextCaps);
|
|
292
|
+
if (persisted.providerContextCapValues === undefined) delete live.providerContextCapValues;
|
|
293
|
+
else live.providerContextCapValues = structuredClone(persisted.providerContextCapValues);
|
|
292
294
|
if (persisted.disabledModels === undefined) delete live.disabledModels;
|
|
293
295
|
else live.disabledModels = [...persisted.disabledModels];
|
|
294
296
|
if (persisted.modelDiscovery === undefined) delete live.modelDiscovery;
|
|
@@ -849,7 +851,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
849
851
|
persisted.modelDiscovery = candidate.config.modelDiscovery;
|
|
850
852
|
for (const name of candidate.removedProviders) {
|
|
851
853
|
dropProviderCustomModels(persisted, name);
|
|
852
|
-
|
|
854
|
+
forgetProviderContextCap(persisted, name);
|
|
853
855
|
}
|
|
854
856
|
return {
|
|
855
857
|
changed: true,
|
|
@@ -1381,7 +1383,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1381
1383
|
delete config.providers[name];
|
|
1382
1384
|
const { dropProviderCustomModels } = await import("../../providers/provider-id-rewrite");
|
|
1383
1385
|
const droppedCustomModels = dropProviderCustomModels(config, name);
|
|
1384
|
-
|
|
1386
|
+
forgetProviderContextCap(config, name);
|
|
1385
1387
|
save(config);
|
|
1386
1388
|
await replaceProviderAccountSet(name, null);
|
|
1387
1389
|
reconcileLiveStateStores();
|
|
@@ -1397,7 +1399,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1397
1399
|
}
|
|
1398
1400
|
|
|
1399
1401
|
if (url.pathname === "/api/provider-context-caps" && req.method === "GET") {
|
|
1400
|
-
return jsonResponse({ cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config) });
|
|
1402
|
+
return jsonResponse({ cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config), values: selectedProviderContextCaps(config) });
|
|
1401
1403
|
}
|
|
1402
1404
|
|
|
1403
1405
|
if (url.pathname === "/api/provider-context-caps" && req.method === "PUT") {
|
|
@@ -1413,7 +1415,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1413
1415
|
ok: true,
|
|
1414
1416
|
cap: DEFAULT_PROVIDER_CONTEXT_CAP,
|
|
1415
1417
|
value: globalContextCapValue(config),
|
|
1416
|
-
caps: providerContextCaps(config),
|
|
1418
|
+
caps: providerContextCaps(config), values: selectedProviderContextCaps(config),
|
|
1417
1419
|
catalogRefresh,
|
|
1418
1420
|
});
|
|
1419
1421
|
|
|
@@ -1432,8 +1434,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1432
1434
|
|
|
1433
1435
|
// Branch 1: per-provider toggle (checked first: a per-provider request may carry an
|
|
1434
1436
|
// explicit `value`, which must never fall through to the global-value branch). Enable
|
|
1435
|
-
//
|
|
1436
|
-
// that value is never copied to other providers.
|
|
1437
|
+
// restores the selected provider value, then the global default, unless an explicit
|
|
1438
|
+
// per-provider value is supplied; that value is never copied to other providers.
|
|
1437
1439
|
if (typeof body.provider === "string" && typeof body.enabled === "boolean") {
|
|
1438
1440
|
const provider = body.provider.trim();
|
|
1439
1441
|
if (!isValidProviderName(provider)) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Declared inventory of every reachable management route.
|
|
3
3
|
*
|
|
4
|
-
* DECLARED, not harvested. A grep cannot see this surface:
|
|
4
|
+
* DECLARED, not harvested. A grep cannot see this surface: some routes are registered
|
|
5
5
|
* through a regex, an `endsWith`, a `pathname.slice`, a prefix decode, a path constant, or a
|
|
6
6
|
* negated `pathname !== "…"` guard, and two of those are live routes whose only textual
|
|
7
7
|
* trace is the negated form. For `GET /api/storage` an equality scan finds solely the dead
|
|
@@ -26,6 +26,8 @@ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
|
|
|
26
26
|
* can check, not a way to quiet the parity test.
|
|
27
27
|
*/
|
|
28
28
|
export type ExemptionReason =
|
|
29
|
+
/** Invalidates dashboard state; the CLI reads the underlying resource directly. */
|
|
30
|
+
| "gui-invalidation"
|
|
29
31
|
/** Requires a dashboard browser session. Includes the user-consent star boundary. */
|
|
30
32
|
| "session-only"
|
|
31
33
|
/** Deliberately returns 405; there is nothing to drive. */
|
|
@@ -36,6 +38,8 @@ export type ExemptionReason =
|
|
|
36
38
|
| "test-seam"
|
|
37
39
|
/** The CLI reaches the same data through a local transport instead of HTTP. */
|
|
38
40
|
| "local-transport"
|
|
41
|
+
/** Older clients use this alias; the current CLI drives its declared replacement. */
|
|
42
|
+
| "compatibility-alias"
|
|
39
43
|
/** Unreachable in the live dispatch order; delete rather than expose. */
|
|
40
44
|
| "dead"
|
|
41
45
|
/**
|
|
@@ -135,6 +139,19 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
135
139
|
{ method: "PUT", path: "/api/subagent-model-fallback", module: "server/management/agent-settings-routes", mutates: true },
|
|
136
140
|
{ method: "PUT", path: "/api/subagent-models", module: "server/management/agent-settings-routes", mutates: true },
|
|
137
141
|
{ method: "PUT", path: "/api/v2", module: "server/management/agent-settings-routes", mutates: true },
|
|
142
|
+
// server/management/aside-profile-routes
|
|
143
|
+
{ method: "GET", path: "/api/client-integrations/aside", module: "server/management/aside-profile-routes", mutates: false, mechanism: "path-constant", exempt: { reason: "compatibility-alias", why: "Legacy Aside status alias; the current CLI reads the same aggregate through GET /api/client-integrations/aside/profiles." } },
|
|
144
|
+
{ method: "PUT", path: "/api/client-integrations/aside", module: "server/management/aside-profile-routes", mutates: true, mechanism: "path-constant", exempt: { reason: "compatibility-alias", why: "Legacy Aside toggle alias; the current CLI uses PUT /api/client-integrations/aside/profiles so older servers cannot mistake a bulk request for a current-account toggle." } },
|
|
145
|
+
{ method: "GET", path: "/api/client-integrations/aside/profiles", module: "server/management/aside-profile-routes", mutates: false, mechanism: "path-constant" },
|
|
146
|
+
{ method: "PUT", path: "/api/client-integrations/aside/profiles", module: "server/management/aside-profile-routes", mutates: true, mechanism: "path-constant" },
|
|
147
|
+
{ method: "POST", path: "/api/client-integrations/aside/sync", module: "server/management/aside-profile-routes", mutates: true },
|
|
148
|
+
{ method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" },
|
|
149
|
+
{ method: "PUT", path: "/api/client-integrations/aside/profiles/{profileId}", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode" },
|
|
150
|
+
{ method: "GET", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" },
|
|
151
|
+
{ method: "DELETE", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside history deletion uses the dashboard journal cleanup; the CLI has history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
|
|
152
|
+
{ method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" },
|
|
153
|
+
{ method: "DELETE", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside profile history deletion uses the dashboard journal cleanup; the CLI has scoped history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
|
|
154
|
+
{ method: "POST", path: "/api/client-integrations/aside/profiles/{profileId}/restore", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode" },
|
|
138
155
|
// server/management/codex-prompt-routes
|
|
139
156
|
{ method: "GET", path: "/api/codex-prompt", module: "server/management/codex-prompt-routes", mutates: false },
|
|
140
157
|
{ method: "GET", path: "/api/codex-prompt/text", module: "server/management/codex-prompt-routes", mutates: false },
|
|
@@ -241,6 +258,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
241
258
|
{ method: "GET", path: "/api/key-providers", module: "server/management/oauth-account-routes", mutates: false },
|
|
242
259
|
{ method: "GET", path: "/api/keys", module: "server/management/oauth-account-routes", mutates: false },
|
|
243
260
|
{ method: "GET", path: "/api/oauth/accounts", module: "server/management/oauth-account-routes", mutates: false },
|
|
261
|
+
{ method: "GET", path: "/api/accounts/events", module: "server/management/oauth-account-routes", mutates: false, exempt: { reason: "gui-invalidation", why: "Dashboard selection invalidation stream; CLI account commands read the authoritative account/key resources directly rather than subscribing to browser refresh notifications." } },
|
|
244
262
|
{ method: "GET", path: "/api/oauth/accounts/pool", module: "server/management/oauth-account-routes", mutates: false },
|
|
245
263
|
{ method: "GET", path: "/api/oauth/providers", module: "server/management/oauth-account-routes", mutates: false },
|
|
246
264
|
{ method: "GET", path: "/api/oauth/status", module: "server/management/oauth-account-routes", mutates: false },
|
|
@@ -303,7 +321,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
303
321
|
{ method: "GET", path: "/api/system/memory", module: "server/management/system-routes", mutates: false },
|
|
304
322
|
{ method: "GET", path: "/api/system/windows-replace-retries", module: "server/management/system-routes", mutates: false },
|
|
305
323
|
{ method: "POST", path: "/api/system/restart", module: "server/management/system-routes", mutates: true },
|
|
306
|
-
// ---
|
|
324
|
+
// --- Further routes an equality scan of their own file cannot see. ---
|
|
307
325
|
// Each carries `mechanism`; the reconciliation test counts these separately.
|
|
308
326
|
{ method: "GET", path: "/api/storage", module: "server/management/storage-log-guard-routes", mutates: false, mechanism: "negated-guard" },
|
|
309
327
|
{ method: "GET", path: "/api/routing-analytics", module: "server/management/routing-analytics-routes", mutates: false, mechanism: "negated-guard" },
|
|
@@ -251,10 +251,26 @@ export function issueGuiSession(
|
|
|
251
251
|
|
|
252
252
|
export interface ManagementSessionControl {
|
|
253
253
|
revokeCurrent(req: Request): boolean;
|
|
254
|
+
/** Revalidate a long-lived request against current authority, without cached admission or renewal. */
|
|
255
|
+
isCurrent(req: Request, config: OcxConfig): boolean;
|
|
254
256
|
}
|
|
255
257
|
|
|
256
258
|
export function createManagementSessionControl(state: ManagementAuthState): ManagementSessionControl {
|
|
257
259
|
return {
|
|
260
|
+
isCurrent(req: Request, config: OcxConfig): boolean {
|
|
261
|
+
if (!state.available) return false;
|
|
262
|
+
const credential = requestManagementCredential(req);
|
|
263
|
+
if (!credential) return false;
|
|
264
|
+
if (equalSecret(credential, state.token)) return true;
|
|
265
|
+
const session = state.sessions.get(credential);
|
|
266
|
+
if (!session) return false;
|
|
267
|
+
// Reuse the full origin/expiry/CSRF predicate against the current record, but
|
|
268
|
+
// isolate its sliding-expiry mutation: SSE heartbeats are not browser activity.
|
|
269
|
+
return authorizeGuiSessionRequest(req, config, {
|
|
270
|
+
sessions: new Map([[credential, { ...session }]]),
|
|
271
|
+
pairingGrants: state.pairingGrants,
|
|
272
|
+
}).ok;
|
|
273
|
+
},
|
|
258
274
|
revokeCurrent(req: Request): boolean {
|
|
259
275
|
if (!state.available) return false;
|
|
260
276
|
const credential = requestManagementCredential(req);
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
createSseTerminalOutputBoundary,
|
|
30
30
|
doneFrame,
|
|
31
31
|
failedTailFrame,
|
|
32
|
+
upstreamErrorTailFrame,
|
|
32
33
|
} from "./relay";
|
|
33
34
|
import {
|
|
34
35
|
nextSseBlock,
|
|
@@ -63,7 +64,7 @@ export type EagerRelayHooks = {
|
|
|
63
64
|
/** True once inspection has reported a protocol terminal (inspector.reported). */
|
|
64
65
|
sawTerminal: () => boolean;
|
|
65
66
|
/** Record a synthetic terminal (caller decides incomplete vs failed-502). */
|
|
66
|
-
onSynthetic: (kind: "incomplete" | "failed") => void;
|
|
67
|
+
onSynthetic: (kind: "incomplete" | "failed", reason?: "upstream_error") => void;
|
|
67
68
|
/** Client cancelled and NO terminal arrived within the drain bounds. */
|
|
68
69
|
onClientCancel: () => void;
|
|
69
70
|
/** Exactly once, after the producer fully stops (unregisterTurn parity). */
|
|
@@ -81,6 +82,8 @@ export type EagerRelayOptions = {
|
|
|
81
82
|
postCancelDrainMs?: number;
|
|
82
83
|
/** Post-cancel discard-drain byte bound. Default 32 MiB. */
|
|
83
84
|
postCancelDrainBytes?: number;
|
|
85
|
+
/** Last known upstream failure to preserve when EOF would otherwise become adapter_eof. */
|
|
86
|
+
upstreamError?: string;
|
|
84
87
|
/** Injectable clock for tests. */
|
|
85
88
|
now?: () => number;
|
|
86
89
|
};
|
|
@@ -239,6 +242,7 @@ export function relaySseEagerBounded(
|
|
|
239
242
|
|
|
240
243
|
const producer = async () => {
|
|
241
244
|
let syntheticKind: "incomplete" | "failed" | null = null;
|
|
245
|
+
let syntheticReason: "upstream_error" | undefined;
|
|
242
246
|
let deliveryFallbackSent = false;
|
|
243
247
|
let priorRewriteFailure = false;
|
|
244
248
|
let priorRewriteError: unknown;
|
|
@@ -303,12 +307,17 @@ export function relaySseEagerBounded(
|
|
|
303
307
|
} else if (!hooks.sawTerminal() && canDeliver()) {
|
|
304
308
|
// A clean 200 EOF without a Responses terminal must be visible to
|
|
305
309
|
// Codex as one incomplete turn, followed by the normal sentinel.
|
|
306
|
-
|
|
310
|
+
const upstreamError = terminalBoundary.upstreamError() ?? opts?.upstreamError;
|
|
311
|
+
const upstreamErrorFrame = upstreamError === undefined
|
|
312
|
+
? adapterEofFrame
|
|
313
|
+
: upstreamErrorTailFrame(terminalEncoder, upstreamError);
|
|
314
|
+
queuedBytes += upstreamErrorFrame.byteLength + terminalSentinel.byteLength;
|
|
307
315
|
try {
|
|
308
|
-
controllerRef?.enqueue(
|
|
316
|
+
controllerRef?.enqueue(upstreamErrorFrame);
|
|
309
317
|
controllerRef?.enqueue(terminalSentinel);
|
|
310
318
|
} catch { /* client already gone */ }
|
|
311
|
-
syntheticKind = "incomplete";
|
|
319
|
+
syntheticKind = upstreamError === undefined ? "incomplete" : "failed";
|
|
320
|
+
syntheticReason = upstreamError === undefined ? undefined : "upstream_error";
|
|
312
321
|
}
|
|
313
322
|
break;
|
|
314
323
|
}
|
|
@@ -449,7 +458,10 @@ export function relaySseEagerBounded(
|
|
|
449
458
|
frameBufferBytes = 0;
|
|
450
459
|
}
|
|
451
460
|
terminalBoundary.dispose();
|
|
452
|
-
if (syntheticKind && canDeliver())
|
|
461
|
+
if (syntheticKind && canDeliver()) {
|
|
462
|
+
if (syntheticReason === undefined) hooks.onSynthetic(syntheticKind);
|
|
463
|
+
else hooks.onSynthetic(syntheticKind, syntheticReason);
|
|
464
|
+
}
|
|
453
465
|
if (cancelled && !hooks.sawTerminal()) {
|
|
454
466
|
hooks.onClientCancel();
|
|
455
467
|
}
|
package/src/server/relay.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
CYBER_POLICY_FALLBACK_MESSAGE,
|
|
6
6
|
isCyberPolicyCode,
|
|
7
7
|
isCyberPolicyMessage,
|
|
8
|
+
upstreamErrorMessageFromPayload,
|
|
8
9
|
} from "../lib/errors";
|
|
9
10
|
import { redactSecretString } from "../lib/redact";
|
|
10
11
|
import { isTranslatorBudgetExceededError } from "../lib/translator-budget";
|
|
@@ -147,11 +148,31 @@ export function failedTailFrame(encoder: TextEncoder, err: unknown): Uint8Array
|
|
|
147
148
|
return encoder.encode(`\n\nevent: response.failed\ndata: ${payload}\n\n${DONE_SSE_FRAME_TEXT}`);
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
export function upstreamErrorTailFrame(encoder: TextEncoder, message: string): Uint8Array {
|
|
152
|
+
const error = {
|
|
153
|
+
type: "upstream_error",
|
|
154
|
+
code: "upstream_server_error",
|
|
155
|
+
message: redactSecretString(message).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS),
|
|
156
|
+
};
|
|
157
|
+
return encoder.encode(`event: response.failed\ndata: ${JSON.stringify({
|
|
158
|
+
type: "response.failed",
|
|
159
|
+
response: { status: "failed", error, last_error: error },
|
|
160
|
+
})}\n\n`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function boundedBareUpstreamErrorMessage(payload: unknown): string | undefined {
|
|
164
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)
|
|
165
|
+
|| (payload as { type?: unknown }).type !== "error") return undefined;
|
|
166
|
+
const message = upstreamErrorMessageFromPayload(payload);
|
|
167
|
+
return message ? redactSecretString(message).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS) : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
150
170
|
export type SseTerminalOutputBoundary = {
|
|
151
171
|
feed(chunk: Uint8Array): Uint8Array;
|
|
152
172
|
finish(): Uint8Array;
|
|
153
173
|
terminalSeen(): boolean;
|
|
154
174
|
doneSeen(): boolean;
|
|
175
|
+
upstreamError(): string | undefined;
|
|
155
176
|
dispose(): void;
|
|
156
177
|
};
|
|
157
178
|
|
|
@@ -170,6 +191,7 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary {
|
|
|
170
191
|
let done = false;
|
|
171
192
|
let pendingDone: { block: Uint8Array; delimiter: Uint8Array } | null = null;
|
|
172
193
|
let disposed = false;
|
|
194
|
+
let upstreamError: string | undefined;
|
|
173
195
|
|
|
174
196
|
const processFrames = (
|
|
175
197
|
frames: ReturnType<BoundedSseFrameBuffer["feed"]>,
|
|
@@ -181,6 +203,10 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary {
|
|
|
181
203
|
const payload = sseDataPayload(decoder.decode(frame.block));
|
|
182
204
|
const isDone = payload === "[DONE]";
|
|
183
205
|
const parsed = payload === null ? undefined : parseSsePayload(payload);
|
|
206
|
+
// Observe on the client reader itself: a tee inspection branch may lag
|
|
207
|
+
// behind EOF, so its log context cannot determine the outgoing terminal.
|
|
208
|
+
const message = boundedBareUpstreamErrorMessage(parsed);
|
|
209
|
+
if (message !== undefined) upstreamError = message;
|
|
184
210
|
const policyError = parsed !== undefined && isPolicyRewriteType(parsed)
|
|
185
211
|
? cyberPolicyTerminalError(parsed)
|
|
186
212
|
: undefined;
|
|
@@ -239,6 +265,7 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary {
|
|
|
239
265
|
},
|
|
240
266
|
terminalSeen: () => terminal,
|
|
241
267
|
doneSeen: () => done,
|
|
268
|
+
upstreamError: () => upstreamError,
|
|
242
269
|
dispose() {
|
|
243
270
|
if (disposed) return;
|
|
244
271
|
disposed = true;
|
|
@@ -260,6 +287,7 @@ export function relaySseWithFailedTail(
|
|
|
260
287
|
body: ReadableStream<Uint8Array>,
|
|
261
288
|
upstream: AbortController,
|
|
262
289
|
onClientGone?: (reason?: unknown) => void,
|
|
290
|
+
opts?: { upstreamError?: string },
|
|
263
291
|
): ReadableStream<Uint8Array> {
|
|
264
292
|
const reader = body.getReader();
|
|
265
293
|
const encoder = new TextEncoder();
|
|
@@ -306,8 +334,10 @@ export function relaySseWithFailedTail(
|
|
|
306
334
|
// A clean upstream EOF is still a failed Responses turn when no
|
|
307
335
|
// protocol terminal arrived. Make that state explicit so Codex
|
|
308
336
|
// does not treat HTTP 200 + bare EOF as a retryable disconnect.
|
|
309
|
-
const
|
|
310
|
-
controller.enqueue(
|
|
337
|
+
const upstreamError = terminalBoundary.upstreamError() ?? opts?.upstreamError;
|
|
338
|
+
controller.enqueue(upstreamError === undefined
|
|
339
|
+
? adapterEofIncompleteFrame(encoder)
|
|
340
|
+
: upstreamErrorTailFrame(encoder, upstreamError));
|
|
311
341
|
controller.enqueue(doneFrame(encoder));
|
|
312
342
|
}
|
|
313
343
|
terminalBoundary.dispose();
|
|
@@ -1354,11 +1384,16 @@ export function consumeForInspection(
|
|
|
1354
1384
|
options?: InspectionConsumerOptions,
|
|
1355
1385
|
): void {
|
|
1356
1386
|
const reader = body.getReader();
|
|
1387
|
+
let bareUpstreamError: string | undefined;
|
|
1357
1388
|
const inspector = (options?.inspectorFactory ?? createSseInspector)({
|
|
1358
1389
|
onTerminal,
|
|
1359
1390
|
logCtx,
|
|
1360
1391
|
onCompletedResponse,
|
|
1361
|
-
onParsedPayload:
|
|
1392
|
+
onParsedPayload: payload => {
|
|
1393
|
+
const message = boundedBareUpstreamErrorMessage(payload);
|
|
1394
|
+
if (message !== undefined) bareUpstreamError = message;
|
|
1395
|
+
options?.onParsedPayload?.(payload);
|
|
1396
|
+
},
|
|
1362
1397
|
onFirstOutput,
|
|
1363
1398
|
pinCompletedResponseIdToFirstSeen: options?.pinCompletedResponseIdToFirstSeen,
|
|
1364
1399
|
});
|
|
@@ -1372,7 +1407,11 @@ export function consumeForInspection(
|
|
|
1372
1407
|
onCleanEof: () => {
|
|
1373
1408
|
if (!inspector.reported()) {
|
|
1374
1409
|
if (logCtx) logCtx.terminalSource = "synthetic";
|
|
1375
|
-
|
|
1410
|
+
if (bareUpstreamError !== undefined) {
|
|
1411
|
+
onTerminal("failed", httpStatusForRequestLogTerminal("failed", logCtx));
|
|
1412
|
+
} else {
|
|
1413
|
+
onTerminal("incomplete");
|
|
1414
|
+
}
|
|
1376
1415
|
}
|
|
1377
1416
|
},
|
|
1378
1417
|
onReadError: () => {
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from "../lib/errors";
|
|
13
13
|
import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths";
|
|
14
14
|
import { readCodexCatalogPath } from "../codex/catalog";
|
|
15
|
-
import type { AttemptTierOutcome, OcxUsage } from "../types";
|
|
15
|
+
import type { AttemptTierOutcome, OcxProviderConfig, OcxUsage } from "../types";
|
|
16
16
|
import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace";
|
|
17
17
|
import type { AdapterRequest } from "../adapters/base";
|
|
18
18
|
import type { AdapterTierMetadata } from "../providers/fastwire";
|
|
@@ -1211,11 +1211,39 @@ export function sealRequestAttemptIdentity(
|
|
|
1211
1211
|
accountLogLabel?: string,
|
|
1212
1212
|
): void {
|
|
1213
1213
|
if (!attempt) return;
|
|
1214
|
+
if (attempt.provider !== provider || attempt.adapter !== adapter) delete attempt.credentialSource;
|
|
1214
1215
|
attempt.provider = provider;
|
|
1215
1216
|
attempt.adapter = adapter;
|
|
1216
1217
|
if (isCodexUsageAccountLogLabel(accountLogLabel)) attempt.accountLogLabel = accountLogLabel;
|
|
1217
1218
|
}
|
|
1218
1219
|
|
|
1220
|
+
/** Capture only the resolved upstream route; inbound auth and today's config cannot label old usage. */
|
|
1221
|
+
export function recordAttemptCredentialSource(
|
|
1222
|
+
attempt: PersistedUsageAttempt | undefined,
|
|
1223
|
+
providerName: string,
|
|
1224
|
+
provider: Pick<OcxProviderConfig, "authMode" | "baseUrl" | "adapter">,
|
|
1225
|
+
adapterName: string = provider.adapter,
|
|
1226
|
+
): void {
|
|
1227
|
+
if (!attempt) return;
|
|
1228
|
+
// Rebinding an attempt to an unrecognized route must not retain its previous attribution.
|
|
1229
|
+
delete attempt.credentialSource;
|
|
1230
|
+
if (providerName !== "xai"
|
|
1231
|
+
|| !["openai-chat", "openai-responses"].includes(adapterName)) return;
|
|
1232
|
+
try {
|
|
1233
|
+
const url = new URL(provider.baseUrl ?? "");
|
|
1234
|
+
if (url.protocol !== "https:" || url.port || url.username || url.password
|
|
1235
|
+
|| url.search || url.hash || !["/v1", "/v1/"].includes(url.pathname)) return;
|
|
1236
|
+
if (provider.authMode === "oauth" && url.hostname === "cli-chat-proxy.grok.com") {
|
|
1237
|
+
attempt.credentialSource = "grok-oauth";
|
|
1238
|
+
} else if ((provider.authMode === "key" || provider.authMode === undefined)
|
|
1239
|
+
&& url.hostname === "api.x.ai") {
|
|
1240
|
+
attempt.credentialSource = "xai-api-key";
|
|
1241
|
+
}
|
|
1242
|
+
} catch {
|
|
1243
|
+
// Invalid/custom destinations have no known subscription provenance.
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1219
1247
|
export function noteAttemptSend(
|
|
1220
1248
|
attempt: PersistedUsageAttempt | undefined,
|
|
1221
1249
|
inputTokenEstimate: number | undefined,
|
|
@@ -149,3 +149,14 @@ export function agentTaskRecoveryWaiterCountForTests(): number {
|
|
|
149
149
|
export function agentTaskRecoveryCacheSnapshotForTests(): { entries: number; bytes: number } {
|
|
150
150
|
return { entries: RECOVERY_CACHE.size, bytes: recoveryCacheBytes };
|
|
151
151
|
}
|
|
152
|
+
|
|
153
|
+
/** Read an existing recovery without starting a request or extending its lifetime. */
|
|
154
|
+
export function cachedAgentTaskRecovery(key: string): string | null {
|
|
155
|
+
const entry = RECOVERY_CACHE.get(key);
|
|
156
|
+
if (!entry) return null;
|
|
157
|
+
if (entry.expiresAt <= Date.now()) {
|
|
158
|
+
deleteRecoveryCacheEntry(key, entry);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
return entry.assignment;
|
|
162
|
+
}
|
|
@@ -5,6 +5,7 @@ import { readBoundedResponseBody } from "../../lib/bounded-body";
|
|
|
5
5
|
import { isApiAuthRequired, isProxyAdmissionSecret } from "../auth-cors";
|
|
6
6
|
import { structurallyValidFernetTokens } from "./encrypted-payload";
|
|
7
7
|
import {
|
|
8
|
+
cachedAgentTaskRecovery,
|
|
8
9
|
discardCachedAgentTaskRecovery,
|
|
9
10
|
resetAgentTaskRecoveryCache,
|
|
10
11
|
resolveCachedAgentTaskRecovery,
|
|
@@ -61,7 +62,7 @@ interface AgentEnvelope {
|
|
|
61
62
|
itemIndex: number;
|
|
62
63
|
encryptedIndex: number;
|
|
63
64
|
headerText: string;
|
|
64
|
-
messageType: "NEW_TASK";
|
|
65
|
+
messageType: "NEW_TASK" | "MESSAGE";
|
|
65
66
|
taskName: string;
|
|
66
67
|
sender: string;
|
|
67
68
|
ciphertext: string;
|
|
@@ -69,7 +70,7 @@ interface AgentEnvelope {
|
|
|
69
70
|
recipient: string;
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/;
|
|
73
|
+
const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK|MESSAGE)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/;
|
|
73
74
|
|
|
74
75
|
function findEnvelope(input: unknown): AgentEnvelope | null {
|
|
75
76
|
if (!Array.isArray(input)) return null;
|
|
@@ -90,7 +91,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
|
|
|
90
91
|
if (!Array.isArray(content)) return null;
|
|
91
92
|
|
|
92
93
|
let headerText: string | null = null;
|
|
93
|
-
let messageType: "NEW_TASK" | null = null;
|
|
94
|
+
let messageType: "NEW_TASK" | "MESSAGE" | null = null;
|
|
94
95
|
let taskName: string | null = null;
|
|
95
96
|
let sender: string | null = null;
|
|
96
97
|
let encryptedIndex = -1;
|
|
@@ -113,7 +114,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
|
|
|
113
114
|
|| part.text.slice(match.index + match[0].length).trim().length > 0
|
|
114
115
|
) return null;
|
|
115
116
|
headerText = match[0].startsWith("\n") ? match[0].slice(1) : match[0];
|
|
116
|
-
messageType = "NEW_TASK";
|
|
117
|
+
messageType = match[1] as "NEW_TASK" | "MESSAGE";
|
|
117
118
|
taskName = match[2]!;
|
|
118
119
|
sender = match[3]!;
|
|
119
120
|
}
|
|
@@ -496,3 +497,22 @@ export function discardEncryptedAgentTaskRecovery(
|
|
|
496
497
|
export function resetAgentTaskRecoveryState(): void {
|
|
497
498
|
resetAgentTaskRecoveryCache();
|
|
498
499
|
}
|
|
500
|
+
|
|
501
|
+
/** Codex replays the original encrypted agent messages after tool calls. Reuse only an admitted cache hit. */
|
|
502
|
+
export function restoreCachedEncryptedAgentTasks(
|
|
503
|
+
req: Request, input: unknown, config: OcxConfig,
|
|
504
|
+
context: { parentThreadId?: string | null } = {},
|
|
505
|
+
): number {
|
|
506
|
+
if (!Array.isArray(input)) return 0;
|
|
507
|
+
let restored = 0;
|
|
508
|
+
for (const item of input) {
|
|
509
|
+
if (!item || typeof item !== "object" || item.type !== "agent_message") continue;
|
|
510
|
+
const single = [item];
|
|
511
|
+
// Revalidates caller credentials and the exact supported agent envelope before cache access.
|
|
512
|
+
const admitted = admittedRecovery(req, single, config, context.parentThreadId);
|
|
513
|
+
if (!admitted) continue;
|
|
514
|
+
const assignment = cachedAgentTaskRecovery(admitted.cacheKey);
|
|
515
|
+
if (assignment && injectAssignment(single, admitted.envelope, assignment)) restored += 1;
|
|
516
|
+
}
|
|
517
|
+
return restored;
|
|
518
|
+
}
|
|
@@ -25,7 +25,7 @@ function digest(input: unknown): string {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/** Identity comes from the selected outgoing request, never a model label or caller hint. */
|
|
28
|
-
export function codexWsReuseIdentity(url: string, headers: Record<string, string>, frameText: string): CodexWsReuseIdentity | null {
|
|
28
|
+
export function codexWsReuseIdentity(url: string, headers: Record<string, string>, frameText: string, proxy?: string): CodexWsReuseIdentity | null {
|
|
29
29
|
if (url !== CODEX_RESPONSES_HTTP_URL) return null;
|
|
30
30
|
let body: unknown;
|
|
31
31
|
try { body = JSON.parse(frameText); } catch { return null; }
|
|
@@ -52,7 +52,7 @@ export function codexWsReuseIdentity(url: string, headers: Record<string, string
|
|
|
52
52
|
const scope = digest([url, account, thread, turn]);
|
|
53
53
|
const lite = metadata.ws_request_header_x_openai_internal_codex_responses_lite;
|
|
54
54
|
if (lite !== undefined && lite !== "true" && lite !== "false") return null;
|
|
55
|
-
return { scope, key: digest([scope, authorization, body.model, body.service_tier ?? null, lite ?? null, immutable]) };
|
|
55
|
+
return { scope, key: digest([scope, authorization, body.model, body.service_tier ?? null, lite ?? null, immutable, proxy ?? null]) };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
interface Entry { identity: CodexWsReuseIdentity; session: CodexWsSession; createdAt: number; idleAt: number; retired: boolean }
|
|
@@ -75,7 +75,7 @@ export class CodexWsPool {
|
|
|
75
75
|
this.maxAgeMs = options.maxAgeMs ?? CODEX_WS_POOL_MAX_AGE_MS;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
acquire(identity: CodexWsReuseIdentity, url: string, headers: Record<string, string
|
|
78
|
+
acquire(identity: CodexWsReuseIdentity, url: string, headers: Record<string, string>, proxy?: string): CodexWsSession | null {
|
|
79
79
|
this.sweep();
|
|
80
80
|
for (const entry of this.entries.values()) {
|
|
81
81
|
if (entry.identity.scope !== identity.scope || entry.identity.key === identity.key) continue;
|
|
@@ -94,7 +94,7 @@ export class CodexWsPool {
|
|
|
94
94
|
this.remove(oldest);
|
|
95
95
|
}
|
|
96
96
|
const createdAt = this.now();
|
|
97
|
-
const session = new CodexWsSession(url, headers, true, () => this.changed(entry));
|
|
97
|
+
const session = new CodexWsSession(url, headers, true, () => this.changed(entry), proxy);
|
|
98
98
|
const entry: Entry = { identity, session, createdAt, idleAt: createdAt, retired: false };
|
|
99
99
|
session.reserve();
|
|
100
100
|
this.entries.set(identity.key, entry);
|
|
@@ -10,8 +10,8 @@ export class CodexWsSession {
|
|
|
10
10
|
private readonly completedIds = new Set<string>();
|
|
11
11
|
|
|
12
12
|
constructor(url: string, headers: Record<string, string>, readonly retainable = false,
|
|
13
|
-
private readonly changed: () => void = () => {}) {
|
|
14
|
-
this.socket = new WebSocket(url, { headers } as unknown as string[]);
|
|
13
|
+
private readonly changed: () => void = () => {}, proxy?: string) {
|
|
14
|
+
this.socket = new WebSocket(url, { headers, ...(proxy ? { proxy } : {}) } as unknown as string[]);
|
|
15
15
|
this.socket.addEventListener("open", this.onOpen);
|
|
16
16
|
this.socket.addEventListener("message", this.onIdleMessage);
|
|
17
17
|
this.socket.addEventListener("close", this.onClose);
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
resolveEnvValue,
|
|
8
8
|
} from "../../config";
|
|
9
9
|
import { parseRequest } from "../../responses/parser";
|
|
10
|
+
import { externalTaskInputContent } from "../../responses/task-input";
|
|
10
11
|
import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction";
|
|
11
12
|
import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses";
|
|
12
13
|
import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state";
|
|
@@ -558,7 +559,7 @@ function leadingDeveloperPrefixLength(items: readonly unknown[]): number {
|
|
|
558
559
|
|
|
559
560
|
function isConversationalItem(item: unknown): boolean {
|
|
560
561
|
if (!isRecord(item)) return false;
|
|
561
|
-
if (item.type === "agent_message") return true;
|
|
562
|
+
if (item.type === "agent_message" || externalTaskInputContent(item) !== undefined) return true;
|
|
562
563
|
const type = item.type ?? (typeof item.role === "string" ? "message" : undefined);
|
|
563
564
|
return type === "message" && (item.role === "user" || item.role === "assistant");
|
|
564
565
|
}
|
|
@@ -135,9 +135,13 @@ export type ComboStreamPreflightResult =
|
|
|
135
135
|
export async function preflightComboStreamResponse(
|
|
136
136
|
response: Response,
|
|
137
137
|
logCtx: RequestLogContext,
|
|
138
|
+
retryableTerminal: (payload: unknown) => boolean = retryableZeroOutputTerminal,
|
|
139
|
+
options?: { allowMissingContentType?: boolean; replayReadErrors?: boolean },
|
|
138
140
|
): Promise<ComboStreamPreflightResult> {
|
|
139
141
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
140
|
-
|
|
142
|
+
const isEventStream = contentType.includes("text/event-stream")
|
|
143
|
+
|| (!contentType && options?.allowMissingContentType === true);
|
|
144
|
+
if (!response.ok || !response.body || !isEventStream) {
|
|
141
145
|
return { kind: "accepted", response };
|
|
142
146
|
}
|
|
143
147
|
|
|
@@ -150,18 +154,30 @@ export async function preflightComboStreamResponse(
|
|
|
150
154
|
const inspector = createSseInspector({
|
|
151
155
|
logCtx,
|
|
152
156
|
onParsedPayload: payload => {
|
|
153
|
-
|
|
157
|
+
const retryable = retryableTerminal(payload);
|
|
158
|
+
const matchedBareError = retryable && payload !== null && typeof payload === "object"
|
|
159
|
+
&& !Array.isArray(payload) && (payload as { type?: unknown }).type === "error";
|
|
160
|
+
// Only an explicit caller predicate may opt a known bare error into replay.
|
|
161
|
+
// Default combo classification still commits unknown/error events.
|
|
162
|
+
if (comboStreamPayloadCommitsOutput(payload) && !matchedBareError) outputCommitted = true;
|
|
154
163
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return;
|
|
155
|
-
if (
|
|
156
|
-
retryableTerminalPayload = payload as Record<string, unknown>;
|
|
157
|
-
}
|
|
164
|
+
if (retryable) retryableTerminalPayload = payload as Record<string, unknown>;
|
|
158
165
|
},
|
|
159
166
|
onTerminal: status => { terminalStatus = status; },
|
|
160
167
|
});
|
|
161
168
|
|
|
162
169
|
try {
|
|
163
170
|
for (;;) {
|
|
164
|
-
|
|
171
|
+
let next: Awaited<ReturnType<typeof reader.read>>;
|
|
172
|
+
try {
|
|
173
|
+
next = await reader.read();
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (!options?.replayReadErrors) throw error;
|
|
176
|
+
// The native relay still owns post-header transport failures. Preserve
|
|
177
|
+
// the bounded prefix and the errored reader; cancelling it here would
|
|
178
|
+
// erase the failure before either client relay or inspection sees it.
|
|
179
|
+
return { kind: "accepted", response: replayBufferedResponse(response, reader, buffered) };
|
|
180
|
+
}
|
|
165
181
|
if (next.done) {
|
|
166
182
|
inspector.finish();
|
|
167
183
|
} else {
|
|
@@ -180,7 +196,10 @@ export async function preflightComboStreamResponse(
|
|
|
180
196
|
inspector.feed(retained);
|
|
181
197
|
}
|
|
182
198
|
|
|
183
|
-
|
|
199
|
+
// A bare error event is not a protocol terminal (terminalStatus stays undefined),
|
|
200
|
+
// so its exact-message retryable match doubles as the terminal evidence.
|
|
201
|
+
if ((terminalStatus === "failed" || terminalStatus === "incomplete"
|
|
202
|
+
|| retryableTerminalPayload?.type === "error")
|
|
184
203
|
&& !outputCommitted && retryableTerminalPayload) {
|
|
185
204
|
await reader.cancel("retrying zero-output combo stream terminal").catch(() => undefined);
|
|
186
205
|
return { kind: "failed", response: failedTerminalResponse(response, retryableTerminalPayload, logCtx) };
|