@bitkyc08/opencodex 2.14.0 → 2.14.2
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 +55 -0
- package/gui/dist/assets/index-DUCH59lJ.css +1 -0
- package/gui/dist/assets/index-DUyQeU1j.js +76 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +46 -6
- package/src/adapters/cursor/request-builder.ts +54 -10
- package/src/adapters/cursor/tool-definitions.ts +24 -0
- package/src/adapters/kiro.ts +10 -1
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +7 -4
- package/src/adapters/openai-responses-url.ts +14 -0
- package/src/adapters/openai-responses.ts +111 -2
- package/src/adapters/tool-catalog-nudge.ts +26 -4
- package/src/bridge.ts +50 -3
- package/src/cli/init.ts +4 -17
- package/src/codex/auth-api.ts +2 -74
- package/src/codex/catalog/effort.ts +2 -1
- package/src/codex/catalog/metadata.ts +62 -12
- package/src/codex/catalog/native-models.ts +27 -0
- package/src/codex/catalog/parsing.ts +27 -8
- package/src/codex/catalog/provider-fetch.ts +47 -5
- package/src/codex/catalog/sync.ts +31 -8
- package/src/codex/catalog.ts +1 -1
- package/src/codex/features.ts +14 -3
- package/src/codex/model-cache.ts +7 -1
- package/src/codex/native-main-claim.ts +13 -2
- package/src/config.ts +79 -4
- package/src/generated/compatibility-version.json +74 -46
- package/src/lab/ledger/store.ts +0 -18
- package/src/lab/subject/installation-salt.ts +13 -2
- package/src/lib/app-owned-memory-stores.ts +22 -0
- package/src/lib/tool-argument-integers.ts +158 -0
- package/src/oauth/nous.ts +58 -9
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/command-code-efforts.ts +18 -0
- package/src/providers/model-rename-migration.ts +202 -0
- package/src/providers/model-rename-startup.ts +28 -0
- package/src/providers/openai-tier-startup.ts +31 -2
- package/src/providers/quota.ts +9 -2
- package/src/providers/registry.ts +17 -10
- package/src/responses/spill-store.ts +5 -1
- package/src/responses/state.ts +50 -2
- package/src/router.ts +12 -1
- package/src/server/index.ts +3 -2
- package/src/server/management/api-key-usage.ts +31 -5
- package/src/server/management/config-routes.ts +51 -16
- package/src/server/management/logs-usage-routes.ts +48 -10
- package/src/server/management/provider-routes.ts +2 -1
- package/src/server/management/usage-summary-cache.ts +7 -1
- package/src/server/responses/collaboration.ts +12 -2
- package/src/server/responses/core.ts +33 -17
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/ws-upstream.ts +199 -0
- package/src/server/startup-health-cache.ts +12 -0
- package/src/usage/log.ts +430 -12
- package/src/vision/index.ts +25 -4
- package/src/vision/timeout-bounds.ts +9 -0
- package/gui/dist/assets/index-BNVYzdn0.css +0 -1
- package/gui/dist/assets/index-Co12XTT-.js +0 -76
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
import {
|
|
51
51
|
currentUsageLogRevision,
|
|
52
52
|
readUsageSnapshotForManagement,
|
|
53
|
+
usageLogIdentityKey,
|
|
53
54
|
usageLogRevisionKey,
|
|
54
55
|
type PersistedUsageEntry,
|
|
55
56
|
} from "../../usage/log";
|
|
@@ -84,6 +85,7 @@ import {
|
|
|
84
85
|
getUsageSummaryCacheEntry,
|
|
85
86
|
setUsageSummaryCacheEntry,
|
|
86
87
|
} from "./usage-summary-cache";
|
|
88
|
+
import { cacheApiKeyUsageFromSnapshot } from "./api-key-usage";
|
|
87
89
|
|
|
88
90
|
const USAGE_DAY_MS = 86_400_000;
|
|
89
91
|
function usageEntryMatchesSurface(entry: PersistedUsageEntry, surface: UsageSurface): boolean {
|
|
@@ -215,12 +217,16 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
215
217
|
try {
|
|
216
218
|
const cacheKey = `${range}:${surface}`;
|
|
217
219
|
const effectiveReadLimit = config.managementUsageMaxReadBytes ?? 64 * 1024 * 1024;
|
|
218
|
-
const
|
|
220
|
+
const observed = currentUsageLogRevision();
|
|
221
|
+
const identityKey = `${usageLogIdentityKey(observed)}\0${effectiveReadLimit}`;
|
|
222
|
+
const observedSize = observed?.size ?? 0;
|
|
219
223
|
const cached = getUsageSummaryCacheEntry(cacheKey);
|
|
220
224
|
if (cached
|
|
221
|
-
&& cached.
|
|
225
|
+
&& cached.identityKey === identityKey
|
|
226
|
+
&& cached.maxReadBytes === effectiveReadLimit
|
|
222
227
|
&& cached.overlayVersion === userCostOverlayVersion()
|
|
223
|
-
&& now < cached.
|
|
228
|
+
&& now < cached.freshUntil
|
|
229
|
+
&& observedSize >= cached.lastSeenSize) {
|
|
224
230
|
return jsonResponse(refreshedUsageSummary(cached.summary, range, now));
|
|
225
231
|
}
|
|
226
232
|
if (cached) discardUsageSummaryCacheEntry(cacheKey);
|
|
@@ -249,13 +255,45 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
249
255
|
// mixed-price entry under either version.
|
|
250
256
|
return jsonResponse(summary);
|
|
251
257
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
258
|
+
const freshUntil = now + 60_000;
|
|
259
|
+
const snapshotIdentity = `${usageLogIdentityKey(snapshot.revision)}\0${effectiveReadLimit}`;
|
|
260
|
+
const revisionKey = `${usageLogRevisionKey(snapshot.revision)}\0${effectiveReadLimit}`;
|
|
261
|
+
const lastSeenSize = snapshot.revision?.size ?? 0;
|
|
262
|
+
const ranges: UsageRange[] = ["7d", "30d", "all"];
|
|
263
|
+
const surfaces: UsageSurface[] = ["all", "codex", "claude", "grok"];
|
|
264
|
+
for (const nextRange of ranges) {
|
|
265
|
+
for (const nextSurface of surfaces) {
|
|
266
|
+
const nextSummary = nextRange === range && nextSurface === surface ? summary : {
|
|
267
|
+
...summarizeUsage(snapshot.entries, nextRange, now, nextSurface),
|
|
268
|
+
historyTruncated: summary.historyTruncated,
|
|
269
|
+
truncatedPrefixBytes: summary.truncatedPrefixBytes,
|
|
270
|
+
entriesTruncated: summary.entriesTruncated,
|
|
271
|
+
entriesDropped: summary.entriesDropped,
|
|
272
|
+
snapshotWindowStart: summary.snapshotWindowStart,
|
|
273
|
+
snapshotWindowEnd: summary.snapshotWindowEnd,
|
|
274
|
+
};
|
|
275
|
+
setUsageSummaryCacheEntry(`${nextRange}:${nextSurface}`, {
|
|
276
|
+
revisionKey,
|
|
277
|
+
identityKey: snapshotIdentity,
|
|
278
|
+
maxReadBytes: effectiveReadLimit,
|
|
279
|
+
overlayVersion,
|
|
280
|
+
expiresAt: usageSummaryExpiresAt(snapshot.entries, nextRange, nextSurface, now),
|
|
281
|
+
freshUntil,
|
|
282
|
+
lastSeenSize,
|
|
283
|
+
revisionReadAt,
|
|
284
|
+
summary: nextSummary,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
cacheApiKeyUsageFromSnapshot(
|
|
289
|
+
snapshot.entries,
|
|
290
|
+
(config.apiKeys ?? []).map(key => key.id),
|
|
291
|
+
usageLogIdentityKey(snapshot.revision),
|
|
292
|
+
snapshot.revision?.size ?? 0,
|
|
293
|
+
snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated,
|
|
294
|
+
effectiveReadLimit,
|
|
295
|
+
now,
|
|
296
|
+
);
|
|
259
297
|
return jsonResponse(summary);
|
|
260
298
|
} catch {
|
|
261
299
|
return jsonResponse({
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
submitManualLoginCode,
|
|
28
28
|
upsertOAuthProvider,
|
|
29
29
|
} from "../../oauth";
|
|
30
|
-
import {
|
|
30
|
+
import { replaceProviderAccountSet } from "../../oauth/store";
|
|
31
31
|
import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
32
32
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
33
33
|
import { ProviderOutboundPolicyError, providerOutboundGet, providerOutboundPost, providerRedirectError } from "../../lib/provider-outbound";
|
|
@@ -765,6 +765,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
765
765
|
const droppedCustomModels = dropProviderCustomModels(config, name);
|
|
766
766
|
setProviderContextCap(config, name, false);
|
|
767
767
|
save(config);
|
|
768
|
+
await replaceProviderAccountSet(name, null);
|
|
768
769
|
reconcileLiveStateStores();
|
|
769
770
|
const { clearModelCache: clearCache } = await import("../../codex/model-cache");
|
|
770
771
|
clearCache(name);
|
|
@@ -8,11 +8,17 @@ export type CachedUsageSummary = UsageSummary & {
|
|
|
8
8
|
entriesDropped: number;
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
interface UsageSummaryCacheEntry {
|
|
11
|
+
export interface UsageSummaryCacheEntry {
|
|
12
12
|
revisionKey: string;
|
|
13
|
+
/** path/dev/ino/birthtime only; appends keep this stable. */
|
|
14
|
+
identityKey: string;
|
|
15
|
+
maxReadBytes: number;
|
|
13
16
|
/** userCostOverlayVersion() when the summary was computed; overlay edits invalidate the entry. */
|
|
14
17
|
overlayVersion: number;
|
|
15
18
|
expiresAt: number;
|
|
19
|
+
/** Generation freshness: ignore size/mtime until this instant. */
|
|
20
|
+
freshUntil: number;
|
|
21
|
+
lastSeenSize: number;
|
|
16
22
|
summary: CachedUsageSummary;
|
|
17
23
|
revisionReadAt: number;
|
|
18
24
|
sizeBytes: number;
|
|
@@ -102,18 +102,28 @@ import type { TranslatorBudget } from "../../lib/translator-budget";
|
|
|
102
102
|
|
|
103
103
|
export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: TranslatorBudget): {
|
|
104
104
|
toolNsMap: Map<string, { namespace: string; name: string }>;
|
|
105
|
+
declaredToolNames: Set<string>;
|
|
106
|
+
/** Declared parameter schema per request-visible tool name (#1611 integer repair). */
|
|
107
|
+
toolParameterSchemas: Map<string, Record<string, unknown>>;
|
|
105
108
|
freeformToolNames: Set<string>;
|
|
106
109
|
toolSearchToolNames: Set<string>;
|
|
107
110
|
} {
|
|
108
111
|
const toolNsMap = new Map<string, { namespace: string; name: string }>();
|
|
112
|
+
const declaredToolNames = new Set<string>();
|
|
113
|
+
const toolParameterSchemas = new Map<string, Record<string, unknown>>();
|
|
109
114
|
const freeformToolNames = new Set<string>();
|
|
110
115
|
const toolSearchToolNames = new Set<string>();
|
|
111
116
|
const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice);
|
|
112
117
|
for (const t of parsed.context.tools ?? []) {
|
|
113
118
|
// Upstream output is untrusted: only restore calls for tools the caller authorized.
|
|
114
119
|
if (!toolAllowed(t)) continue;
|
|
120
|
+
const wireName = namespacedToolName(t.namespace, t.name);
|
|
121
|
+
budget?.chargeRetained(new TextEncoder().encode(wireName).byteLength, { kind: "retained_collectors" });
|
|
122
|
+
declaredToolNames.add(wireName);
|
|
123
|
+
// Retained by reference (the schema is already resident in parsed.context.tools),
|
|
124
|
+
// so this adds a map entry rather than a copy of every tool's parameters.
|
|
125
|
+
if (t.parameters && typeof t.parameters === "object") toolParameterSchemas.set(wireName, t.parameters);
|
|
115
126
|
if (t.namespace) {
|
|
116
|
-
const wireName = namespacedToolName(t.namespace, t.name);
|
|
117
127
|
budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([wireName, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
|
|
118
128
|
toolNsMap.set(wireName, { namespace: t.namespace, name: t.name });
|
|
119
129
|
}
|
|
@@ -126,7 +136,7 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
|
|
|
126
136
|
toolSearchToolNames.add(t.name);
|
|
127
137
|
}
|
|
128
138
|
}
|
|
129
|
-
return { toolNsMap, freeformToolNames, toolSearchToolNames };
|
|
139
|
+
return { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames };
|
|
130
140
|
}
|
|
131
141
|
|
|
132
142
|
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
markBodyNonPersistable,
|
|
23
23
|
previousResponseProviderState,
|
|
24
24
|
previousResponseReplayFailure,
|
|
25
|
+
previousResponseScopeMismatch,
|
|
25
26
|
rememberResponseState,
|
|
26
27
|
} from "../../responses/state";
|
|
27
28
|
import {
|
|
@@ -1503,8 +1504,12 @@ async function handleResponsesInner(
|
|
|
1503
1504
|
let unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
|
|
1504
1505
|
(body as { input?: unknown } | undefined)?.input,
|
|
1505
1506
|
);
|
|
1507
|
+
const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
|
|
1506
1508
|
const originalBody = body;
|
|
1507
|
-
body = expandPreviousResponseInput(body);
|
|
1509
|
+
body = expandPreviousResponseInput(body, inboundClientThreadId);
|
|
1510
|
+
if (previousResponseScopeMismatch(body)) {
|
|
1511
|
+
console.warn("[opencodex] dropped a previous_response_id with a mismatched client task scope; continuing fresh");
|
|
1512
|
+
}
|
|
1508
1513
|
if (previousResponseReplayFailure(body)) {
|
|
1509
1514
|
return formatErrorResponse(
|
|
1510
1515
|
400,
|
|
@@ -1512,7 +1517,8 @@ async function handleResponsesInner(
|
|
|
1512
1517
|
"Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
|
|
1513
1518
|
);
|
|
1514
1519
|
}
|
|
1515
|
-
const previousResponseInputExpanded = body !== originalBody
|
|
1520
|
+
const previousResponseInputExpanded = body !== originalBody
|
|
1521
|
+
&& typeof (body as { previous_response_id?: unknown }).previous_response_id === "string";
|
|
1516
1522
|
|
|
1517
1523
|
// Spawn-message compatibility (both directions): agent_message task payloads ride in
|
|
1518
1524
|
// encrypted_content slots as plaintext. Rewrite them to input_text on the RAW body BEFORE
|
|
@@ -1529,7 +1535,7 @@ async function handleResponsesInner(
|
|
|
1529
1535
|
);
|
|
1530
1536
|
}
|
|
1531
1537
|
|
|
1532
|
-
let parsed;
|
|
1538
|
+
let parsed: OcxParsedRequest;
|
|
1533
1539
|
let toolBridgeMaps: ReturnType<typeof buildToolBridgeMaps>;
|
|
1534
1540
|
try {
|
|
1535
1541
|
parsed = parseRequest(body);
|
|
@@ -1537,10 +1543,9 @@ async function handleResponsesInner(
|
|
|
1537
1543
|
if (previousResponseInputExpanded) parsed._previousResponseInputExpanded = true;
|
|
1538
1544
|
parsed._providerContinuation = previousResponseProviderState(parsed.previousResponseId);
|
|
1539
1545
|
parsed._cursorConversationId = parsed._providerContinuation?.cursor?.conversationId;
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
parsed.
|
|
1543
|
-
parsed._reasoningReplayScope = { clientThreadId };
|
|
1546
|
+
if (inboundClientThreadId) {
|
|
1547
|
+
parsed._clientThreadId = inboundClientThreadId;
|
|
1548
|
+
parsed._reasoningReplayScope = { clientThreadId: inboundClientThreadId };
|
|
1544
1549
|
}
|
|
1545
1550
|
} catch (err) {
|
|
1546
1551
|
if (isTranslatorBudgetExceededError(err)) {
|
|
@@ -1550,6 +1555,10 @@ async function handleResponsesInner(
|
|
|
1550
1555
|
}
|
|
1551
1556
|
return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
1552
1557
|
}
|
|
1558
|
+
const responseStateOptions = (force = false): { force?: boolean; clientThreadId?: string } => ({
|
|
1559
|
+
...(force ? { force: true } : {}),
|
|
1560
|
+
...(parsed._clientThreadId ? { clientThreadId: parsed._clientThreadId } : {}),
|
|
1561
|
+
});
|
|
1553
1562
|
// Prefer a pre-populated id (routed Claude) over Responses headers that may be
|
|
1554
1563
|
// absent or synthetically injected (session_id from prompt_cache_key).
|
|
1555
1564
|
if (!logCtx.conversationId) {
|
|
@@ -1779,7 +1788,6 @@ async function handleResponsesInner(
|
|
|
1779
1788
|
);
|
|
1780
1789
|
}
|
|
1781
1790
|
}
|
|
1782
|
-
toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget);
|
|
1783
1791
|
} catch {
|
|
1784
1792
|
unreadableEncryptedAgentTask = true;
|
|
1785
1793
|
}
|
|
@@ -2138,7 +2146,7 @@ async function handleResponsesInner(
|
|
|
2138
2146
|
&& (!parsed.previousResponseId || parsed._previousResponseInputExpanded === true);
|
|
2139
2147
|
const rememberPassthroughResponse = passthroughRecordEligible
|
|
2140
2148
|
? (response: { id?: unknown; output?: unknown; status?: unknown }) =>
|
|
2141
|
-
rememberResponseState(parsed._rawBody, response, undefined,
|
|
2149
|
+
rememberResponseState(parsed._rawBody, response, undefined, responseStateOptions(true))
|
|
2142
2150
|
: undefined;
|
|
2143
2151
|
if (parsed.previousResponseId && !parsed._previousResponseInputExpanded) {
|
|
2144
2152
|
console.warn(
|
|
@@ -2963,7 +2971,7 @@ async function handleResponsesInner(
|
|
|
2963
2971
|
parsed._rawBody,
|
|
2964
2972
|
response,
|
|
2965
2973
|
continuationStateForResponse(providerState),
|
|
2966
|
-
adapterNeedsForcedContinuation(adapter.name)
|
|
2974
|
+
responseStateOptions(adapterNeedsForcedContinuation(adapter.name)),
|
|
2967
2975
|
),
|
|
2968
2976
|
});
|
|
2969
2977
|
if (imgResponse.body) {
|
|
@@ -3076,7 +3084,7 @@ async function handleResponsesInner(
|
|
|
3076
3084
|
}
|
|
3077
3085
|
};
|
|
3078
3086
|
|
|
3079
|
-
const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
3087
|
+
const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
3080
3088
|
if (parsed.stream) {
|
|
3081
3089
|
void runTurn();
|
|
3082
3090
|
let eventSource: AsyncIterable<AdapterEvent> = queue.stream();
|
|
@@ -3102,6 +3110,8 @@ async function handleResponsesInner(
|
|
|
3102
3110
|
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
3103
3111
|
stallTimeoutSec: config.stallTimeoutSec,
|
|
3104
3112
|
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
3113
|
+
declaredToolNames,
|
|
3114
|
+
toolParameterSchemas,
|
|
3105
3115
|
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
3106
3116
|
...(routedCompaction ? { compaction: true } : {}),
|
|
3107
3117
|
onUsage: usage => {
|
|
@@ -3119,7 +3129,7 @@ async function handleResponsesInner(
|
|
|
3119
3129
|
parsed._rawBody,
|
|
3120
3130
|
response,
|
|
3121
3131
|
continuationStateForResponse(providerState),
|
|
3122
|
-
adapterNeedsForcedContinuation(adapter.name)
|
|
3132
|
+
responseStateOptions(adapterNeedsForcedContinuation(adapter.name)),
|
|
3123
3133
|
),
|
|
3124
3134
|
}),
|
|
3125
3135
|
},
|
|
@@ -3148,6 +3158,8 @@ async function handleResponsesInner(
|
|
|
3148
3158
|
replayCacheScope: parsed._reasoningReplayScope,
|
|
3149
3159
|
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
3150
3160
|
toolNsMap,
|
|
3161
|
+
declaredToolNames,
|
|
3162
|
+
toolParameterSchemas,
|
|
3151
3163
|
freeformToolNames,
|
|
3152
3164
|
toolSearchToolNames,
|
|
3153
3165
|
...(routedCompaction ? { compaction: true } : {}),
|
|
@@ -3165,7 +3177,7 @@ async function handleResponsesInner(
|
|
|
3165
3177
|
parsed._rawBody,
|
|
3166
3178
|
json,
|
|
3167
3179
|
continuationStateForResponse(providerState),
|
|
3168
|
-
adapterNeedsForcedContinuation(adapter.name)
|
|
3180
|
+
responseStateOptions(adapterNeedsForcedContinuation(adapter.name)),
|
|
3169
3181
|
);
|
|
3170
3182
|
}
|
|
3171
3183
|
return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
|
|
@@ -3836,7 +3848,7 @@ async function handleResponsesInner(
|
|
|
3836
3848
|
continuation: fetchTerminalGuardContinuation,
|
|
3837
3849
|
})
|
|
3838
3850
|
: initialEventStream;
|
|
3839
|
-
const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
3851
|
+
const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
3840
3852
|
const sseStream = bridgeToResponsesSSE(
|
|
3841
3853
|
eventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
|
|
3842
3854
|
() => upstream.abort(), 2_000,
|
|
@@ -3846,6 +3858,8 @@ async function handleResponsesInner(
|
|
|
3846
3858
|
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
3847
3859
|
stallTimeoutSec: config.stallTimeoutSec,
|
|
3848
3860
|
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
3861
|
+
declaredToolNames,
|
|
3862
|
+
toolParameterSchemas,
|
|
3849
3863
|
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
3850
3864
|
...(routedCompaction ? { compaction: true } : {}),
|
|
3851
3865
|
onUsage: usage => {
|
|
@@ -3865,7 +3879,7 @@ async function handleResponsesInner(
|
|
|
3865
3879
|
parsed._rawBody,
|
|
3866
3880
|
response,
|
|
3867
3881
|
continuationStateForResponse(providerState),
|
|
3868
|
-
activeAdapter.name === "kiro"
|
|
3882
|
+
responseStateOptions(activeAdapter.name === "kiro"),
|
|
3869
3883
|
),
|
|
3870
3884
|
}),
|
|
3871
3885
|
},
|
|
@@ -3896,13 +3910,15 @@ async function handleResponsesInner(
|
|
|
3896
3910
|
} finally {
|
|
3897
3911
|
cleanupUpstreamAbort();
|
|
3898
3912
|
}
|
|
3899
|
-
const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
3913
|
+
const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
3900
3914
|
let providerState: OcxProviderContinuationState | undefined;
|
|
3901
3915
|
const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, {
|
|
3902
3916
|
translatorBudget,
|
|
3903
3917
|
replayCacheScope: parsed._reasoningReplayScope,
|
|
3904
3918
|
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
3905
3919
|
toolNsMap,
|
|
3920
|
+
declaredToolNames,
|
|
3921
|
+
toolParameterSchemas,
|
|
3906
3922
|
freeformToolNames,
|
|
3907
3923
|
toolSearchToolNames,
|
|
3908
3924
|
...(routedCompaction ? { compaction: true } : {}),
|
|
@@ -3921,7 +3937,7 @@ async function handleResponsesInner(
|
|
|
3921
3937
|
parsed._rawBody,
|
|
3922
3938
|
json,
|
|
3923
3939
|
continuationStateForResponse(providerState),
|
|
3924
|
-
activeAdapter.name === "kiro"
|
|
3940
|
+
responseStateOptions(activeAdapter.name === "kiro"),
|
|
3925
3941
|
);
|
|
3926
3942
|
}
|
|
3927
3943
|
return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
|
+
import { codexWsUpstreamFetch, shouldUseCodexWsUpstream } from "./ws-upstream";
|
|
2
3
|
import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
|
|
3
4
|
import {
|
|
4
5
|
getConfigPath,
|
|
@@ -131,7 +132,17 @@ export function safeOriginLabel(url: string): string {
|
|
|
131
132
|
|
|
132
133
|
|
|
133
134
|
export function providerFetch(provider: OcxProviderConfig): typeof globalThis.fetch {
|
|
134
|
-
|
|
135
|
+
const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch;
|
|
136
|
+
// ChatGPT Codex backend: streaming turns ride the responses_websockets
|
|
137
|
+
// transport (measured ~3s faster TTFT than the SSE POST queue); everything
|
|
138
|
+
// else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
|
|
139
|
+
const wrapped = (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
|
|
140
|
+
if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init)) {
|
|
141
|
+
return codexWsUpstreamFetch(input, init, base);
|
|
142
|
+
}
|
|
143
|
+
return base(input, init);
|
|
144
|
+
};
|
|
145
|
+
return wrapped as typeof globalThis.fetch;
|
|
135
146
|
}
|
|
136
147
|
|
|
137
148
|
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Upstream WebSocket transport for the ChatGPT Codex backend.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: the Codex backend serves the responses_websockets path from
|
|
4
|
+
// a measurably faster queue than the plain SSE POST path. Measured 2026-08-12
|
|
5
|
+
// KST (same account, same payload, strictly sequential): gpt-5.6-luna TTFT p50
|
|
6
|
+
// ~1.0s over WS vs ~3.9s over SSE. Codex CLI itself defaults to the WS
|
|
7
|
+
// transport; opencodex previously always POSTed SSE, which is where its extra
|
|
8
|
+
// 2-3s of TTFT came from.
|
|
9
|
+
//
|
|
10
|
+
// The wrapper only swaps the transport. It dials wss:// with the same headers,
|
|
11
|
+
// sends the JSON body as a single `response.create` frame, and re-encodes the
|
|
12
|
+
// returned event frames as an SSE byte stream, so every downstream consumer
|
|
13
|
+
// (passthrough relay, adapter parsers, usage sniffing) is unchanged.
|
|
14
|
+
|
|
15
|
+
const CODEX_RESPONSES_HTTP_URL = "https://chatgpt.com/backend-api/codex/responses";
|
|
16
|
+
const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
|
|
17
|
+
const WS_BETA = "responses_websockets=2026-02-06";
|
|
18
|
+
// If the 101 never arrives (network black hole), give SSE a chance well before
|
|
19
|
+
// the caller's connect timeout (default 200s) would fire.
|
|
20
|
+
const UPGRADE_DEADLINE_MS = 10_000;
|
|
21
|
+
|
|
22
|
+
export function shouldUseCodexWsUpstream(url: string, init?: RequestInit): boolean {
|
|
23
|
+
if (url !== CODEX_RESPONSES_HTTP_URL) return false;
|
|
24
|
+
if ((init?.method ?? "GET").toUpperCase() !== "POST") return false;
|
|
25
|
+
const body = init?.body;
|
|
26
|
+
if (typeof body !== "string") return false;
|
|
27
|
+
// Only root-level stream:true selects WS: JSON-mode calls keep the HTTP path
|
|
28
|
+
// because the WS path only speaks the event protocol, and a nested
|
|
29
|
+
// {"metadata":{"stream":true}} must not flip the transport. Parsing (not
|
|
30
|
+
// substring matching) also keeps whitespace-formatted bodies routable.
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(body) as unknown;
|
|
33
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
34
|
+
&& (parsed as Record<string, unknown>).stream === true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function codexWsUpstreamFetch(
|
|
41
|
+
url: string,
|
|
42
|
+
init: RequestInit,
|
|
43
|
+
sseFallback: typeof globalThis.fetch,
|
|
44
|
+
): Promise<Response> {
|
|
45
|
+
const signal = init.signal ?? undefined;
|
|
46
|
+
if (signal?.aborted) {
|
|
47
|
+
return Promise.reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let frameText: string;
|
|
51
|
+
try {
|
|
52
|
+
const body = JSON.parse(init.body as string) as Record<string, unknown>;
|
|
53
|
+
// The WS create frame is implicitly streaming; the backend rejects the
|
|
54
|
+
// HTTP-only `stream` flag inside a frame.
|
|
55
|
+
delete body.stream;
|
|
56
|
+
frameText = JSON.stringify({ ...body, type: "response.create" });
|
|
57
|
+
} catch {
|
|
58
|
+
return sseFallback(url, init);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const headers: Record<string, string> = {};
|
|
62
|
+
new Headers(init.headers ?? {}).forEach((value, key) => {
|
|
63
|
+
// HTTP-body framing headers do not apply to a WS handshake.
|
|
64
|
+
if (key === "content-type" || key === "content-length" || key === "accept" || key === "accept-encoding") return;
|
|
65
|
+
headers[key] = value;
|
|
66
|
+
});
|
|
67
|
+
headers["openai-beta"] = headers["openai-beta"]
|
|
68
|
+
? headers["openai-beta"].includes("responses_websockets")
|
|
69
|
+
? headers["openai-beta"]
|
|
70
|
+
: `${headers["openai-beta"]}, ${WS_BETA}`
|
|
71
|
+
: WS_BETA;
|
|
72
|
+
// A genuine caller `originator` is already in these headers via the forward
|
|
73
|
+
// set. Never fabricate one here: pool/forward traffic must not impersonate
|
|
74
|
+
// Codex CLI, per the metadata-integrity contract. (The backend's fast lane
|
|
75
|
+
// keys on WS + originator, so callers without the tag simply keep their own
|
|
76
|
+
// provenance and scheduling.)
|
|
77
|
+
|
|
78
|
+
return new Promise<Response>((resolve, reject) => {
|
|
79
|
+
let ws: WebSocket;
|
|
80
|
+
try {
|
|
81
|
+
// Bun accepts per-handshake headers; the DOM lib types only list protocol arrays.
|
|
82
|
+
ws = new WebSocket(CODEX_RESPONSES_WS_URL, { headers } as unknown as string[]);
|
|
83
|
+
} catch {
|
|
84
|
+
resolve(sseFallback(url, init));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let opened = false;
|
|
89
|
+
let settledPreOpen = false;
|
|
90
|
+
let terminal = false;
|
|
91
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
92
|
+
const encoder = new TextEncoder();
|
|
93
|
+
|
|
94
|
+
const upgradeTimer = setTimeout(() => {
|
|
95
|
+
if (opened || settledPreOpen) return;
|
|
96
|
+
settledPreOpen = true;
|
|
97
|
+
try { ws.close(); } catch { /* already closing */ }
|
|
98
|
+
resolve(sseFallback(url, init));
|
|
99
|
+
}, UPGRADE_DEADLINE_MS);
|
|
100
|
+
|
|
101
|
+
const onAbort = () => {
|
|
102
|
+
if (!opened) {
|
|
103
|
+
if (settledPreOpen) return;
|
|
104
|
+
// Settle BEFORE close(): the close handler treats a pre-open close as
|
|
105
|
+
// an upgrade rejection and would dial the SSE fallback for a request
|
|
106
|
+
// the caller just cancelled.
|
|
107
|
+
settledPreOpen = true;
|
|
108
|
+
clearTimeout(upgradeTimer);
|
|
109
|
+
try { ws.close(); } catch { /* already closing */ }
|
|
110
|
+
reject(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError"));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try { ws.close(); } catch { /* already closing */ }
|
|
114
|
+
if (controller && !terminal) {
|
|
115
|
+
terminal = true;
|
|
116
|
+
// Mirror an aborted fetch: the body read rejects with the abort reason.
|
|
117
|
+
try { controller.error(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError")); } catch { /* stream already done */ }
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
121
|
+
|
|
122
|
+
ws.addEventListener("open", () => {
|
|
123
|
+
if (settledPreOpen) return;
|
|
124
|
+
clearTimeout(upgradeTimer);
|
|
125
|
+
try {
|
|
126
|
+
ws.send(frameText);
|
|
127
|
+
} catch {
|
|
128
|
+
// send() throwing means the frame never left, so no upstream turn
|
|
129
|
+
// started and the SSE resend cannot double-generate. Falling back
|
|
130
|
+
// (instead of erroring a synthetic 200 body) keeps the pre-stream
|
|
131
|
+
// HTTP error/refresh/failover machinery in charge.
|
|
132
|
+
settledPreOpen = true;
|
|
133
|
+
try { ws.close(); } catch { /* already closing */ }
|
|
134
|
+
resolve(sseFallback(url, init));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
opened = true;
|
|
138
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
139
|
+
start(c) { controller = c; },
|
|
140
|
+
cancel() { try { ws.close(); } catch { /* already closing */ } },
|
|
141
|
+
});
|
|
142
|
+
resolve(new Response(stream, {
|
|
143
|
+
status: 200,
|
|
144
|
+
// The 101 response headers (x-codex-*-reset-at quota hints) are not
|
|
145
|
+
// exposed by Bun's WebSocket; the periodic quota poller covers those.
|
|
146
|
+
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
|
147
|
+
}));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
ws.addEventListener("message", (event) => {
|
|
151
|
+
if (!controller || terminal) return;
|
|
152
|
+
const text = typeof event.data === "string" ? event.data : "";
|
|
153
|
+
if (!text) return;
|
|
154
|
+
let type: unknown;
|
|
155
|
+
try { type = (JSON.parse(text) as { type?: unknown }).type; } catch { return; }
|
|
156
|
+
if (typeof type !== "string") return;
|
|
157
|
+
// Relay only the event surface the SSE path produces today. WS-only
|
|
158
|
+
// frames (codex.rate_limits, responsesapi.websocket_timing) are dropped
|
|
159
|
+
// so downstream clients see exactly the stream shape they always got.
|
|
160
|
+
if (!type.startsWith("response.") && type !== "error") return;
|
|
161
|
+
try {
|
|
162
|
+
controller.enqueue(encoder.encode(`event: ${type}\ndata: ${text}\n\n`));
|
|
163
|
+
} catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (type === "response.completed" || type === "response.failed" || type === "response.incomplete" || type === "error") {
|
|
167
|
+
terminal = true;
|
|
168
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
169
|
+
try { ws.close(); } catch { /* already closing */ }
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
ws.addEventListener("close", () => {
|
|
174
|
+
signal?.removeEventListener("abort", onAbort);
|
|
175
|
+
if (!opened) {
|
|
176
|
+
if (settledPreOpen) return;
|
|
177
|
+
settledPreOpen = true;
|
|
178
|
+
clearTimeout(upgradeTimer);
|
|
179
|
+
// Upgrade rejected (401/403/429/5xx). Retry over plain SSE so the real
|
|
180
|
+
// HTTP status reaches the existing refresh/rotation handlers. No turn
|
|
181
|
+
// started upstream, so the resend cannot double-generate.
|
|
182
|
+
resolve(sseFallback(url, init));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (controller && !terminal) {
|
|
186
|
+
terminal = true;
|
|
187
|
+
// Connection dropped before a Responses terminal event. A clean EOF
|
|
188
|
+
// here would reach clients with no response.completed/failed at all —
|
|
189
|
+
// relaySseWithFailedTail() only synthesizes a failed terminal when the
|
|
190
|
+
// body read THROWS. Error the stream like a reset TCP socket.
|
|
191
|
+
try { controller.error(new Error("codex websocket closed before a Responses terminal event")); } catch { /* stream already done */ }
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
ws.addEventListener("error", () => {
|
|
196
|
+
/* Bun always follows error with close; the close handler settles. */
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
@@ -10,6 +10,7 @@ import { truncateRetainedUtf8 } from "../lib/admission";
|
|
|
10
10
|
|
|
11
11
|
const CACHE_TTL_MS = 30_000;
|
|
12
12
|
const PROBE_TIMEOUT_MS = 5_000;
|
|
13
|
+
const INITIAL_PROBE_WAIT_MS = 5_500;
|
|
13
14
|
const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
|
|
14
15
|
let cached: { timestamp: number; value: StartupHealth } | null = null;
|
|
15
16
|
let inflight: Promise<StartupHealth> | null = null;
|
|
@@ -109,6 +110,17 @@ function refreshInBackground(config: Pick<OcxConfig, "codexAutoStart">): void {
|
|
|
109
110
|
export async function getCachedStartupHealth(config: Pick<OcxConfig, "codexAutoStart">): Promise<StartupHealth> {
|
|
110
111
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) return cached.value;
|
|
111
112
|
refreshInBackground(config);
|
|
113
|
+
// An expired or empty read is an explicit protection check. Wait for the
|
|
114
|
+
// isolated probe instead of presenting a synthetic failure while that probe
|
|
115
|
+
// is still running. The probe remains child-process isolated and hard-capped
|
|
116
|
+
// at 5s; stale state is returned only if that bounded probe cannot settle.
|
|
117
|
+
if (inflight) {
|
|
118
|
+
const settled = await Promise.race([
|
|
119
|
+
inflight,
|
|
120
|
+
new Promise<null>(resolve => setTimeout(() => resolve(null), INITIAL_PROBE_WAIT_MS)),
|
|
121
|
+
]);
|
|
122
|
+
if (settled) return settled;
|
|
123
|
+
}
|
|
112
124
|
return cached ? markStartupHealthDiagnosticStale(cached.value) : conservativeFallback(config);
|
|
113
125
|
}
|
|
114
126
|
|